Skip to content

OpenAPI Generation

OpenAPI 3.1 generated from the DI graph

@justscale/feature-openapiemits OpenAPI 3.1 from the scope's AbstractContainer. Security schemes, permission-scoped responses, and model schemas are all derived from the composed graph — not a parallel annotation stream like swagger-jsdoc. There is nothing to decorate and no document to hand-assemble; if a controller exists in a scope, its routes show up in that scope's spec.

Installation

Bash
pnpm add @justscale/feature-openapi

Wiring

Provide an OpenApiConfig partial and add the feature. Everything else is defaults.

src/app.tsTypeScript
import JustScale, { createConfig } from '@justscale/core';
import { OpenApiConfig, OpenApiFeature } from '@justscale/feature-openapi';
import { PlayersController } from './controllers/players';

const openApiConfig = createConfig({
  provides: [OpenApiConfig],
  factory: () => ({
    [OpenApiConfig.key]: {
      info: { title: 'Poker API', version: '1.0.0' },
    },
  }),
});

const app = JustScale()
  .add(openApiConfig)
  .add(OpenApiFeature)
  .add(PlayersController)
  .build();

await app.serve({ http: 3000 });

Defaults: specPath: '/openapi.json', docsPath: '/docs', ui: 'scalar', cache: true. Override any of them on the config partial — scopes don't share values.

Per-Scope Specs

Each compiled JustScale() scope binds its own AbstractContainer, so reflection is scope-local. Add the feature inside a sub-app and you get a spec covering only that sub-app's controllers — no manual path filtering, no shared state with the root.

src/admin/admin.sub-app.tsTypeScript
import JustScale, { createConfig } from '@justscale/core';
import { OpenApiConfig, OpenApiFeature } from '@justscale/feature-openapi';
import { CatalogService } from '../domains/catalog/catalog.service.js';
import { InventoryService } from '../domains/catalog/inventory.service.js';
import { AdminStockController } from './admin-stock.controller.js';

// Admin-scoped OpenAPI config: different title + paths from the shop
// root, pinned to the admin surface. Because each JustScale() scope
// binds its own AbstractContainer, the spec emitted here reflects
// only the controllers in this sub-app — not the shop's.
const AdminOpenApiConfig = createConfig({
  provides: [OpenApiConfig],
  factory: () => ({
    [OpenApiConfig.key]: {
      info: { title: 'Admin API', version: '1.0.0' },
      specPath: '/admin/openapi.json',
      docsPath: '/admin/docs',
    },
  }),
});

export const AdminSubApp = JustScale()
  .requires(CatalogService)
  .requires(InventoryService)
  .add(AdminStockController)
  .add(AdminOpenApiConfig)
  .add(OpenApiFeature)
  .build();

Mounted under the shop root, the admin surface serves /admin/openapi.json and /admin/docsfrom its own container. The shop's /openapi.json never sees the admin routes; the admin spec never sees shop routes.

UI Flavours

ui: 'scalar' — the default. Modern single-page renderer, good for public-facing docs.

ui: 'swagger' — Swagger UI, familiar to teams used to the traditional explorer.

ui: 'none' — disables the HTML route. The JSON spec at specPath stays reachable; pick this when the spec is only consumed by tooling.

What Gets Auto-Derived

  • Security schemes. Auth middlewares publish an AUTH_SCHEME symbol; the generator lifts it into components.securitySchemes and attaches security to each route that used the middleware. No duplicate config.
  • Permission-scoped responses. Routes declared with .returns(status, schema, permission) fold into a oneOf under that status; each alternative carries an x-permission annotation. A .guard(Model.can.X) also emits x-permissions on the operation and a default 403.
  • Model schemas. Models declared with defineModel are converted through modelToJsonSchema / jsonSchemaFor and emitted as $ref-backed components the first time they're encountered.
  • Zod body + query. .body(schema) and .query(schema) publish symbols read by the generator and feed zodToJsonSchema. Path parameters come from the route pattern.

Emitting the Spec Outside HTTP

buildOpenApiDocument(container, opts) is exported for callers that want the raw document — writing it to disk at build time, feeding a contract-testing pipeline, whatever. Pass the AbstractContainer of the scope you want to reflect on.

scripts/emit-spec.tsTypeScript
import { AbstractContainer } from '@justscale/core';
import { buildOpenApiDocument } from '@justscale/feature-openapi';
import { writeFile } from 'node:fs/promises';
import { app } from '../src/app.js';

const container = app.container.resolve(AbstractContainer);
const doc = buildOpenApiDocument(container, {
  info: { title: 'Poker API', version: '1.0.0' },
});

await writeFile('openapi.json', JSON.stringify(doc, null, 2));

Caching

cache: trueis the default — the spec is built on first request and memoised per scope. Dev hot-reload invalidates it naturally because the container itself is rebuilt, so there's no bust step to remember. Set cache: falseonly if you're mutating reflection at runtime and want each request to regenerate.