SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
6.5 KB · 131 lines typescript
Raw Blame History
1/**2 * Route helper for public GET endpoints: zod query parsing → cache (memory + Redis) → envelope → ETag.3 * The zod schema is also converted to JSON Schema for the OpenAPI document (validation itself is zod-only), and every4 * public route is recorded in `routeRegistry` so `/docs-meta` can describe the API from the live route table.5 */6import type { FastifyInstance, FastifyRequest } from "fastify";7import { z, type ZodType } from "zod";8import type { SourceRef } from "@dci/core";9import { cacheKeyFor, envelope, parseQuery, sendJson } from "./http.js";10import { cached } from "../cache.js";1112export interface RouteResult<T> { data: T; meta?: Record<string, unknown>; sources?: SourceRef[] }1314export interface ResponseDoc {15  /** name of the api-types.ts type carried in `data` (e.g. "FacilitySummary[]") */16  type: string;17  description?: string;18  /** small illustrative example of `data` (published in OpenAPI as x-example) */19  example?: unknown;20}2122export interface PublicRouteOptions<Q> {23  url: string;24  ttl: number;25  query?: ZodType<Q>;26  summary: string;27  description?: string;28  tags?: string[];29  params?: Record<string, string>;30  response?: ResponseDoc;31  /** override the prefix recorded in the route registry (default /api/v1) */32  prefix?: string;33}3435export interface RegisteredRoute {36  method: "GET" | "POST" | "DELETE" | "PATCH";37  path: string;38  summary: string;39  description?: string;40  group: string;41  params: Array<{ name: string; in: "query" | "path"; type: string; description: string; example?: string }>;42  responseType: string;43  responseDescription?: string;44  example?: unknown;45}4647/** Every public route registered through publicGet (or registerRoute), in registration order. */48export const routeRegistry: RegisteredRoute[] = [];4950export function registerRoute(r: RegisteredRoute): void {51  if (!routeRegistry.some((x) => x.method === r.method && x.path === r.path)) routeRegistry.push(r);52}5354export function jsonSchemaOf(schema: ZodType | undefined): Record<string, unknown> | undefined {55  if (!schema) return undefined;56  try {57    const js = z.toJSONSchema(schema, { unrepresentable: "any", io: "input" }) as Record<string, unknown>;58    delete js.$schema;59    return js;60  } catch {61    return { type: "object" };62  }63}6465function paramsSchema(params?: Record<string, string>): Record<string, unknown> | undefined {66  if (!params) return undefined;67  return { type: "object", properties: Object.fromEntries(Object.entries(params).map(([k, d]) => [k, { type: "string", description: d }])), required: Object.keys(params) };68}6970/** OpenAPI response schema for the `{ data, meta, sources }` envelope. */71export function envelopeSchema(doc?: ResponseDoc): Record<string, unknown> {72  const schema: Record<string, unknown> = {73    description: doc?.description ?? `Envelope with \`data\` = ${doc?.type ?? "object"} (see packages/core/src/api-types.ts).`,74    type: "object",75    properties: {76      data: { description: doc?.type ?? "payload", oneOf: [{ type: "object", additionalProperties: true }, { type: "array", items: {} }] },77      meta: { type: "object", additionalProperties: true, properties: { total: { type: "integer" }, page: { type: "integer" }, perPage: { type: "integer" }, generatedAt: { type: "string", format: "date-time" }, methodology: { type: "string" } } },78      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"] } } } },79    },80    required: ["data"],81  };82  if (doc?.example !== undefined) schema["x-example"] = { data: doc.example, meta: { generatedAt: "2026-09-12T00:00:00.000Z" }, sources: [] };83  return schema;84}8586const ERROR_SCHEMA = { description: "Error", type: "object", properties: { error: { type: "string" }, statusCode: { type: "integer" }, details: {} }, required: ["error", "statusCode"] };8788/** Describe the zod query schema as docs params. */89export function queryParamsDoc(schema: ZodType | undefined): RegisteredRoute["params"] {90  const js = jsonSchemaOf(schema);91  const props = (js?.properties as Record<string, Record<string, unknown>> | undefined) ?? {};92  return Object.entries(props).map(([name, p]) => {93    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";94    const desc = typeof p.description === "string" ? p.description : p.enum ? `one of ${(p.enum as unknown[]).join(", ")}` : "";95    return { name, in: "query" as const, type: t || "string", description: desc };96  });97}9899/** Register a cached public GET route. `handler` receives the parsed query and route params. */100export function publicGet<Q = Record<string, never>>(app: FastifyInstance, opts: PublicRouteOptions<Q>, handler: (q: Q, params: Record<string, string>, req: FastifyRequest) => Promise<RouteResult<unknown>>): void {101  const schema: Record<string, unknown> = { summary: opts.summary, tags: opts.tags ?? ["public"] };102  if (opts.description) schema.description = opts.description;103  const qs = jsonSchemaOf(opts.query as ZodType | undefined);104  if (qs) schema.querystring = qs;105  const ps = paramsSchema(opts.params);106  if (ps) schema.params = ps;107  schema.response = { 200: envelopeSchema(opts.response), 400: ERROR_SCHEMA, 404: ERROR_SCHEMA };108  registerRoute({109    method: "GET",110    path: `${opts.prefix ?? "/api/v1"}${opts.url}`,111    summary: opts.summary,112    description: opts.description,113    group: opts.tags?.[0] ?? "public",114    params: [...Object.entries(opts.params ?? {}).map(([name, d]) => ({ name, in: "path" as const, type: "string", description: d })), ...queryParamsDoc(opts.query as ZodType | undefined)],115    responseType: opts.response?.type ?? "object",116    responseDescription: opts.response?.description,117    example: opts.response?.example,118  });119  app.get(opts.url, { schema }, async (req, reply) => {120    const q = (opts.query ? parseQuery(opts.query, req.query) : {}) as Q;121    const params = (req.params ?? {}) as Record<string, string>;122    const key = cacheKeyFor(`${opts.url}|${Object.values(params).join("/")}`, q);123    const { value, layer } = await cached(key, opts.ttl, async () => {124      const r = await handler(q, params, req);125      return envelope(r.data, r.meta, r.sources);126    });127    reply.header("x-route", opts.url);128    return sendJson(req, reply, value, { ttl: opts.ttl, cache: layer === "miss" || layer === "bypass" ? "MISS" : "HIT" });129  });130}131