895 lines
34 KiB
JavaScript
895 lines
34 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',
|
|
'GET /auth/me',
|
|
'PUT /auth/selected-company',
|
|
'GET /category/export.zip',
|
|
'DELETE /company/{id}',
|
|
'POST /company',
|
|
'POST /category/{id}/image',
|
|
'POST /delivery/parse',
|
|
'GET /events',
|
|
'GET /health',
|
|
'GET /image/{fullHash}',
|
|
'POST /serper/image',
|
|
'POST /serper/images',
|
|
'GET /thumb/{fullHash}',
|
|
'GET /v1/category',
|
|
'GET /v1/client',
|
|
'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;
|
|
}
|
|
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',
|
|
];
|
|
|
|
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) || '';
|
|
|
|
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(`${markOptional ? 1 : 0}\0${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) {
|
|
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 key = `${markOptional ? 1 : 0}\0${shape}`;
|
|
const current = shapes.get(key);
|
|
if (current) {
|
|
current.count += 1;
|
|
} else {
|
|
shapes.set(key, { 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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const candidates = [...shapes.entries()]
|
|
.filter(([, item]) => item.count >= 3 && item.shape.length >= 36)
|
|
.sort((a, b) => b[1].shape.length - a[1].shape.length || a[0].localeCompare(b[0]));
|
|
const aliases = new Map();
|
|
const definitions = [];
|
|
for (const [key, item] of candidates) {
|
|
const alias = `T${definitions.length + 1}`;
|
|
aliases.set(key, alias);
|
|
definitions.push({ alias, ...item });
|
|
}
|
|
aliases.compactTypes = true;
|
|
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) || '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 responses are binary or long-lived ' +
|
|
'streams, which the `dashback_request` tool cannot return usefully. They ARE reachable from ' +
|
|
'inside `javascript_execute` with `fetch("/route")`, where you can decode the bytes, write ' +
|
|
'them to the VFS and embed them in your reply:',
|
|
);
|
|
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('');
|
|
lines.push('Save the bytes to the VFS, then embed them in your reply as ``:');
|
|
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(
|
|
'`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.');
|