Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-openapi-body-validation-http-core.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inversifyjs/http-core": minor
---

Export `getControllerMethodParameterMetadataList` function and `ControllerMethodParameterMetadata` type from the public API.
5 changes: 5 additions & 0 deletions .changeset/add-openapi-body-validation-http-open-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inversifyjs/http-open-api": minor
---

Add `openApiObject` getter to `SwaggerUiProvider` (v3.1 and v3.2) to expose the populated OpenAPI spec object. Export `getControllerOpenApiMetadata`, `controllerOpenApiMetadataReflectKey`, and `ControllerOpenApiMetadata` from both version subpaths.
5 changes: 5 additions & 0 deletions .changeset/add-openapi-body-validation-new-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inversifyjs/http-openapi-validation": minor
---

Add `@inversifyjs/http-openapi-validation` package for OpenAPI-driven request body validation. Includes `@Validate()` parameter decorator and `OpenApiValidationPipe` for both OpenAPI v3.1 and v3.2, with Ajv-based schema validation and content-type resolution.
1 change: 1 addition & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
[
"@inversifyjs/ajv-validation",
"@inversifyjs/class-validation",
"@inversifyjs/http-openapi-validation",
"@inversifyjs/standard-schema-validation",
"@inversifyjs/validation-common"
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
slug: openapi-body-validation-zero-extra-schemas
title: "OpenAPI Body Validation — Your Spec Is Your Schema"
authors: [notaphplover]
tags: [features]
---
import apiIntroductionSource from '@inversifyjs/validation-code-examples/generated/examples/v1/openApiValidation/apiIntroduction.ts.txt';
import CodeBlock from '@theme/CodeBlock';

Raise your hand if you've ever copy-pasted a JSON schema from your OpenAPI spec into a validation layer. Now keep it raised if the two drifted apart within a week.

Yeah, we've been there too.

{/* truncate */}

## The Problem Nobody Talks About

Every validation library asks you to define schemas. Ajv wants JSON Schema objects. Class-validator wants decorated classes. Zod wants its own DSL. And then your OpenAPI spec describes the **exact same shape** in `requestBody`. You end up maintaining two copies of the truth — and they inevitably disagree.

The result? Your Swagger UI happily shows one contract while your server enforces a completely different one. Bugs sneak in, clients get confused, and you spend Friday afternoon figuring out which version is "right."

## What If Your OpenAPI Spec Was Enough?

That's the idea behind `@inversifyjs/http-openapi-validation`. If you're already decorating your controllers with `@OasRequestBody`, you've already written your validation schema — you just didn't know it.

This package reads the OpenAPI schemas you defined through `@inversifyjs/http-open-api`, compiles them with [Ajv](https://ajv.js.org/), and validates incoming request bodies automatically. **Zero extra schema definitions. Zero drift.**

## See It in Action

Here's a complete working example — from defining the controller to wiring up validation:

<CodeBlock language="ts">{apiIntroductionSource}</CodeBlock>

That's it. The `@OasRequestBody` decorator defines the contract. The `@Validate()` decorator opts a parameter into validation. And `OpenApiValidationPipe` bridges the two by resolving schemas from the populated OpenAPI document.

When a request body doesn't match the spec, the pipe throws an `InversifyValidationError` with detailed Ajv error messages. The `InversifyValidationErrorFilter` catches it and responds with a `400 Bad Request` — exactly what your clients expect.

## How It Works Under the Hood

1. **Schema extraction** — When you call `swaggerProvider.provide(container)`, the `@OasRequestBody` metadata is merged into the OpenAPI document. `OpenApiValidationPipe` receives the fully populated spec.

2. **JSON Pointer resolution** — For each request, the pipe resolves the matching operation, finds the `content` entry for the incoming `Content-Type`, and walks to the `schema` property using a JSON Pointer.

3. **Lazy Ajv compilation** — Schemas are compiled into Ajv validators on first use and cached. Subsequent requests with the same schema skip compilation entirely.

4. **Validation** — The compiled validator runs against the request body. On failure, an `InversifyValidationError` is thrown with all Ajv error details.

The whole pipeline adds negligible overhead to your request cycle — Ajv is one of the fastest JSON Schema validators available, and lazy compilation means cold starts don't pay the full cost upfront.

## OpenAPI 3.1 and 3.2

Both OpenAPI versions are supported through dedicated subpath exports:

```typescript
// OpenAPI 3.1
import { OpenApiValidationPipe, Validate } from '@inversifyjs/http-openapi-validation';

// OpenAPI 3.2
import { OpenApiValidationPipe, Validate } from '@inversifyjs/http-openapi-validation/v3Dot2';
```

The API is identical across versions — only the underlying OpenAPI types differ.

## Why This Matters

- **Single source of truth** — Your OpenAPI spec **is** your runtime validation. No duplication.
- **Automatic consistency** — Change an `@OasRequestBody` decorator, and validation updates instantly. No second file to remember.
- **Battle-tested validation engine** — Powered by Ajv with `ajv-formats`, supporting `email`, `uri`, `date-time`, and dozens more string formats out of the box.
- **Gradual adoption** — Add `@Validate()` to the parameters you want validated. Everything else stays untouched.
- **Framework-agnostic** — Works with Express, Fastify, Hono, uWebSockets.js, and any adapter supported by InversifyJS HTTP.

## Getting Started

Install the package alongside its peer dependency:

```bash
npm install @inversifyjs/http-openapi-validation ajv ajv-formats
```

Then head over to the [OpenAPI Validation documentation](../../validation/openapi/introduction) for a detailed walkthrough, API reference, and more examples.

If you're already using `@inversifyjs/http-open-api` to document your API, adding body validation is a five-minute change. And you'll never need to maintain a separate set of schemas again.

---

Have questions or feedback? Open an issue on [GitHub](https://github.com/inversify/monorepo) or join the conversation on Discord. We'd love to hear how you're using it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"label": "OpenAPI",
"position": 5
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
sidebar_position: 2
title: API
---
import apiOpenApiValidationPipeSource from '@inversifyjs/validation-code-examples/generated/examples/v1/openApiValidation/apiOpenApiValidationPipe.ts.txt';
import apiValidateDecoratorSource from '@inversifyjs/validation-code-examples/generated/examples/v1/openApiValidation/apiValidateDecorator.ts.txt';
import CodeBlock from '@theme/CodeBlock';

## OpenApiValidationPipe

Validates incoming request bodies against the schemas defined in your OpenAPI specification. The pipe uses Ajv for JSON Schema validation and supports format validation via `ajv-formats`.

```ts
constructor(
openApiObject: OpenApi3Dot1Object,
requestContentTypeProvider?: () => string | undefined,
)
```

Parameters

- **openApiObject** (`OpenApi3Dot1Object` or `OpenApi3Dot2Object`): The populated OpenAPI document. Use `SwaggerUiProvider.openApiObject` to get a fully populated spec after calling `provide(container)`.
- **requestContentTypeProvider** (optional `() => string | undefined`): A function that returns the `Content-Type` header of the current request. When omitted, the pipe falls back to the single declared content type in the OpenAPI spec.

### Schema resolution

The pipe constructs a JSON pointer to locate the schema for each request:

```
<spec-id>#/paths/<path>/<method>/requestBody/content/<content-type>/schema
```

The path and method are resolved from the controller metadata. The content type is resolved using the following strategy:

1. If a `requestContentTypeProvider` is given and returns a value, use it.
2. If the operation declares exactly one content type, use it as the fallback.
3. Otherwise, throw an error — the content type is ambiguous.

### Lazy initialization

The Ajv instance is created on the first call to `execute()`. This avoids compilation overhead during application bootstrap and ensures the OpenAPI spec is fully populated before schema compilation.

### Example: register an OpenApiValidationPipe globally

<CodeBlock language="ts">{apiOpenApiValidationPipeSource}</CodeBlock>

## Validate

Marks a controller method parameter for OpenAPI schema validation. Only parameters decorated with `@Validate()` are validated by the `OpenApiValidationPipe`.

```ts
Validate(): ParameterDecorator
```

Apply `@Validate()` alongside `@Body()` on the parameter you want to validate:

```ts
@Post('/')
public createUser(@Validate() @Body() user: User): string {
return `Created user: ${user.name}`;
}
```

The `@Validate()` decorator stores a boolean marker on the parameter index. During request processing, the `OpenApiValidationPipe` checks for this marker and skips parameters that aren't decorated.

### Example: validate request body with OpenAPI schema

<CodeBlock language="ts">{apiValidateDecoratorSource}</CodeBlock>

### Error Handling

When validation fails, the pipe throws an `InversifyValidationError` with a detailed message containing:
- The JSON Schema path where validation failed
- The instance path in the request body
- The validation error message from Ajv

For example:
```
[schema: #/properties/email/format, instance: /email]: "must match format \"email\""
```

Use `InversifyValidationErrorFilter` (from `@inversifyjs/http-validation`) or a custom `@CatchError(InversifyValidationError)` filter to convert these errors into HTTP 400 responses.
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
sidebar_position: 1
title: Introduction
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import apiIntroductionSource from '@inversifyjs/validation-code-examples/generated/examples/v1/openApiValidation/apiIntroduction.ts.txt';
import CodeBlock from '@theme/CodeBlock';

The OpenAPI Validation package validates incoming request bodies against your existing [OpenAPI](https://www.openapis.org/) specification — no extra schema definitions needed. If you already document your API with `@OasRequestBody` decorators, this package reuses those schemas at runtime to ensure that every request matches the contract.

It uses [Ajv](https://ajv.js.org/) under the hood for fast, standards-compliant JSON Schema validation and supports both OpenAPI v3.1 and v3.2.

## Install dependencies

Install the package along with its peer dependencies:

<Tabs>
<TabItem value="npm" label="npm">

```bash
npm install ajv ajv-formats @inversifyjs/http-openapi-validation
```

</TabItem>
<TabItem value="yarn" label="yarn">

```bash
yarn add ajv ajv-formats @inversifyjs/http-openapi-validation
```

</TabItem>
<TabItem value="pnpm" label="pnpm">

```bash
pnpm install ajv ajv-formats @inversifyjs/http-openapi-validation
```

</TabItem>
</Tabs>

## Quick Start

Here's a complete example showing OpenAPI-driven body validation:

<CodeBlock language="ts">{apiIntroductionSource}</CodeBlock>

This example:

1. Defines an OpenAPI request body schema using `@OasRequestBody` on the controller method.
2. Marks the `@Body()` parameter with `@Validate()` so the pipe knows to validate it.
3. Uses `SwaggerUiProvider` to populate the OpenAPI spec from controller metadata.
4. Creates an `OpenApiValidationPipe` from the populated spec and registers it globally.

When a request arrives, the pipe validates the body against the OpenAPI schema. Invalid requests are rejected with an `InversifyValidationError`, which the `InversifyValidationErrorFilter` converts into a 400 Bad Request response.

## How It Works

The validation flow:

1. **Schema discovery** — `SwaggerUiProvider.provide(container)` reads all `@OasRequestBody` decorators and populates the OpenAPI document with paths and schemas.
2. **Pipe initialization** — `OpenApiValidationPipe` receives the populated OpenAPI document and lazily initializes an Ajv instance with the full spec on first use.
3. **Request validation** — For each request, the pipe:
- Checks if the parameter is a `@Body()` marked with `@Validate()`.
- Resolves the content type (from the request header or a single declared type).
- Locates the matching schema using JSON pointer navigation.
- Validates the body against the schema using Ajv.
4. **Error handling** — Validation failures throw `InversifyValidationError` with detailed error information.

## OpenAPI Version Support

The package supports both OpenAPI v3.1 and v3.2 through subpath exports:

```ts
// OpenAPI v3.1 (default)
import { OpenApiValidationPipe, Validate } from '@inversifyjs/http-openapi-validation';

// OpenAPI v3.2
import { OpenApiValidationPipe, Validate } from '@inversifyjs/http-openapi-validation/v3Dot2';
```

Use the matching OpenAPI decorators and types from `@inversifyjs/http-open-api` or `@inversifyjs/http-open-api/v3Dot2`.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
"@inversifyjs/eslint-plugin-require-extensions": "workspace:*",
"@inversifyjs/http-core": "workspace:*",
"@inversifyjs/http-express": "workspace:*",
"@inversifyjs/http-open-api": "workspace:*",
"@inversifyjs/http-openapi-validation": "workspace:*",
"@inversifyjs/http-validation": "workspace:*",
"@inversifyjs/open-api-types": "workspace:*",
"@inversifyjs/standard-schema-validation": "workspace:*",
"@inversifyjs/validation-common": "workspace:*",
"@types/express": "5.0.6",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';

import { SwaggerUiProvider } from '@inversifyjs/http-open-api';
import { OpenApiValidationPipe } from '@inversifyjs/http-openapi-validation';
import { InversifyValidationErrorFilter } from '@inversifyjs/http-validation';
import { type OpenApi3Dot1Object } from '@inversifyjs/open-api-types/v3Dot1';
import { Container } from 'inversify';

import { buildExpressServer } from '../../../server/adapter/express/actions/buildExpressServer.js';
import { type Server } from '../../../server/models/Server.js';
import { UserController } from './apiIntroduction.js';

describe('API Introduction (OpenAPI Validation)', () => {
describe('having an OpenApiValidationPipe in an HTTP server with Quick Start user validation', () => {
let server: Server;

beforeAll(async () => {
const container: Container = new Container();

const openApiObject: OpenApi3Dot1Object = {
info: { title: 'My API', version: '1.0.0' },
openapi: '3.1.1',
};

const swaggerProvider: SwaggerUiProvider = new SwaggerUiProvider({
api: {
openApiObject,
path: '/docs',
},
});

container
.bind(InversifyValidationErrorFilter)
.toSelf()
.inSingletonScope();
container.bind(UserController).toSelf().inSingletonScope();

swaggerProvider.provide(container);

server = await buildExpressServer(
container,
[InversifyValidationErrorFilter],
[new OpenApiValidationPipe(swaggerProvider.openApiObject)],
);
});

afterAll(async () => {
await server.shutdown();
});

describe('when a valid POST /users request is made', () => {
let response: Response;

beforeAll(async () => {
response = await fetch(
`http://${server.host}:${server.port.toString()}/users`,
{
body: JSON.stringify({
email: 'jane.doe@example.com',
name: 'Jane Doe',
}),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
},
);
});

it('should return expected Response', async () => {
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe('Created user: Jane Doe');
});
});

describe('when an invalid POST /users request is made', () => {
let response: Response;

beforeAll(async () => {
response = await fetch(
`http://${server.host}:${server.port.toString()}/users`,
{
body: JSON.stringify({
email: 'jane.doe@example.com',
extra: 'not allowed',
name: 'Jane Doe',
}),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
},
);
});

it('should return expected Response', async () => {
expect(response.status).toBe(400);
});
});
});
});
Loading
Loading