feat(F-003): HTTP foundation with request context and error envelope
- request_id generated or sanitized-propagated on every request (x-request-id)
- structured JSON logging (pino), one correlated line per request, injectable logger
- error envelope v2 { error: { statusCode, code, message, details? }, requestId }
- 5xx messages always generic; stack traces stay in server logs only
- explicit parseJson (zod) input validation hook at the API layer
- README HTTP contract section; deps justified in spec/tech.md
- all gates approved; verify.sh green
This commit is contained in:
@@ -1,27 +1,96 @@
|
||||
/**
|
||||
* Composition root. The only place allowed to wire modules together.
|
||||
* Cross-cutting HTTP behavior (request id, logging, errors) is explicit here.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import { registerHealthRoutes } from '../modules/health/index.js';
|
||||
import { errorEnvelope } from '../shared/errors.js';
|
||||
import { AppError, errorEnvelope } from '../shared/errors.js';
|
||||
import { createLogger, type Logger } from '../infrastructure/logging/logger.js';
|
||||
|
||||
function notFoundHandler(_request: FastifyRequest, reply: FastifyReply): void {
|
||||
void reply.code(404).send(errorEnvelope(404, 'Not Found'));
|
||||
const REQUEST_ID_HEADER = 'x-request-id';
|
||||
const SAFE_REQUEST_ID = /^[A-Za-z0-9._-]{1,128}$/;
|
||||
|
||||
export interface BuildAppDeps {
|
||||
/** Injectable logger so tests can capture structured output. */
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
function errorHandler(error: FastifyError, _request: FastifyRequest, reply: FastifyReply): void {
|
||||
const statusCode =
|
||||
error.statusCode !== undefined && error.statusCode >= 400 ? error.statusCode : 500;
|
||||
const message = statusCode >= 500 ? 'Internal Server Error' : error.message;
|
||||
void reply.code(statusCode).send(errorEnvelope(statusCode, message));
|
||||
function generateRequestId(raw: IncomingMessage): string {
|
||||
const incoming = raw.headers[REQUEST_ID_HEADER];
|
||||
const value = Array.isArray(incoming) ? incoming[0] : incoming;
|
||||
if (typeof value === 'string' && SAFE_REQUEST_ID.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Composition root. The only place allowed to wire modules together.
|
||||
*/
|
||||
export async function buildApp(): Promise<FastifyInstance> {
|
||||
const app = Fastify({ logger: false });
|
||||
export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance> {
|
||||
const logger = deps.logger ?? createLogger();
|
||||
const startTimes = new WeakMap<FastifyRequest, number>();
|
||||
|
||||
app.setErrorHandler(errorHandler);
|
||||
app.setNotFoundHandler(notFoundHandler);
|
||||
const app = Fastify({ logger: false, genReqId: generateRequestId });
|
||||
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
startTimes.set(request, performance.now());
|
||||
void reply.header(REQUEST_ID_HEADER, request.id);
|
||||
});
|
||||
|
||||
app.addHook('onResponse', async (request, reply) => {
|
||||
const startedAt = startTimes.get(request);
|
||||
const durationMs =
|
||||
startedAt === undefined ? 0 : Math.round((performance.now() - startedAt) * 100) / 100;
|
||||
logger.info(
|
||||
{
|
||||
requestId: request.id,
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
statusCode: reply.statusCode,
|
||||
durationMs,
|
||||
},
|
||||
'request completed',
|
||||
);
|
||||
});
|
||||
|
||||
app.setErrorHandler((error: FastifyError, request: FastifyRequest, reply: FastifyReply) => {
|
||||
const requestId = request.id;
|
||||
let statusCode =
|
||||
error.statusCode !== undefined && error.statusCode >= 400 && error.statusCode < 600
|
||||
? error.statusCode
|
||||
: 500;
|
||||
let code = 'INTERNAL_ERROR';
|
||||
let message = 'Internal Server Error';
|
||||
let details: AppError['details'];
|
||||
|
||||
if (error instanceof AppError) {
|
||||
statusCode = error.statusCode;
|
||||
code = error.code;
|
||||
details = error.details;
|
||||
message = statusCode < 500 ? error.message : 'Internal Server Error';
|
||||
} else if (statusCode < 500) {
|
||||
code = error.code ?? 'BAD_REQUEST';
|
||||
message = error.message;
|
||||
}
|
||||
|
||||
if (statusCode >= 500) {
|
||||
// Stack stays server-side: logs only, tagged with the request id.
|
||||
logger.error({ err: error, requestId }, 'request failed');
|
||||
} else {
|
||||
logger.info({ requestId, statusCode, code }, 'request rejected');
|
||||
}
|
||||
|
||||
void reply.code(statusCode).send(errorEnvelope(statusCode, code, message, requestId, details));
|
||||
});
|
||||
|
||||
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
||||
void reply.code(404).send(errorEnvelope(404, 'NOT_FOUND', 'Not Found', request.id));
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerHealthRoutes(instance);
|
||||
|
||||
Reference in New Issue
Block a user