455 lines
17 KiB
JavaScript
455 lines
17 KiB
JavaScript
/**
|
|
* Code-first API docs: scan `@openapi` JSDoc in routes → OpenAPI JSON + Markdown under docs/api/.
|
|
*
|
|
* Usage: npm run docs:api
|
|
*/
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import swaggerJsdoc from 'swagger-jsdoc';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const root = path.resolve(__dirname, '..');
|
|
const docsDir = path.join(root, 'docs', 'api');
|
|
const routesDocsDir = path.join(docsDir, 'routes');
|
|
|
|
const PUBLIC_PATHS = new Set(['/auth/login', '/auth/logout', '/health']);
|
|
|
|
const options = {
|
|
definition: {
|
|
openapi: '3.0.3',
|
|
info: {
|
|
title: 'Dash API',
|
|
version: '1.0.0',
|
|
description:
|
|
'Backend API for Dash — Google SSO auth, MSSQL, session cookies.\n\n' +
|
|
'Browser clients must send cookies (`credentials: \'include\'`). ' +
|
|
'Sessions use an HTTP-only `token` cookie (`Secure`, `SameSite=Strict`, 24h); HTTPS is required for login.',
|
|
},
|
|
servers: [{ url: 'http://localhost:4208/api', description: 'Local default (PORT / .env)' }],
|
|
tags: [
|
|
{ name: 'Auth', description: 'Google SSO and session' },
|
|
{ name: 'Health', description: 'Connectivity checks' },
|
|
{ name: 'Events', description: 'SSE update signals for live clients' },
|
|
{ name: 'Article', description: 'Articles under categories' },
|
|
{ name: 'Category', description: 'Category tree and membership' },
|
|
{ name: 'Company', description: 'Company CRUD' },
|
|
{ name: 'CompanySetting', description: 'Company-scoped settings' },
|
|
{ name: 'Unit', description: 'Company-scoped units of measure' },
|
|
{ name: 'Brand', description: 'Company-scoped brands' },
|
|
{ name: 'Supplier', description: 'Company-scoped suppliers (Lieferanten)' },
|
|
{ name: 'Delivery', description: 'Company-scoped goods receipts (Lieferungen)' },
|
|
{ name: 'Quote', description: 'Company-scoped quotes (Angebote)' },
|
|
{ name: 'Order', description: 'Company-scoped sales orders (Aufträge)' },
|
|
{ name: 'Customergroup', description: 'Company-scoped customer groups' },
|
|
{ name: 'Entity', description: 'Company-scoped entities, addresses, and contacts' },
|
|
{ name: 'Image', description: 'Content-addressed AVIF by fullHash' },
|
|
{ name: 'Posterminal', description: 'Company-scoped POS terminals' },
|
|
{ name: 'Printer', description: 'Company-scoped receipt and label printers' },
|
|
{ name: 'Ledger', description: 'Append-only ledger transactions' },
|
|
{ name: 'Stock', description: 'Manual stock additions and removals' },
|
|
],
|
|
},
|
|
apis: [
|
|
path.join(root, 'src/api/openapi.js'),
|
|
path.join(root, 'src/api/routes/**/*.js'),
|
|
],
|
|
};
|
|
|
|
const spec = swaggerJsdoc(options);
|
|
|
|
function ensureDir(dir) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
function rmDirContents(dir) {
|
|
if (!fs.existsSync(dir)) return;
|
|
for (const name of fs.readdirSync(dir)) {
|
|
fs.rmSync(path.join(dir, name), { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function schemaRefName(schema) {
|
|
if (!schema) return null;
|
|
if (schema.$ref) return schema.$ref.split('/').pop();
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Build a sample JSON value from an OpenAPI schema (expands $ref / allOf / array items).
|
|
* @param {object} schema
|
|
* @param {object} [components]
|
|
* @param {number} [depth]
|
|
* @returns {*}
|
|
*/
|
|
function toExample(schema, components, depth = 0) {
|
|
if (!schema || depth > 8) return null;
|
|
|
|
if (schema.example !== undefined) return schema.example;
|
|
|
|
if (schema.$ref) {
|
|
const resolved = components?.schemas?.[schemaRefName(schema)];
|
|
return resolved ? toExample(resolved, components, depth) : {};
|
|
}
|
|
|
|
if (Array.isArray(schema.allOf) && schema.allOf.length) {
|
|
const out = {};
|
|
for (const part of schema.allOf) {
|
|
const sample = toExample(part, components, depth + 1);
|
|
if (sample && typeof sample === 'object' && !Array.isArray(sample)) {
|
|
Object.assign(out, sample);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
if (schema.type === 'array') {
|
|
if (schema.items) {
|
|
return [toExample(schema.items, components, depth + 1)];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
if (schema.type === 'object' || schema.properties) {
|
|
const out = {};
|
|
for (const [key, prop] of Object.entries(schema.properties || {})) {
|
|
out[key] = toExample(prop, components, depth + 1);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
if (schema.type === 'string') {
|
|
if (schema.enum?.length) return schema.enum[0];
|
|
if (schema.format === 'email') return 'user@example.com';
|
|
if (schema.format === 'date-time') return '2026-08-10T00:00:00.000Z';
|
|
if (schema.format === 'date') return '2026-08-10';
|
|
return '…';
|
|
}
|
|
|
|
if (schema.type === 'integer') return 1;
|
|
if (schema.type === 'number') return 1;
|
|
if (schema.type === 'boolean') return true;
|
|
if (schema.nullable) return null;
|
|
return '…';
|
|
}
|
|
|
|
function formatSchema(schema, components) {
|
|
if (!schema) return '_none_';
|
|
const example = toExample(schema, components);
|
|
return '```json\n' + JSON.stringify(example, null, 2) + '\n```';
|
|
}
|
|
|
|
function contentSchema(content) {
|
|
if (!content) return null;
|
|
const json = content['application/json'];
|
|
return json?.schema ?? null;
|
|
}
|
|
|
|
function renderRequestBody(requestBody) {
|
|
if (!requestBody?.content) return null;
|
|
const lines = [];
|
|
const types = Object.keys(requestBody.content);
|
|
const jsonSchema = contentSchema(requestBody.content);
|
|
|
|
if (jsonSchema) {
|
|
lines.push('## Body (JSON)');
|
|
lines.push('');
|
|
lines.push(formatSchema(jsonSchema, spec.components));
|
|
lines.push('');
|
|
}
|
|
|
|
// multipart/form-data — render fields as a table when properties are present
|
|
const multipart = requestBody.content['multipart/form-data'];
|
|
if (multipart?.schema?.properties) {
|
|
const schema = multipart.schema;
|
|
const required = new Set(schema.required || []);
|
|
lines.push('## Body (`multipart/form-data`)');
|
|
lines.push('');
|
|
lines.push('| Field | Required | Type | Description |');
|
|
lines.push('|-------|----------|------|-------------|');
|
|
for (const [name, prop] of Object.entries(schema.properties)) {
|
|
const type = prop.format === 'binary' || prop.items?.format === 'binary' ? 'file' : (prop.type || '');
|
|
lines.push(`| \`${name}\` | ${required.has(name) ? 'yes' : 'no'} | ${type} | ${prop.description || ''} |`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
if (!jsonSchema && !multipart?.schema?.properties) {
|
|
lines.push('## Body');
|
|
lines.push('');
|
|
for (const type of types) {
|
|
const schema = requestBody.content[type]?.schema;
|
|
if (schema?.format === 'binary') {
|
|
lines.push(`Raw binary (\`${type}\`).`);
|
|
} else {
|
|
lines.push(`\`${type}\``);
|
|
if (schema) {
|
|
lines.push('');
|
|
lines.push(formatSchema(schema, spec.components));
|
|
}
|
|
}
|
|
lines.push('');
|
|
}
|
|
}
|
|
return lines.length ? lines : null;
|
|
}
|
|
|
|
function docsFileFor(apiPath, method) {
|
|
const custom = spec.paths[apiPath]?.[method]?.['x-markdown'];
|
|
if (custom) return custom;
|
|
|
|
const cleaned = apiPath
|
|
.replace(/^\//, '')
|
|
.replace(/\{([^}]+)\}/g, ':$1');
|
|
const segments = cleaned.split('/').filter(Boolean);
|
|
if (segments.length === 0) return `${method}.md`;
|
|
|
|
const fileBase = segments.pop();
|
|
const dir = segments.join('/');
|
|
const methodsOnPath = Object.keys(spec.paths[apiPath]).filter((m) => !m.startsWith('x-'));
|
|
const name =
|
|
methodsOnPath.length > 1 ? `${fileBase}.${method}.md` : `${fileBase}.md`;
|
|
return dir ? path.join(dir, name) : name;
|
|
}
|
|
|
|
function renderOperation(apiPath, method, operation) {
|
|
const isPublic = PUBLIC_PATHS.has(apiPath) || operation.security?.length === 0;
|
|
const lines = [];
|
|
lines.push(`# \`${method.toUpperCase()} ${apiPath}\``);
|
|
lines.push('');
|
|
if (isPublic) {
|
|
lines.push('Public. No cookie required.');
|
|
} else {
|
|
lines.push('Protected. Requires a valid session cookie (`credentials: \'include\'`).');
|
|
}
|
|
lines.push('');
|
|
if (operation.summary) {
|
|
lines.push(operation.summary);
|
|
lines.push('');
|
|
}
|
|
if (operation.description && operation.description !== operation.summary) {
|
|
lines.push(operation.description);
|
|
lines.push('');
|
|
}
|
|
|
|
const params = operation.parameters || [];
|
|
const pathParams = params.filter((p) => p.in === 'path');
|
|
const queryParams = params.filter((p) => p.in === 'query');
|
|
|
|
if (pathParams.length) {
|
|
lines.push('## Path parameters');
|
|
lines.push('');
|
|
lines.push('| Param | Required | Type | Description |');
|
|
lines.push('|-------|----------|------|-------------|');
|
|
for (const p of pathParams) {
|
|
lines.push(
|
|
`| \`${p.name}\` | ${p.required ? 'yes' : 'no'} | ${p.schema?.type || ''} | ${p.description || ''} |`,
|
|
);
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
if (queryParams.length) {
|
|
lines.push('## Query parameters');
|
|
lines.push('');
|
|
lines.push('| Param | Required | Type | Description |');
|
|
lines.push('|-------|----------|------|-------------|');
|
|
for (const p of queryParams) {
|
|
lines.push(
|
|
`| \`${p.name}\` | ${p.required ? 'yes' : 'no'} | ${p.schema?.type || ''} | ${p.description || ''} |`,
|
|
);
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
if (operation.requestBody) {
|
|
const bodyLines = renderRequestBody(operation.requestBody);
|
|
if (bodyLines) lines.push(...bodyLines);
|
|
}
|
|
|
|
const signals = operation['x-signals'];
|
|
if (Array.isArray(signals) && signals.length) {
|
|
lines.push('## Signals');
|
|
lines.push('');
|
|
lines.push(
|
|
`On success, emits SSE signal${signals.length === 1 ? '' : 's'}: ${signals.map((s) => `\`${s}\``).join(', ')} (see [\`GET /events\`](../events.md)).`,
|
|
);
|
|
lines.push('');
|
|
}
|
|
|
|
const sseEvents = operation['x-sse-events'];
|
|
if (Array.isArray(sseEvents) && sseEvents.length) {
|
|
lines.push('## SSE Events');
|
|
lines.push('');
|
|
lines.push('The response is a `text/event-stream`. Events are emitted in order until the stream closes.');
|
|
lines.push('');
|
|
lines.push('| Event | Description | Payload |');
|
|
lines.push('|-------|-------------|---------|');
|
|
for (const ev of sseEvents) {
|
|
lines.push(`| \`${ev.name}\` | ${ev.description || ''} | ${ev.payload ? `\`${ev.payload}\`` : ''} |`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
const responses = operation.responses || {};
|
|
const successCodes = Object.keys(responses)
|
|
.filter((c) => c.startsWith('2'))
|
|
.sort();
|
|
const errorCodes = Object.keys(responses)
|
|
.filter((c) => !c.startsWith('2'))
|
|
.sort();
|
|
|
|
for (const code of successCodes) {
|
|
const resp = responses[code];
|
|
lines.push(`## Response \`${code}\``);
|
|
lines.push('');
|
|
if (resp.description) {
|
|
lines.push(resp.description);
|
|
lines.push('');
|
|
}
|
|
const schema = contentSchema(resp.content);
|
|
if (schema) {
|
|
lines.push(formatSchema(schema, spec.components));
|
|
lines.push('');
|
|
} else if (code === '204') {
|
|
lines.push('_No content_');
|
|
lines.push('');
|
|
}
|
|
}
|
|
|
|
if (errorCodes.length) {
|
|
lines.push('## Errors');
|
|
lines.push('');
|
|
lines.push('| Status | Description |');
|
|
lines.push('|--------|-------------|');
|
|
for (const code of errorCodes) {
|
|
const resp = responses[code];
|
|
const schema = contentSchema(resp.content);
|
|
let detail = resp.description || '';
|
|
if (schema?.example) {
|
|
detail += (detail ? ' — ' : '') + `\`${JSON.stringify(schema.example)}\``;
|
|
} else if (schemaRefName(schema) === 'Error') {
|
|
detail += (detail ? ' — ' : '') + '`' + '{"error":"…"}' + '`';
|
|
}
|
|
lines.push(`| ${code} | ${detail} |`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
lines.push('---');
|
|
lines.push('');
|
|
lines.push('_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._');
|
|
lines.push('');
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function renderReadme(operations) {
|
|
const lines = [];
|
|
lines.push('# Dash API');
|
|
lines.push('');
|
|
lines.push('```');
|
|
lines.push('http://localhost:4208/api');
|
|
lines.push('```');
|
|
lines.push('');
|
|
lines.push('## CORS & cookies');
|
|
lines.push('');
|
|
lines.push('All browser requests that need auth must send cookies:');
|
|
lines.push('');
|
|
lines.push('```js');
|
|
lines.push("fetch('http://localhost:4208/api/auth/me', {");
|
|
lines.push(" credentials: 'include',");
|
|
lines.push('});');
|
|
lines.push('```');
|
|
lines.push('');
|
|
lines.push(
|
|
'Sessions use an HTTP-only `token` cookie (`Secure`, `SameSite=Strict`, 24h). The browser must talk to the API over **HTTPS** for login to work — `Secure` cookies are not sent on plain HTTP.',
|
|
);
|
|
lines.push('');
|
|
lines.push('## Auth flow');
|
|
lines.push('');
|
|
lines.push('1. Call [`GET /auth/me`](routes/auth/me.md) to get `googleClientId` (included in 200 and 401 responses).');
|
|
lines.push('2. Sign in with Google on the frontend (Google Identity Services / One Tap).');
|
|
lines.push('3. POST the Google ID token to [`POST /auth/login`](routes/auth/login.md).');
|
|
lines.push('4. The API sets the session cookie.');
|
|
lines.push("5. Call protected routes with `credentials: 'include'`. The cookie is refreshed automatically when it has less than 2 hours left.");
|
|
lines.push('');
|
|
lines.push('## Routes');
|
|
lines.push('');
|
|
lines.push('Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/routes/). Source of truth is the code; regenerate with `npm run docs:api`.');
|
|
lines.push('');
|
|
lines.push('| File | Endpoint |');
|
|
lines.push('|------|----------|');
|
|
for (const op of operations) {
|
|
lines.push(`| [\`routes/${op.file}\`](routes/${op.file}) | \`${op.method.toUpperCase()} ${op.path}\` |`);
|
|
}
|
|
lines.push('');
|
|
lines.push('Machine-readable spec: [`openapi.json`](openapi.json).');
|
|
lines.push('');
|
|
lines.push('## Signals (SSE)');
|
|
lines.push('');
|
|
lines.push(
|
|
'Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). After a successful mutation, the API emits a named SSE event (signal) to subscribers of the same selected company.',
|
|
);
|
|
lines.push('');
|
|
lines.push('| Signal | Emitted by |');
|
|
lines.push('|--------|------------|');
|
|
|
|
/** @type {Map<string, string[]>} */
|
|
const bySignal = new Map();
|
|
for (const op of operations) {
|
|
const signals = spec.paths[op.path]?.[op.method]?.['x-signals'];
|
|
if (!Array.isArray(signals)) continue;
|
|
const label = `\`${op.method.toUpperCase()} ${op.path}\``;
|
|
for (const signal of signals) {
|
|
if (!bySignal.has(signal)) bySignal.set(signal, []);
|
|
bySignal.get(signal).push(label);
|
|
}
|
|
}
|
|
const signalNames = [...bySignal.keys()].sort();
|
|
if (signalNames.length === 0) {
|
|
lines.push('| — | _(none yet)_ |');
|
|
} else {
|
|
for (const signal of signalNames) {
|
|
lines.push(`| \`${signal}\` | ${bySignal.get(signal).join(', ')} |`);
|
|
}
|
|
}
|
|
lines.push('');
|
|
lines.push('## DESIGN');
|
|
lines.push('');
|
|
lines.push('- for every route we have a file in /src/api/routes');
|
|
lines.push('- that should loosly bound to services in /src/services');
|
|
lines.push('- for every service we have a CLI tester in /scripts/');
|
|
lines.push('- all DB SQL code shall be in /src/db');
|
|
lines.push('- with SQL staements in /src/db/');
|
|
lines.push('- API docs: `@openapi` JSDoc on handlers → `npm run docs:api` → `docs/api/`');
|
|
lines.push('');
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// --- generate ---
|
|
ensureDir(docsDir);
|
|
rmDirContents(routesDocsDir);
|
|
ensureDir(routesDocsDir);
|
|
|
|
fs.writeFileSync(path.join(docsDir, 'openapi.json'), JSON.stringify(spec, null, 2) + '\n');
|
|
|
|
const operations = [];
|
|
const paths = Object.keys(spec.paths || {}).sort();
|
|
|
|
for (const apiPath of paths) {
|
|
const item = spec.paths[apiPath];
|
|
for (const method of Object.keys(item).sort()) {
|
|
if (method.startsWith('x-')) continue;
|
|
const operation = item[method];
|
|
const relFile = docsFileFor(apiPath, method);
|
|
const absFile = path.join(routesDocsDir, relFile);
|
|
ensureDir(path.dirname(absFile));
|
|
fs.writeFileSync(absFile, renderOperation(apiPath, method, operation));
|
|
operations.push({ path: apiPath, method, file: relFile.replace(/\\/g, '/') });
|
|
}
|
|
}
|
|
|
|
fs.writeFileSync(path.join(docsDir, 'README.md'), renderReadme(operations));
|
|
|
|
console.log(`Wrote ${operations.length} route docs + openapi.json → docs/api/`);
|