first commit
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
# `request-pipeline-validation` — Zod gates for request + response
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the two Zod-validation functions the §6 pipeline runs: `validateRequest` (step 6) parses body / query / params against per-route schemas, and `validateResponse` (step 9) confirms the handler-returned JSON matches the declared response shape for the resolved status. Both functions wrap their work in the shared `ErrorBoundary` so a throw becomes a fully-tagged `BoundaryFailure` instead of an unhandled rejection.
|
||||
|
||||
The extraction from `request-pipeline.ts` is purely mechanical — the bodies were lifted verbatim and reshaped to take an `ErrorBoundary` parameter instead of accessing it via `this`. No behaviour changed; every existing pipeline test continues to pass.
|
||||
|
||||
## Features
|
||||
|
||||
- `validateRequest` returns a discriminated union `{ ok: true, value: { body, query } } | { ok: false, failure }`. Body, query, and params are each opt-in via `route.options.request.{body,params,query}`.
|
||||
- `validateResponse` short-circuits to `{ ok: true }` for streaming routes, routes without declared `responses`, statuses not enumerated in `responses`, and non-JSON content types.
|
||||
- Zod errors are truncated to the first 10 issues to keep envelope `context.issues` bounded.
|
||||
- Body size is enforced by `safeReadBody`, so over-limit bodies surface as `HOST.REQUEST_VALIDATION_FAILED` with `context.bytes`.
|
||||
- Both functions emit `api.errors_total` through the boundary's standard channel — the caller adds the dedicated `api.validation.failures_total{where=request|response|response.warn}` counter.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
validateRequest,
|
||||
validateResponse,
|
||||
} from "@suaveplan/api-host/request-pipeline-validation";
|
||||
|
||||
const reqOutcome = await validateRequest(
|
||||
boundary,
|
||||
request,
|
||||
route,
|
||||
parsedQuery,
|
||||
span
|
||||
);
|
||||
if (!reqOutcome.ok) return failureFromBoundary(reqOutcome.failure);
|
||||
const { body, query } = reqOutcome.value;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
For warn-mode response validation, the caller increments a counter and proceeds with the original response:
|
||||
|
||||
```ts
|
||||
const resOutcome = await validateResponse(boundary, route, response, span);
|
||||
if (!resOutcome.ok && mode === "warn") {
|
||||
instruments.counters["api.validation.failures_total"].add(1, {
|
||||
where: "response.warn",
|
||||
plugin: route.plugin,
|
||||
route: route.path,
|
||||
});
|
||||
logger.error("response validation warning", {
|
||||
code: resOutcome.failure.code,
|
||||
});
|
||||
// continue with the handler's original response
|
||||
}
|
||||
```
|
||||
|
||||
Tests can invoke either function directly with a minimal `ErrorBoundary` to confirm step-level wiring without setting up the full pipeline.
|
||||
|
||||
## API Reference
|
||||
|
||||
```ts
|
||||
type RequestValidationOutcome =
|
||||
| { ok: true; value: { body: unknown; query: Readonly<Record<string, string>> } }
|
||||
| { ok: false; failure: BoundaryFailure };
|
||||
|
||||
type ResponseValidationOutcome =
|
||||
| { ok: true }
|
||||
| { ok: false; failure: BoundaryFailure };
|
||||
|
||||
function validateRequest(
|
||||
boundary: ErrorBoundary,
|
||||
request: Request,
|
||||
route: PipelineRoute,
|
||||
query: Readonly<Record<string, string>>,
|
||||
span: BoundarySpan
|
||||
): Promise<RequestValidationOutcome>;
|
||||
|
||||
function validateResponse(
|
||||
boundary: ErrorBoundary,
|
||||
route: PipelineRoute,
|
||||
response: Response,
|
||||
span: BoundarySpan
|
||||
): Promise<ResponseValidationOutcome>;
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Both functions delegate every error path to `HostError` so the boundary can map them to `BoundaryFailure` consistently. Direct `throw new Error(...)` inside the closure would bypass the host-code registry and surface as `HOST.HANDLER_THREW` instead of the more specific `HOST.REQUEST_VALIDATION_FAILED` / `HOST.RESPONSE_VALIDATION_FAILED` — the audit and metric labels for those two paths are spec-required.
|
||||
|
||||
`validateResponse` clones the response before reading the body so the original stream remains intact for downstream consumers (the failure-response builder, the audit envelope, the eventual return to the caller). Failing to clone would empty the body and the caller would serialise an empty payload.
|
||||
|
||||
Streaming routes (`route.options.streaming === true`) skip response validation entirely. Streaming bodies are by definition unbuffered, and forcing a `.clone().json()` on them would defeat the streaming intent and risk OOMing on long-lived responses.
|
||||
Reference in New Issue
Block a user