Repository Public
1/**2 * `/api/v1/openapi.json`: assembled from the live route table (so it cannot drift from what is3 * actually registered) and the zod schemas attached to each route via `config.doc`.4 */5import type { FastifyInstance } from 'fastify';6import { z } from 'zod';7import { ApiError, PRODUCT_NAME } from '../dto-auth.js';8import type { Ctx, RouteEntry } from '../http.js';910type Json = Record<string, unknown>;1112function toSchema(schema: z.ZodType, io: 'input' | 'output'): Json {13 try {14 const s = z.toJSONSchema(schema, { io, unrepresentable: 'any' }) as Json;15 delete s.$schema;16 return s;17 } catch {18 return {};19 }20}2122export function buildOpenApi(routes: readonly RouteEntry[], version: string, origin: string, productName = PRODUCT_NAME, cookieName = '__Host-aos_sid'): Json {23 const schemas: Json = { ApiError: toSchema(ApiError, 'output') };24 const ref = (name: string | undefined, schema: z.ZodType, io: 'input' | 'output'): Json => {25 if (!name) return toSchema(schema, io);26 const key = io === 'input' && name in schemas && schemas[name] !== undefined ? name : name;27 schemas[key] ??= toSchema(schema, io);28 return { $ref: `#/components/schemas/${key}` };29 };30 const paths: Record<string, Json> = {};31 for (const r of routes) {32 if (r.method === 'HEAD' || r.method === 'OPTIONS' || !r.url.startsWith('/api/v1')) continue;33 const url = r.url.replace(/:([A-Za-z0-9_]+)/g, '{$1}');34 const doc = r.doc;35 const params: Json[] = [...r.url.matchAll(/:([A-Za-z0-9_]+)/g)].map((m) => ({ name: m[1], in: 'path', required: true, schema: { type: 'string' } }));36 if (doc?.query) {37 const q = toSchema(doc.query, 'input');38 const required = Array.isArray(q.required) ? (q.required as string[]) : [];39 for (const [name, schema] of Object.entries((q.properties ?? {}) as Json)) params.push({ name, in: 'query', required: required.includes(name), schema });40 }41 const mutating = !['GET'].includes(r.method);42 const op: Json = {43 summary: doc?.summary ?? '',44 'x-auth': r.auth, // public | preauth | restricted | full (see README)45 'x-websocket': r.websocket,46 security: r.auth === 'public' || r.auth === 'preauth' ? [] : [{ sessionCookie: [], ...(mutating ? { csrfToken: [] } : {}) }],47 parameters: params,48 responses: {49 [r.websocket ? '101' : '200']: {50 description: r.websocket ? 'WebSocket upgrade' : 'OK',51 ...(doc?.response ? { content: { 'application/json': { schema: ref(doc.responseName, doc.response, 'output') } } } : {}),52 },53 default: { description: 'Error', content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } } },54 },55 };56 if (doc?.body) op.requestBody = { required: true, content: { 'application/json': { schema: ref(doc.bodyName, doc.body, 'input') } } };57 (paths[url] ??= {})[r.method.toLowerCase()] = op;58 }59 return {60 openapi: '3.1.0',61 info: { title: `${productName} API`, version, description: 'Private single-administrator API. Mutations need X-CSRF-Token and a matching Origin.' },62 servers: [{ url: origin }],63 paths,64 components: {65 schemas,66 securitySchemes: {67 sessionCookie: { type: 'apiKey', in: 'cookie', name: cookieName },68 csrfToken: { type: 'apiKey', in: 'header', name: 'X-CSRF-Token' },69 },70 },71 };72}7374export default async function openapiPlugin(app: FastifyInstance, ctx: Ctx): Promise<void> {75 let cached: Json | null = null;76 app.get('/openapi.json', { config: { doc: { summary: 'This document' } } }, () => {77 cached ??= buildOpenApi(ctx.routes, ctx.config.version, ctx.config.publicOrigin, PRODUCT_NAME, ctx.config.cookieName);78 return cached;79 });80}81