Files
dashback/scripts/generateApiDocs.js
2026-10-24 02:57:32 +02:00

1010 lines
38 KiB
JavaScript

/**
* Code-first API docs: scan `@openapi` JSDoc in routes → Markdown under docs/api/.
* The OpenAPI JSON is NOT written to disk; the interactive docs at /docs/ build it from
* source at runtime via getOpenApiSpec() (src/api/openapiSpec.js).
*
* Usage: npm run docs:api
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { openApiOptions, getOpenApiSpec } from '../src/api/openapiSpec.js';
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', '/v1/client']);
const OMITTED_ROUTES = new Set([
'GET /article/export.zip',
'POST /article/{sku}/image',
'POST /attributevalue/{id}/image',
'GET /auth/me',
'PUT /auth/selected-company',
'POST /brand/{id}/image',
'GET /category/export.zip',
'DELETE /company/{id}',
'POST /company',
'GET /company/export',
'POST /company/export',
'POST /category/{id}/image',
'GET /delivery/{id}/file/{fileId}',
'GET /events',
'GET /health',
'GET /image/{fullHash}',
'GET /order/{id}/file/{fileId}',
'GET /quote/{id}/file/{fileId}',
'POST /serper/image',
'POST /serper/images',
'GET /shipment/{id}/file/{fileId}',
'GET /thumb/{fullHash}',
'GET /v1/category',
'GET /v1/cimage',
'GET /v1/client',
'GET /v1/customer',
'GET /v1/customergroup',
'GET /v1/init',
'GET /v1/pimage',
'GET /v1/product',
]);
function isRouteOmitted(method, apiPath) {
if (apiPath === '/posterminal' || apiPath.startsWith('/posterminal/')) {
return true;
}
if (apiPath === '/modelconversation' || apiPath.startsWith('/modelconversation/')) {
return true;
}
if (apiPath === '/public' || apiPath.startsWith('/public/')) {
return true;
}
if (apiPath === '/vfs' || apiPath.startsWith('/vfs/')) {
return true;
}
if (apiPath === '/backup' || apiPath.startsWith('/backup/')) {
return true;
}
if (apiPath === '/mcp' || apiPath.startsWith('/mcp/')) {
return true;
}
if (apiPath === '/oauth' || apiPath.startsWith('/oauth/')) {
return true;
}
if (apiPath === '/.well-known' || apiPath.startsWith('/.well-known/')) {
return true;
}
if (apiPath === '/account' || apiPath.startsWith('/account/')) {
return true;
}
if (apiPath === '/auth' || apiPath.startsWith('/auth/')) {
return true;
}
return OMITTED_ROUTES.has(`${method.toUpperCase()} ${apiPath}`);
}
/**
* Routes omitted from the main overview because their payload is binary or streaming, which is
* useless to the `dashback_request` tool — but which ARE reachable with `fetch()` from inside
* `javascript_execute`, where the bytes can be decoded, transformed, written to the VFS and shown
* as an image. These are documented separately so the model knows about them without cluttering
* the request-shaped route table.
*
* Deliberately NOT listed: import/export (administrative bulk operations the model must not
* trigger), `/v1/*` (legacy POS compat), `/events` and `/serper/images` (long-lived SSE),
* `/modelconversation/*` and `/posterminal/*` (UI plumbing, recursion risk), `/public/*`, and every
* auth/company mutation.
*/
const JS_FETCH_ONLY_ROUTES = [
'GET /image/{fullHash}',
'GET /thumb/{fullHash}',
'POST /serper/image',
'POST /article/{sku}/image',
'POST /attributevalue/{id}/image',
'POST /brand/{id}/image',
'POST /category/{id}/image',
];
const spec = getOpenApiSpec(true);
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 resolveSchema(schema, components) {
if (!schema) return null;
if (schema.$ref) {
const name = schemaRefName(schema);
const resolved = components?.schemas?.[name];
return resolved ? resolveSchema(resolved, components) : schema;
}
if (Array.isArray(schema.allOf) && schema.allOf.length) {
const merged = { type: 'object', properties: {}, required: [] };
for (const part of schema.allOf) {
const resolvedPart = resolveSchema(part, components);
if (resolvedPart?.properties) {
Object.assign(merged.properties, resolvedPart.properties);
}
if (Array.isArray(resolvedPart?.required)) {
merged.required.push(...resolvedPart.required);
}
}
return merged;
}
return schema;
}
/**
* 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 renderSchemaPropertiesTable(schema, components) {
const resolved = resolveSchema(schema, components);
if (!resolved?.properties) return [];
const lines = [];
const required = new Set(resolved.required || []);
const entries = Object.entries(resolved.properties);
if (entries.length === 0) return [];
lines.push('| Field | Required | Type | Description |');
lines.push('|-------|----------|------|-------------|');
for (const [name, prop] of entries) {
const isReq = required.has(name) ? 'yes' : 'no';
let type = prop.type || '';
if (!type && prop.$ref) {
type = schemaRefName(prop);
} else if (type === 'array') {
const itemType = prop.items?.type || schemaRefName(prop.items) || 'any';
type = `${itemType}[]`;
}
const desc = (prop.description || '').replace(/[\r\n|]+/g, ' ').trim();
lines.push(`| \`${name}\` | ${isReq} | ${type} | ${desc} |`);
if (prop.type === 'array' && prop.items?.properties) {
const itemReq = new Set(prop.items.required || []);
for (const [subName, subProp] of Object.entries(prop.items.properties)) {
const subIsReq = itemReq.has(subName) ? 'yes' : 'no';
const subType = subProp.type || schemaRefName(subProp) || '';
const subDesc = (subProp.description || '').replace(/[\r\n|]+/g, ' ').trim();
lines.push(`| \`${name}[].${subName}\` | ${subIsReq} | ${subType} | ${subDesc} |`);
}
}
}
lines.push('');
return lines;
}
function renderRequestBody(requestBody) {
if (!requestBody?.content) return null;
const lines = [];
const types = Object.keys(requestBody.content);
const jsonSchema = contentSchema(requestBody.content);
if (jsonSchema) {
const isOpt = requestBody.required === false;
lines.push(`## Body (JSON)${isOpt ? ' — _optional_' : ''}`);
lines.push('');
const propTable = renderSchemaPropertiesTable(jsonSchema, spec.components);
if (propTable.length) {
lines.push(...propTable);
}
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')
.replace(/:([a-zA-Z0-9_]+)/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 (apiPath !== '/events' && 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('https://localhost:8443/api');
lines.push('https://localhost:8443/v1 (POS 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('Route overview: [`routes.md`](routes.md) | All routes: [`routes-all.md`](routes-all.md).');
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');
}
/**
* Compact one-line schema summary. Every leaf carries its explicit type
* (`string`, `integer`, `number`, `boolean`, `object`), arrays render as `T[]`,
* and objects render as `{ key: type, … }` at every nesting level.
*
* @param {object} [schema]
* @param {object} [components]
* @param {number} [depth]
* @param {boolean} [markOptional] suffix non-required props with `?` (request bodies)
* @returns {string}
*/
function summarizeSchema(
schema,
components = spec.components,
depth = 0,
markOptional = false,
aliases = null,
skipRootAlias = false,
) {
if (!schema) return '';
if (depth > 6) return aliases?.compactTypes ? '{}' : 'object';
const resolved = resolveSchema(schema, components);
if (!resolved) return schemaRefName(schema) || '';
if (resolved.$ref) return schemaRefName(resolved) || '';
const union = resolved.oneOf || resolved.anyOf;
if (Array.isArray(union) && union.length) {
const parts = union
.map((s) => summarizeSchema(s, components, depth + 1, markOptional, aliases))
.filter(Boolean);
if (parts.length) {
return [...new Set(parts)].join('|');
}
}
if (resolved.type === 'array') {
const item = summarizeSchema(resolved.items, components, depth + 1, markOptional, aliases) || 'any';
return `${item}[]`;
}
if (resolved.type === 'object' || resolved.properties) {
const shape = aliases && summarizeSchema(resolved, components, 0, markOptional);
const alias = shape && aliases.get(shape);
if (alias && !skipRootAlias) return alias;
const hasExplicitRequired = Array.isArray(resolved.required);
const required = new Set(resolved.required || []);
let entries = Object.entries(resolved.properties || {});
if (entries.length === 0) {
if (resolved.additionalProperties && typeof resolved.additionalProperties === 'object') {
const valType = summarizeSchema(resolved.additionalProperties, components, depth + 1, markOptional, aliases) || 'any';
return `{ [k]: ${valType} }`;
}
return aliases?.compactTypes ? '{}' : 'object';
}
// Required props first, then optional ones (stable within each group).
if (markOptional || hasExplicitRequired) {
entries = entries
.map(([name], i) => ({ name, i }))
.sort((a, b) => (required.has(a.name) ? 0 : 1) - (required.has(b.name) ? 0 : 1) || a.i - b.i)
.map(({ name }) => [name, resolved.properties[name]]);
}
const parts = entries.map(([name, prop]) => {
const isOpt = markOptional ? !required.has(name) : (hasExplicitRequired && !required.has(name));
const opt = isOpt ? '?' : '';
let type = summarizeSchema(prop, components, depth + 1, markOptional, aliases) || 'any';
if (prop?.nullable && !type.includes('|null')) {
type = `${type}|null`;
}
return `${name}${opt}: ${type}`;
});
return `{ ${parts.join(', ')} }`;
}
if (resolved.type) {
if (Array.isArray(resolved.type)) {
const types = resolved.type.map((raw) => {
if (raw === 'null') return 'null';
if (aliases?.compactTypes) {
return {
string: 'str',
integer: 'int',
number: 'num',
boolean: 'bool',
object: '{}',
}[raw] || raw;
}
return raw;
});
return types.join('|');
}
let t = resolved.type;
if (aliases?.compactTypes) {
t = {
string: 'str',
integer: 'int',
number: 'num',
boolean: 'bool',
object: '{}',
}[resolved.type] || resolved.type;
}
if (resolved.nullable && !t.includes('|null')) {
t = `${t}|null`;
}
return t;
}
return schemaRefName(schema) || '';
}
function collectSchemaAliases(operations) {
const shapes = new Map();
const visit = (schema, markOptional) => {
const resolved = resolveSchema(schema, spec.components);
if (!resolved) return;
if (resolved.type === 'array') {
visit(resolved.items, markOptional);
return;
}
if (resolved.type !== 'object' && !resolved.properties) return;
const shape = summarizeSchema(resolved, spec.components, 0, markOptional);
const current = shapes.get(shape);
if (current) {
current.count += 1;
} else {
shapes.set(shape, { count: 1, shape, schema: resolved, markOptional });
}
for (const prop of Object.values(resolved.properties || {})) {
visit(prop, markOptional);
}
};
for (const { operation } of operations) {
for (const param of operation.parameters || []) visit(param.schema, false);
const content = operation.requestBody?.content || {};
visit(content['application/json']?.schema, true);
visit(content['multipart/form-data']?.schema, true);
for (const [code, response] of Object.entries(operation.responses || {})) {
if (code.startsWith('2')) {
for (const media of Object.values(response.content || {})) {
visit(media.schema, false);
}
}
}
}
let candidates = [...shapes.values()]
.filter((item) => item.count >= 3 && item.shape.length >= 36)
.sort((a, b) => b.shape.length - a.shape.length || a.shape.localeCompare(b.shape));
const isShort = (item, rendered) => {
const propCount = Object.keys(item.schema.properties || {}).length;
if (propCount <= 1) return true;
if (rendered.length < 36) return true;
return false;
};
while (true) {
const aliases = new Map();
aliases.compactTypes = true;
for (let i = 0; i < candidates.length; i++) {
aliases.set(candidates[i].shape, `T${i + 1}`);
}
const filtered = [];
let droppedAny = false;
for (const item of candidates) {
const rendered = summarizeSchema(item.schema, spec.components, 0, item.markOptional, aliases, true);
if (isShort(item, rendered)) {
droppedAny = true;
} else {
filtered.push(item);
}
}
candidates = filtered;
if (!droppedAny) break;
}
const aliases = new Map();
aliases.compactTypes = true;
const definitions = [];
for (const item of candidates) {
const alias = `T${definitions.length + 1}`;
aliases.set(item.shape, alias);
definitions.push({ alias, ...item });
}
return { aliases, definitions };
}
/**
* Always-visible type for a path/query parameter, so declarations are uniform.
* Falls back to `string` only when the parameter schema omits a type entirely.
* @param {object} param
* @returns {string}
*/
function paramType(param, aliases = null) {
return summarizeSchema(param?.schema, spec.components, 0, false, aliases) || (aliases?.compactTypes ? 'str' : 'string');
}
function summarizeOperationParams(operation, aliases = null) {
const parts = [];
const params = operation.parameters || [];
const pathParams = [];
const queryParams = [];
for (const p of params) {
if (p.in === 'header' || p.in === 'cookie') continue;
const isOpt = !p.required;
const optSuffix = isOpt ? '?' : '';
if (p.in === 'path') {
pathParams.push(`${p.name}: ${paramType(p, aliases)}`);
} else if (p.in === 'query') {
queryParams.push(`${p.name}${optSuffix}: ${paramType(p, aliases)}`);
}
}
if (pathParams.length) {
parts.push(...pathParams);
}
if (queryParams.length) {
parts.push(`query: ${queryParams.join(', ')}`);
}
if (operation.requestBody?.content) {
const isBodyRequired = operation.requestBody.required === true;
const bodyPrefix = isBodyRequired ? 'body' : 'body?';
const content = operation.requestBody.content;
if (content['application/json']?.schema) {
const s = content['application/json'].schema;
const sub = summarizeSchema(s, spec.components, 0, true, aliases);
parts.push(`${bodyPrefix}: ${sub || 'json'}`);
} else if (content['multipart/form-data']) {
const formSchema = content['multipart/form-data'].schema;
const formSub = summarizeSchema(formSchema, spec.components, 0, true, aliases);
parts.push(`form: ${formSub || 'multipart'}`);
} else {
const ct = Object.keys(content)[0];
parts.push(isBodyRequired ? ct : `${ct}?`);
}
}
return parts.length ? parts.join(', ') : '—';
}
function summarizeOperationReturns(operation, aliases = null) {
const responses = operation.responses || {};
const successCodes = Object.keys(responses)
.filter((c) => c.startsWith('2'))
.sort();
if (!successCodes.length) return '—';
const parts = [];
for (const code of successCodes) {
const resp = responses[code];
if (code === '204') {
parts.push('204 void');
continue;
}
if (!resp?.content) {
parts.push(code);
continue;
}
const ct = Object.keys(resp.content)[0];
if (ct === 'text/event-stream') {
parts.push(`${code} SSE`);
continue;
}
if (ct.startsWith('image/')) {
parts.push(`${code} ${ct.split('/')[1]}`);
continue;
}
if (ct === 'application/zip') {
parts.push(`${code} zip`);
continue;
}
const s = resp.content[ct]?.schema;
const sub = summarizeSchema(s, spec.components, 0, false, aliases);
if (sub) {
parts.push(`${code} ${sub}`);
} else {
parts.push(code);
}
}
return parts.join(', ');
}
function renderRoutesOverview(operations, title = 'Dash API Routes Overview') {
const { aliases, definitions } = collectSchemaAliases(operations);
const lines = [];
lines.push(`# ${title}`);
lines.push('');
lines.push(`Overview of all ${operations.length} API endpoints across all modules.`);
lines.push('');
lines.push('### Notation');
lines.push('');
lines.push('Types: `str` string, `int` integer, `num` number, `bool` boolean; `T[]` array; `?` optional.');
lines.push('Params: path values first, then `query:` and `body:` (`body?` = optional body). Responses: status code + JSON shape; `Tn` refers to a reusable type below.');
if (definitions.length) {
lines.push('');
lines.push('### Reusable types');
lines.push('');
for (const def of definitions) {
const shape = summarizeSchema(def.schema, spec.components, 0, def.markOptional, aliases, true);
lines.push(`- \`${def.alias}\` = \`${shape}\``);
}
}
lines.push('');
lines.push('### Routes');
lines.push('');
let currentSection = null;
for (const op of operations) {
const section = op.path.split('/').filter(Boolean)[0] || '/';
if (section !== currentSection) {
currentSection = section;
lines.push('');
lines.push(`### Section: ${section === '/' ? '/' : `/${section}`}`);
lines.push('');
}
const params = summarizeOperationParams(op.operation, aliases);
const returns = summarizeOperationReturns(op.operation, aliases);
const hasSseResponse = Object.values(op.operation.responses || {}).some((response) =>
Object.hasOwn(response.content || {}, 'text/event-stream'),
);
const method = op.method.toUpperCase();
const summary = (op.summary || '').replace(/[\r\n]+/g, ' ').trim();
const heading = summary ? `${summary} — \`${method} ${op.path}\`` : `\`${method} ${op.path}\``;
lines.push(`#### ${heading}`);
lines.push(params === '—' ? '_none_' : `- Params: \`${params}\``);
lines.push(returns === '—' ? '_no response body_' : `- Returns: \`${returns}\``);
const sseEvents = op.operation['x-sse-events'];
if (hasSseResponse && Array.isArray(sseEvents) && sseEvents.length) {
lines.push('');
lines.push('- Events:');
for (const event of sseEvents) {
lines.push(` - \`${event.name}\`: \`${event.payload || '{}'}\``);
}
}
}
lines.push('');
return lines.join('\n');
}
/**
* Extra section appended to routes.md only. `all: false` renders it into the model-facing
* overview; routes-all.md stays a complete listing with no behavioural advice.
*/
function renderJsFetchSection(allOperations) {
const lines = [];
lines.push('### Binary & streaming routes (reachable only via `fetch()` in javascript_execute)');
lines.push('');
lines.push(
'These routes are NOT in the table above because their payloads (binary request body or binary ' +
'response) or long-lived streams cannot be handled usefully by the `dashback_request` tool. They ARE ' +
'reachable from inside `javascript_execute` with `fetch("/route")`:',
);
lines.push('');
lines.push('| Method | Route | Returns | Notes |');
lines.push('|:---|:---|:---|:---|');
lines.push(
'| `GET` | `/image/{fullHash}` | `image/avif` | Full-size article/brand/category image by SHA-256 hash. ' +
'Use for inspecting or re-encoding an existing image.',
);
lines.push(
'| `GET` | `/thumb/{fullHash}` | `image/avif` | Thumb AVIF by SHA-256 hash — cheaper than the full image. ' +
'Takes the same `fullHash` as `/image/{fullHash}`.',
);
lines.push(
'| `POST` | `/serper/image` | `image/avif` | Crawl an image from a URL, auto-crop transparent/white borders. ' +
'Body: `{ url }`.',
);
lines.push(
'| `POST` | `/article/{sku}/image` | `201 { image: { picId, sort, full, thumb, hash, thumbHash } }` | ' +
'Upload full-size AVIF or WebP image for article SKU. WebP is auto-converted to AVIF. Body: raw binary (`image/avif` or `image/webp`).',
);
lines.push(
'| `POST` | `/attributevalue/{id}/image` | `201 { attributevalue: { id, fullHash, thumbW, thumbH, thumbHash, pics } }` | ' +
'Upload or replace AVIF/WebP image for attribute value ID. Body: raw binary (`image/avif` or `image/webp`).',
);
lines.push(
'| `POST` | `/brand/{id}/image` | `201 { brand: { id, fullHash, thumbW, thumbH, thumbHash, pics } }` | ' +
'Upload or replace AVIF/WebP image for brand ID. Body: raw binary (`image/avif` or `image/webp`).',
);
lines.push(
'| `POST` | `/category/{id}/image` | `201 { category: { id, thumbW, thumbH, thumbHash } }` | ' +
'Upload or replace AVIF/WebP image for category ID. Body: raw binary (`image/avif` or `image/webp`).',
);
lines.push('');
lines.push('Save downloaded bytes to the VFS, then embed them in your reply as `![title](artifacts/thumb.avif)`:');
lines.push('');
lines.push('```js');
lines.push('module.exports = (async () => {');
lines.push(' const hash = "…64-char sha-256…";');
lines.push(' const res = await fetch(`/thumb/${hash}`); // authenticated automatically');
lines.push(' const buf = Buffer.from(await res.arrayBuffer());');
lines.push(' fs.writeFileSync("artifacts/thumb.avif", buf);');
lines.push(' return { bytes: buf.length };');
lines.push('})();');
lines.push('```');
lines.push('');
lines.push('Upload an image from the VFS:');
lines.push('');
lines.push('```js');
lines.push('module.exports = (async () => {');
lines.push(' const buf = fs.readFileSync("images/product.avif");');
lines.push(' const res = await fetch("/article/SKU123/image", {');
lines.push(' method: "POST",');
lines.push(' headers: { "Content-Type": "image/avif" },');
lines.push(' body: buf,');
lines.push(' });');
lines.push(' return await res.json();');
lines.push('})();');
lines.push('```');
lines.push('');
lines.push(
'`fullHash` appears on articles, brands and categories. `thumbHash` is a base64 blur-up placeholder, ' +
'not a content hash, and is not accepted here.',
);
lines.push('');
return lines.join('\n');
}
// --- generate ---
ensureDir(docsDir);
rmDirContents(routesDocsDir);
ensureDir(routesDocsDir);
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, '/'),
summary: operation.summary || '',
tags: operation.tags || [],
operation,
omittedFromOverview: isRouteOmitted(method, apiPath),
});
}
}
const overviewOperations = operations.filter((op) => !op.omittedFromOverview);
fs.writeFileSync(
path.join(docsDir, 'routes.md'),
renderRoutesOverview(overviewOperations) + renderJsFetchSection(operations)
);
fs.writeFileSync(path.join(docsDir, 'routes-all.md'), renderRoutesOverview(operations, 'Dash API Routes Overview (All)'));
fs.writeFileSync(path.join(docsDir, 'README.md'), renderReadme(operations));
console.log(`Wrote ${operations.length} route docs + ${overviewOperations.length} in routes.md + ${operations.length} in routes-all.md → docs/api/`);
console.log('No openapi.json written — the interactive docs at /docs/ build it from source at runtime.');