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:
@@ -19,6 +19,17 @@ npm run lint # eslint + prettier check
|
||||
npm run lint:boundaries # module boundary check
|
||||
```
|
||||
|
||||
## HTTP contract
|
||||
|
||||
- Every response carries an `x-request-id` header (propagated from a safe incoming
|
||||
`x-request-id`, or a fresh UUID). Every JSON log line for a request carries the same id.
|
||||
- Errors always use one envelope:
|
||||
`{ "error": { "statusCode", "code", "message", "details?" }, "requestId" }`
|
||||
Codes: `NOT_FOUND`, `VALIDATION_ERROR`, `BAD_REQUEST`/Fastify 4xx codes, `INTERNAL_ERROR`.
|
||||
5xx messages are always generic; stack traces stay in server logs only.
|
||||
- Input validation is explicit per route: `parseJson(schema, body)` (zod) in the handler.
|
||||
- Log level via `LOG_LEVEL` env var (default `info`); logs are JSON only.
|
||||
|
||||
## Database (local dev)
|
||||
|
||||
```bash
|
||||
|
||||
13
project/package-lock.json
generated
13
project/package-lock.json
generated
@@ -10,7 +10,9 @@
|
||||
"dependencies": {
|
||||
"fastify": "^5.2.0",
|
||||
"node-pg-migrate": "^9.0.0",
|
||||
"pg": "^8.23.0"
|
||||
"pg": "^8.23.0",
|
||||
"pino": "^10.3.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
@@ -4176,6 +4178,15 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
"dependencies": {
|
||||
"fastify": "^5.2.0",
|
||||
"node-pg-migrate": "^9.0.0",
|
||||
"pg": "^8.23.0"
|
||||
"pg": "^8.23.0",
|
||||
"pino": "^10.3.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
|
||||
@@ -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);
|
||||
|
||||
205
project/src/app/tests/http-foundation.test.ts
Normal file
205
project/src/app/tests/http-foundation.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,15 @@
|
||||
import { buildApp } from '../../app/build-app.js';
|
||||
import { createLogger } from '../logging/logger.js';
|
||||
|
||||
const logger = createLogger();
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
const host = process.env.HOST ?? '0.0.0.0';
|
||||
|
||||
try {
|
||||
const app = await buildApp();
|
||||
const app = await buildApp({ logger });
|
||||
await app.listen({ port, host });
|
||||
logger.info({ port, host }, 'HTTP server listening');
|
||||
} catch (error) {
|
||||
console.error('Failed to start HTTP server', error);
|
||||
logger.error({ err: error }, 'Failed to start HTTP server');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
25
project/src/infrastructure/logging/logger.ts
Normal file
25
project/src/infrastructure/logging/logger.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Structured JSON logging. pino is the boring, fast, Fastify-ecosystem choice.
|
||||
* No pretty printers, no transports: JSON lines to a writable destination.
|
||||
*/
|
||||
import pino, { type DestinationStream, type Logger } from 'pino';
|
||||
|
||||
const SERVICE_NAME = 'mercadodevida-backend';
|
||||
|
||||
export interface LoggerOptions {
|
||||
/** Log level; defaults to LOG_LEVEL env var or 'info'. */
|
||||
level?: string;
|
||||
/** Explicit destination stream (tests use an in-memory stream). */
|
||||
destination?: DestinationStream;
|
||||
}
|
||||
|
||||
export function createLogger(options: LoggerOptions = {}): Logger {
|
||||
const level = options.level ?? process.env.LOG_LEVEL ?? 'info';
|
||||
const settings = { level, base: { service: SERVICE_NAME } };
|
||||
if (options.destination) {
|
||||
return pino(settings, options.destination);
|
||||
}
|
||||
return pino(settings);
|
||||
}
|
||||
|
||||
export type { Logger };
|
||||
@@ -1,15 +1,44 @@
|
||||
/**
|
||||
* Shared error envelope. Single shape for every API error.
|
||||
* Shared error model and envelope. Single shape for every API error.
|
||||
* Never leak stack traces or internal details to the client.
|
||||
*/
|
||||
|
||||
export type ErrorDetail = Readonly<Record<string, unknown>>;
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public readonly statusCode: number,
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
public readonly details?: ReadonlyArray<ErrorDetail>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface ErrorEnvelope {
|
||||
error: {
|
||||
statusCode: number;
|
||||
code: string;
|
||||
message: string;
|
||||
details?: ReadonlyArray<ErrorDetail>;
|
||||
};
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export function errorEnvelope(statusCode: number, message: string): ErrorEnvelope {
|
||||
return { error: { statusCode, message } };
|
||||
export function errorEnvelope(
|
||||
statusCode: number,
|
||||
code: string,
|
||||
message: string,
|
||||
requestId: string,
|
||||
details?: ReadonlyArray<ErrorDetail>,
|
||||
): ErrorEnvelope {
|
||||
return {
|
||||
error:
|
||||
details === undefined
|
||||
? { statusCode, code, message }
|
||||
: { statusCode, code, message, details },
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
18
project/src/shared/http-input.ts
Normal file
18
project/src/shared/http-input.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Explicit input validation hook for the API layer.
|
||||
* Call parseJson inside handlers; nothing is validated by magic.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { AppError, type ErrorDetail } from './errors.js';
|
||||
|
||||
export function parseJson<T>(schema: z.ZodType<T>, input: unknown): T {
|
||||
const result = schema.safeParse(input);
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
const details: ReadonlyArray<ErrorDetail> = result.error.issues.map((issue) => ({
|
||||
path: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
}));
|
||||
throw new AppError(400, 'VALIDATION_ERROR', 'Invalid request payload', details);
|
||||
}
|
||||
33
project/src/shared/tests/http-input.test.ts
Normal file
33
project/src/shared/tests/http-input.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { parseJson } from '../http-input.js';
|
||||
import { AppError } from '../errors.js';
|
||||
|
||||
const schema = z.object({ name: z.string().min(1), age: z.number().int().nonnegative() });
|
||||
|
||||
describe('parseJson', () => {
|
||||
it('returns the parsed value for valid input', () => {
|
||||
expect(parseJson(schema, { name: 'ana', age: 3 })).toEqual({ name: 'ana', age: 3 });
|
||||
});
|
||||
|
||||
it('throws AppError 400 VALIDATION_ERROR with field issues for invalid input', () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
parseJson(schema, { name: '', age: -1 });
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AppError);
|
||||
const appError = caught as AppError;
|
||||
expect(appError.statusCode).toBe(400);
|
||||
expect(appError.code).toBe('VALIDATION_ERROR');
|
||||
expect(appError.details).toEqual([
|
||||
{ path: 'name', message: expect.any(String) },
|
||||
{ path: 'age', message: expect.any(String) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects non-object input when the schema expects an object', () => {
|
||||
expect(() => parseJson(schema, 'not-an-object')).toThrowError(AppError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user