This commit is contained in:
seb
2026-10-04 23:39:49 +02:00
parent 9feca54a0b
commit ab40a38a75
10 changed files with 1217 additions and 865 deletions

View File

@@ -518,23 +518,34 @@ function renderReadme(operations) {
* @param {boolean} [markOptional] suffix non-required props with `?` (request bodies)
* @returns {string}
*/
function summarizeSchema(schema, components = spec.components, depth = 0, markOptional = false) {
function summarizeSchema(
schema,
components = spec.components,
depth = 0,
markOptional = false,
aliases = null,
skipRootAlias = false,
) {
if (!schema) return '';
if (depth > 6) return 'object';
if (depth > 6) return aliases?.compactTypes ? 'obj' : '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) || 'any';
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 required = new Set(resolved.required || []);
let entries = Object.entries(resolved.properties || {});
if (entries.length === 0) return 'object';
if (entries.length === 0) return aliases?.compactTypes ? 'obj' : 'object';
// Required props first, then optional ones (stable within each group).
if (markOptional) {
@@ -546,27 +557,91 @@ function summarizeSchema(schema, components = spec.components, depth = 0, markOp
const parts = entries.map(([name, prop]) => {
const opt = markOptional && !required.has(name) ? '?' : '';
const type = summarizeSchema(prop, components, depth + 1, markOptional) || 'any';
const type = summarizeSchema(prop, components, depth + 1, markOptional, aliases) || 'any';
return `${name}${opt}: ${type}`;
});
return `{ ${parts.join(', ')} }`;
}
if (resolved.type) return resolved.type;
if (resolved.type) {
if (aliases?.compactTypes) {
return {
string: 'str',
integer: 'int',
number: 'num',
boolean: 'bool',
object: 'obj',
}[resolved.type] || resolved.type;
}
return resolved.type;
}
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) {
return summarizeSchema(param?.schema, spec.components) || 'string';
function paramType(param, aliases = null) {
return summarizeSchema(param?.schema, spec.components, 0, false, aliases) || 'string';
}
function summarizeOperationParams(operation) {
function summarizeOperationParams(operation, aliases = null) {
const parts = [];
const params = operation.parameters || [];
const pathParams = [];
@@ -577,9 +652,9 @@ function summarizeOperationParams(operation) {
const isOpt = !p.required;
const optSuffix = isOpt ? '?' : '';
if (p.in === 'path') {
pathParams.push(`${p.name}: ${paramType(p)}`);
pathParams.push(`${p.name}: ${paramType(p, aliases)}`);
} else if (p.in === 'query') {
queryParams.push(`${p.name}${optSuffix}: ${paramType(p)}`);
queryParams.push(`${p.name}${optSuffix}: ${paramType(p, aliases)}`);
}
}
@@ -596,11 +671,11 @@ function summarizeOperationParams(operation) {
const content = operation.requestBody.content;
if (content['application/json']?.schema) {
const s = content['application/json'].schema;
const sub = summarizeSchema(s, spec.components, 0, true);
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);
const formSub = summarizeSchema(formSchema, spec.components, 0, true, aliases);
parts.push(`form: ${formSub || 'multipart'}`);
} else {
const ct = Object.keys(content)[0];
@@ -611,7 +686,7 @@ function summarizeOperationParams(operation) {
return parts.length ? parts.join(', ') : '—';
}
function summarizeOperationReturns(operation) {
function summarizeOperationReturns(operation, aliases = null) {
const responses = operation.responses || {};
const successCodes = Object.keys(responses)
.filter((c) => c.startsWith('2'))
@@ -643,7 +718,7 @@ function summarizeOperationReturns(operation) {
continue;
}
const s = resp.content[ct]?.schema;
const sub = summarizeSchema(s);
const sub = summarizeSchema(s, spec.components, 0, false, aliases);
if (sub) {
parts.push(`${code} ${sub}`);
} else {
@@ -654,30 +729,37 @@ function summarizeOperationReturns(operation) {
}
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('### Parameter Notation');
lines.push('### Notation');
lines.push('');
lines.push('- **Every value carries its type** — `string`, `integer`, `number`, `boolean`, `object`, arrays as `type[]`.');
lines.push('- **Required parameters** have no `?` (e.g. `sku: string`, `id: integer`, `query: si: string`, `body: { name: string }`).');
lines.push('- **Optional parameters** are marked with `?` (e.g. `query: limit?: integer`, `body: { sort?: integer }`, `body?: { … }`).');
lines.push('- **Path parameters** match `{param}` in the route URL.');
lines.push('- **Query parameters** are prefixed with `query: ...`.');
lines.push('- **Request bodies** are prefixed with `body: ...` (or `body?: ...` if the whole body is optional).');
lines.push('- **Response objects** use the same notation, so `201 { article: { … } }` means a JSON object with an `article` property.');
lines.push('Types: `str` string, `int` integer, `num` number, `bool` boolean, `obj` object; `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('');
for (const op of operations) {
const params = summarizeOperationParams(op.operation, aliases);
const returns = summarizeOperationReturns(op.operation, aliases);
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(op.params === '—' ? '_none_' : `- Params: \`${op.params}\``);
lines.push(op.returns === '—' ? '_no response body_' : `- Returns: \`${op.returns}\``);
lines.push(params === '—' ? '_none_' : `- Params: \`${params}\``);
lines.push(returns === '—' ? '_no response body_' : `- Returns: \`${returns}\``);
}
lines.push('');
return lines.join('\n');
@@ -756,8 +838,7 @@ for (const apiPath of paths) {
file: relFile.replace(/\\/g, '/'),
summary: operation.summary || '',
tags: operation.tags || [],
params: summarizeOperationParams(operation),
returns: summarizeOperationReturns(operation),
operation,
omittedFromOverview: isRouteOmitted(method, apiPath),
});
}