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.