/** * Route helper for public GET endpoints: zod query parsing → cache (memory + Redis) → envelope → ETag. * The zod schema is also converted to JSON Schema for the OpenAPI document (validation itself is zod-only), and every * public route is recorded in `routeRegistry` so `/docs-meta` can describe the API from the live route table. */ import type { FastifyInstance, FastifyRequest } from "fastify"; import { z, type ZodType } from "zod"; import type { SourceRef } from "@dci/core"; import { cacheKeyFor, envelope, parseQuery, sendJson } from "./http.js"; import { cached } from "../cache.js"; export interface RouteResult { data: T; meta?: Record; sources?: SourceRef[] } export interface ResponseDoc { /** name of the api-types.ts type carried in `data` (e.g. "FacilitySummary[]") */ type: string; description?: string; /** small illustrative example of `data` (published in OpenAPI as x-example) */ example?: unknown; } export interface PublicRouteOptions { url: string; ttl: number; query?: ZodType; summary: string; description?: string; tags?: string[]; params?: Record; response?: ResponseDoc; /** override the prefix recorded in the route registry (default /api/v1) */ prefix?: string; } export interface RegisteredRoute { method: "GET" | "POST" | "DELETE" | "PATCH"; path: string; summary: string; description?: string; group: string; params: Array<{ name: string; in: "query" | "path"; type: string; description: string; example?: string }>; responseType: string; responseDescription?: string; example?: unknown; } /** Every public route registered through publicGet (or registerRoute), in registration order. */ export const routeRegistry: RegisteredRoute[] = []; export function registerRoute(r: RegisteredRoute): void { if (!routeRegistry.some((x) => x.method === r.method && x.path === r.path)) routeRegistry.push(r); } export function jsonSchemaOf(schema: ZodType | undefined): Record | undefined { if (!schema) return undefined; try { const js = z.toJSONSchema(schema, { unrepresentable: "any", io: "input" }) as Record; delete js.$schema; return js; } catch { return { type: "object" }; } } function paramsSchema(params?: Record): Record | undefined { if (!params) return undefined; return { type: "object", properties: Object.fromEntries(Object.entries(params).map(([k, d]) => [k, { type: "string", description: d }])), required: Object.keys(params) }; } /** OpenAPI response schema for the `{ data, meta, sources }` envelope. */ export function envelopeSchema(doc?: ResponseDoc): Record { const schema: Record = { description: doc?.description ?? `Envelope with \`data\` = ${doc?.type ?? "object"} (see packages/core/src/api-types.ts).`, type: "object", properties: { data: { description: doc?.type ?? "payload", oneOf: [{ type: "object", additionalProperties: true }, { type: "array", items: {} }] }, meta: { type: "object", additionalProperties: true, properties: { total: { type: "integer" }, page: { type: "integer" }, perPage: { type: "integer" }, generatedAt: { type: "string", format: "date-time" }, methodology: { type: "string" } } }, sources: { type: "array", items: { type: "object", additionalProperties: true, properties: { id: { type: "string" }, name: { type: "string" }, kind: { type: "string" }, license: { type: ["string", "null"] }, redistribution: { type: ["string", "null"] } } } }, }, required: ["data"], }; if (doc?.example !== undefined) schema["x-example"] = { data: doc.example, meta: { generatedAt: "2026-09-12T00:00:00.000Z" }, sources: [] }; return schema; } const ERROR_SCHEMA = { description: "Error", type: "object", properties: { error: { type: "string" }, statusCode: { type: "integer" }, details: {} }, required: ["error", "statusCode"] }; /** Describe the zod query schema as docs params. */ export function queryParamsDoc(schema: ZodType | undefined): RegisteredRoute["params"] { const js = jsonSchemaOf(schema); const props = (js?.properties as Record> | undefined) ?? {}; return Object.entries(props).map(([name, p]) => { const t = Array.isArray(p.type) ? (p.type as string[]).filter((x) => x !== "null").join("|") : typeof p.type === "string" ? p.type : p.enum ? "enum" : p.anyOf ? "string" : "string"; const desc = typeof p.description === "string" ? p.description : p.enum ? `one of ${(p.enum as unknown[]).join(", ")}` : ""; return { name, in: "query" as const, type: t || "string", description: desc }; }); } /** Register a cached public GET route. `handler` receives the parsed query and route params. */ export function publicGet>(app: FastifyInstance, opts: PublicRouteOptions, handler: (q: Q, params: Record, req: FastifyRequest) => Promise>): void { const schema: Record = { summary: opts.summary, tags: opts.tags ?? ["public"] }; if (opts.description) schema.description = opts.description; const qs = jsonSchemaOf(opts.query as ZodType | undefined); if (qs) schema.querystring = qs; const ps = paramsSchema(opts.params); if (ps) schema.params = ps; schema.response = { 200: envelopeSchema(opts.response), 400: ERROR_SCHEMA, 404: ERROR_SCHEMA }; registerRoute({ method: "GET", path: `${opts.prefix ?? "/api/v1"}${opts.url}`, summary: opts.summary, description: opts.description, group: opts.tags?.[0] ?? "public", params: [...Object.entries(opts.params ?? {}).map(([name, d]) => ({ name, in: "path" as const, type: "string", description: d })), ...queryParamsDoc(opts.query as ZodType | undefined)], responseType: opts.response?.type ?? "object", responseDescription: opts.response?.description, example: opts.response?.example, }); app.get(opts.url, { schema }, async (req, reply) => { const q = (opts.query ? parseQuery(opts.query, req.query) : {}) as Q; const params = (req.params ?? {}) as Record; const key = cacheKeyFor(`${opts.url}|${Object.values(params).join("/")}`, q); const { value, layer } = await cached(key, opts.ttl, async () => { const r = await handler(q, params, req); return envelope(r.data, r.meta, r.sources); }); reply.header("x-route", opts.url); return sendJson(req, reply, value, { ttl: opts.ttl, cache: layer === "miss" || layer === "bypass" ? "MISS" : "HIT" }); }); }