OpenAPI
@modularityjs/openapi generates an OpenAPI 3.1 document from the metadata your HTTP controllers and validation schemas already carry. The contract owns the decorators, the schema-serializer pool, and an abstract OpenApiBuilder; drivers translate validation schemas to JSON Schema; the http-openapi extension walks the controller registry, builds the document, and serves /openapi.json plus a Swagger UI / Redoc / RapiDoc / Scalar page at /docs.
Boot Recipe
import { inversify } from '@modularityjs/di-inversify';
import { HttpModule } from '@modularityjs/http';
import { HttpFastifyModule } from '@modularityjs/http-fastify';
import { HttpValidationModule } from '@modularityjs/http-validation';
import { HttpOpenApiModule } from '@modularityjs/http-openapi';
import { createApp, ModularityModule } from '@modularityjs/modularity';
import { OpenApiModule } from '@modularityjs/openapi';
import { OpenApiZodModule } from '@modularityjs/openapi-zod';
import { ValidationModule } from '@modularityjs/validation';
import { ValidationZodModule } from '@modularityjs/validation-zod';
const app = await createApp({
di: inversify,
modules: [
ModularityModule,
HttpModule.forRoot({ port: 3000 }),
HttpFastifyModule,
ValidationModule,
ValidationZodModule,
HttpValidationModule,
OpenApiModule.forRoot({
info: { title: 'My API', version: '1.0.0' },
}),
OpenApiZodModule,
HttpOpenApiModule.forRoot(),
// …your controller modules…
],
});
await app.start();After app.start():
GET /openapi.json→ the generated document (JSON).GET /docs→ Swagger UI, loaded from JSDelivr.
info.title and info.version are the only required config fields. Boot fails at config validation time if either is missing — OpenApiModule.forRoot({}) will throw.
What Gets Generated Automatically
For every controller bound in the container (and not marked @ApiHidden()):
- Path comes from
@Controller(prefix) + @Get/@Post/@…(suffix), with Fastify-style:paramrewritten to OpenAPI's{param}. - HTTP verb comes from the method decorator.
- Path parameters — every
:paramin the URL becomes a required path parameter withschema: { type: 'string' }, whether the handler addresses it via@Params('id')or relies on the synthesised placeholder. - Query / header parameters —
@Query('limit'),@Headers('x-tenant'), etc. emit per-name parameters.@Validated{Query,Params,Headers}(schema)is expanded: each property of the schema becomes its own parameter, withrequiredtaken from the schema (path parameters are always emitted asrequired: trueregardless, per OpenAPI spec). - Request body —
@ValidatedBody(schema)produces anapplication/jsonbody whose schema is the serialized validation schema.@UploadedFile/@UploadedFiles(name)produces amultipart/form-databody. Bare@Body()produces an empty-schema JSON body. Add@ApiRequestBody(schema, options?)to a method to document the body explicitly — useful when the handler extracts individual fields via@Body('name')(which alone leaves the spec without a body shape) or when the documented shape should differ from the validated one. - Responses — declared via
@ApiResponse(status, options). If none are declared, the operation gets200: { description: 'OK' }so the document remains valid.
The document is built lazily on the first /openapi.json request and memoised. Subsequent requests return the cached value — restart the process to pick up code changes.
Decorators
import {
ApiHidden,
ApiOperation,
ApiResponse,
ApiSecurity,
ApiTag,
} from '@modularityjs/openapi';
import { Controller, Get, Params, Post } from '@modularityjs/http';
import { ValidatedBody } from '@modularityjs/http-validation';
import { zodSchema } from '@modularityjs/validation-zod';
import { z } from 'zod';
const userBody = zodSchema(
z.object({ name: z.string().min(1), age: z.number().int() }),
);
const userResponse = zodSchema(
z.object({ id: z.string(), name: z.string(), age: z.number() }),
);
@Controller('/users')
@ApiTag('users', 'User accounts')
export class UsersController {
@Get('/:id')
@ApiOperation({ summary: 'Get a user by id', operationId: 'getUser' })
@ApiResponse(200, { description: 'The user', schema: userResponse })
@ApiResponse(404, { description: 'User not found' })
async get(@Params('id') _id: string) {
/* … */
}
@Post('/')
@ApiOperation({ summary: 'Create a user' })
@ApiResponse(201, { description: 'Created', schema: userResponse })
@ApiSecurity('bearer')
async create(@ValidatedBody(userBody) _body: unknown) {
/* … */
}
@Get('/debug')
@ApiHidden()
async debug() {
/* internal only — not in the document */
}
}| Decorator | Where | Notes |
|---|---|---|
@ApiTag(name, description?) | class or method | Groups operations in the UI. Class-level tags apply to every method on the controller; method-level tags add to that set. |
@ApiOperation({ summary, … }) | method | summary, description, operationId, deprecated. All optional. |
@ApiResponse(status, { schema, … }) | method (repeatable) | description defaults to "Response". contentType defaults to application/json. schema is a Schema<T> from validation, serialized via a pool driver. |
@ApiRequestBody(schema, options?) | method | Documents the request body without forcing validation. Wins over @ValidatedBody. Options: description, contentType (default application/json), required (default true). |
@ApiSecurity(...schemes) | class or method | References names defined in OpenApiConfig.securitySchemes. Method-level overrides class-level. |
@ApiHidden() | class or method | Excludes the route(s) from the document. Routes still serve traffic — they just don't appear in the spec. |
Schema Serializers
OpenApiBuilder doesn't know how to convert a Schema<T> to JSON Schema — that's a driver concern. Each validation backend ships a sibling OpenAPI driver:
| Driver | Backend |
|---|---|
@modularityjs/openapi-zod | Zod 4 — uses the built-in z.toJSONSchema(), no third-party converter. |
@modularityjs/openapi-ajv | Ajv — identity pass-through, since Ajv schemas already are JSON Schema. |
Load whichever matches your Validator. Mixed apps (some routes Zod, some Ajv) can load both — the builder dispatches on the schema's recorded kind. Schemas with no matching serializer become {} and emit a console.warn so you can spot misconfiguration — [http-openapi] no serializer registered for kind "<kind>" — emitting empty schema. Import the matching @modularityjs/openapi-* driver. (or [http-openapi] schema has no native record — emitting empty schema. Ensure validation drivers were imported before schemas were created. when the schema wasn't produced by a validation driver at all).
Behind the scenes
The contract Schema<T> is intentionally opaque — only parse() is public. To let an OpenAPI driver recover the native type (ZodType, JSONSchemaType<T>, …), @modularityjs/validation carries a tiny side channel:
export function recordNativeSchema(
schema: Schema<unknown>,
kind: string,
native: unknown,
): void;
export function getNativeSchema(
schema: Schema<unknown>,
): { kind: string; native: unknown } | undefined;zodSchema() and jsonSchema() self-register. The store is a WeakMap, so entries die with the wrapper. Custom validation drivers can record their own kind and supply a matching SchemaSerializer via SchemaSerializersPool.
Configuration
OpenApiModule.forRoot() accepts:
interface OpenApiConfig {
info: {
title: string; // required
version: string; // required
description?: string;
termsOfService?: string;
contact?: { name?: string; url?: string; email?: string };
license?: { name: string; url?: string };
};
servers?: { url: string; description?: string }[];
securitySchemes?: Record<string, SecurityScheme>; // OpenAPI 3.1 security scheme objects
security?: { [name: string]: string[] }[]; // default applied to operations without @ApiSecurity
tags?: { name: string; description?: string }[];
}HttpOpenApiModule.forRoot() (the extension) accepts:
interface HttpOpenApiConfig {
pageTitle?: string; // default 'API Docs'
cacheDocument?: boolean; // default true; flip to false in dev to rebuild on every request
}Picking a UI
/docs is rendered by an OpenApiUiRenderer bound via DI preference. Import one of the four driver packages to wire it; without one the route returns 404 (the document at /openapi.json still works). Each driver registers its JS / CSS in StaticAssetsPool, so they're served through @modularityjs/http-assets — locally vendored, content-hashed, and SRI-verified after assets:collect runs.
import { HttpOpenApiModule } from '@modularityjs/http-openapi';
import { HttpOpenApiSwaggerModule } from '@modularityjs/http-openapi-swagger';
// or: HttpOpenApiRedocModule from '@modularityjs/http-openapi-redoc'
// or: HttpOpenApiRapidocModule from '@modularityjs/http-openapi-rapidoc'
// or: HttpOpenApiScalarModule from '@modularityjs/http-openapi-scalar'
import { HttpAssetsModule } from '@modularityjs/http-assets';
createApp({
di: inversify,
modules: [
// ...
HttpOpenApiModule.forRoot(),
HttpOpenApiSwaggerModule,
HttpAssetsModule, // serves the collected JS / CSS at /static/...
],
});Run pnpm <app> assets:collect at deploy time to hash each asset and write the manifest; production traffic is then served entirely from local disk. In dev without a collect, the same /static/... route lazy-fetches and caches the bytes from the upstream CDN on first hit.
Path configuration
/openapi.json and /docs are fixed in v1 — they're hardcoded into the extension's controller because route paths must be known at decorator-evaluation time. If you need different paths, mark HttpOpenApiController hidden (or omit HttpOpenApiModule entirely) and register your own controller that calls OpenApiBuilder.getDocument().
Programmatic access
The full document is available via DI without going over the network:
import { OpenApiBuilder } from '@modularityjs/openapi';
@Injectable()
class ApiExporter {
constructor(
@Inject(OpenApiBuilder) private readonly builder: OpenApiBuilder,
) {}
toJson(): string {
return JSON.stringify(this.builder.getDocument(), null, 2);
}
}Useful for build-time spec generation, contract tests, or feeding the document into other tooling.
CLI export
@modularityjs/openapi-cli ships an openapi:export command that emits the document to stdout or a file — useful for CI artefacts and client codegen pipelines (openapi-typescript, OpenAPI Generator, Orval, …).
Wire the module into your CLI bootstrap:
import { CliModule } from '@modularityjs/cli';
import { CliCommanderModule } from '@modularityjs/cli-commander';
import { OpenApiCliModule } from '@modularityjs/openapi-cli';
// alongside the usual openapi modules
modules: [
CliModule.forRoot({ name: 'my-app' }),
CliCommanderModule,
OpenApiCliModule,
/* … */
];Then:
# Pretty-printed JSON on stdout
my-app openapi:export > openapi.json
# Single-line minified JSON to a file (smaller diff in version control)
my-app openapi:export --minify --output openapi.jsonStatus messages (e.g. openapi:export → wrote openapi.json) go to stderr so stdout stays clean for piping.
The command depends only on the abstract OpenApiBuilder, so it works with any concrete builder — today that's HttpOpenApiBuilder from http-openapi.
Named schemas + $ref dedup
Wrap any Schema<T> with apiSchema(name, schema) to land it in components.schemas.<name> and have every operation that uses it emit { "$ref": "#/components/schemas/<name>" } instead of inlining the whole shape:
import { apiSchema } from '@modularityjs/openapi';
import { zodSchema } from '@modularityjs/validation-zod';
import { z } from 'zod';
const userSchema = apiSchema(
'User',
zodSchema(z.object({ id: z.number().int(), name: z.string() })),
);
@Controller('/users')
class UsersController {
@Get('/')
@ApiResponse(200, { description: 'all users', schema: userSchema })
list() {
/* … */
}
@Get('/:id')
@ApiResponse(200, { description: 'one user', schema: userSchema })
get() {
/* … */
}
}The document carries one copy of the User schema under components.schemas.User; both operations reference it. Re-using userSchema across modules, controllers, and request / response positions is fine — the builder is idempotent on the same schema instance.
Tagging two different schemas with the same name throws StateException at document-build time — a single component name has to map to exactly one shape. If your shapes legitimately diverge (e.g. UserInput vs User), give them distinct names.
Named schemas used in @ValidatedQuery / @ValidatedParams / @ValidatedHeaders are still expanded property-by-property — OpenAPI parameters don't permit a top-level $ref to an object schema. The $ref form only applies to request and response bodies (and response header schemas).
Limitations (v1)
/openapi.jsonand/docspaths are fixed (see above).- Caching is process-lifetime by default. Set
cacheDocument: falsein dev when you want every request to pick up newly registered routes or decorator changes without restarting. - No WebSocket support.
@modularityjs/wsroutes don't appear in the document. AsyncAPI for WebSocket gateways is out of scope for now.