/** * 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} */ 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/`);