/** * 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 /smallthumb/{fullHash}', 'GET /thumb/{fullHash}', 'GET /v1/category', 'GET /v1/cimage', 'GET /v1/client', 'GET /v1/customer', 'GET /v1/customergroup', 'GET /v1/deletedentity', 'GET /v1/init', 'POST /v1/order', '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; } if (apiPath === '/agent' || apiPath.startsWith('/agent/')) { return true; } if (apiPath === '/agentprinter' || apiPath.startsWith('/agentprinter/')) { return true; } if (apiPath === '/printer' || apiPath.startsWith('/printer/')) { return true; } if (apiPath === '/printeragent' || apiPath.startsWith('/printeragent/')) { return true; } if (apiPath === '/printerjob' || apiPath.startsWith('/printerjob/')) { return true; } if (apiPath === '/smallthumb' || apiPath.startsWith('/smallthumb/')) { return true; } if (apiPath === '/v1' || apiPath.startsWith('/v1/')) { return true; } if (apiPath === '/health' || apiPath.startsWith('/health/')) { return true; } if (apiPath === '/subagentschedule' || apiPath.startsWith('/subagentschedule/')) { return true; } if (apiPath === '/zpljob' || apiPath.startsWith('/zpljob/')) { 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} */ 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.');