SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
3.0 KB · 72 lines typescript
Raw Blame History
1import Fastify, { type FastifyInstance } from 'fastify';2import cors from '@fastify/cors';3import compress from '@fastify/compress';4import etag from '@fastify/etag';5import rateLimit from '@fastify/rate-limit';6import { randomUUID } from 'node:crypto';7import { getSql } from '@rareindex/database';8import { authPlugin, rateKeyFor, rateLimitFor } from './plugins/auth.js';9import { v1Routes } from './routes/v1.js';10import { internalRoutes } from './routes/internal.js';11import { problem } from './lib/envelope.js';1213export interface BuildOptions {14  logger?: boolean;15}1617export async function buildApp(opts: BuildOptions = {}): Promise<FastifyInstance> {18  const app = Fastify({19    logger: opts.logger === false ? false : { level: process.env.LOG_LEVEL ?? 'info', redact: ['req.headers.authorization'] },20    genReqId: (req) => (req.headers['x-request-id'] as string | undefined) ?? randomUUID(),21    trustProxy: true,22  });2324  await app.register(cors, { origin: true, methods: ['GET', 'HEAD', 'OPTIONS'], exposedHeaders: ['x-request-id', 'x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset'] });25  await app.register(compress, { global: true, encodings: ['gzip', 'br', 'deflate'] });26  await app.register(etag);27  await app.register(authPlugin);28  await app.register(rateLimit, {29    global: true,30    max: (req) => rateLimitFor(req),31    timeWindow: '1 minute',32    keyGenerator: (req) => rateKeyFor(req),33    errorResponseBuilder: (req, ctx) => ({34      statusCode: 429,35      type: 'https://www.rareindex.io/api-docs#errors',36      title: 'Rate limit exceeded',37      status: 429,38      detail: `Limit ${ctx.max} requests per ${ctx.after}. Retry after ${ctx.ttl} ms.`,39      instance: req.url,40      request_id: req.id,41    }),42  });4344  app.addHook('onSend', async (req, reply, payload) => {45    reply.header('x-request-id', req.id);46    return payload;47  });4849  app.setErrorHandler((err: Error & { statusCode?: number; status?: number; title?: string; detail?: string }, req, reply) => {50    const status = err.statusCode ?? err.status ?? 500;51    if (status >= 500) req.log.error({ err }, 'unhandled api error');52    const title = status >= 500 ? 'Internal error' : (err.title ?? err.message);53    const detail = status >= 500 ? 'The request could not be completed.' : (err.detail ?? err.message);54    return problem(reply, status, title, detail);55  });56  app.setNotFoundHandler((req, reply) => problem(reply, 404, 'Not found', `No route for ${req.method} ${req.url}`));5758  app.get('/healthz', async (_req, reply) => {59    try {60      await getSql()`select 1`;61      return { ok: true, service: 'rareindex-api', time: new Date().toISOString() };62    } catch (err) {63      return reply.code(503).send({ ok: false, service: 'rareindex-api', error: err instanceof Error ? err.message : String(err) });64    }65  });66  app.get('/', async () => ({ name: 'RareIndex Public API', docs: 'https://www.rareindex.io/api-docs', openapi: '/v1/openapi.json' }));6768  await app.register(v1Routes);69  await app.register(internalRoutes);70  return app;71}72