import { randomUUID } from 'node:crypto'; import Fastify, { type FastifyInstance } from 'fastify'; import cors from '@fastify/cors'; import compress from '@fastify/compress'; import etag from '@fastify/etag'; import rateLimit from '@fastify/rate-limit'; import swagger from '@fastify/swagger'; import swaggerUi from '@fastify/swagger-ui'; import { sql } from 'drizzle-orm'; import { hasZodFastifySchemaValidationErrors, isResponseSerializationError, jsonSchemaTransform, serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; import { getDb, type Database } from '@cancerindex/database'; import { HttpError } from './lib/errors.js'; import { rateLimitKey, rateLimitMax, registerApiKeyAuth } from './plugins/auth.js'; import { closeBoss } from './lib/queue.js'; import './types.js'; import { healthRoutes } from './routes/health.js'; import { cancerRoutes } from './routes/cancers.js'; import { geneRoutes } from './routes/genes.js'; import { variantRoutes } from './routes/variants.js'; import { drugRoutes } from './routes/drugs.js'; import { trialRoutes } from './routes/trials.js'; import { publicationRoutes } from './routes/publications.js'; import { rankingRoutes } from './routes/rankings.js'; import { searchRoutes } from './routes/search.js'; import { sourceRoutes } from './routes/sources.js'; import { statsRoutes } from './routes/stats.js'; import { changeRoutes } from './routes/changes.js'; import { adminRoutes } from './routes/admin.js'; import { graphRoutes } from './routes/graph.js'; import { epidemiologyRoutes } from './routes/epidemiology.js'; import { approvalRoutes } from './routes/approvals.js'; import { intelligenceRoutes } from './routes/intelligence.js'; import { researchGapRoutes } from './routes/research-gap.js'; import { trialSiteRoutes } from './routes/trial-sites.js'; import { biomarkerRoutes } from './routes/biomarkers.js'; export interface BuildOptions { db?: Database; logger?: boolean; logLevel?: string; } /** Build the Fastify app (used by server.ts and by tests via app.inject). */ export async function buildApp(opts: BuildOptions = {}): Promise { const app = Fastify({ logger: opts.logger === false ? false : { level: opts.logLevel ?? process.env.LOG_LEVEL ?? 'info', base: { service: process.env.CI_SERVICE ?? 'api' } }, // Correlation id on every request (CLAUDE.md §175): honour an incoming x-request-id, else mint one. requestIdHeader: 'x-request-id', genReqId: () => randomUUID(), trustProxy: true, ajv: { customOptions: { coerceTypes: true } }, }); app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler); const db = opts.db ?? getDb(); app.decorate('db', db); app.decorate('dbHealthy', async () => { try { await db.execute(sql`SELECT 1`); return true; } catch { return false; } }); app.addHook('onRequest', async (req, reply) => { reply.header('x-request-id', req.id); }); await app.register(cors, { origin: true, methods: ['GET', 'POST', 'OPTIONS'], exposedHeaders: ['x-request-id', 'x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset', 'retry-after'] }); await app.register(compress, { global: true, encodings: ['gzip', 'br', 'deflate'], threshold: 1024 }); await app.register(etag, { weak: true }); registerApiKeyAuth(app); await app.register(rateLimit, { global: true, hook: 'preValidation', // runs after the onRequest auth hook so per-key limits apply timeWindow: '1 minute', max: (req) => rateLimitMax(req), keyGenerator: (req) => rateLimitKey(req), allowList: (req) => req.url === '/healthz' || req.url.startsWith('/v1/docs') || req.url === '/v1/openapi.json', addHeaders: { 'x-ratelimit-limit': true, 'x-ratelimit-remaining': true, 'x-ratelimit-reset': true, 'retry-after': true }, addHeadersOnExceeding: { 'x-ratelimit-limit': true, 'x-ratelimit-remaining': true, 'x-ratelimit-reset': true }, errorResponseBuilder: (req, ctx) => ({ error: { code: 'rate_limited', message: `Rate limit exceeded (${ctx.max} requests per ${ctx.after}). Use an API key for higher limits.` }, requestId: req.id }), }); await app.register(swagger, { openapi: { openapi: '3.1.0', info: { title: 'CancerIndex API', version: '1.0.0', description: 'Public read-only API of CancerIndex.io — the global index of cancer. Every response is wrapped in `{ data, sources, dataRelease, generatedAt }`; `sources` lists the upstream sources (with license and attribution) behind the returned data. Numbers are never invented: absent data is absent, not zero. Rankings carry scope, formula version and inputs hash. Not medical advice.', contact: { name: 'Simon-Pierre Boucher — CancerIndex', url: 'https://www.cancerindex.io/about', email: 'contact@spboucher.ai' }, license: { name: 'Data licensed per source; see /v1/sources' }, }, servers: [{ url: '/', description: 'This host' }], tags: [ { name: 'cancers', description: 'Canonical cancer entities, hierarchy, statistics, evidence' }, { name: 'genes' }, { name: 'variants' }, { name: 'drugs' }, { name: 'trials' }, { name: 'publications' }, { name: 'rankings', description: 'Ranking snapshots with scope, formula version and lineage' }, { name: 'search' }, { name: 'sources', description: 'Source registry, licenses, connector health' }, { name: 'stats' }, { name: 'changes' }, { name: 'epidemiology', description: 'Time-aware incidence/mortality/prevalence observations with provenance (Data Explorer)' }, { name: 'approvals', description: 'Jurisdiction-aware regulatory approvals and the drug development pipeline' }, { name: 'biomarkers', description: 'Curated canonical biomarkers (NCIt-verified) with derived links to cancers, drugs, approvals and trials' }, { name: 'graph', description: 'Cancer–gene–variant–drug–trial knowledge graph (contextual neighbourhoods)' }, { name: 'intelligence', description: 'Derived clinical-trial intelligence and research-gap components (computed metrics with formula versions)' }, { name: 'admin', description: 'Operator endpoints (x-admin-token)' }, { name: 'system' }, ], components: { securitySchemes: { apiKey: { type: 'http', scheme: 'bearer', description: 'Optional. Raises the per-minute rate limit.' }, adminToken: { type: 'apiKey', in: 'header', name: 'x-admin-token' }, }, }, }, transform: jsonSchemaTransform, }); await app.register(swaggerUi, { routePrefix: '/v1/docs', uiConfig: { docExpansion: 'list', deepLinking: true } }); app.setErrorHandler((err, req, reply) => { if (err instanceof HttpError) { return reply.code(err.statusCode).send({ error: { code: err.code, message: err.message, details: err.details }, requestId: req.id }); } if (hasZodFastifySchemaValidationErrors(err)) { return reply.code(400).send({ error: { code: 'bad_request', message: 'request does not match the schema', details: err.validation }, requestId: req.id }); } if (isResponseSerializationError(err)) { req.log.error({ err, issues: err.cause.issues }, 'response serialization error'); return reply.code(500).send({ error: { code: 'internal', message: 'response does not match the schema' }, requestId: req.id }); } const e = err as { statusCode?: number; message?: string }; const status = e.statusCode ?? 500; if (status >= 500) req.log.error({ err }, 'unhandled error'); return reply.code(status).send({ error: { code: status === 429 ? 'rate_limited' : status >= 500 ? 'internal' : 'error', message: status >= 500 ? 'internal server error' : (e.message ?? 'error') }, requestId: req.id }); }); app.setNotFoundHandler((req, reply) => { reply.code(404).send({ error: { code: 'not_found', message: `route ${req.method} ${req.url} not found` }, requestId: req.id }); }); await app.register(healthRoutes); await app.register( async (v1) => { v1.get('/openapi.json', { schema: { hide: true } }, async () => app.swagger()); await v1.register(cancerRoutes); await v1.register(geneRoutes); await v1.register(variantRoutes); await v1.register(drugRoutes); await v1.register(trialRoutes); await v1.register(publicationRoutes); await v1.register(rankingRoutes); await v1.register(searchRoutes); await v1.register(sourceRoutes); await v1.register(statsRoutes); await v1.register(changeRoutes); await v1.register(epidemiologyRoutes); await v1.register(approvalRoutes); await v1.register(graphRoutes); await v1.register(intelligenceRoutes); await v1.register(researchGapRoutes); await v1.register(trialSiteRoutes); await v1.register(biomarkerRoutes); await v1.register(adminRoutes, { prefix: '/admin' }); }, { prefix: '/v1' }, ); app.addHook('onClose', async () => { await closeBoss().catch(() => {}); }); return app; }