import { z } from 'zod'; /** * Response envelope for every /v1 endpoint (CLAUDE.md §321): * { data, sources: [...], dataRelease: "CancerIndex ", generatedAt, ...pagination } * `sources` lists the distinct upstream sources behind the returned data with their license and * attribution so consumers can comply with redistribution terms (§142). */ export const SourceRef = z.object({ id: z.string().describe('CI-SOURCE-… identifier'), slug: z.string().describe('Connector id (e.g. "oncotree")'), name: z.string(), license: z.string().nullable(), attribution: z.string().nullable(), url: z.string().nullable().describe('Source homepage'), }); export type SourceRef = z.infer; export const Pagination = z.object({ total: z.number().int().nonnegative().describe('Total rows matching the query (before limit/offset)'), limit: z.number().int().positive(), offset: z.number().int().nonnegative(), hasMore: z.boolean(), }); export type Pagination = z.infer; export interface Envelope { data: T; sources: SourceRef[]; dataRelease: string; generatedAt: string; total?: number; limit?: number; offset?: number; hasMore?: boolean; } /** Zod schema factory for OpenAPI generation: envelope around a data schema. */ export function envelopeSchema(data: T, paginated = false) { const base = z.object({ data, sources: z.array(SourceRef), dataRelease: z.string().describe('Data release label, "CancerIndex YYYY-MM"'), generatedAt: z.string().describe('ISO-8601 timestamp of the response'), }); return paginated ? base.extend(Pagination.shape) : base; } /** "CancerIndex 2026-09" — month of the most recent successful ingest run, or of the request when unknown. */ export function dataRelease(asOf: Date = new Date()): string { return `CancerIndex ${asOf.toISOString().slice(0, 7)}`; } export function envelope(data: T, sources: SourceRef[], pagination?: Pagination, asOf?: Date): Envelope { const out: Envelope = { data, sources: dedupeSources(sources), dataRelease: dataRelease(asOf), generatedAt: new Date().toISOString() }; if (pagination) Object.assign(out, pagination); return out; } export function paginate(total: number, limit: number, offset: number): Pagination { return { total, limit, offset, hasMore: offset + limit < total }; } /** Distinct by id, stable order by slug. */ export function dedupeSources(list: SourceRef[]): SourceRef[] { const seen = new Map(); for (const s of list) if (!seen.has(s.id)) seen.set(s.id, s); return [...seen.values()].sort((a, b) => a.slug.localeCompare(b.slug)); }