OpenAPI Generation

Generate OpenAPI specs from controllers

The @justscale/feature-openapi package automatically generates OpenAPI 3.1 specifications from your JustScale controllers and Zod schemas.

Installation

Bash
pnpm add @justscale/feature-openapi zod-openapi

Basic Usage

Generate an OpenAPI document from your app:

src/app.tsTypeScript
import { createClusterBuilder } from '@justscale/core';
import { createOpenAPIDocument } from '@justscale/feature-openapi';
import { PlayersController } from './controllers/players';
import { GamesController } from './controllers/games';
import { PlayerRepository } from './services/player-repository';
import { GameService } from './services/game-service';

const app = createClusterBuilder()
  .add(PlayerRepository)
  .add(GameService)
  .add(PlayersController)
  .add(GamesController)
  .build()
  .compile();

const spec = createOpenAPIDocument(app, {
  info: {
    title: 'Poker API',
    version: '1.0.0',
    description: 'A real-time poker game backend',
  },
  servers: [
    { url: 'http://localhost:3000', description: 'Development' },
    { url: 'https://api.poker.com', description: 'Production' },
  ],
});

// Serve the OpenAPI spec
const DocsController = createController('/openapi', {
  routes: () => ({
    spec: Get('.json').handle(({ res }) => res.json(spec)),
  }),
});

Automatic Schema Extraction

The generator automatically extracts Zod schemas from body() and query() middleware:

src/controllers/players.tsTypeScript
import { z } from 'zod';
import { createController } from '@justscale/core';
import { Get, Post, body, query } from '@justscale/http/builder';
import { PlayerRepository } from '../repositories/player-repository';

const CreatePlayerSchema = z.object({
  name: z.string().min(1),
  chips: z.number().default(1000),
});

const PaginationSchema = z.object({
  page: z.coerce.number().default(1),
  limit: z.coerce.number().default(10),
});

export const PlayersController = createController('/players', {
  inject: { players: PlayerRepository },
  routes: (services) => ({
    // Automatically includes query parameters in OpenAPI
    list: Get('/')
      .apply(query(PaginationSchema))
      .handle(async ({ query, res }) => {
        const results = await services.players.find({ limit: query.limit });
        res.json({ players: results });
      }),

    // Automatically includes request body schema
    create: Post('/')
      .apply(body(CreatePlayerSchema))
      .handle(async ({ body, res }) => {
        const player = await services.players.save(body);
        res.json({ player });
      }),
  }),
});

The generated spec will include:

  • Request body schemas for POST/PUT/PATCH routes
  • Query parameter schemas with types and defaults
  • Path parameters extracted from route patterns

Adding Documentation

Use the documented() helper to add rich metadata:

src/controllers/players.tsTypeScript
import { createController } from '@justscale/core';
import { Post, Get, body, populate } from '@justscale/http';
import { documented } from '@justscale/feature-openapi';
import { PlayerRepository } from '../repositories/player-repository';
import { CreatePlayerSchema, PlayerResponseSchema } from '../schemas/player';

export const PlayersController = createController('/players', {
  inject: { players: PlayerRepository },
  routes: (services) => ({
    create: documented(
      Post('/')
        .apply(body(CreatePlayerSchema))
        .handle(async ({ body, res }) => {
          const player = await services.players.save(body);
          res.json({ player });
        }),
      {
        summary: 'Create a new player',
        description: 'Creates a player with the specified name and chip count',
        tags: ['Players'],
        operationId: 'createPlayer',
        responses: {
          200: {
            description: 'Player created successfully',
            schema: PlayerResponseSchema,
          },
          400: {
            description: 'Invalid request body',
          },
        },
      }
    ),

    show: documented(
      Get('/:id')
        .use(populate(services.players, 'player', 'id'))
        .handle(({ player, res }) => res.json({ player })),
      {
        summary: 'Get player by ID',
        tags: ['Players'],
        responses: {
          200: {
            description: 'Player found',
            schema: PlayerResponseSchema,
          },
          404: {
            description: 'Player not found',
          },
        },
      }
    ),
  }),
});

OpenAPI Configuration

Info Object

src/openapi-config.tsTypeScript
import { createOpenAPIDocument } from '@justscale/feature-openapi';
import { app } from './app';

const spec = createOpenAPIDocument(app, {
  info: {
    title: 'Poker API',
    version: '1.0.0',
    description: 'A comprehensive poker game backend',
    termsOfService: 'https://poker.com/terms',
    contact: {
      name: 'API Support',
      email: 'support@poker.com',
      url: 'https://poker.com/support',
    },
    license: {
      name: 'MIT',
      url: 'https://opensource.org/licenses/MIT',
    },
  },
});

