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-openapiBasic 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),
});