<!-- Markdown mirror of https://justscale.sh/docs/techniques/openapi -->

# OpenAPI Generation

OpenAPI 3.1 generated from the DI graph

`@justscale/feature-openapi`emits 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

```bash
pnpm add @justscale/feature-openapi
```

## Wiring

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

src/app.tsTypeScript

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

```typescript
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/docs`from 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

```typescript
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: true`is 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: false`only if you're mutating reflection at runtime and want each request to regenerate.

## Next Steps

- Permissions
- Authentication
- Features
