This commit is contained in:
seb
2026-09-30 19:29:13 +02:00
parent d6fe6ff1d2
commit e358eccca9
80 changed files with 1185 additions and 225 deletions

View File

@@ -82,6 +82,29 @@ function schemaRefName(schema) {
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
@@ -152,6 +175,33 @@ function contentSchema(content) {
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} |`);
}
lines.push('');
return lines;
}
function renderRequestBody(requestBody) {
if (!requestBody?.content) return null;
const lines = [];
@@ -159,8 +209,13 @@ function renderRequestBody(requestBody) {
const jsonSchema = contentSchema(requestBody.content);
if (jsonSchema) {
lines.push('## Body (JSON)');
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('');
}
@@ -436,55 +491,125 @@ function renderReadme(operations) {
return lines.join('\n');
}
function summarizeSchemaCompact(schema) {
if (!schema) return '';
if (schema.$ref) return schemaRefName(schema);
if (schema.type === 'array') {
const item = summarizeSchemaCompact(schema.items) || 'any';
function summarizeSchemaCompact(schema, components = spec.components, depth = 0) {
if (!schema || depth > 4) return '';
const resolved = resolveSchema(schema, components);
if (!resolved) return schemaRefName(schema) || '';
if (resolved.type === 'array') {
const item = summarizeSchemaCompact(resolved.items, components, depth + 1) || 'any';
return `${item}[]`;
}
if (schema.type === 'object' || schema.properties) {
const props = Object.keys(schema.properties || {});
if (props.length === 0) return 'object';
if (resolved.type === 'object' || resolved.properties) {
const props = Object.keys(resolved.properties || {});
if (props.length === 0) return schemaRefName(schema) || 'object';
if (props.length === 1) {
const k = props[0];
const v = summarizeSchemaCompact(schema.properties[k]);
const v = summarizeSchemaCompact(resolved.properties[k], components, depth + 1);
return v ? `{ ${k}: ${v} }` : `{ ${k} }`;
}
if (props.length <= 3) return `{ ${props.join(', ')} }`;
return `{ ${props.slice(0, 3).join(', ')}, … }`;
const inner = props.map((k) => {
const sub = resolved.properties[k];
if (sub && sub.type === 'array') {
const subItem = summarizeSchemaCompact(sub.items, components, depth + 1) || 'any';
return `${k}: ${subItem}[]`;
}
return k;
});
return `{ ${inner.join(', ')} }`;
}
if (schema.type) return schema.type;
return '';
if (resolved.type) return resolved.type;
return schemaRefName(schema) || '';
}
function summarizeParamSchema(schema, components) {
if (!schema) return '';
const resolved = resolveSchema(schema, components);
if (!resolved) return schemaRefName(schema) || '';
if (resolved.type === 'array') {
const item = summarizeParamSchema(resolved.items, components) || 'any';
return `${item}[]`;
}
if (resolved.type === 'object' || resolved.properties) {
const required = new Set(resolved.required || []);
const props = Object.keys(resolved.properties || {});
if (props.length === 0) {
return schemaRefName(schema) || 'object';
}
// Prioritize required props before optional props
const sortedProps = [...props].sort((a, b) => {
const reqA = required.has(a) ? 0 : 1;
const reqB = required.has(b) ? 0 : 1;
return reqA - reqB;
});
const formattedProps = sortedProps.map((k) => {
const isOpt = !required.has(k);
const propSchema = resolved.properties[k];
const optSuffix = isOpt ? '?' : '';
if (propSchema?.type === 'array') {
const subItem = summarizeParamSchema(propSchema.items, components) || 'any';
return `${k}${optSuffix}: ${subItem}[]`;
}
if (propSchema?.type === 'object' && propSchema.properties) {
const sub = summarizeParamSchema(propSchema, components);
return `${k}${optSuffix}: ${sub}`;
}
if (sortedProps.length === 1 && propSchema?.type) {
return `${k}${optSuffix}: ${propSchema.type}`;
}
return `${k}${optSuffix}`;
});
return `{ ${formattedProps.join(', ')} }`;
}
if (resolved.type) return resolved.type;
return schemaRefName(schema) || '';
}
function summarizeOperationParams(operation) {
const parts = [];
const params = operation.parameters || [];
const pathParams = [];
const queryParams = [];
for (const p of params) {
if (p.in === 'header') continue;
const prefix = p.in === 'query' ? '?' : '';
if (p.in === 'header' || p.in === 'cookie') continue;
const type = p.schema?.type ? `:${p.schema.type}` : '';
parts.push(`${prefix}${p.name}${type}`);
const isOpt = !p.required;
const optSuffix = isOpt ? '?' : '';
if (p.in === 'path') {
pathParams.push(`${p.name}${type}`);
} else if (p.in === 'query') {
queryParams.push(`${p.name}${optSuffix}${type}`);
}
}
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 ref = schemaRefName(s);
if (ref) {
parts.push(`body: ${ref}`);
} else {
const sub = summarizeSchemaCompact(s);
parts.push(sub ? `body: ${sub}` : 'body: json');
}
const sub = summarizeParamSchema(s, spec.components);
parts.push(`${bodyPrefix}: ${sub || 'json'}`);
} else if (content['multipart/form-data']) {
const props = Object.keys(content['multipart/form-data'].schema?.properties || {});
parts.push(props.length ? `form: { ${props.join(', ')} }` : 'form-data');
const formSchema = content['multipart/form-data'].schema;
const formSub = summarizeParamSchema(formSchema, spec.components);
parts.push(`form: ${formSub || 'multipart'}`);
} else {
const ct = Object.keys(content)[0];
parts.push(ct);
parts.push(isBodyRequired ? ct : `${ct}?`);
}
}
@@ -539,14 +664,22 @@ function renderRoutesOverview(operations) {
lines.push('');
lines.push(`Overview of all ${operations.length} API endpoints across all modules.`);
lines.push('');
lines.push('| Method | Route | Params | Return Type | Summary | Docs |');
lines.push('|:---|:---|:---|:---|:---|:---|');
lines.push('### Parameter Notation');
lines.push('');
lines.push('- **Required parameters** have no `?` (e.g. `sku:string`, `categoryId`, `query: si:string`, `body: { name }`).');
lines.push('- **Optional parameters** are marked with `?` (e.g. `query: categoryId?:integer`, `track_stock?`, `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 optional).');
lines.push('');
lines.push('| Method | Route | Params | Return Type | Summary |');
lines.push('|:---|:---|:---|:---|:---|');
for (const op of operations) {
const method = op.method.toUpperCase();
const summary = (op.summary || '').replace(/[\r\n|]+/g, ' ').trim();
const paramsStr = op.params === '—' ? '—' : `\`${op.params.replace(/\|/g, '\\|')}\``;
const returnsStr = op.returns === '—' ? '—' : `\`${op.returns.replace(/\|/g, '\\|')}\``;
lines.push(`| \`${method}\` | \`${op.path}\` | ${paramsStr} | ${returnsStr} | ${summary} | [\`routes/${op.file}\`](routes/${op.file}) |`);
lines.push(`| \`${method}\` | \`${op.path}\` | ${paramsStr} | ${returnsStr} | ${summary} |`);
}
lines.push('');
return lines.join('\n');