SPB Git

Repository Public

openapi.ts 3.6 KB 81 lines typescript

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
Files main
Menu

Theme