# `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> } } | { ok: false; failure: BoundaryFailure }; type ResponseValidationOutcome = | { ok: true } | { ok: false; failure: BoundaryFailure }; function validateRequest( boundary: ErrorBoundary, request: Request, route: PipelineRoute, query: Readonly>, span: BoundarySpan ): Promise; function validateResponse( boundary: ErrorBoundary, route: PipelineRoute, response: Response, span: BoundarySpan ): Promise; ``` ## 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.