diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..cf970b5 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,76 @@ +import Fastify, { FastifyInstance } from 'fastify'; +import { AppError } from './utils/errors.js'; +import databasePlugin from './plugins/database.js'; +import redisPlugin from './plugins/redis.js'; +import securityPlugin from './plugins/security.js'; +import rateLimitPlugin from './plugins/rateLimit.js'; +import { env } from './config/env.js'; +import { randomUUID } from 'node:crypto'; + +export async function buildApp(): Promise { + const isDev = env.NODE_ENV === 'development'; + + const app = Fastify({ + logger: { + level: isDev ? 'debug' : 'info', + transport: + isDev + ? { + target: 'pino-pretty', + options: { + translateTime: 'HH:MM:ss Z', + ignore: 'pid,hostname', + }, + } + : undefined, + }, + requestIdHeader: 'x-request-id', + requestIdLogLabel: 'requestId', + genReqId: () => randomUUID(), + }); + + app.setErrorHandler((err: unknown, request, reply) => { + const error = err as Error & { statusCode?: number; validation?: unknown }; + request.log.error({ err }, error.message); + + if (err instanceof AppError) { + const statusCode = err.statusCode; + const payload = err.toJSON(); + if (err.code === 'RATE_LIMIT_EXCEEDED' && 'retryAfter' in err) { + reply.header('Retry-After', String((err as AppError & { retryAfter?: number }).retryAfter ?? 60)); + } + return reply.status(statusCode).send(payload); + } + + if (error.validation) { + return reply.status(422).send({ + error: { + code: 'VALIDATION_ERROR', + message: 'Validation failed', + details: error.validation, + }, + }); + } + + const statusCode = error.statusCode ?? 500; + return reply.status(statusCode).send({ + error: { + code: 'INTERNAL_ERROR', + message: env.NODE_ENV === 'production' ? 'Internal server error' : error.message, + }, + }); + }); + + app.addHook('onRequest', async (request, reply) => { + reply.header('x-request-id', request.id); + }); + + await app.register(redisPlugin); + await app.register(databasePlugin); + await app.register(securityPlugin); + await app.register(rateLimitPlugin); + + app.get('/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() })); + + return app; +}