Files
skills/docs/api-stack/api/api-host/src/request-pipeline/request-pipeline-validation.md
2026-05-29 08:20:56 +01:00

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.