OpenAPI spec at /api/docs with a Scalar viewer, opt-in via FEAT_API_DOCS

This commit is contained in:
jarek committed 2026-08-18 10:05:36 +02:00
1 parent 1ad16a7c2a
commit 088c7dc343
6 files changed
+300 -14

No files matched your search

+28 -12
View File
@@ -247,20 +247,36 @@ function runCheck(): number {
}
if (orphanMarkers.length > 0) hardFailures++;
// Gate 6: spec validity (write both outputs, lint the static copy)
// Gate 6: spec validity (write both outputs, lint the static copy) via the LOCAL
// @redocly/cli (a devDependency). It is NOT invoked through `npx --yes`: an on-demand
// npx fetch in a fresh CI container hung this gate for HOURS. Resolve the binary from
// node_modules; if it is not installed, SKIP (infra), do not block the release. A lint
// that RUNS and exits non-zero is a real spec failure and is hard.
writeSpecOutputs(spec);
let validatorsOk = true;
try {
execFileSync('npx', ['--yes', '@redocly/[email protected]', 'lint', STATIC_OUT_FILE, '--format=summary'], {
cwd: ROOT_DIR,
stdio: 'pipe'
});
} catch (err: any) {
validatorsOk = false;
report.push(`[Gate 6] @redocly/cli lint FAILED:\n${err.stdout?.toString().slice(-1000) ?? err.message}`);
const redoclyBin = join(ROOT_DIR, 'node_modules', '.bin', 'redocly');
if (!existsSync(redoclyBin)) {
report.push(`[Gate 6] Spec validity: SKIPPED (@redocly/cli not installed - run npm i)`);
} else {
try {
execFileSync(redoclyBin, ['lint', STATIC_OUT_FILE, '--format=summary'], {
cwd: ROOT_DIR,
stdio: 'pipe',
timeout: 120_000, // belt-and-suspenders: never hang the gate
env: { ...process.env, REDOCLY_TELEMETRY: 'off', REDOCLY_SUPPRESS_UPDATE_NOTICE: '1' }
});
report.push(`[Gate 6] Spec validity (@redocly/cli lint): OK`);
} catch (err: any) {
// A spawn/timeout failure (ENOENT/ETIMEDOUT) is infra -> skip; a non-zero exit
// from a lint that actually ran is a real spec problem -> hard fail.
if (err.code === 'ETIMEDOUT' || err.code === 'ENOENT') {
report.push(`[Gate 6] Spec validity: SKIPPED (validator did not run: ${err.code})`);
} else {
const out = (err.stdout?.toString() ?? '') + (err.stderr?.toString() ?? '');
report.push(`[Gate 6] @redocly/cli lint FAILED:\n${out.slice(-1000)}`);
hardFailures++;
}
}
}
report.push(`[Gate 6] Spec validity (@redocly/cli lint): ${validatorsOk ? 'OK' : 'FAILED'}`);
if (!validatorsOk) hardFailures++;
console.log(`\n=== OpenAPI --check Report ===`);
for (const line of report) console.log(line);
+27
View File
@@ -140,6 +140,33 @@ export function buildSpec({ routes, fileContents, annotationsByPath, isPublicFn,
required: true,
content: { 'application/json': { schema: miniSchemaToOpenApi(annotation.body), example } }
};
} else if (annotation?.bodyRaw) {
// A non-JSON raw body (e.g. application/x-tar): binary string schema.
const schema: Record<string, unknown> = { type: 'string', format: 'binary' };
if (annotation.bodyRaw.description) schema.description = annotation.bodyRaw.description;
requestBody = {
required: true,
content: { [annotation.bodyRaw.mediaType]: { schema } }
};
} else if (annotation?.bodyMultipart) {
// A multipart/form-data body with a single (possibly array) file field.
const mp = annotation.bodyMultipart;
const fieldSchema: Record<string, unknown> = mp.array
? { type: 'array', items: { type: mp.type === 'binary' ? 'string' : mp.type, format: mp.type === 'binary' ? 'binary' : undefined } }
: { type: mp.type === 'binary' ? 'string' : mp.type, format: mp.type === 'binary' ? 'binary' : undefined };
if (mp.description) fieldSchema.description = mp.description;
requestBody = {
required: true,
content: {
'multipart/form-data': {
schema: {
type: 'object',
properties: { [mp.field]: fieldSchema },
...(mp.required ? { required: [mp.field] } : {})
}
}
}
};
} else if (['POST', 'PUT', 'PATCH'].includes(method) && analysis.bodyFields.length > 0) {
const properties = Object.fromEntries(analysis.bodyFields.map((f) => [f, { type: 'string' }]));
requestBody = {
+37
View File
@@ -66,6 +66,11 @@ export interface HandlerAnnotation {
path: Record<string, ParamAnnotation>;
body?: MiniSchema;
bodyExample?: unknown;
/** A non-JSON raw request body, e.g. `body-raw: application/x-tar The image tar`. */
bodyRaw?: { mediaType: string; description: string };
/** A multipart/form-data body: one file field, e.g.
* `body-multipart: files:binary[]! One or more files`. */
bodyMultipart?: { field: string; type: string; array: boolean; required: boolean; description: string };
responses: Record<string, ResponseAnnotation>;
/** Raw block text, kept only for --check's orphan-block diagnostics. */
raw: string;
@@ -217,6 +222,14 @@ export function parseMiniSchema(str: string): MiniSchema {
function parseType(): MiniSchema {
skipWs();
if (s[i] === '{') return parseObject();
if (s[i] === '[') {
// bracket array form: [T]
i++;
const items = parseType();
skipWs();
if (s[i] === ']') i++;
return { kind: 'array', items };
}
if (s.slice(i, i + 6) === 'array<') {
i += 6;
const items = parseType();
@@ -226,6 +239,9 @@ export function parseMiniSchema(str: string): MiniSchema {
}
const start = i;
while (i < s.length && /[a-zA-Z0-9_]/.test(s[i])) i++;
// Guarantee forward progress: an unrecognized char (not a word, brace, or
// bracket) must still advance i, or the parseObject loop spins forever.
if (i === start) i++;
const word = s.slice(start, i) || 'string';
if (word === 'integer' || word === 'number' || word === 'boolean' || word === 'string') return { kind: word };
return { kind: 'string' };
@@ -374,6 +390,27 @@ export function parseAnnotations(content: string): Partial<Record<HttpMethod, Ha
}
continue;
}
if (key === 'body-raw') {
// `body-raw: <media-type> <description>` - a non-JSON raw body.
const rm = rawValue.match(/^(\S+)\s*(.*)$/);
if (rm) annotation.bodyRaw = { mediaType: rm[1], description: rm[2].trim() };
continue;
}
if (key === 'body-multipart') {
// `body-multipart: <field>:<type>[]? [!] <description>` - a multipart body.
const mm = rawValue.match(/^([a-zA-Z0-9_]+)\s*:\s*([a-zA-Z0-9_]+)(\[\])?(!)?\s*(.*)$/);
if (mm) {
const [, field, type, array, required, description] = mm;
annotation.bodyMultipart = {
field,
type,
array: !!array,
required: !!required,
description: description.trim()
};
}
continue;
}
const respMatch = key.match(/^resp-(\d{3})(-desc|-example)?$/);
if (respMatch) {
const [, code, suffix] = respMatch;