mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
feat(server): @ResponseContract checks a handler's responses against its shared schema in tests
The decorator records the @trek/shared response schema a route answers with. Under NODE_ENV=test it adds an interceptor that parses every response and fails the request with a logged 500 naming the handler and the issues, so the e2e and integration suites catch a response that drifted from its contract; in production nothing is added to the request path and the body is never rewritten. lint:response-contracts holds the handlers still without one to a per-file baseline that only shrinks, and runs in server-quality.
This commit is contained in:
@@ -65,7 +65,9 @@ over an injected `AddonsService`.
|
||||
## Cross-cutting pieces
|
||||
|
||||
- `common/`: the exception filter, the Zod validation pipe, the idempotency
|
||||
interceptor (`X-Idempotency-Key` replay), `validate-route-guards.ts`, and
|
||||
interceptor (`X-Idempotency-Key` replay), `@ResponseContract` (the shared
|
||||
response schema a handler answers with, checked in every test that reaches
|
||||
it), `validate-route-guards.ts`, and
|
||||
stateless helpers (`avatarUrl`, `conflictResult`, `demo`, `passwordPolicy`,
|
||||
`timezoneService`, `cookie`, `rowShape`, `geo`, `crypto/`). The helpers are
|
||||
free functions on purpose: numbered migrations and `src/demo/demo-seed.ts`
|
||||
@@ -222,7 +224,8 @@ is measured, so moving code into this tree starts measuring it: run
|
||||
|
||||
## Definition of done (per module)
|
||||
|
||||
Contract in `@trek/shared` → service over injected repositories → controller
|
||||
with the routes, statuses and bodies of the contract → the parallel MCP tool on
|
||||
Contract in `@trek/shared` (request and response schemas) → service over
|
||||
injected repositories → controller with the routes, statuses and bodies of the
|
||||
contract, each handler declaring its `@ResponseContract` → the parallel MCP tool on
|
||||
the same service and permission → unit and e2e tests → module registered in
|
||||
`app.module.ts` → the client calls it through the typed contract.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { readEnv } from '../../app-config';
|
||||
import {
|
||||
applyDecorators,
|
||||
SetMetadata,
|
||||
UseInterceptors,
|
||||
type CallHandler,
|
||||
type ExecutionContext,
|
||||
type NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { map, type Observable } from 'rxjs';
|
||||
import type { ZodType } from 'zod';
|
||||
|
||||
/** The metadata key `@ResponseContract()` writes: the handler's response schema from @trek/shared. */
|
||||
export const RESPONSE_CONTRACT_KEY = 'trekResponseContract';
|
||||
|
||||
/** How many Zod issues a violation names; the first few are enough to find the drift. */
|
||||
const ISSUES_SHOWN = 5;
|
||||
|
||||
/**
|
||||
* A handler answered with a body its shared response schema does not describe. A plain
|
||||
* error on purpose: the exception filter answers it with the generic 500 and logs it
|
||||
* with its message (to the access log, or to the console in a hand-built test app), so
|
||||
* a failing test prints which handler drifted and where.
|
||||
*/
|
||||
export class ResponseContractViolation extends Error {
|
||||
override name = 'ResponseContractViolation';
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws a ResponseContractViolation when `body` does not parse under `schema`. The body
|
||||
* itself is never replaced by the parse result: the contract describes the wire, and a
|
||||
* parse that strips or coerces must not change what the client receives.
|
||||
*/
|
||||
export function assertResponseContract(schema: ZodType, body: unknown, where: string): void {
|
||||
const result = schema.safeParse(body);
|
||||
if (result.success) return;
|
||||
const issues = result.error.issues
|
||||
.slice(0, ISSUES_SHOWN)
|
||||
.map((issue) => `${issue.path.length ? issue.path.join('.') : '(root)'}: ${issue.message}`)
|
||||
.join('; ');
|
||||
throw new ResponseContractViolation(`${where} answered outside its response contract: ${issues}`);
|
||||
}
|
||||
|
||||
/** Parses every response of one handler against its schema (see ResponseContract). */
|
||||
export class ResponseContractInterceptor implements NestInterceptor {
|
||||
constructor(private readonly schema: ZodType) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const where = `${context.getClass().name}.${context.getHandler().name}`;
|
||||
return next.handle().pipe(
|
||||
map((body: unknown) => {
|
||||
assertResponseContract(this.schema, body, where);
|
||||
return body;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the response contracts are checked: under NODE_ENV=test only. Decided once,
|
||||
* when a controller class is defined, so a production build carries no interceptor at
|
||||
* all on these routes rather than one that asks the environment on every request. The
|
||||
* test setup sets NODE_ENV before any controller is imported.
|
||||
*/
|
||||
export function responseContractsEnforced(): boolean {
|
||||
return readEnv().app.isTest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares the shape a handler answers with: a response schema from @trek/shared, the
|
||||
* same one the client can type its call with.
|
||||
*
|
||||
* Under NODE_ENV=test every response of the handler is parsed against it, and a body
|
||||
* the schema does not describe fails the request with a 500, logged with the handler and
|
||||
* the first issues, so every e2e or integration test that reaches the route becomes a drift
|
||||
* check. In production it only records the schema (the metadata the ratchet and the
|
||||
* docs read) and adds nothing to the request path.
|
||||
*
|
||||
* Only for handlers whose return value is the JSON body. A handler that writes through
|
||||
* `@Res()` (a stream, a redirect, a file) returns nothing to check and stays without
|
||||
* one, marked `response-contract-exempt: <reason>` in a comment above it.
|
||||
*
|
||||
* When a check fails, the schema is what gets fixed, to describe the response the route
|
||||
* really sends: parity is law, the response does not change to suit the contract.
|
||||
*/
|
||||
export function ResponseContract(schema: ZodType): MethodDecorator {
|
||||
const decorators: MethodDecorator[] = [SetMetadata(RESPONSE_CONTRACT_KEY, schema)];
|
||||
if (responseContractsEnforced()) decorators.push(UseInterceptors(new ResponseContractInterceptor(schema)));
|
||||
return applyDecorators(...decorators);
|
||||
}
|
||||
Reference in New Issue
Block a user