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,334 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* OpenAPI 3.0 generator / CI-checker / annotation-scaffolder for Dockhand.
|
||||||
|
*
|
||||||
|
* Runs under plain Node via tsx (no Bun-specific APIs) — see the
|
||||||
|
* "generate:openapi"/"generate:openapi:check" scripts in package.json.
|
||||||
|
*
|
||||||
|
* Three modes, one engine (scripts/openapi/{lib,build-spec}.ts):
|
||||||
|
*
|
||||||
|
* npx tsx scripts/generate-openapi.ts -> writes static/openapi.json
|
||||||
|
* npx tsx scripts/generate-openapi.ts --check -> CI gate, exits non-zero on drift
|
||||||
|
* npx tsx scripts/generate-openapi.ts --check --strict-coverage
|
||||||
|
* -> also fails on <100% @openapi coverage
|
||||||
|
* npx tsx scripts/generate-openapi.ts --scaffold <file> -> prints a code-grounded JSDoc draft
|
||||||
|
* for every un-annotated handler in <file>
|
||||||
|
*
|
||||||
|
* Path/method/tag/auth are derived automatically from the SvelteKit route
|
||||||
|
* tree (src/routes/**\/+server.ts, ANY depth — not just src/routes/api,
|
||||||
|
* this also picks up src/routes/metrics and src/routes/audit* for free) and
|
||||||
|
* from hooks.server.ts PUBLIC_PATHS. Adding a new endpoint costs ZERO manual
|
||||||
|
* spec edits for those fields. Request/response schemas + examples come from
|
||||||
|
* an optional, additive `@openapi` JSDoc block — see scripts/openapi/lib.ts
|
||||||
|
* for the full grammar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'node:fs';
|
||||||
|
import { dirname, join, relative } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import {
|
||||||
|
type HandlerAnnotation,
|
||||||
|
type HttpMethod,
|
||||||
|
analyzeHandlerBody,
|
||||||
|
countRawMarkers,
|
||||||
|
discoverRoutes,
|
||||||
|
extractPublicPaths,
|
||||||
|
isPublic,
|
||||||
|
parseAnnotations,
|
||||||
|
splitMethodBodies
|
||||||
|
} from './openapi/lib';
|
||||||
|
import { buildSpec } from './openapi/build-spec';
|
||||||
|
|
||||||
|
// `import.meta.dir` is Bun-only; this script runs under plain Node via tsx,
|
||||||
|
// so derive the script directory the portable way.
|
||||||
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT_DIR = join(SCRIPT_DIR, '..');
|
||||||
|
const ROUTES_ROOT = join(ROOT_DIR, 'src', 'routes');
|
||||||
|
const HOOKS_FILE = join(ROOT_DIR, 'src', 'hooks.server.ts');
|
||||||
|
const PACKAGE_JSON = join(ROOT_DIR, 'package.json');
|
||||||
|
// Bundled as an importable module (src/lib) rather than read from disk at
|
||||||
|
// runtime — see src/routes/api/docs/+server.ts. The production Docker image
|
||||||
|
// only copies build/, not static/, so a static/-only spec would 404 in the
|
||||||
|
// container even though it exists in the repo/build client assets.
|
||||||
|
const LIB_OUT_FILE = join(ROOT_DIR, 'src', 'lib', 'openapi.generated.json');
|
||||||
|
// Also written to static/ so it's copied into build/client/ verbatim by the
|
||||||
|
// SvelteKit build (useful for direct static-asset access / debugging), even
|
||||||
|
// though /api/docs no longer reads from this path.
|
||||||
|
const STATIC_OUT_FILE = join(ROOT_DIR, 'static', 'openapi.json');
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const mode = args.includes('--check') ? 'check' : args.includes('--scaffold') ? 'scaffold' : 'generate';
|
||||||
|
const strictCoverage = args.includes('--strict-coverage');
|
||||||
|
|
||||||
|
function loadEverything() {
|
||||||
|
const { routes, skipped, fileContents } = discoverRoutes([ROUTES_ROOT], ROUTES_ROOT);
|
||||||
|
const publicPaths = extractPublicPaths(HOOKS_FILE);
|
||||||
|
const isPublicFn = (p: string) => isPublic(p, publicPaths);
|
||||||
|
|
||||||
|
const annotationsByPath: Record<string, Partial<Record<HttpMethod, HandlerAnnotation>>> = {};
|
||||||
|
const orphanMarkers: { filePath: string; rawCount: number; matchedCount: number }[] = [];
|
||||||
|
|
||||||
|
for (const route of routes) {
|
||||||
|
const content = fileContents.get(route.filePath) ?? readFileSync(route.filePath, 'utf-8');
|
||||||
|
const annotations = parseAnnotations(content);
|
||||||
|
if (Object.keys(annotations).length > 0) annotationsByPath[route.openapiPath] = annotations;
|
||||||
|
|
||||||
|
const rawCount = countRawMarkers(content);
|
||||||
|
const matchedCount = Object.keys(annotations).length;
|
||||||
|
if (rawCount !== matchedCount) orphanMarkers.push({ filePath: route.filePath, rawCount, matchedCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkg = JSON.parse(readFileSync(PACKAGE_JSON, 'utf-8'));
|
||||||
|
|
||||||
|
return { routes, skipped, fileContents, publicPaths, isPublicFn, annotationsByPath, orphanMarkers, version: pkg.version ?? '0.0.0' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// generate
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Self-hosted Scalar API Reference — the interactive docs viewer at
|
||||||
|
// GET /api/docs/ui, with built-in multi-language request code samples.
|
||||||
|
// Vendor the standalone browser bundle so it never depends on a CDN
|
||||||
|
// (secret-safe, offline-friendly, works behind an airgapped/internal
|
||||||
|
// Dockhand deployment too).
|
||||||
|
const SCALAR_ASSETS = ['standalone.js'];
|
||||||
|
|
||||||
|
function copyScalarAssets() {
|
||||||
|
const srcDir = join(ROOT_DIR, 'node_modules', '@scalar', 'api-reference', 'dist', 'browser');
|
||||||
|
const destDir = join(ROOT_DIR, 'static', 'scalar');
|
||||||
|
if (!existsSync(srcDir)) {
|
||||||
|
console.warn('@scalar/api-reference not found in node_modules — skipping asset copy (run `npm install` first)');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
mkdirSync(destDir, { recursive: true });
|
||||||
|
for (const asset of SCALAR_ASSETS) {
|
||||||
|
copyFileSync(join(srcDir, asset), join(destDir, asset));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes the spec to both the importable src/lib/ location (what
|
||||||
|
// /api/docs actually serves — bundled into the built server, works
|
||||||
|
// regardless of whether static/ ships in the runtime image) and to
|
||||||
|
// static/ (copied verbatim into build/client/ by the SvelteKit build;
|
||||||
|
// kept for direct static-asset access / debugging parity with before).
|
||||||
|
function writeSpecOutputs(spec: unknown) {
|
||||||
|
const json = JSON.stringify(spec, null, 2);
|
||||||
|
mkdirSync(dirname(LIB_OUT_FILE), { recursive: true });
|
||||||
|
writeFileSync(LIB_OUT_FILE, json);
|
||||||
|
mkdirSync(dirname(STATIC_OUT_FILE), { recursive: true });
|
||||||
|
writeFileSync(STATIC_OUT_FILE, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runGenerate() {
|
||||||
|
const { routes, skipped, fileContents, publicPaths, isPublicFn, annotationsByPath, version } = loadEverything();
|
||||||
|
const { spec, stats } = buildSpec({ routes, fileContents, annotationsByPath, publicPaths, isPublicFn, version });
|
||||||
|
|
||||||
|
writeSpecOutputs(spec);
|
||||||
|
const scalarAssetsCopied = copyScalarAssets();
|
||||||
|
|
||||||
|
const totalOperations = Object.values(spec.paths).reduce((sum, item: any) => sum + Object.keys(item).length, 0);
|
||||||
|
const annotatedHandlerCount = Object.values(annotationsByPath).reduce((sum, m) => sum + Object.keys(m).length, 0);
|
||||||
|
|
||||||
|
console.log(`\n=== OpenAPI Generator ===`);
|
||||||
|
console.log(`Route files walked: ${routes.length + skipped.length}`);
|
||||||
|
console.log(`Routes with methods found: ${routes.length}`);
|
||||||
|
console.log(`Unique OpenAPI paths: ${Object.keys(spec.paths).length}`);
|
||||||
|
console.log(`Total operations: ${totalOperations}`);
|
||||||
|
console.log(`Tags discovered: ${spec.tags.length}`);
|
||||||
|
console.log(`Public (no-auth) paths: ${routes.filter((r) => isPublicFn(r.openapiPath)).length}`);
|
||||||
|
console.log(`Annotated handlers: ${annotatedHandlerCount} of ${totalOperations} (${((annotatedHandlerCount / totalOperations) * 100).toFixed(1)}%)`);
|
||||||
|
console.log(`Operations with parameters (auto+annotated): ${stats.autoParamsCount} of ${totalOperations}`);
|
||||||
|
console.log(`Operations with >1 response (auto+annotated): ${stats.autoMultiResponseCount} of ${totalOperations}`);
|
||||||
|
console.log(`Operations with a requestBody (auto+annotated): ${stats.autoBodyCount} of ${totalOperations}`);
|
||||||
|
console.log(`Skipped files (no method): ${skipped.length}`);
|
||||||
|
for (const s of skipped) console.log(` - ${relative(ROOT_DIR, s.filePath)}: ${s.reason}`);
|
||||||
|
console.log(`Scalar assets copied: ${scalarAssetsCopied ? 'yes (static/scalar/)' : 'SKIPPED (see warning above)'}`);
|
||||||
|
console.log(`\nOutput written to: ${relative(ROOT_DIR, LIB_OUT_FILE)} (served by /api/docs) and ${relative(ROOT_DIR, STATIC_OUT_FILE)} (static asset copy)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// --check (6 gates)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function runCheck(): number {
|
||||||
|
const { routes, publicPaths, isPublicFn, annotationsByPath, orphanMarkers, fileContents, version } = loadEverything();
|
||||||
|
const { spec } = buildSpec({ routes, fileContents, annotationsByPath, publicPaths, isPublicFn, version });
|
||||||
|
const totalOperations = Object.values(spec.paths).reduce((sum, item: any) => sum + Object.keys(item).length, 0);
|
||||||
|
const annotatedHandlerCount = Object.values(annotationsByPath).reduce((sum, m) => sum + Object.keys(m).length, 0);
|
||||||
|
|
||||||
|
let hardFailures = 0;
|
||||||
|
const report: string[] = [];
|
||||||
|
|
||||||
|
// Gate 1: coverage (soft by default)
|
||||||
|
const coveragePct = (annotatedHandlerCount / totalOperations) * 100;
|
||||||
|
report.push(`[Gate 1] Coverage: ${annotatedHandlerCount}/${totalOperations} handlers annotated (${coveragePct.toFixed(1)}%)`);
|
||||||
|
if (strictCoverage && coveragePct < 100) {
|
||||||
|
report.push(` HARD FAIL (--strict-coverage): coverage below 100%`);
|
||||||
|
hardFailures++;
|
||||||
|
} else if (coveragePct < 100) {
|
||||||
|
report.push(` (warning only — pass --strict-coverage to make this a hard gate)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate 2: path-param consistency (annotation path: name must exist in route.pathParams)
|
||||||
|
let pathParamIssues = 0;
|
||||||
|
for (const route of routes) {
|
||||||
|
const perMethod = annotationsByPath[route.openapiPath];
|
||||||
|
if (!perMethod) continue;
|
||||||
|
for (const [method, ann] of Object.entries(perMethod)) {
|
||||||
|
for (const name of Object.keys(ann.path)) {
|
||||||
|
if (!route.pathParams.includes(name)) {
|
||||||
|
report.push(` [Gate 2] ${relative(ROOT_DIR, route.filePath)} ${method} ${route.openapiPath}: annotation "path: ${name}" does not match any {${name}} in the route path`);
|
||||||
|
pathParamIssues++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report.splice(report.length - pathParamIssues, 0, `[Gate 2] Path-param consistency: ${pathParamIssues} issue(s)`);
|
||||||
|
if (pathParamIssues > 0) hardFailures++;
|
||||||
|
|
||||||
|
// Gates 3+4: query-param drift, status-code drift (only for annotated handlers)
|
||||||
|
let queryDrift = 0;
|
||||||
|
let statusDrift = 0;
|
||||||
|
const driftLines: string[] = [];
|
||||||
|
for (const route of routes) {
|
||||||
|
const perMethod = annotationsByPath[route.openapiPath];
|
||||||
|
if (!perMethod) continue;
|
||||||
|
const content = fileContents.get(route.filePath) ?? readFileSync(route.filePath, 'utf-8');
|
||||||
|
const bodies = splitMethodBodies(content);
|
||||||
|
for (const [method, ann] of Object.entries(perMethod) as [HttpMethod, HandlerAnnotation][]) {
|
||||||
|
const body = bodies[method];
|
||||||
|
if (!body) continue;
|
||||||
|
const analysis = analyzeHandlerBody(body, route.pathParams);
|
||||||
|
|
||||||
|
const docQuery = new Set(Object.keys(ann.query));
|
||||||
|
const codeQuery = new Set(analysis.queryParams.map((q) => q.name));
|
||||||
|
for (const q of codeQuery) {
|
||||||
|
if (!docQuery.has(q)) {
|
||||||
|
driftLines.push(` [Gate 3] ${relative(ROOT_DIR, route.filePath)} ${method} ${route.openapiPath}: query param "${q}" used in code but not documented`);
|
||||||
|
queryDrift++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const q of docQuery) {
|
||||||
|
if (!codeQuery.has(q)) {
|
||||||
|
driftLines.push(` [Gate 3] ${relative(ROOT_DIR, route.filePath)} ${method} ${route.openapiPath}: query param "${q}" documented but not found in code (stale?)`);
|
||||||
|
queryDrift++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const docCodes = new Set(Object.keys(ann.responses));
|
||||||
|
const codeCodes = new Set(analysis.statusCodes.filter((c) => c !== '200'));
|
||||||
|
for (const c of codeCodes) {
|
||||||
|
if (!docCodes.has(c)) {
|
||||||
|
driftLines.push(` [Gate 4] ${relative(ROOT_DIR, route.filePath)} ${method} ${route.openapiPath}: status ${c} used in code but not documented`);
|
||||||
|
statusDrift++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const c of docCodes) {
|
||||||
|
if (c !== '200' && !codeCodes.has(c)) {
|
||||||
|
driftLines.push(` [Gate 4] ${relative(ROOT_DIR, route.filePath)} ${method} ${route.openapiPath}: response ${c} documented but no matching status(${c})/error(${c}) found in code (stale?)`);
|
||||||
|
statusDrift++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report.push(`[Gate 3] Query-param drift: ${queryDrift} issue(s)`);
|
||||||
|
report.push(`[Gate 4] Status-code drift: ${statusDrift} issue(s)`);
|
||||||
|
report.push(...driftLines);
|
||||||
|
if (queryDrift > 0) hardFailures++;
|
||||||
|
if (statusDrift > 0) hardFailures++;
|
||||||
|
|
||||||
|
// Gate 5: orphan JSDoc (a @openapi marker that didn't attach to an export)
|
||||||
|
report.push(`[Gate 5] Orphan @openapi blocks: ${orphanMarkers.length} file(s)`);
|
||||||
|
for (const o of orphanMarkers) {
|
||||||
|
report.push(` ${relative(ROOT_DIR, o.filePath)}: ${o.rawCount} @openapi marker(s) found, only ${o.matchedCount} attached to an export`);
|
||||||
|
}
|
||||||
|
if (orphanMarkers.length > 0) hardFailures++;
|
||||||
|
|
||||||
|
// Gate 6: spec validity (write both outputs, lint the static copy)
|
||||||
|
writeSpecOutputs(spec);
|
||||||
|
let validatorsOk = true;
|
||||||
|
try {
|
||||||
|
execFileSync('npx', ['--yes', '@redocly/cli@2.43.1', '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}`);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
console.log(`\nHard failures: ${hardFailures}`);
|
||||||
|
return hardFailures > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// --scaffold <file>
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function runScaffold() {
|
||||||
|
const idx = args.indexOf('--scaffold');
|
||||||
|
const target = args[idx + 1];
|
||||||
|
if (!target) {
|
||||||
|
console.error('Usage: generate-openapi.ts --scaffold <path/to/+server.ts>');
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filePath = target.startsWith('/') ? target : join(ROOT_DIR, target);
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
console.error(`File not found: ${filePath}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
const existing = parseAnnotations(content);
|
||||||
|
const bodies = splitMethodBodies(content);
|
||||||
|
|
||||||
|
const { routes } = discoverRoutes([ROUTES_ROOT], ROUTES_ROOT);
|
||||||
|
const route = routes.find((r) => r.filePath === filePath);
|
||||||
|
|
||||||
|
console.log(`# Scaffold draft for ${relative(ROOT_DIR, filePath)}`);
|
||||||
|
console.log(`# (code-grounded from static analysis — review and refine before committing)\n`);
|
||||||
|
|
||||||
|
for (const [method, body] of Object.entries(bodies)) {
|
||||||
|
if (existing[method as HttpMethod]) {
|
||||||
|
console.log(`## ${method}: already has an @openapi annotation, skipped\n`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const analysis = analyzeHandlerBody(body, route?.pathParams ?? []);
|
||||||
|
console.log(`## ${method} ${route?.openapiPath ?? '(path unknown)'}\n`);
|
||||||
|
console.log('/**');
|
||||||
|
console.log(' * @openapi');
|
||||||
|
console.log(' * summary: TODO — one-line description');
|
||||||
|
for (const q of analysis.queryParams) {
|
||||||
|
console.log(` * query: ${q.name}:${q.type} TODO — description (verify required/type)`);
|
||||||
|
}
|
||||||
|
if (analysis.bodyFields.length > 0) {
|
||||||
|
console.log(` * body: {${analysis.bodyFields.map((f) => `${f}:string`).join(', ')}} # TODO: verify types + required (!) markers`);
|
||||||
|
}
|
||||||
|
const codes = analysis.statusCodes.length > 0 ? analysis.statusCodes : ['200'];
|
||||||
|
for (const c of codes) {
|
||||||
|
console.log(` * resp-${c}: TODO — description${c === '200' ? ' (or a {field:type} schema)' : ''}`);
|
||||||
|
}
|
||||||
|
console.log(' */');
|
||||||
|
console.log(`export const ${method}: RequestHandler = ...\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// entrypoint
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (mode === 'check') {
|
||||||
|
process.exitCode = runCheck();
|
||||||
|
} else if (mode === 'scaffold') {
|
||||||
|
runScaffold();
|
||||||
|
} else {
|
||||||
|
runGenerate();
|
||||||
|
}
|
||||||
@@ -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 } };
|
||||||
|
}
|
||||||
@@ -0,0 +1,545 @@
|
|||||||
|
/**
|
||||||
|
* OpenAPI generation — shared engine.
|
||||||
|
*
|
||||||
|
* Used by `scripts/generate-openapi.ts` in three modes (generate / --check /
|
||||||
|
* --scaffold). Everything here is pure, dependency-free (plain node:fs +
|
||||||
|
* node:path) — no swagger-jsdoc (CJS-only, the thing PR #816 avoided), no
|
||||||
|
* AST framework. A regex/string-scan approach is sufficient because this
|
||||||
|
* codebase is 100% consistent in its `export const METHOD:` convention
|
||||||
|
* (verified during Phase A: 0 exceptions across 232 route files).
|
||||||
|
*
|
||||||
|
* Three responsibilities, kept separate so --check and --scaffold can reuse
|
||||||
|
* exactly what `generate` uses (no drift between "what the spec says" and
|
||||||
|
* "what the checker validates against"):
|
||||||
|
*
|
||||||
|
* 1. Route discovery — filesystem walk -> path/method/tag (ZERO manual steps)
|
||||||
|
* 2. Annotation parsing — optional @openapi JSDoc blocks -> rich operations
|
||||||
|
* 3. Static analysis — best-effort extraction of query params / status
|
||||||
|
* codes / body fields ACTUALLY used in a handler's
|
||||||
|
* source, so --check can flag drift between code
|
||||||
|
* and annotation, and --scaffold can propose a
|
||||||
|
* code-grounded starting annotation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||||
|
import { join, relative, dirname } from 'node:path';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD'] as const;
|
||||||
|
export type HttpMethod = (typeof HTTP_METHODS)[number];
|
||||||
|
|
||||||
|
export interface DiscoveredRoute {
|
||||||
|
filePath: string; // ABSOLUTE path — callers relativize for display as needed
|
||||||
|
openapiPath: string; // e.g. /api/stacks/{name}/env
|
||||||
|
pathParams: string[]; // e.g. ['name']
|
||||||
|
methods: HttpMethod[];
|
||||||
|
tag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MiniSchema =
|
||||||
|
| { kind: 'string' | 'integer' | 'number' | 'boolean' }
|
||||||
|
| { kind: 'object'; properties: Record<string, MiniSchema>; required: string[] }
|
||||||
|
| { kind: 'array'; items: MiniSchema };
|
||||||
|
|
||||||
|
export interface ParamAnnotation {
|
||||||
|
type: string;
|
||||||
|
required: boolean;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseAnnotation {
|
||||||
|
schema?: MiniSchema;
|
||||||
|
description: string;
|
||||||
|
example?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HandlerAnnotation {
|
||||||
|
summary?: string;
|
||||||
|
/** Optional long-form explanation (OpenAPI operation.description) — use for
|
||||||
|
* context the one-line summary can't carry: purpose, read-only/write
|
||||||
|
* semantics, or where a related write operation lives. Markdown-safe. */
|
||||||
|
description?: string;
|
||||||
|
query: Record<string, ParamAnnotation>;
|
||||||
|
path: Record<string, ParamAnnotation>;
|
||||||
|
body?: MiniSchema;
|
||||||
|
bodyExample?: unknown;
|
||||||
|
responses: Record<string, ResponseAnnotation>;
|
||||||
|
/** Raw block text, kept only for --check's orphan-block diagnostics. */
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InferredScalarType = 'string' | 'integer' | 'boolean';
|
||||||
|
|
||||||
|
export interface QueryParamAnalysis {
|
||||||
|
name: string;
|
||||||
|
type: InferredScalarType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StaticAnalysis {
|
||||||
|
queryParams: QueryParamAnalysis[]; // deduped, sorted by name, with inferred scalar type
|
||||||
|
pathParamTypes: Record<string, InferredScalarType>; // inferred from parseInt(params.X)/Number(params.X) — string params not in here default to 'string'
|
||||||
|
statusCodes: string[]; // deduped, sorted, as strings ("200", "403", ...)
|
||||||
|
bodyFields: string[]; // deduped, sorted, top-level destructured field names
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1. Route discovery
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function walkServerFiles(rootDirs: string[]): string[] {
|
||||||
|
const acc: string[] = [];
|
||||||
|
function walk(dir: string) {
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = readdirSync(dir);
|
||||||
|
} catch {
|
||||||
|
return; // root dir may not exist (e.g. optional feature dirs) — skip silently
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = join(dir, entry);
|
||||||
|
const st = statSync(full);
|
||||||
|
if (st.isDirectory()) walk(full);
|
||||||
|
else if (entry === '+server.ts') acc.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const root of rootDirs) walk(root);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toOpenApiPath(serverFile: string, routesRoot: string): { path: string; params: string[] } {
|
||||||
|
const rel = relative(routesRoot, dirname(serverFile));
|
||||||
|
const segments = rel.split('/').filter(Boolean);
|
||||||
|
const params: string[] = [];
|
||||||
|
const converted = segments.map((seg) => {
|
||||||
|
const m = seg.match(/^\[(\.\.\.)?([^\]=]+?)(=.*)?\]$/);
|
||||||
|
if (m) {
|
||||||
|
const isRest = !!m[1];
|
||||||
|
const name = m[2];
|
||||||
|
params.push(name);
|
||||||
|
return isRest ? `{${name}*}` : `{${name}}`;
|
||||||
|
}
|
||||||
|
return seg;
|
||||||
|
});
|
||||||
|
return { path: '/' + converted.join('/'), params };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveTag(openapiPath: string): string {
|
||||||
|
const segments = openapiPath.split('/').filter(Boolean);
|
||||||
|
if (segments[0] === 'api') return segments[1] ?? 'root';
|
||||||
|
return segments[0] ?? 'root';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMethods(content: string): HttpMethod[] {
|
||||||
|
const found: HttpMethod[] = [];
|
||||||
|
for (const m of HTTP_METHODS) {
|
||||||
|
if (new RegExp(`^export const ${m}\\s*:`, 'm').test(content)) found.push(m);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rough per-method body isolation: from `export const METHOD:` to the next
|
||||||
|
* `export const <OTHER_METHOD>:` (or EOF). Not a real parser — good enough
|
||||||
|
* for the static-analysis heuristics below, which only need "roughly the
|
||||||
|
* right chunk of source", not a compiled AST.
|
||||||
|
*/
|
||||||
|
export function splitMethodBodies(content: string): Partial<Record<HttpMethod, string>> {
|
||||||
|
const positions: { method: HttpMethod; start: number }[] = [];
|
||||||
|
for (const m of HTTP_METHODS) {
|
||||||
|
const re = new RegExp(`^export const ${m}\\s*:`, 'm');
|
||||||
|
const match = re.exec(content);
|
||||||
|
if (match) positions.push({ method: m, start: match.index! });
|
||||||
|
}
|
||||||
|
positions.sort((a, b) => a.start - b.start);
|
||||||
|
const result: Partial<Record<HttpMethod, string>> = {};
|
||||||
|
for (let idx = 0; idx < positions.length; idx++) {
|
||||||
|
const start = positions[idx].start;
|
||||||
|
const end = idx + 1 < positions.length ? positions[idx + 1].start : content.length;
|
||||||
|
result[positions[idx].method] = content.slice(start, end);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discoverRoutes(rootDirs: string[], routesRoot: string): { routes: DiscoveredRoute[]; skipped: { filePath: string; reason: string }[]; fileContents: Map<string, string> } {
|
||||||
|
const routes: DiscoveredRoute[] = [];
|
||||||
|
const skipped: { filePath: string; reason: string }[] = [];
|
||||||
|
const fileContents = new Map<string, string>();
|
||||||
|
const files = walkServerFiles(rootDirs);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const content = readFileSync(file, 'utf-8');
|
||||||
|
fileContents.set(file, content);
|
||||||
|
const methods = extractMethods(content);
|
||||||
|
if (methods.length === 0) {
|
||||||
|
skipped.push({ filePath: file, reason: 'no export const <METHOD>: match found' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { path, params } = toOpenApiPath(file, routesRoot);
|
||||||
|
routes.push({ filePath: file, openapiPath: path, pathParams: params, methods, tag: deriveTag(path) });
|
||||||
|
}
|
||||||
|
return { routes, skipped, fileContents };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2. Annotation parsing — @openapi JSDoc blocks with a compact typed mini-DSL
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// /**
|
||||||
|
// * @openapi
|
||||||
|
// * summary: <text>
|
||||||
|
// * description: <text> (optional; longer explanation
|
||||||
|
// * than summary — purpose,
|
||||||
|
// * read-only/write semantics,
|
||||||
|
// * pointer to a related endpoint)
|
||||||
|
// * query: <name>:<type>[!] <description> (repeatable)
|
||||||
|
// * path: <name>:<type>[!] <description> (repeatable; enriches an
|
||||||
|
// * auto-detected {name} segment)
|
||||||
|
// * body: <mini-schema>
|
||||||
|
// * body-example: <json>
|
||||||
|
// * resp-<code>: <mini-schema> OR resp-<code>: <plain text>
|
||||||
|
// * resp-<code>-desc: <text>
|
||||||
|
// * resp-<code>-example: <json>
|
||||||
|
// */
|
||||||
|
//
|
||||||
|
// mini-schema := 'string'|'integer'|'number'|'boolean' | '{' (name':'type'!'?','?)* '}' | 'array<' type '>'
|
||||||
|
|
||||||
|
export function parseMiniSchema(str: string): MiniSchema {
|
||||||
|
let i = 0;
|
||||||
|
const s = str;
|
||||||
|
|
||||||
|
function skipWs() {
|
||||||
|
while (i < s.length && /\s/.test(s[i])) i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseType(): MiniSchema {
|
||||||
|
skipWs();
|
||||||
|
if (s[i] === '{') return parseObject();
|
||||||
|
if (s.slice(i, i + 6) === 'array<') {
|
||||||
|
i += 6;
|
||||||
|
const items = parseType();
|
||||||
|
skipWs();
|
||||||
|
if (s[i] === '>') i++;
|
||||||
|
return { kind: 'array', items };
|
||||||
|
}
|
||||||
|
const start = i;
|
||||||
|
while (i < s.length && /[a-zA-Z0-9_]/.test(s[i])) i++;
|
||||||
|
const word = s.slice(start, i) || 'string';
|
||||||
|
if (word === 'integer' || word === 'number' || word === 'boolean' || word === 'string') return { kind: word };
|
||||||
|
return { kind: 'string' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObject(): MiniSchema {
|
||||||
|
i++; // '{'
|
||||||
|
const properties: Record<string, MiniSchema> = {};
|
||||||
|
const required: string[] = [];
|
||||||
|
skipWs();
|
||||||
|
while (i < s.length && s[i] !== '}') {
|
||||||
|
skipWs();
|
||||||
|
const nameStart = i;
|
||||||
|
while (i < s.length && /[a-zA-Z0-9_]/.test(s[i])) i++;
|
||||||
|
const name = s.slice(nameStart, i);
|
||||||
|
skipWs();
|
||||||
|
if (s[i] === ':') i++;
|
||||||
|
const type = parseType();
|
||||||
|
skipWs();
|
||||||
|
let isRequired = false;
|
||||||
|
if (s[i] === '!') {
|
||||||
|
isRequired = true;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (name) {
|
||||||
|
properties[name] = type;
|
||||||
|
if (isRequired) required.push(name);
|
||||||
|
}
|
||||||
|
skipWs();
|
||||||
|
if (s[i] === ',') {
|
||||||
|
i++;
|
||||||
|
skipWs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (s[i] === '}') i++;
|
||||||
|
return { kind: 'object', properties, required };
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseType();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function miniSchemaToOpenApi(schema: MiniSchema): Record<string, unknown> {
|
||||||
|
switch (schema.kind) {
|
||||||
|
case 'object': {
|
||||||
|
const out: Record<string, unknown> = {
|
||||||
|
type: 'object',
|
||||||
|
properties: Object.fromEntries(Object.entries(schema.properties).map(([k, v]) => [k, miniSchemaToOpenApi(v)]))
|
||||||
|
};
|
||||||
|
if (schema.required.length > 0) out.required = schema.required;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
case 'array':
|
||||||
|
return { type: 'array', items: miniSchemaToOpenApi(schema.items) };
|
||||||
|
default:
|
||||||
|
return { type: schema.kind };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function synthesizeExample(schema: MiniSchema): unknown {
|
||||||
|
switch (schema.kind) {
|
||||||
|
case 'object':
|
||||||
|
return Object.fromEntries(Object.entries(schema.properties).map(([k, v]) => [k, synthesizeExample(v)]));
|
||||||
|
case 'array':
|
||||||
|
return [synthesizeExample(schema.items)];
|
||||||
|
case 'integer':
|
||||||
|
case 'number':
|
||||||
|
return 0;
|
||||||
|
case 'boolean':
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return 'string';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both halves use a negated-lookahead scan `(?:(?!\*\/)[\s\S])*?` instead of
|
||||||
|
// plain `[\s\S]*?` so the match can never cross a `*/` — i.e. it can only ever
|
||||||
|
// consume characters that belong to ONE comment block. Without this guard, a
|
||||||
|
// handler preceded by an earlier, unrelated `/** file-header */` JSDoc comment
|
||||||
|
// (with no `@openapi` of its own) makes the lazy quantifier skip past that
|
||||||
|
// comment's own `*/` and swallow every line of CODE in between as if it were
|
||||||
|
// part of the annotation body — including any `key: value,` object-literal
|
||||||
|
// line (e.g. `description: sys.description,`) that happens to match a
|
||||||
|
// recognized annotation key. Real `/** ... */` comments can never contain a
|
||||||
|
// literal `*/` (that would end the comment in valid JS/TS), so this
|
||||||
|
// restriction never rejects a legitimate block.
|
||||||
|
const ANNOTATION_BLOCK_RE =
|
||||||
|
/\/\*\*((?:(?!\*\/)[\s\S])*?@openapi\b(?:(?!\*\/)[\s\S])*?)\*\/\s*export const (GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD)\s*:/g;
|
||||||
|
|
||||||
|
/** Every `@openapi` marker found in a file, regardless of whether it could be
|
||||||
|
* matched to a following `export const METHOD:` — used by --check's
|
||||||
|
* "orphan JSDoc" gate to catch a block that's present but detached (typo'd
|
||||||
|
* method, comment not directly above the export, etc). */
|
||||||
|
export function countRawMarkers(content: string): number {
|
||||||
|
return (content.match(/@openapi\b/g) ?? []).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAnnotations(content: string): Partial<Record<HttpMethod, HandlerAnnotation>> {
|
||||||
|
const result: Partial<Record<HttpMethod, HandlerAnnotation>> = {};
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
ANNOTATION_BLOCK_RE.lastIndex = 0;
|
||||||
|
while ((m = ANNOTATION_BLOCK_RE.exec(content)) !== null) {
|
||||||
|
const [raw, blockBody, method] = m;
|
||||||
|
const lines = blockBody
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.replace(/^\s*\*\s?/, '').trim())
|
||||||
|
.filter((l) => l && l !== '@openapi');
|
||||||
|
|
||||||
|
const annotation: HandlerAnnotation = { query: {}, path: {}, responses: {}, raw };
|
||||||
|
const respDescOverride: Record<string, string> = {};
|
||||||
|
const respExampleOverride: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const kv = line.match(/^([a-z0-9-]+)\s*:\s*(.+)$/i);
|
||||||
|
if (!kv) continue;
|
||||||
|
const [, key, rawValue] = kv;
|
||||||
|
|
||||||
|
if (key === 'summary') {
|
||||||
|
annotation.summary = rawValue;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === 'description') {
|
||||||
|
annotation.description = rawValue;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === 'query' || key === 'path') {
|
||||||
|
const pm = rawValue.match(/^([a-zA-Z0-9_]+)\s*:\s*([a-zA-Z0-9_]+)(!)?\s*(.*)$/);
|
||||||
|
if (pm) {
|
||||||
|
const [, name, type, required, description] = pm;
|
||||||
|
(annotation[key] as Record<string, ParamAnnotation>)[name] = {
|
||||||
|
type,
|
||||||
|
required: !!required,
|
||||||
|
description: description.trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === 'body') {
|
||||||
|
annotation.body = parseMiniSchema(rawValue);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === 'body-example') {
|
||||||
|
try {
|
||||||
|
annotation.bodyExample = JSON.parse(rawValue);
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed example */
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const respMatch = key.match(/^resp-(\d{3})(-desc|-example)?$/);
|
||||||
|
if (respMatch) {
|
||||||
|
const [, code, suffix] = respMatch;
|
||||||
|
if (suffix === '-desc') respDescOverride[code] = rawValue;
|
||||||
|
else if (suffix === '-example') {
|
||||||
|
try {
|
||||||
|
respExampleOverride[code] = JSON.parse(rawValue);
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed example */
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const looksLikeSchema = /^(\{|array<)/.test(rawValue.trim());
|
||||||
|
annotation.responses[code] = looksLikeSchema
|
||||||
|
? { schema: parseMiniSchema(rawValue), description: code === '200' ? 'Successful response' : 'Response' }
|
||||||
|
: { description: rawValue };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [code, desc] of Object.entries(respDescOverride)) {
|
||||||
|
annotation.responses[code] = { ...(annotation.responses[code] ?? { description: desc }), description: desc };
|
||||||
|
}
|
||||||
|
for (const [code, ex] of Object.entries(respExampleOverride)) {
|
||||||
|
annotation.responses[code] = { ...(annotation.responses[code] ?? { description: 'Response' }), example: ex };
|
||||||
|
}
|
||||||
|
|
||||||
|
result[method as HttpMethod] = annotation;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 3. Static analysis — code-grounded facts used by --check (drift) and
|
||||||
|
// --scaffold (starting point). Deliberately conservative: false negatives
|
||||||
|
// (missing a query param) are more likely than false positives, so this
|
||||||
|
// is a HELPER, not a source of truth — the annotation is still authored
|
||||||
|
// by a human who reads the handler.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function parseDestructuredFields(inner: string): string[] {
|
||||||
|
// Naive top-level split on commas. Doesn't handle nested destructuring
|
||||||
|
// (`{ a: { b } }`) correctly, but every body in this codebase destructures
|
||||||
|
// shallowly (verified via spot-check across the annotated + scaffold sets).
|
||||||
|
const fields: string[] = [];
|
||||||
|
let depth = 0;
|
||||||
|
let current = '';
|
||||||
|
for (const ch of inner) {
|
||||||
|
if (ch === '{' || ch === '[') depth++;
|
||||||
|
if (ch === '}' || ch === ']') depth--;
|
||||||
|
if (ch === ',' && depth === 0) {
|
||||||
|
fields.push(current);
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current.trim()) fields.push(current);
|
||||||
|
return fields
|
||||||
|
.map((f) => f.split(':')[0].split('=')[0].trim())
|
||||||
|
.filter((f) => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type inference is deliberately conservative: a query/path param defaults to
|
||||||
|
* 'string' unless the code contains an UNAMBIGUOUS, textually-local signal
|
||||||
|
* (parseInt/Number wrapping the same local variable or the same literal
|
||||||
|
* searchParams key, or a `=== 'true'`/`!== 'false'` boolean comparison).
|
||||||
|
* When in doubt this returns 'string' — never a guessed 'integer'/'boolean'
|
||||||
|
* that isn't backed by an actual pattern match in the source.
|
||||||
|
*/
|
||||||
|
function inferQueryParamType(body: string, key: string): InferredScalarType {
|
||||||
|
// Direct wrap: parseInt(url.searchParams.get('key')) / Number(url.searchParams.get('key'))
|
||||||
|
const directNumRe = new RegExp(`(?:parseInt|Number)\\(\\s*url\\.searchParams\\.get\\(\\s*['"]${key}['"]\\s*\\)`);
|
||||||
|
if (directNumRe.test(body)) return 'integer';
|
||||||
|
|
||||||
|
// Two-step: const NAME = url.searchParams.get('key'); ... parseInt(NAME) / Number(NAME)
|
||||||
|
const assignRe = new RegExp(`(?:const|let)\\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\\s*=\\s*url\\.searchParams\\.get\\(\\s*['"]${key}['"]\\s*\\)`);
|
||||||
|
const assignMatch = assignRe.exec(body);
|
||||||
|
if (assignMatch) {
|
||||||
|
const varName = assignMatch[1];
|
||||||
|
const numRe = new RegExp(`(?:parseInt|Number)\\(\\s*${varName}\\b`);
|
||||||
|
if (numRe.test(body)) return 'integer';
|
||||||
|
const boolRe = new RegExp(`\\b${varName}\\s*(?:===|!==)\\s*['"](?:true|false)['"]`);
|
||||||
|
if (boolRe.test(body)) return 'boolean';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct boolean comparison: url.searchParams.get('key') === 'true' / !== 'false'
|
||||||
|
const directBoolRe = new RegExp(`url\\.searchParams\\.get\\(\\s*['"]${key}['"]\\s*\\)\\s*(?:===|!==)\\s*['"](?:true|false)['"]`);
|
||||||
|
if (directBoolRe.test(body)) return 'boolean';
|
||||||
|
|
||||||
|
return 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferPathParamType(body: string, name: string): InferredScalarType {
|
||||||
|
const numRe = new RegExp(`(?:parseInt|Number)\\(\\s*params\\.${name}\\b`);
|
||||||
|
return numRe.test(body) ? 'integer' : 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function analyzeHandlerBody(body: string, pathParamNames: string[] = []): StaticAnalysis {
|
||||||
|
const queryKeys = new Set<string>();
|
||||||
|
const qRe = /url\.searchParams\.get(?:All)?\(\s*['"]([a-zA-Z0-9_]+)['"]\s*\)/g;
|
||||||
|
let qm;
|
||||||
|
while ((qm = qRe.exec(body)) !== null) queryKeys.add(qm[1]);
|
||||||
|
const queryParams: QueryParamAnalysis[] = [...queryKeys]
|
||||||
|
.sort()
|
||||||
|
.map((name) => ({ name, type: inferQueryParamType(body, name) }));
|
||||||
|
|
||||||
|
const pathParamTypes: Record<string, InferredScalarType> = {};
|
||||||
|
for (const name of pathParamNames) {
|
||||||
|
const type = inferPathParamType(body, name);
|
||||||
|
if (type !== 'string') pathParamTypes[name] = type; // only record non-default to keep the map small
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCodes = new Set<string>();
|
||||||
|
const statusRe = /\bstatus:\s*(\d{3})\b/g;
|
||||||
|
let sm;
|
||||||
|
while ((sm = statusRe.exec(body)) !== null) statusCodes.add(sm[1]);
|
||||||
|
const errorRe = /\berror\(\s*(\d{3})/g;
|
||||||
|
let em;
|
||||||
|
while ((em = errorRe.exec(body)) !== null) statusCodes.add(em[1]);
|
||||||
|
// SvelteKit `redirect(3xx, location)` — e.g. `throw redirect(302, '/login')`.
|
||||||
|
// Without this, an annotated resp-3xx on a redirect-only handler is flagged
|
||||||
|
// as stale drift by Gate 4, even though the code genuinely emits it.
|
||||||
|
const redirectRe = /\bredirect\(\s*(\d{3})/g;
|
||||||
|
let rm;
|
||||||
|
while ((rm = redirectRe.exec(body)) !== null) statusCodes.add(rm[1]);
|
||||||
|
|
||||||
|
const bodyFields = new Set<string>();
|
||||||
|
// `const { a, b } = await request.json();` and `const { a, b } = body;`
|
||||||
|
const destructureRe = /const\s*\{([^}]*)\}\s*=\s*(?:await\s+)?(?:request\.json\(\)|body)\b/g;
|
||||||
|
let dm;
|
||||||
|
while ((dm = destructureRe.exec(body)) !== null) {
|
||||||
|
for (const f of parseDestructuredFields(dm[1])) bodyFields.add(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
queryParams,
|
||||||
|
pathParamTypes,
|
||||||
|
statusCodes: [...statusCodes].sort(),
|
||||||
|
bodyFields: [...bodyFields].sort()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PUBLIC_PATHS extraction (from hooks.server.ts) — auth-exemption discovery
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function extractPublicPaths(hooksFile: string): string[] {
|
||||||
|
const content = readFileSync(hooksFile, 'utf-8');
|
||||||
|
const m = content.match(/const PUBLIC_PATHS\s*=\s*\[([\s\S]*?)\];/);
|
||||||
|
if (!m) return [];
|
||||||
|
const paths: string[] = [];
|
||||||
|
const strRe = /'([^']+)'/g;
|
||||||
|
let sm;
|
||||||
|
while ((sm = strRe.exec(m[1])) !== null) paths.push(sm[1]);
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two exceptions hardcoded directly in isPublicPath() as regexes (webhook
|
||||||
|
// signature/secret auth instead of session/token) — not expressible as a
|
||||||
|
// simple PUBLIC_PATHS prefix string. The one manual special-case this
|
||||||
|
// generator needs; documented in the research doc's coverage-gap section.
|
||||||
|
export const PUBLIC_PATH_REGEXES = [/^\/api\/git\/stacks\/\d+\/webhook$/, /^\/api\/git\/webhook\/\d+$/];
|
||||||
|
|
||||||
|
export function isPublic(openapiPath: string, publicPaths: string[]): boolean {
|
||||||
|
if (publicPaths.some((p) => openapiPath === p || openapiPath.startsWith(p + '/'))) return true;
|
||||||
|
const asConcretePath = openapiPath.replace(/\{[^}]+\}/g, '1');
|
||||||
|
return PUBLIC_PATH_REGEXES.some((re) => re.test(asConcretePath));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user