Skip to content

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

typescript
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 :param rewritten to OpenAPI's {param}.
  • HTTP verb comes from the method decorator.
  • Path parameters — every :param in the URL becomes a required path parameter with schema: { 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, with required taken from the schema (path parameters are always emitted as required: true regardless, per OpenAPI spec).
  • Request body@ValidatedBody(schema) produces an application/json body whose schema is the serialized validation schema. @UploadedFile/@UploadedFiles(name) produces a multipart/form-data body. 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 gets 200: { 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

typescript
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 */
  }
}
DecoratorWhereNotes
@ApiTag(name, description?)class or methodGroups operations in the UI. Class-level tags apply to every method on the controller; method-level tags add to that set.
@ApiOperation({ summary, … })methodsummary, 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?)methodDocuments the request body without forcing validation. Wins over @ValidatedBody. Options: description, contentType (default application/json), required (default true).
@ApiSecurity(...schemes)class or methodReferences names defined in OpenApiConfig.securitySchemes. Method-level overrides class-level.
@ApiHidden()class or methodExcludes 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:

DriverBackend
@modularityjs/openapi-zodZod 4 — uses the built-in z.toJSONSchema(), no third-party converter.
@modularityjs/openapi-ajvAjv — 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:

typescript
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:

typescript
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:

typescript
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.

typescript
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:

typescript
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:

typescript
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:

bash
# 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.json

Status 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:

typescript
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.json and /docs paths are fixed (see above).
  • Caching is process-lifetime by default. Set cacheDocument: false in dev when you want every request to pick up newly registered routes or decorator changes without restarting.
  • No WebSocket support. @modularityjs/ws routes don't appear in the document. AsyncAPI for WebSocket gateways is out of scope for now.