/** * OpenAPI 3.0 spec generator from observed network traffic. * * Takes the ApiEndpointObservationInput[] collected by the network observer * during crawl and generates a minimal but valid OpenAPI 3.0 spec. * * The spec captures: endpoints, methods, observed response shapes, status codes. * It does NOT capture request bodies (we'd need to intercept requests too) — that's * marked as a TODO. But it's enough for generating smoke-test suites. */ import type { ApiEndpointObservationInput } from './network-observer.js'; export interface OpenApiSpec { openapi: '3.0.3'; info: { title: string; version: string; description?: string }; servers: Array<{ url: string }>; paths: Record>; } interface OpenApiOperation { summary: string; parameters?: OpenApiParam[]; responses: Record }>; tags?: string[]; } interface OpenApiParam { name: string; in: 'path' | 'query'; required: boolean; schema: { type: string }; } function extractPathParams(urlPattern: string): OpenApiParam[] { const params: OpenApiParam[] = []; const matches = urlPattern.matchAll(/\{(\w+)\}/g); for (const m of matches) { params.push({ name: m[1], in: 'path', required: true, schema: { type: 'string' } }); } return params; } function methodSummary(method: string, path: string): string { const resource = path.split('/').filter(s => !s.startsWith('{') && s.length > 0).pop() ?? 'resource'; const summaries: Record = { GET: `Get ${resource}`, POST: `Create ${resource}`, PUT: `Update ${resource}`, PATCH: `Partially update ${resource}`, DELETE: `Delete ${resource}`, }; return summaries[method.toUpperCase()] ?? `${method} ${resource}`; } function inferTag(path: string): string { const parts = path.split('/').filter(s => s.length > 0 && !s.startsWith('{')); return parts[0] ?? 'default'; } export function generateOpenApiSpec( observations: ApiEndpointObservationInput[], opts: { title?: string; baseUrl?: string; version?: string } = {}, ): OpenApiSpec { const { title = 'ZeTa Discovered API', baseUrl = '/', version = '1.0.0' } = opts; const spec: OpenApiSpec = { openapi: '3.0.3', info: { title, version, description: 'Auto-generated by ZeTa crawler from observed network traffic.' }, servers: [{ url: baseUrl }], paths: {}, }; for (const obs of observations) { const path = obs.urlPattern; const method = obs.method.toLowerCase(); if (!spec.paths[path]) spec.paths[path] = {}; const params = extractPathParams(path); const statusCode = obs.statusCode ?? 200; const responses: OpenApiOperation['responses'] = { [String(statusCode)]: { description: statusCode < 400 ? 'Successful response' : 'Error response', content: obs.responseShape ? { 'application/json': { schema: shapeToJsonSchema(obs.responseShape) } } : undefined, }, }; const op: OpenApiOperation = { summary: methodSummary(obs.method, path), parameters: params.length > 0 ? params : undefined, responses, tags: [inferTag(path)], }; spec.paths[path][method] = op; } return spec; } function shapeToJsonSchema(shape: unknown): unknown { if (Array.isArray(shape)) return { type: 'array', items: shape.length > 0 ? shapeToJsonSchema(shape[0]) : {} }; if (shape && typeof shape === 'object') { const props: Record = {}; for (const [k, v] of Object.entries(shape as object)) props[k] = shapeToJsonSchema(v); return { type: 'object', properties: props }; } return { type: String(shape) }; } export function specToYaml(spec: OpenApiSpec): string { // Minimal YAML serializer — avoids yaml dep function toYaml(val: unknown, indent = 0): string { const pad = ' '.repeat(indent); if (val === null || val === undefined) return 'null'; if (typeof val === 'string') return val.includes('\n') || val.includes(':') ? `"${val.replace(/"/g, '\\"')}"` : val; if (typeof val === 'number' || typeof val === 'boolean') return String(val); if (Array.isArray(val)) return val.map(v => `\n${pad}- ${toYaml(v, indent + 1).trimStart()}`).join('') || '[]'; if (typeof val === 'object') { const entries = Object.entries(val as object); if (entries.length === 0) return '{}'; return entries.map(([k, v]) => { const vStr = toYaml(v, indent + 1); return typeof v === 'object' && v !== null && !Array.isArray(v) ? `\n${pad}${k}:${vStr}` : `\n${pad}${k}: ${vStr.trimStart()}`; }).join(''); } return String(val); } return `openapi: "${spec.openapi}"${toYaml(spec, 0)}`; }