/** * The API, described: every route in `routes.ts` that declares `api`, as an * OpenAPI 3.1 document. Each operation also names the contract cell it is * (`x-contract`: the resource, the verb, and the least role the table asks * for), so a change of policy is a change here too, and shows in the pull. * * bun run contract:emit write contract/openapi.json from the table * * `src/openapi.test.ts` fails when the committed file and the routes * disagree: the description can go stale only in a commit that fails. */ import { contract } from "./contract.ts"; import { description, title, version } from "./env.ts"; import { AUDIT_ROW, ERROR, NOTE, routes, type Schema } from "./routes.ts"; import { doorOf } from "./security.ts"; // The shapes said once, under components, and referred to wherever they appear. const NAMED = new Map([ [NOTE, "Note"], [AUDIT_ROW, "AuditRow"], [ERROR, "Error"], ]); const named = (schema: Schema): Schema => { const name = NAMED.get(schema); if (name !== undefined) return { $ref: `#/components/schemas/${name}` }; return Object.fromEntries( Object.entries(schema).map(([key, value]) => [ key, typeof value === "object" && value !== null && !Array.isArray(value) ? named(value as Schema) : value, ]), ); }; const content = (schema: Schema) => ({ "application/json": { schema: named(schema) }, }); export function openapi(): Record { const paths: Record> = {}; for (const route of routes) { if (route.api === undefined) continue; const { summary, body, answer, status, query } = route.api; const path = route.path.replace(/:(\w+)/g, "{$1}"); const params = [...route.path.matchAll(/:(\w+)/g)].map(([, name]) => ({ name, in: "path", required: true, schema: { type: "integer" }, })); const door = doorOf(route.method, route.path.replace(/:\w+/g, "0")); const least = door === undefined ? undefined : contract.resources[door.resource]?.can[door.verb]; paths[path] ??= {}; paths[path][route.method.toLowerCase()] = { summary, operationId: `${route.method.toLowerCase()}${path.replace(/[{}]/g, "").replace(/(^|[/._-])(\w)/g, (_, __, c: string) => c.toUpperCase())}`, ...(params.length > 0 || query !== undefined ? { parameters: [ ...params, ...Object.entries(query ?? {}).map(([name, schema]) => ({ name, in: "query", schema: named(schema), })), ], } : {}), ...(body === undefined ? {} : { requestBody: { required: true, content: content(body) } }), responses: { [String(status ?? 200)]: { description: summary, content: content(answer), }, default: { description: "{ error, message }: why not", content: content(ERROR), }, }, ...(door === undefined ? {} : { "x-contract": { ...door, least } }), }; } return { openapi: "3.1.0", info: { title, version, description }, paths, components: { schemas: Object.fromEntries( [...NAMED].map(([schema, name]) => [name, schema]), ), }, }; } /** The file's text: stable, so the same table always writes the same bytes. */ export const emit = (): string => `${JSON.stringify(openapi(), null, 2)}\n`;