4.2 KiB
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
validateRequestreturns a discriminated union{ ok: true, value: { body, query } } | { ok: false, failure }. Body, query, and params are each opt-in viaroute.options.request.{body,params,query}.validateResponseshort-circuits to{ ok: true }for streaming routes, routes without declaredresponses, statuses not enumerated inresponses, and non-JSON content types.- Zod errors are truncated to the first 10 issues to keep envelope
context.issuesbounded. - Body size is enforced by
safeReadBody, so over-limit bodies surface asHOST.REQUEST_VALIDATION_FAILEDwithcontext.bytes. - Both functions emit
api.errors_totalthrough the boundary's standard channel — the caller adds the dedicatedapi.validation.failures_total{where=request|response|response.warn}counter.
Usage: Basic
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:
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
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.