Skip to main content

OpenAPI Overview

OpenAPI is a standard for representing API specifications. It is useful whether your service will be consumed by other services or front end clients such as mobile devices. Psychic and Dream work together to automatically generate OpenAPI specs for your web application with as little additional work from you as possible. They do this by leveraging:

  1. the routes already defined in api/src/conf/routes.ts
  2. the serializers you already use to turn models into JSON
  3. an @OpenAPI decorator in controllers

The resource generator automatically adds @OpenAPI decorator declarations corresponding to each endpoint, and since serializer attributes are typed back to the database schema, these types will stay in sync as you add migrations. (For example, if you add a migration to change a column from not allowing null to allowing null, the OpenAPI spec will change to include nullable: true for the corresponding property.)

Serializers and the @OpenAPI decorator also support hand-written specs when a shape is genuinely ad hoc. For model-shaped data, let Psychic derive the schema instead of hand-writing it: model responses should use serializers, model request bodies should use requestBody.params or requestBody.including, and computed response objects should use ObjectSerializer. Hand-written JSON Schema is reserved for inputs or outputs that genuinely cannot be represented by a Dream model, serializer, or ObjectSerializer — don't reach for it just because a serializer doesn't exist yet; create one instead. Because the spec is customizable centrally, fix drift at its source rather than working around it downstream: if every request needs a bearer header, declare a security scheme once instead of attaching it by hand in the generated client, and if an endpoint's response shape is wrong, fix the serializer rather than hand-writing a responses block.

OpenAPI validation is configured per spec via the validate option (headers, requestBody, query, responseBody) in conf/app.ts, and can be overridden per endpoint in the @OpenAPI decorator. A fresh application validates requests by default (a mismatch returns 400) and validates responses only under test, so response-shape mistakes are caught before they reach production without paying the validation cost on every live request. See OpenAPI - Validation for the full configuration and precedence rules.

To generate the OpenAPI spec, simply run:

pnpm psy sync

Psychic provides built-in support for generating OpenAPI schema to define your response payloads. Using the definitions you provide, Psychic will regenerate an openapi.json file (or one file per configured spec — see Conf-level configuration) whenever a sync occurs. Psychic will also use this to generate type files for your client app, facilitating easy synchronization of your API mechanisms to the current values provided by the backend.

Automatic response generation

import { OpenAPI } from '@rvoh/psychic'
import Place from '../../../models/Place.js'
import V1HostBaseController from './BaseController.js'

const openApiTags = ['places']

export default class V1HostPlacesController extends V1HostBaseController {
@OpenAPI(Place, {
status: 200,
tags: openApiTags,
description: 'Fetch multiple Places',
many: true,
serializerKey: 'summary',
})
public async index() {
const places = await this.currentHost.associationQuery('places').preloadFor('summary').all()
this.ok(places)
}
}

If the first argument to the OpenAPI decorator is a callback function, then the return value of that function will dictate the success response payload. In the above case, because the many: true flag has been passed, the openapi response will yield an array type for the response content:

"paths": {
"/places": {
"parameters": [],
"get": {
"tags": [],
"summary": "",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PlaceSummary"
}
}
}
},
"description": "index"
}
}
}
},
}

Implicit serializer scanning

In the above example, a reference to the #/components/schemas/PlaceSummary component is made. This component is also generated in the components section of the openapi.json, leveraging the serializer attribute definitions to construct an image of the serializer when building component schemas.

"components": {
"schemas": {
"PlaceSummary": {
"type": "object",
"required": [
"id",
"name",
],
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
}
},
}
}

Dream supports OpenAPI notation within serializer definitions, allowing us to build openapi specs for each attribute:

import { DreamSerializer } from '@rvoh/dream'

const PlaceSerializer = (place: Place) =>
DreamSerializer(Place, place)
.attribute('id')
.attribute('name')
.customAttribute(
'formattedDescription',
() => ({
label: 'Description',
value: place.description,
}),
{
openapi: {
type: 'object',
properties: {
label: { type: 'string' },
value: { type: 'string', nullable: true },
},
},
}
)

Extra response payloads

In addition to the default response shapes described, you can pass custom response objects to the responses field, enabling you to handle custom response codes. Use this for genuinely ad hoc shapes only. If the response can be represented by a Dream serializer or an ObjectSerializer, derive it there instead so the rendered data and OpenAPI schema stay attached.

export default class V1HostPlacesController extends V1HostBaseController {
@OpenAPI(Place, {
status: 200,
tags: openApiTags,
description: 'Fetch multiple Places',
many: true,
serializerKey: 'summary',
responses: {
400: {
type: 'object',
properties: {
errors: 'string[]',
},
},
},
})
public async index() {
const places = await this.currentHost.associationQuery('places').preloadFor('summary').all()
this.ok(places)
}
}

Parameters

To populate request metadata in your OpenAPI definition for this route, use headers, requestBody, pathParams, and query. For model-backed request bodies, prefer the requestBody shorthand instead of hand-written schema:

