SPB Git

spb/cancerindex

Public
7.5 KB · 166 lines typescript
1import { randomUUID } from 'node:crypto';2import Fastify, { type FastifyInstance } from 'fastify';3import cors from '@fastify/cors';4import compress from '@fastify/compress';5import etag from '@fastify/etag';6import rateLimit from '@fastify/rate-limit';7import swagger from '@fastify/swagger';8import swaggerUi from '@fastify/swagger-ui';9import { sql } from 'drizzle-orm';10import { hasZodFastifySchemaValidationErrors, isResponseSerializationError, jsonSchemaTransform, serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';11import { getDb, type Database } from '@cancerindex/database';12import { HttpError } from './lib/errors.js';13import { rateLimitKey, rateLimitMax, registerApiKeyAuth } from './plugins/auth.js';14import { closeBoss } from './lib/queue.js';15import './types.js';16import { healthRoutes } from './routes/health.js';17import { cancerRoutes } from './routes/cancers.js';18import { geneRoutes } from './routes/genes.js';19import { variantRoutes } from './routes/variants.js';20import { drugRoutes } from './routes/drugs.js';21import { trialRoutes } from './routes/trials.js';22import { publicationRoutes } from './routes/publications.js';23import { rankingRoutes } from './routes/rankings.js';24import { searchRoutes } from './routes/search.js';25import { sourceRoutes } from './routes/sources.js';26import { statsRoutes } from './routes/stats.js';27import { changeRoutes } from './routes/changes.js';28import { adminRoutes } from './routes/admin.js';2930export interface BuildOptions {31  db?: Database;32  logger?: boolean;33  logLevel?: string;34}3536/** Build the Fastify app (used by server.ts and by tests via app.inject). */37export async function buildApp(opts: BuildOptions = {}): Promise<FastifyInstance> {38  const app = Fastify({39    logger: opts.logger === false ? false : { level: opts.logLevel ?? process.env.LOG_LEVEL ?? 'info', base: { service: process.env.CI_SERVICE ?? 'api' } },40    // Correlation id on every request (CLAUDE.md §175): honour an incoming x-request-id, else mint one.41    requestIdHeader: 'x-request-id',42    genReqId: () => randomUUID(),43    trustProxy: true,44    ajv: { customOptions: { coerceTypes: true } },45  });4647  app.setValidatorCompiler(validatorCompiler);48  app.setSerializerCompiler(serializerCompiler);4950  const db = opts.db ?? getDb();51  app.decorate('db', db);52  app.decorate('dbHealthy', async () => {53    try {54      await db.execute(sql`SELECT 1`);55      return true;56    } catch {57      return false;58    }59  });6061  app.addHook('onRequest', async (req, reply) => {62    reply.header('x-request-id', req.id);63  });6465  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'] });66  await app.register(compress, { global: true, encodings: ['gzip', 'br', 'deflate'], threshold: 1024 });67  await app.register(etag, { weak: true });6869  registerApiKeyAuth(app);70  await app.register(rateLimit, {71    global: true,72    hook: 'preValidation', // runs after the onRequest auth hook so per-key limits apply73    timeWindow: '1 minute',74    max: (req) => rateLimitMax(req),75    keyGenerator: (req) => rateLimitKey(req),76    allowList: (req) => req.url === '/healthz' || req.url.startsWith('/v1/docs') || req.url === '/v1/openapi.json',77    addHeaders: { 'x-ratelimit-limit': true, 'x-ratelimit-remaining': true, 'x-ratelimit-reset': true, 'retry-after': true },78    addHeadersOnExceeding: { 'x-ratelimit-limit': true, 'x-ratelimit-remaining': true, 'x-ratelimit-reset': true },79    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 }),80  });8182  await app.register(swagger, {83    openapi: {84      openapi: '3.1.0',85      info: {86        title: 'CancerIndex API',87        version: '1.0.0',88        description:89          '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.',90        contact: { name: 'CancerIndex', url: 'https://www.cancerindex.io' },91        license: { name: 'Data licensed per source; see /v1/sources' },92      },93      servers: [{ url: '/', description: 'This host' }],94      tags: [95        { name: 'cancers', description: 'Canonical cancer entities, hierarchy, statistics, evidence' },96        { name: 'genes' },97        { name: 'variants' },98        { name: 'drugs' },99        { name: 'trials' },100        { name: 'publications' },101        { name: 'rankings', description: 'Ranking snapshots with scope, formula version and lineage' },102        { name: 'search' },103        { name: 'sources', description: 'Source registry, licenses, connector health' },104        { name: 'stats' },105        { name: 'changes' },106        { name: 'admin', description: 'Operator endpoints (x-admin-token)' },107        { name: 'system' },108      ],109      components: {110        securitySchemes: {111          apiKey: { type: 'http', scheme: 'bearer', description: 'Optional. Raises the per-minute rate limit.' },112          adminToken: { type: 'apiKey', in: 'header', name: 'x-admin-token' },113        },114      },115    },116    transform: jsonSchemaTransform,117  });118  await app.register(swaggerUi, { routePrefix: '/v1/docs', uiConfig: { docExpansion: 'list', deepLinking: true } });119120  app.setErrorHandler((err, req, reply) => {121    if (err instanceof HttpError) {122      return reply.code(err.statusCode).send({ error: { code: err.code, message: err.message, details: err.details }, requestId: req.id });123    }124    if (hasZodFastifySchemaValidationErrors(err)) {125      return reply.code(400).send({ error: { code: 'bad_request', message: 'request does not match the schema', details: err.validation }, requestId: req.id });126    }127    if (isResponseSerializationError(err)) {128      req.log.error({ err, issues: err.cause.issues }, 'response serialization error');129      return reply.code(500).send({ error: { code: 'internal', message: 'response does not match the schema' }, requestId: req.id });130    }131    const e = err as { statusCode?: number; message?: string };132    const status = e.statusCode ?? 500;133    if (status >= 500) req.log.error({ err }, 'unhandled error');134    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 });135  });136  app.setNotFoundHandler((req, reply) => {137    reply.code(404).send({ error: { code: 'not_found', message: `route ${req.method} ${req.url} not found` }, requestId: req.id });138  });139140  await app.register(healthRoutes);141  await app.register(142    async (v1) => {143      v1.get('/openapi.json', { schema: { hide: true } }, async () => app.swagger());144      await v1.register(cancerRoutes);145      await v1.register(geneRoutes);146      await v1.register(variantRoutes);147      await v1.register(drugRoutes);148      await v1.register(trialRoutes);149      await v1.register(publicationRoutes);150      await v1.register(rankingRoutes);151      await v1.register(searchRoutes);152      await v1.register(sourceRoutes);153      await v1.register(statsRoutes);154      await v1.register(changeRoutes);155      await v1.register(adminRoutes, { prefix: '/admin' });156    },157    { prefix: '/v1' },158  );159160  app.addHook('onClose', async () => {161    await closeBoss().catch(() => {});162  });163164  return app;165}166
Files · 4354e64ec39d7ace4e77b667021fe28e703ed10d
Menu

Theme