add the missing OpenAPI generator (scripts/generate-openapi.ts + scripts/openapi) so a clean checkout builds
This commit is contained in:
3 files changed
+1099
No files matched your search
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Assembles the final OpenAPI 3.0.3 document from discovered routes +
|
||||
* static analysis of every handler + (optional, additive) annotations.
|
||||
* Shared by `generate` and `--check` (check needs the assembled spec to
|
||||
* hand to the validators).
|
||||
*
|
||||
* Layering, cheapest to most precise:
|
||||
* 1. Auto-base — path/method/tag/auth (always, from the route tree +
|
||||
* hooks.server.ts) PLUS parameters/responses/requestBody
|
||||
* derived from static analysis of the handler's own code
|
||||
* (query params via url.searchParams, status codes via
|
||||
* status:/error(), body fields via destructuring).
|
||||
* 2. JSDoc override — an `@openapi` block on the handler replaces/extends
|
||||
* the auto values with hand-authored summaries, exact
|
||||
* types, examples, and precise descriptions.
|
||||
* An un-annotated handler is therefore NOT a bare stub anymore — it shows
|
||||
* every parameter, status code, and body field the static analysis could
|
||||
* find in its own source, just with generic descriptions instead of
|
||||
* hand-written ones.
|
||||
*/
|
||||
|
||||
import {
|
||||
type DiscoveredRoute,
|
||||
type HandlerAnnotation,
|
||||
type HttpMethod,
|
||||
type StaticAnalysis,
|
||||
analyzeHandlerBody,
|
||||
miniSchemaToOpenApi,
|
||||
splitMethodBodies,
|
||||
synthesizeExample
|
||||
} from './lib';
|
||||
|
||||
export interface BuildSpecInput {
|
||||
routes: DiscoveredRoute[];
|
||||
fileContents: Map<string, string>;
|
||||
annotationsByPath: Record<string, Partial<Record<HttpMethod, HandlerAnnotation>>>;
|
||||
publicPaths: string[];
|
||||
isPublicFn: (path: string) => boolean;
|
||||
version: string;
|
||||
}
|
||||
|
||||
function genericParamDescription(kind: 'path' | 'query', name: string): string {
|
||||
return `${kind === 'path' ? 'Path' : 'Query'} parameter "${name}" (auto-detected from the handler's source — no @openapi annotation for this parameter yet)`;
|
||||
}
|
||||
|
||||
export function buildSpec({ routes, fileContents, annotationsByPath, isPublicFn, version }: BuildSpecInput) {
|
||||
const paths: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
// Track auto-enrichment stats for the generator's own report (parameters
|
||||
// beyond path, responses beyond a single 200, or a requestBody — any of
|
||||
// which the static analysis contributed without a human writing a line).
|
||||
let autoParamsCount = 0;
|
||||
let autoMultiResponseCount = 0;
|
||||
let autoBodyCount = 0;
|
||||
|
||||
for (const route of routes) {
|
||||
const pathItem = (paths[route.openapiPath] ??= {});
|
||||
const security = isPublicFn(route.openapiPath) ? [] : [{ cookieAuth: [] }, { bearerAuth: [] }];
|
||||
const content = fileContents.get(route.filePath);
|
||||
const bodies = content ? splitMethodBodies(content) : {};
|
||||
|
||||
for (const method of route.methods) {
|
||||
const operationId = `${method.toLowerCase()}_${route.openapiPath.replace(/^\//, '').replace(/[{}]/g, '').replace(/\//g, '_')}`;
|
||||
const annotation = annotationsByPath[route.openapiPath]?.[method];
|
||||
const methodBody = bodies[method];
|
||||
const analysis: StaticAnalysis = methodBody
|
||||
? analyzeHandlerBody(methodBody, route.pathParams)
|
||||
: { queryParams: [], pathParamTypes: {}, statusCodes: [], bodyFields: [] };
|
||||
|
||||
// --- parameters: auto path + auto query, JSDoc enriches/adds -------
|
||||
const pathParamAnnotations = annotation?.path ?? {};
|
||||
const parameters: Record<string, unknown>[] = route.pathParams.map((p) => {
|
||||
const enrich = pathParamAnnotations[p];
|
||||
const autoType = analysis.pathParamTypes[p]; // only set when inferred as non-string
|
||||
return {
|
||||
name: p,
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: enrich?.type ?? autoType ?? 'string' },
|
||||
description: enrich?.description || genericParamDescription('path', p)
|
||||
};
|
||||
});
|
||||
|
||||
// Query params: union of auto-detected (from code) and annotated
|
||||
// (JSDoc can also document a query param the static analysis missed,
|
||||
// e.g. a dynamically-built key). Annotation wins when both exist.
|
||||
const annotatedQueryNames = new Set(Object.keys(annotation?.query ?? {}));
|
||||
for (const q of analysis.queryParams) {
|
||||
if (annotatedQueryNames.has(q.name)) continue; // annotation takes precedence, added below
|
||||
parameters.push({
|
||||
name: q.name,
|
||||
in: 'query',
|
||||
required: false, // static analysis can't safely tell required vs optional for query params
|
||||
schema: { type: q.type },
|
||||
description: genericParamDescription('query', q.name)
|
||||
});
|
||||
}
|
||||
if (annotation) {
|
||||
for (const [name, q] of Object.entries(annotation.query)) {
|
||||
parameters.push({ name, in: 'query', required: q.required, schema: { type: q.type }, description: q.description });
|
||||
}
|
||||
}
|
||||
if (parameters.length > route.pathParams.length) autoParamsCount++;
|
||||
|
||||
// --- responses: auto status codes, JSDoc enriches/adds ------------
|
||||
const responses: Record<string, Record<string, unknown>> = {};
|
||||
const annotatedCodes = new Set(Object.keys(annotation?.responses ?? {}));
|
||||
for (const code of analysis.statusCodes) {
|
||||
if (annotatedCodes.has(code)) continue; // annotation takes precedence, added below
|
||||
responses[code] = {
|
||||
description: code.startsWith('2')
|
||||
? 'Successful response (auto-detected status code — no @openapi annotation for this response yet)'
|
||||
: 'Error response (auto-detected status code — no @openapi annotation for this response yet)'
|
||||
};
|
||||
}
|
||||
if (annotation) {
|
||||
for (const [code, resp] of Object.entries(annotation.responses)) {
|
||||
const entry: Record<string, unknown> = { description: resp.description };
|
||||
if (resp.schema) {
|
||||
const example = resp.example ?? synthesizeExample(resp.schema);
|
||||
entry.content = { 'application/json': { schema: miniSchemaToOpenApi(resp.schema), example } };
|
||||
}
|
||||
responses[code] = entry;
|
||||
}
|
||||
}
|
||||
const hasSuccessResponse = Object.keys(responses).some((code) => code.startsWith('2'));
|
||||
if (!hasSuccessResponse) {
|
||||
// Neither static analysis nor annotation found an explicit 2xx —
|
||||
// every SvelteKit handler that doesn't throw returns *some*
|
||||
// success response, so default to 200 rather than omit it.
|
||||
responses['200'] = { description: 'Successful response' };
|
||||
}
|
||||
if (Object.keys(responses).length > 1) autoMultiResponseCount++;
|
||||
|
||||
// --- requestBody: auto body fields (POST/PUT/PATCH only), JSDoc enriches/replaces ---
|
||||
let requestBody: Record<string, unknown> | undefined;
|
||||
if (annotation?.body) {
|
||||
const example = annotation.bodyExample ?? synthesizeExample(annotation.body);
|
||||
requestBody = {
|
||||
required: true,
|
||||
content: { 'application/json': { schema: miniSchemaToOpenApi(annotation.body), example } }
|
||||
};
|
||||
} else if (['POST', 'PUT', 'PATCH'].includes(method) && analysis.bodyFields.length > 0) {
|
||||
const properties = Object.fromEntries(analysis.bodyFields.map((f) => [f, { type: 'string' }]));
|
||||
requestBody = {
|
||||
required: false, // static analysis can't tell which destructured fields are actually required
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties,
|
||||
description:
|
||||
'Fields auto-detected from the handler’s destructuring of the request body — generic string type, no @openapi annotation for this body yet.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
if (requestBody) autoBodyCount++;
|
||||
|
||||
const operation: Record<string, unknown> = {
|
||||
operationId,
|
||||
tags: [route.tag],
|
||||
summary: annotation?.summary ?? `${method} ${route.openapiPath} (auto-generated — no @openapi annotation yet)`,
|
||||
parameters,
|
||||
responses,
|
||||
security
|
||||
};
|
||||
if (annotation?.description) operation.description = annotation.description;
|
||||
if (requestBody) operation.requestBody = requestBody;
|
||||
|
||||
pathItem[method.toLowerCase()] = operation;
|
||||
}
|
||||
}
|
||||
|
||||
const spec = {
|
||||
openapi: '3.0.3',
|
||||
info: {
|
||||
title: 'Dockhand API',
|
||||
version,
|
||||
description:
|
||||
'Auto-generated from src/routes/**/+server.ts by scripts/generate-openapi.ts. ' +
|
||||
'Path, HTTP method, tag, and auth requirement are derived automatically from the ' +
|
||||
'route tree and hooks.server.ts PUBLIC_PATHS. Parameters, response status codes, and ' +
|
||||
'request body fields are additionally auto-detected from each handler’s own source ' +
|
||||
'(query params via url.searchParams, status codes via status:/error(), body fields via ' +
|
||||
'destructuring) — adding a new endpoint therefore requires ZERO manual spec edits to show ' +
|
||||
'up with real params/responses. An optional, additive `@openapi` JSDoc annotation on a ' +
|
||||
'handler replaces the generic auto-descriptions with hand-written summaries, exact types, ' +
|
||||
'and examples.'
|
||||
},
|
||||
servers: [{ url: '/' }],
|
||||
tags: Array.from(new Set(routes.map((r) => r.tag)))
|
||||
.sort()
|
||||
.map((t) => ({ name: t })),
|
||||
components: {
|
||||
securitySchemes: {
|
||||
cookieAuth: {
|
||||
type: 'apiKey',
|
||||
in: 'cookie',
|
||||
name: 'dockhand_session',
|
||||
description: 'Session cookie set on login (src/lib/server/auth.ts validateSession).'
|
||||
},
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'dh_<43-char base64url>',
|
||||
description:
|
||||
'User-scoped API token (src/lib/server/api-tokens.ts). Only evaluated on /api/* and ' +
|
||||
'/metrics when no session cookie is present (src/hooks.server.ts). Rate-limited: ' +
|
||||
'10 failures/IP -> 429 for 5 minutes.'
|
||||
}
|
||||
}
|
||||
},
|
||||
security: [{ cookieAuth: [] }, { bearerAuth: [] }],
|
||||
paths
|
||||
};
|
||||
|
||||
return { spec, stats: { autoParamsCount, autoMultiResponseCount, autoBodyCount } };
|
||||
}
|
||||
Reference in new issue
Block a user