91 lines
4.2 KiB
Markdown
91 lines
4.2 KiB
Markdown
# `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.
|