Validate HTTP requests and responses against your OpenAPI spec. OpenAPI 3.0, 3.1, and 3.2, for JavaScript and TypeScript services. Large JSON bodies stream through a separate validator that checks them as they arrive, and a design-time analyzer reports which bodies stream and which must buffer before you ship.
import { createValidator } from "@aahoughton/oav";
const validator = createValidator(document); // your parsed OpenAPI spec
const result = validator.validateRequest({
method: "POST",
path: "/pets",
contentType: "application/json",
body: { name: "Fido" },
});
if (!result.valid) {
console.log(result.errors);
// [{ code: "required", path: ["body", "age"], message: "...", params: {} }]
}One call covers the whole HTTP frame: method, path, parameters, body,
content type, status, and headers. Errors come back as a flat list of
typed leaves (code, path, message, params) you render yourself,
or a nested tree on request. Invalid input is a return value, not a
thrown exception, and oav never mutates your req / res.
Reach for oav when you want
- A pre-deploy answer to "which request and response bodies can stream,
which must buffer, and how large can a buffer get?"
analyzeSpecreports a per-operation peak-buffer budget from the spec alone;oav stream-check openapi.yamlprints it as a table. - To validate large JSON bodies as they arrive, without holding a
multi-GB payload in heap. A separate streaming engine
(
@aahoughton/oav-stream-validator) checks the body against its operation schema while echoing the bytes through. - Structured errors with stable codes and paths, ready for
application/problem+json, logs, or your own client messages. - A spec compiled to a single zero-dependency module that runs where
new Function()is unavailable: Cloudflare Workers, Vercel Edge, Lambda@Edge, Deno Deploy. (oav compile-spec openapi.yaml.) - To extend a spec you don't own (a per-tenant required header, an added auth requirement, a tightened schema) with overlays instead of forking.
At a glance:
| If you need | Start with |
|---|---|
| Generic JSON Schema validation across many drafts | Ajv |
| Turnkey Express middleware with uploads + auth | express-openapi-validator |
| Framework-neutral OpenAPI request/response checking | oav |
| Streaming validation of large JSON bodies | oav |
| A pre-deploy report of which bodies can stream | oav |
| Per-tenant or per-deployment spec overlays | oav |
| A standalone validator for edge / serverless | oav |
See docs/comparison.md for the full feature map.
Tested against the JSON Schema 2020-12 test suite, OpenAPI 3.0 / 3.1 / 3.2 fixtures, real-world specs (Stripe, GitHub, Twilio, and more), and Express 4 / 5 + Fastify integration. See what works today.
Pick the package that matches what you need. (oav is the shorthand
used elsewhere in the docs for @aahoughton/oav.)
| You need | Install |
|---|---|
YAML specs, HTTP/YAML readers, and the oav CLI |
@aahoughton/oav |
| JSON or pre-parsed specs with zero runtime deps | @aahoughton/oav-core |
| Express 4 request middleware | @aahoughton/oav + @aahoughton/oav-express4 |
| Express 5 request middleware | @aahoughton/oav + @aahoughton/oav-express5 |
Fastify preValidation hook |
@aahoughton/oav + @aahoughton/oav-fastify |
| Streaming large bodies + buffer-budget analysis | @aahoughton/oav-stream-validator (+ @aahoughton/oav or @aahoughton/oav-core to load the spec) |
| Edge/serverless validator emitted at build time | @aahoughton/oav as a dev/build dependency |
oav is a superset of oav-core: same programmatic surface plus YAML
readers and the oav CLI. The CLI's commander and esbuild deps
load on demand from the binary entry, never from the library
entrypoints, so application bundles tree-shake them out.
npm install @aahoughton/oav # YAML + CLI
npm install @aahoughton/oav-core # JSON only, zero runtime deps
npm install @aahoughton/oav-express4 # Express 4 adapter (transitively pulls oav-core)
npm install @aahoughton/oav-express5 # Express 5 adapter
npm install @aahoughton/oav-fastify # Fastify adapter
npm install @aahoughton/oav-stream-validator # streaming body validation + analyzeSpecWant to try it against your own spec before wiring anything in? The CLI validates a request from the command line, no code required:
npx @aahoughton/oav validate openapi.yaml --path "POST /pets" --body pet.jsonA valid request prints nothing and exits 0; validation errors print to
stdout and exit non-zero. (Redirect with --output <file>, or silence the
report and rely on the exit code with --quiet.)
oav re-exports oav-core at five subpath entrypoints (/schema,
/spec, /overlay-spec, /formats, /core); on the lean package,
substitute oav-core in imports that don't touch the YAML readers
(createYamlFileReader, createSmartHttpReader) or the CLI. See
docs/modules.md for what each subpath exports.
import express from "express";
import { createValidator, createYamlFileReader } from "@aahoughton/oav";
import { loadSpec } from "@aahoughton/oav/spec";
import { validateRequests } from "@aahoughton/oav-express5";
const { document } = await loadSpec({
reader: createYamlFileReader(),
entry: "openapi.yaml",
});
const validator = createValidator(document);
const app = express();
app.use(express.json());
app.use(validateRequests(validator));
app.post("/pets", (req, res) => res.json({ ok: true }));Invalid requests receive an application/problem+json response.
Valid requests continue to your route handlers. Express 4 uses the
same shape with oav-express4; Fastify uses oav-fastify as a
preValidation hook. See docs/integration.md.
import { createValidator, createYamlFileReader, formatText } from "@aahoughton/oav";
import { loadSpec } from "@aahoughton/oav/spec";
const { document } = await loadSpec({
reader: createYamlFileReader(),
entry: "openapi.yaml",
});
const validator = createValidator(document);
const result = validator.validateRequest({
method: "POST",
path: "/pets",
contentType: "application/json",
headers: { "x-tenant": "acme" },
body: { name: "Fido" },
});
if (!result.valid) console.error(formatText(result.errors));For a multi-file spec or a spec hosted over HTTP, compose readers:
composeReaders([createYamlFileReader(), createSmartHttpReader(), createFileReader()])
handles local YAML, remote JSON / YAML, and local JSON transparently.
validateRequest / validateResponse return { valid: true }, or
{ valid: false, errors, truncated } on failure. The default is a flat
errors list that stops at the first problem (maxErrors: 1);
maxErrors and output: "tree" | "predicate" tune count and shape.
Each leaf carries a stable code, an HTTP-rooted path (e.g.
["body", "pets", 3, "name"]), a message, and a params object; see
docs/configuration.md
and the ValidatorOptions TSDoc for the full contract.
Runnable end-to-end demos in examples/:
custom formats, custom keywords, cross-field constraints, error
budgets, version differences, overlays, and spec-derived middleware
config.
createValidator validates a fully-parsed value. For a body too large
to hold in memory, the separate @aahoughton/oav-stream-validator
package validates it as it streams, echoing the bytes through to a sink
while reporting violations on a side channel. It is a second engine,
not a mode of createValidator: your router still picks the operation,
and the validator checks one resolved schema.
import { pipeline } from "node:stream/promises";
import { streamValidatorForOperation } from "@aahoughton/oav-stream-validator";
// `document` is the parsed spec from loadSpec, as above.
const validator = streamValidatorForOperation(document, { method: "post", path: "/pets" });
validator.on("violation", (v) => console.warn(v.code, v.path, v.byteOffset));
await pipeline(request, validator, sink);
const { valid, peakBufferedBytes } = await validator.result;Not every schema can stream: uniqueItems, contains, an
object-level const, or an asserting format force a subtree to
buffer. analyzeSpec answers which bodies stream and which buffer (and
how large a buffer can get) from the spec alone, before any traffic:
import { analyzeSpec } from "@aahoughton/oav-stream-validator";
for (const op of analyzeSpec(document).operations) {
for (const body of op.bodies) {
console.log(`${op.method} ${op.path} ${body.role}: ${body.report?.peakBytes ?? body.error}`);
}
}oav stream-check openapi.yaml prints the same per-operation budget as
a table (--fail-on-unbounded makes it a CI gate). The stream validator
versions independently of the oav-core family on its own 1.x line;
see packages/stream-validator/README.md
for the engine, the buffer model, and the edit hooks.
applyOverlays rewrites the document in memory before the validator
is constructed. Typical shapes:
import { applyOverlays } from "@aahoughton/oav/spec";
import type { SpecOverlay } from "@aahoughton/oav/spec";
// Add a deployment-specific server. `addServers` appends; `servers`
// replaces wholesale.
const eu: SpecOverlay = {
addServers: [{ url: "https://eu.api.example.com" }],
};
// Require an API key on POST /pets without forking the base spec.
const requireKey: SpecOverlay = {
overrides: {
"/pets": {
operations: { post: { addSecurity: [{ apiKey: [] }] } },
},
},
};
// Apply one rule to every operation matching a tag (walks paths and
// webhooks).
const lockInternals: SpecOverlay = {
modifyOperations: [
{
where: { tags: ["internal"] },
apply: { addSecurity: [{ internalKey: [] }] },
},
],
};
// Tighten an upstream schema; the original definition still applies.
const requirePetId: SpecOverlay = {
extendSchemas: { Pet: { required: ["id"] } },
};
const patched = applyOverlays(document, [eu, requireKey, lockInternals, requirePetId]);
const validator = createValidator(patched);The full verb surface (component-bucket fan-out, predicate iterators,
operation-level metadata) is documented in
docs/overlays.md; cross-cutting integration
shapes live in docs/integration.md.
| Task | Read |
|---|---|
| Wire into Express, Fastify, Next.js, Hono | docs/integration.md |
| Stream large bodies / check buffer budgets | packages/stream-validator/README.md |
| Patch a spec you do not own | docs/overlays.md |
| Emit standalone validators | packages/cli/README.md |
| Compare against Ajv and other tools | docs/comparison.md |
| Migrate from express-openapi-validator | docs/migration-from-eov.md |
| Use custom formats, keywords, or limits | docs/configuration.md |
The JavaScript ecosystem already has solid OpenAPI validation tools:
Ajv for JSON Schema, express-openapi-validator for Express,
openapi-backend for operationId routing plus validation, and smaller
request/response validators for custom stacks. oav is aimed at
HTTP-aware validation with structured errors, streaming validation of
large bodies plus design-time buffer budgets, overlays, and standalone
OpenAPI validator output. See docs/comparison.md
for the feature map, and docs/migration-from-eov.md
if you are migrating from express-openapi-validator.
On raw speed, oav and Ajv trade wins: oav compiles schemas one to two
orders of magnitude faster, validates competitively on typical bodies,
and runs a touch lighter on memory than express-openapi-validator;
Ajv leads on fast-fail rejection of some plain object shapes. At normal
request volumes these gaps are nanoseconds per call.
For the host-stamped per-shape numbers, the memory comparison, and the
methodology, see docs/comparison.md.
Raw benchmark data lives in
performance/.
The conformance/ sub-package drives the
compiler and CLI against the upstream JSON Schema 2020-12 Test Suite,
a set of OpenAPI 3.0 / 3.1 / 3.2 petstore scenarios, and a handful of
real-world specs (Stripe, GitHub, DigitalOcean, Twilio, Asana, Box,
Adyen) that have to load and compile without error. See
conformance/REPORT.md for pass / fail
counts by category.
Categories oav does not aim to cover:
$dynamicRefwith runtime dynamic-scope rebinding (oav resolves statically against the anchor map).- The
optional/format/*subtree (formatis annotation-only by default per JSON Schema 2020-12 §6.3). - A small tail of isolated optional cases (float-overflow handling, external-ref loading tied to dynamic scope).
OpenAPI specs hand-authored or generated for typical APIs rarely touch any of these. If they matter for your use case, the report lays out which tests fail and why.
oav resolve openapi.yaml
oav validate openapi.yaml --request req.http
oav validate openapi.yaml --path "POST /pets" --body payload.json
oav validate openapi.yaml --path "GET /pets" --response --status 200 --body resp.json
oav compile-schema schema.json -o validator.mjs # JSON Schema -> standalone validator
oav compile-spec openapi.yaml -o validator.mjs # OpenAPI -> standalone HTTP validator (edge / Lambda)
oav stream-check openapi.yaml # per-operation streamability + peak-buffer budgetFlags: --format text|json|summary, --depth n, --overlay file
(repeatable), -o file, --quiet, --dialect (compile-schema /
compile-spec), --requests-only (compile-spec), --only METHOD PATH
(compile-spec, repeatable), --verbose / --fail-on-unbounded /
--envelope json (stream-check). See
packages/cli/README.md for the full
surface, the .http file format, and both compile commands' output
contracts.
createValidator reads the spec's openapi string once at construction
and picks the matching dialect. No per-request branching.
| Spec | Dialect | Notes |
|---|---|---|
| 3.0.x | OAS 3.0 Schema Object | nullable, boolean exclusiveMin/Max, sibling-$ref drop |
| 3.1.x | JSON Schema 2020-12 | Assertive format |
| 3.2.x | JSON Schema 2020-12 | Same as 3.1 + the QUERY HTTP method |
3.2 coverage is the Schema Object (unchanged from 3.1) plus QUERY.
Other 3.2 document-level additions (additionalOperations,
in: querystring, streaming media types) aren't recognized yet.
Override via createValidator(spec, { dialect }) to force or customize
one of the built-in dialects (jsonSchemaDialect, openapi31Dialect,
oas30Dialect). Unknown / missing openapi strings fall back to the
3.1 dialect by default; configure with
onUnknownVersion: "throw" | "warn" | "fallback31".
Swagger 2.0 specs aren't supported directly: createValidator
throws on swagger: "2.0" documents. Convert to OpenAPI 3.0 first with
swagger2openapi
and pass the 3.0 output to createValidator:
npx swagger2openapi swagger.json -o openapi.jsoncreateValidator(spec, options) accepts options for dialect override,
custom formats and keywords, error budget, strict-mode lint, security
shape-checking, ignored paths, and version-mismatch policy. See
docs/configuration.md for the option
table, custom-keyword recipe, and bounded-error-collection details.
The canonical contract is the ValidatorOptions TSDoc.
oav is a validator, not a middleware package: you write a short
adapter between your framework and validateRequest /
validateResponse, or use one of the companion adapter packages. An
inline Express 5 adapter is about this long:
import { allowHeaderFor, httpStatusFor, toProblemDetails } from "@aahoughton/oav";
app.use(async (req, res, next) => {
const result = validator.validateRequest({
method: req.method,
path: req.path,
query: req.query as Record<string, string | string[]>,
headers: req.headers as Record<string, string | string[]>,
contentType: req.get("content-type") ?? undefined,
body: req.body,
});
if (result.valid) return next();
const allow = allowHeaderFor(result.errors);
if (allow !== undefined) res.setHeader("Allow", allow);
res
.status(httpStatusFor(result.errors))
.type("application/problem+json")
.json(toProblemDetails(result.errors, { instance: req.originalUrl }));
});httpStatusFor, allowHeaderFor, and toProblemDetails accept either
the flat errors list or a tree error, so this wiring is the same
whichever output the validator uses.
Companion adapter packages cover common request-validation wiring:
oav-express4,
oav-express5, and
oav-fastify. They share export
names and option shapes; only the framework type differs.
For Next.js, Hono, Bun, Deno, and custom frameworks, use the
framework-agnostic validateRequest / validateResponse calls or the
Fetch helpers (validateFetchRequest, validateFetchResponse). See
docs/integration.md for body parsing,
response validation, uploads, security, ignored paths, and custom error
envelopes.
oav is not a drop-in replacement for express-openapi-validator.
The adapters cover request validation; response validation, auth
dispatch, upload parsing, and custom error envelopes stay explicit in
your application. In return, the validator does not mutate req or
res, OpenAPI 3.0 behavior is built into the dialect, and failures
come back as structured errors (a flat list by default, a nested tree
on request) rather than framework-specific error classes.
Runtime behavior corners. For feature-scope tradeoffs against Ajv and
OpenAPI middleware packages (draft versions, $data, async
validation, response interception, upload helpers), see
docs/comparison.md.
$dynamicRefbehaves like$refwith anchor lookup; no runtime dynamic-scope traversal.style: deepObjectquery parameters support only single-level nesting (obj[key]=value); OpenAPI 3.0–3.2 don't define nested semantics.patternkeywords andformat: "regex"compile to the JavaScript built-inRegExp, which has no execution timeout. If your OpenAPI spec is attacker-controlled (e.g. multi-tenant upload), a catastrophic pattern like(a+)+$is a ReDoS vector against any string the validator checks. Pass aregexCompilertocreateValidatorto plug inre2or a complexity-checking engine; see "Hardening against untrusted regex patterns" .- Recursive schemas validate by recursing on the JavaScript call
stack. Unbounded, a deeply nested payload (a few thousand levels,
only a few KB on the wire) can exhaust the stack and throw
RangeError: Maximum call stack size exceeded. Set themaxDepthoption (CompileOptions/ValidatorOptions) to bound recursion at the validator: a payload past the cap fails as adeptherror (HTTP 400) instead of crashing. For untrusted input setmaxDepth, and optionally cap nesting at the parse boundary as a backstop; see "Guarding against deeply nested payloads" .
See CONTRIBUTING.md for branch / PR / release flow. Development workflow (lint / typecheck / test / build) and the conformance and performance sub-packages are described there and in CLAUDE.md.
MIT. See LICENSE.