Files
dashback/scripts/generateApiDocs.js
2026-08-01 02:15:29 +02:00

344 lines
12 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: 'Article', description: 'Articles under categories' },
{ name: 'Category', description: 'Category tree and membership' },
{ name: 'Company', description: 'Company CRUD' },
{ name: 'Image', description: 'Content-addressed AVIF by fullHash' },
],
},
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;
}
function formatSchema(schema, components, indent = 0) {
if (!schema) return '_none_';
if (schema.$ref) {
const name = schemaRefName(schema);
const resolved = components?.schemas?.[name];
if (resolved) {
return formatSchema({ ...resolved, title: name }, components, indent);
}
return `\`${name}\``;
}
if (schema.example !== undefined) {
return '```json\n' + JSON.stringify(schema.example, null, 2) + '\n```';
}
if (schema.type === 'object' && schema.properties) {
const lines = ['```json', '{'];
const keys = Object.keys(schema.properties);
keys.forEach((key, i) => {
const prop = schema.properties[key];
const comma = i < keys.length - 1 ? ',' : '';
let value = '…';
if (prop.type === 'string') value = prop.format === 'email' ? '"user@example.com"' : '"…"';
else if (prop.type === 'integer' || prop.type === 'number') value = prop.nullable ? 'null' : '1';
else if (prop.type === 'boolean') value = 'true';
else if (prop.type === 'array') value = '[]';
else if (prop.type === 'object' || prop.$ref) value = '{ … }';
if (prop.example !== undefined) value = JSON.stringify(prop.example);
lines.push(` "${key}": ${value}${comma}`);
});
lines.push('}', '```');
return lines.join('\n');
}
if (schema.type === 'array') {
return '```json\n[]\n```';
}
return '```json\n' + JSON.stringify(schema, 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('');
return lines;
}
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;
}
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 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('## 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 mssql staements in /src/db/mssql/');
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/`);