Security Schemes

Add authentication schemes:

src/openapi-security.tsTypeScript
import { createOpenAPIDocument } from '@justscale/feature-openapi';
import { app } from './app';

const spec = createOpenAPIDocument(app, {
  info: { title: 'API', version: '1.0.0' },
  components: {
    securitySchemes: {
      bearerAuth: {
        type: 'http',
        scheme: 'bearer',
        bearerFormat: 'JWT',
      },
      apiKey: {
        type: 'apiKey',
        in: 'header',
        name: 'X-API-Key',
      },
    },
  },
  security: [
    { bearerAuth: [] },
  ],
});

Schema Descriptions

Add descriptions for better documentation:

src/schemas/game.tsTypeScript
import { z } from 'zod';

const CreateGameSchema = z.object({
  name: z.string()
    .min(1)
    .describe('The display name for the game'),
  maxPlayers: z.number()
    .int()
    .min(2)
    .max(10)
    .describe('Maximum number of players (2-10)'),
  buyIn: z.number()
    .positive()
    .describe('Buy-in amount in chips'),
});

Response Schemas

Document multiple response status codes:

src/controllers/games.tsTypeScript
import { createController } from '@justscale/core';
import { Post, body } from '@justscale/http/builder';
import { documented } from '@justscale/feature-openapi';
import { GameRepository } from '../repositories/game-repository';
import { CreateGameSchema, CreateGameResponseSchema } from '../schemas/game';

export const GamesController = createController('/games', {
  inject: { games: GameRepository },
  routes: (services) => ({
    create: documented(
      Post('/')
        .apply(body(CreateGameSchema))
        .handle(async ({ body, res }) => {
          const game = await services.games.create(body);
          res.json({ game });
        }),
      {
        summary: 'Create a new game',
        tags: ['Games'],
        responses: {
          201: {
            description: 'Game created',
            schema: CreateGameResponseSchema,
          },
          400: {
            description: 'Invalid game configuration',
          },
          409: {
            description: 'Game with this name already exists',
          },
        },
      }
    ),
  }),
});

Path Parameters

Path parameters are automatically extracted from route patterns:

src/controllers/games.tsTypeScript
import { createController } from '@justscale/core';
import { Get } from '@justscale/http';

export const GamesController = createController('/games', {
  routes: () => ({
    getPlayer: Get('/:gameId/players/:playerId')
      .handle(({ params, res }) => {
        // params.gameId and params.playerId are documented
        res.json({ gameId: params.gameId, playerId: params.playerId });
      }),
  }),
});

OpenAPI spec will include:

JSON
{
  "parameters": [
    {
      "name": "gameId",
      "in": "path",
      "required": true,
      "schema": { "type": "string" }
    },
    {
      "name": "playerId",
      "in": "path",
      "required": true,
      "schema": { "type": "string" }
    }
  ]
}

Serving the Spec

JSON Endpoint

src/controllers/docs.tsTypeScript
import { createController } from '@justscale/core';
import { Get } from '@justscale/http';
import { createOpenAPIDocument } from '@justscale/feature-openapi';
import { app } from '../app';

const DocsController = createController('/openapi', {
  routes: () => ({
    spec: Get('.json').handle(({ res }) => {
      const spec = createOpenAPIDocument(app, {
        info: { title: 'API', version: '1.0.0' },
      });
      res.json(spec);
    }),
  }),
});

Swagger UI

src/swagger-setup.tsTypeScript
import { Get } from '@justscale/http';
import swaggerUi from 'swagger-ui-express';
import { createOpenAPIDocument } from '@justscale/feature-openapi';
import { app } from './app';

const spec = createOpenAPIDocument(app, {
  info: { title: 'API', version: '1.0.0' },
});

// Serve with Express
app.use('/docs', swaggerUi.serve, swaggerUi.setup(spec));

Best Practices

  • Use descriptions - Add .describe() to schema fields
  • Document all responses - Include error status codes
  • Tag routes - Group related endpoints with tags
  • Version your API - Include version in the info object
  • Add examples - Use Zod's .example() for sample data
  • Keep schemas DRY - Reuse common schemas across routes
src/schemas/player.tsTypeScript
import { z } from 'zod';

const PlayerSchema = z.object({
  id: z.string().describe('Unique player identifier'),
  name: z.string().describe('Player display name'),
  chips: z.number().describe('Current chip count'),
}).describe('A poker player');

// Reuse in multiple routes
const PlayerResponseSchema = z.object({
  player: PlayerSchema,
});

const PlayersResponseSchema = z.object({
  players: z.array(PlayerSchema),
});