/** * HTTP plumbing shared by every route: envelope, ETag / Cache-Control, zod query parsing, error types. */ import { createHash } from "node:crypto"; import type { FastifyReply, FastifyRequest } from "fastify"; import type { ZodType } from "zod"; import type { ApiEnvelope, SourceRef } from "@dci/core"; export class HttpError extends Error { constructor(readonly statusCode: number, message: string, readonly details?: unknown) { super(message); this.name = "HttpError"; } } export function notFound(what = "resource"): HttpError { return new HttpError(404, `${what} not found`); } export function badRequest(message: string, details?: unknown): HttpError { return new HttpError(400, message, details); } /** Parse `req.query` with a zod schema; a failure becomes a 400 with the zod issues. */ export function parseQuery(schema: ZodType, query: unknown): T { const res = schema.safeParse(query ?? {}); if (!res.success) throw new HttpError(400, "invalid query", res.error.issues.map((i) => ({ path: i.path.join("."), message: i.message }))); return res.data; } export function parseBody(schema: ZodType, body: unknown): T { const res = schema.safeParse(body ?? {}); if (!res.success) throw new HttpError(400, "invalid body", res.error.issues.map((i) => ({ path: i.path.join("."), message: i.message }))); return res.data; } /** Cache lifetimes (seconds) per route family. */ export const TTL = { dashboard: 60, list: 120, detail: 300, map: 300, events: 30, search: 60, sitemap: 600, rankings: 300 } as const; export interface SendOptions { meta?: ApiEnvelope["meta"]; sources?: SourceRef[]; /** s-maxage in seconds; 0 = no-store */ ttl?: number; cache?: "HIT" | "MISS" | "BYPASS"; } export function envelope(data: T, meta?: ApiEnvelope["meta"], sources?: SourceRef[]): ApiEnvelope { const out: ApiEnvelope = { data }; out.meta = { ...(meta ?? {}), generatedAt: meta?.generatedAt ?? new Date().toISOString() }; if (sources) out.sources = sources; return out; } /** Weak ETag over the body with the volatile `generatedAt` stamp removed, so unchanged data validates across cache refreshes. */ export function etagOf(body: string): string { return `W/"${createHash("sha1").update(body.replace(/"generatedAt":"[^"]*",?/g, "")).digest("base64url").slice(0, 27)}"`; } /** * Send a JSON payload with ETag (304 on If-None-Match), Cache-Control and X-Cache headers. * `payload` is sent as-is (callers pass an envelope or an already-cached envelope). */ export function sendJson(req: FastifyRequest, reply: FastifyReply, payload: unknown, opts: SendOptions = {}): FastifyReply { const body = JSON.stringify(payload); const tag = etagOf(body); const ttl = opts.ttl ?? 0; reply.header("etag", tag); reply.header("vary", "accept-encoding"); if (ttl > 0) reply.header("cache-control", `public, max-age=${Math.min(ttl, 60)}, s-maxage=${ttl}, stale-while-revalidate=${ttl}`); else reply.header("cache-control", "no-store"); if (opts.cache) reply.header("x-cache", opts.cache); const inm = req.headers["if-none-match"]; if (inm && inm.split(",").map((s) => s.trim()).includes(tag)) return reply.code(304).send(); reply.type("application/json; charset=utf-8"); return reply.send(body); } /** Stable cache key from route + sorted query. */ export function cacheKeyFor(routePrefix: string, query: unknown): string { const q = (query ?? {}) as Record; const parts = Object.keys(q) .filter((k) => q[k] !== undefined && q[k] !== "") .sort() .map((k) => `${k}=${Array.isArray(q[k]) ? (q[k] as unknown[]).join(",") : String(q[k])}`); return `${routePrefix}?${parts.join("&")}`; } export function clampInt(v: number | undefined, lo: number, hi: number, dflt: number): number { if (v == null || !Number.isFinite(v)) return dflt; return Math.max(lo, Math.min(hi, Math.trunc(v))); } export function csv(v: string | undefined | null): string[] { if (!v) return []; return v.split(",").map((s) => s.trim()).filter(Boolean); }