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:
rikrdo
2026-08-14 22:13:28 +02:00
parent 425fedd13e
commit 41f144d7bd
26 changed files with 779 additions and 29 deletions

View File

@@ -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);

View File

@@ -0,0 +1,205 @@
import { describe, expect, it } from 'vitest';
import type { DestinationStream } from 'pino';
import { z } from 'zod';
import { buildApp } from '../build-app.js';
import { createLogger } from '../../infrastructure/logging/logger.js';
import { parseJson } from '../../shared/http-input.js';
type LogLine = Record<string, unknown>;
function memoryLogger(): { logger: ReturnType<typeof createLogger>; lines: LogLine[] } {
const lines: LogLine[] = [];
const destination: DestinationStream = {
write(msg: string): void {
lines.push(JSON.parse(msg) as LogLine);
},
};
return { logger: createLogger({ level: 'info', destination }), lines };
}
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
interface ErrorBody {
error: { statusCode: number; code: string; message: string; details?: unknown[] };
requestId: string;
}
describe('http foundation', () => {
it('every response carries an x-request-id header', async () => {
const { logger } = memoryLogger();
const app = await buildApp({ logger });
const ok = await app.inject({ method: 'GET', url: '/health' });
expect(ok.headers['x-request-id']).toMatch(UUID_V4);
const missing = await app.inject({ method: 'GET', url: '/nope' });
expect(missing.headers['x-request-id']).toMatch(UUID_V4);
await app.close();
});
it('a valid incoming x-request-id is propagated to response and logs', async () => {
const { logger, lines } = memoryLogger();
const app = await buildApp({ logger });
const response = await app.inject({
method: 'GET',
url: '/health',
headers: { 'x-request-id': 'client-trace-42' },
});
expect(response.headers['x-request-id']).toBe('client-trace-42');
const completed = lines.filter((line) => line.msg === 'request completed');
expect(completed).toHaveLength(1);
expect(completed[0]?.requestId).toBe('client-trace-42');
await app.close();
});
it('a malicious x-request-id is replaced by a generated UUID', async () => {
const { logger, lines } = memoryLogger();
const app = await buildApp({ logger });
const badCharset = await app.inject({
method: 'GET',
url: '/health',
headers: { 'x-request-id': 'evil\nx-injected: 1' },
});
expect(badCharset.headers['x-request-id']).toMatch(UUID_V4);
expect(badCharset.headers['x-request-id']).not.toContain('\n');
const tooLong = await app.inject({
method: 'GET',
url: '/health',
headers: { 'x-request-id': 'a'.repeat(129) },
});
expect(tooLong.headers['x-request-id']).toMatch(UUID_V4);
const completed = lines.filter((line) => line.msg === 'request completed');
expect(completed).toHaveLength(2);
expect(completed[0]?.requestId).toBe(badCharset.headers['x-request-id']);
expect(completed[1]?.requestId).toBe(tooLong.headers['x-request-id']);
await app.close();
});
it('unknown routes return the stable envelope with code NOT_FOUND and requestId', async () => {
const { logger } = memoryLogger();
const app = await buildApp({ logger });
const response = await app.inject({ method: 'GET', url: '/does-not-exist' });
expect(response.statusCode).toBe(404);
const body = response.json() as ErrorBody;
expect(body.error.statusCode).toBe(404);
expect(body.error.code).toBe('NOT_FOUND');
expect(body.error.message).toBe('Not Found');
expect(body.requestId).toBe(response.headers['x-request-id']);
await app.close();
});
describe('validation hook on the API layer', () => {
async function buildValidatingApp() {
const memory = memoryLogger();
const app = await buildApp({ logger: memory.logger });
const schema = z.object({ name: z.string().min(1) });
app.post('/__test/echo', async (request) => {
return parseJson(schema, request.body);
});
return { app, lines: memory.lines };
}
it('malformed JSON yields the stable 400 envelope', async () => {
const { app, lines } = await buildValidatingApp();
const response = await app.inject({
method: 'POST',
url: '/__test/echo',
headers: { 'content-type': 'application/json' },
body: '{"name": broken',
});
expect(response.statusCode).toBe(400);
const body = response.json() as ErrorBody;
expect(body.error.statusCode).toBe(400);
expect(typeof body.error.code).toBe('string');
expect(typeof body.error.message).toBe('string');
expect(body.requestId).toBe(response.headers['x-request-id']);
expect(response.body).not.toContain('stack');
const rejected = lines.filter((line) => line.msg === 'request rejected');
expect(rejected).toHaveLength(1);
expect(rejected[0]?.requestId).toBe(body.requestId);
await app.close();
});
it('schema-invalid payloads yield code VALIDATION_ERROR with field paths only', async () => {
const { app } = await buildValidatingApp();
const response = await app.inject({
method: 'POST',
url: '/__test/echo',
headers: { 'content-type': 'application/json' },
payload: { name: '' },
});
expect(response.statusCode).toBe(400);
const body = response.json() as ErrorBody;
expect(body.error.code).toBe('VALIDATION_ERROR');
expect(body.error.details).toEqual([{ path: 'name', message: expect.any(String) }]);
expect(JSON.stringify(body)).not.toContain('stack');
await app.close();
});
it('valid payloads pass through the hook', async () => {
const { app } = await buildValidatingApp();
const response = await app.inject({
method: 'POST',
url: '/__test/echo',
headers: { 'content-type': 'application/json' },
payload: { name: 'ana' },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ name: 'ana' });
await app.close();
});
});
it('internal errors never leak message or stack to the client', async () => {
const { logger, lines } = memoryLogger();
const app = await buildApp({ logger });
app.get('/__test/boom', () => {
throw new Error('secret internal detail');
});
const response = await app.inject({ method: 'GET', url: '/__test/boom' });
expect(response.statusCode).toBe(500);
const body = response.json() as ErrorBody;
expect(body.error.code).toBe('INTERNAL_ERROR');
expect(body.error.message).toBe('Internal Server Error');
expect(response.body).not.toContain('secret internal detail');
expect(response.body).not.toContain('stack');
const failed = lines.filter((line) => line.msg === 'request failed');
expect(failed).toHaveLength(1);
expect(failed[0]?.requestId).toBe(response.headers['x-request-id']);
const err = failed[0]?.err as { message?: string; stack?: string };
expect(err.message).toBe('secret internal detail');
expect(err.stack).toContain('Error');
await app.close();
});
it('every request produces exactly one request completed log line', async () => {
const { logger, lines } = memoryLogger();
const app = await buildApp({ logger });
await app.inject({ method: 'GET', url: '/health' });
await app.inject({ method: 'GET', url: '/missing' });
const completed = lines.filter((line) => line.msg === 'request completed');
expect(completed).toHaveLength(2);
for (const line of completed) {
expect(line.requestId).toMatch(UUID_V4);
expect(line).toHaveProperty('method');
expect(line).toHaveProperty('statusCode');
expect(line).toHaveProperty('durationMs');
}
await app.close();
});
});