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,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