export default class V1HostPlacesController extends V1HostBaseController {
@OpenAPI(Place, {
status: 201,
tags: openApiTags,
description: 'Create a Place',
requestBody: {
params: ['name', 'description'],
},
query: [{ name: 'search', required: false }],
headers: [{ name: 'Authorization', required: true }],
pathParams: [{ name: 'id', required: true }],
})
public async create() {
let place = await this.currentHost.createAssociation(
'places',
this.extractParams(Place, ['name', 'description']),
)
if (place.isPersisted) place = await place.loadFor('default').execute()
this.created(place)
}
}

Tags

When openapi routes are read, they are often grouped by the fields provided in their tags array. The tags option enables you to populate this field.

export default class V1HostPlacesController extends V1HostBaseController {
@OpenAPI(Place, {
status: 200,
tags: openApiTags,
description: 'Fetch multiple Places',
many: true,
serializerKey: 'summary',
tags: ['places'],
})
public async index() {
const places = await this.currentHost.associationQuery('places').preloadFor('summary').all()
this.ok(places)
}
}

Conf-level configuration

Spec-wide concerns — where the spec is written, default headers and responses, security schemes, and validation — are configured once in conf/app.ts rather than repeated on every controller action. The default (client-facing) spec is configured with psy.set('openapi', { ... }); named specs (for splitting endpoints by audience) use psy.set('openapi', '<name>', { ... }), covered in Multiple specs.

outputFilepath

outputFilepath is where the spec JSON is written:

// conf/app.ts
psy.set('openapi', {
outputFilepath: path.join('src', 'openapi', 'openapi.json'),
})

defaults

defaults applies values to every endpoint in the spec unless a per-action @OpenAPI decorator overrides them. It holds headers, responses, securitySchemes, security, and components:

// conf/app.ts
psy.set('openapi', {
outputFilepath: path.join('src', 'openapi', 'openapi.json'),
defaults: {
// applied to every endpoint
headers: {
locale: { type: 'string', enum: LocalesEnumValues },
},
// Psychic already supplies 400/401/403/404/409/500 — only override to change or add
responses: {
429: { description: 'Too many requests' },
},
securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer' } },
security: [{ bearerAuth: [] }],
// reusable schema components referenced elsewhere in the spec
components: {
schemas: {
HealthCheck: { type: 'object', properties: { ok: { type: 'boolean' } } },
},
},
},
})

See Customizing default error responses for how defaults.responses and defaults.components.responses interact with the per-action responses option and the framework's built-in defaults.

Declaring a security scheme

Declaring a security scheme is conf-level customization: state the scheme once and Psychic stamps it across the spec, rather than wiring an Authorization header by hand into every generated client call.

// conf/app.ts
psy.set('openapi', {
outputFilepath: path.join('src', 'openapi', 'openapi.json'),
defaults: {
securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer' } },
security: [{ bearerAuth: [] }],
},
})

After pnpm psy sync, the spec gains components.securitySchemes.bearerAuth and a top-level security: [{ bearerAuth: [] }], which Psychic stamps onto every operation. A client generated from the spec then honors the declared scheme and attaches the bearer token to requests automatically.

note

defaults.security is typed as an array of objects (Record<string, string[]>[]). Use the array form [{ bearerAuth: [] }], not the bare object { bearerAuth: [] }.

validate and syncTypes

validate sets the request/response validation rules applied to every action tied to this spec, unless a per-action @OpenAPI decorator overrides them — see OpenAPI - Validation for the full picture, including which validation options actually mutate the data your controller reads.

When syncTypes is true, pnpm psy sync runs the spec through openapi-typescript and writes a declaration file exporting a paths type, which downstream request/response typing can consume. A fresh application only turns this on for an internal tests spec that aggregates every surface's endpoints into one document, so specs across the app can type-check against one source of truth.

The long tail

info (version/title/description), servers, and checkDiffs are also configured in this block — see Detect Breaking Changes below for checkDiffs.

Relocating or renaming a controller is spec-neutral

The spec is keyed by URL path plus HTTP method — no controller class name and no operationId is emitted. Moving a controller to a different base class, or renaming the controller class, while keeping its route the same produces zero diff in the generated spec, and the downstream-generated SDK function names track the path, not the class.

Detect Breaking Changes in Your OpenAPI Specs

When you update your code, your generated OpenAPI specs will often change too. To help catch breaking changes early, you can enable a built-in tool that compares your updated specs against the previous version (version in your head branch, probably main) and reports any issues.

To enable this feature, set the checkDiffs option to true in your Psychic app configuration.

Note: By default, this setting will fail your builds if breaking changes are found. This can be customized to your needs.

Example

// conf/app.ts
export default async (psy: PsychicApp) => {
// Other config...

psy.set('openapi', {
// Other OpenAPI settings...
checkDiffs: true,
})
}