OpenAPI - Controllers
Controllers provide an entry point for all openapi entries. When Psychic builds openapi files for your application, it starts by scanning all of your controllers and finding all @OpenAPI decorators. It then computes the schema for those endpoints, and then spits that schema out into the corresponding json files.
To leverage the @OpenAPI decorator in your controllers, simply import it and use it to decorate the methods you wish to expose, like so:
import { OpenAPI } from '@rvoh/psychic'
export default class PlacesController extends PsychicController {
@OpenAPI(Place, {
many: true,
status: 200,
serializerKey: 'summary',
})
public async index() {
this.ok(await this.currentHost.associationQuery('places').all())
}
}
By default, Psychic will match this endpoint to the corresponding route entry in your conf/routes.ts file. If it does not find a matching route, it will raise an exception, so be sure to add a matching entry pointing to that method on your controller.
// conf/routes.ts
export default function routes(r: PsychicRouter) {
r.resources('places', { only: ['index'] })
// OR
r.get('/places', PlacesController, 'index')
}
Implicit serialization
If a serializable class is provided as the first argument to the @OpenAPI decorator, it will automatically read all related attribute definitions on the corresponding serializer, formulating an object shape that it injects into the OpenAPI document.
export default class PlacesController extends PsychicController {
// by providing Place here, we are telling OpenAPI to locate the default
// serializer attached to the Place model and serialize its attribute shapes
// into an openapi document, which will become the 200 response shape for
// this endpoint
@OpenAPI(Place, {
status: 200,
})
public async show() {
this.ok(await this.currentHost.associationQuery('places').findOrFail(this.castParam('id', 'bigint')))
}
}
In addition to providing a model class as the first argument, you can also specify a serializer directly, which takes away the implicit decision making that Psychic does to decide on a serializer for you.
Fast serialization
The OpenAPI spec can be leveraged to automatically stringify using fast-json-stringify, speeding up JSON stringification from 2-5× (once the endpoint is hit for the first time and the compiled stringification function generated—which takes a few milliseconds on the first call—and cached). Just set fastJsonStringify: true in the OpenAPI decorator options.
export default class PlacesController extends PsychicController {
@OpenAPI(Place, {
status: 200,
fastJsonStringify: true,
})
public async show() {
this.ok(await this.currentHost.associationQuery('places').findOrFail(this.castParam('id', 'bigint')))
}
}
Direct serializer specification
@OpenAPI(PlaceSerializer, {
status: 200,
})
serializerKey
You can provide a serializerKey option, which will inform the @OpenAPI decorator to use a specific serializer attached to your model, like so:
// models/Place.ts
export default class Place extends ApplicationModel {
public get table() {
return 'places' as const
}
public get serializers(): DreamSerializers<Place> {
return { default: 'PlaceSerializer', summary: 'PlaceSummarySerializer' }
}
// ...
}
// controllers/V1/Host/PlacesController.ts
export default class V1HostPlacesController extends PsychicController {
// by specifying the "summary" serializerKey, we are telling OpenAPI
// to use the `PlaceSummarySerializer`.
@OpenAPI(Place, {
serializerKey: 'summary',
status: 200,
})
public async index() {
this.ok(await this.currentHost.associationQuery('places').preloadFor('summary').all())
}
}
many
Use the many option when you are providing a serializable class, and you want the endpoint to render an array of them:
export default class AdminPlacesController extends PsychicController {
@OpenAPI(Place, {
many: true,
status: 200,
})
public async index() {
this.ok(await this.currentHost.associationQuery('places').limit(100).all())
}
}
nullable
The nullable option enables you to specify that the default response can also be null:
export default class AdminPlacesController extends PsychicController {
@OpenAPI(Place, {
nullable: true,
status: 200,
})
public async show() {
this.ok(await this.currentHost.associationQuery('places').first())
}
}
status
By providing a status option to the @OpenAPI decorator, you can specify which HTTP status code will be returned if the request succeeds:
export default class PostsController extends PsychicController {
@OpenAPI({
status: 204,
})
public async helloWorld() {
this.noContent()
}
}
requestBody
Using the requestBody option, we can inform OpenAPI what the shape of our incoming request will be. For Dream models, prefer derived request bodies using params, including, and combining; hand-written JSON schema is only for request shapes that are not sensibly represented by a model.
export default class PostsController extends PsychicController {
@OpenAPI({
status: 204,
requestBody: {
type: 'object',
required: ['searchTerm'],
properties: {
searchTerm: {
type: 'string',
description: 'A search term provided by the user',
},
},
},
})
public async helloWorld() {
this.noContent()
}
}
Model-derived request bodies
A Dream model passed to @OpenAPI does not by itself produce a request body. With no requestBody params, Psychic renders no request body at all, so the body is neither documented nor validated. Declare requestBody: { params: [...] } on every model-derived create/update, listing the columns the action accepts — mirroring its extractParams allowlist — and layer the options below onto that list. Types, nullability, and enum constraints then come from the model:
export default class UsersController extends PsychicController {
@OpenAPI(User, {
status: 204,
requestBody: { params: ['email', 'name'] },
})
public async create() {
await User.create(this.extractParams(User, ['email', 'name']))
this.noContent()
}
}
This still applies when the action can't use extractParams directly — an STI create action that reads a type discriminator with castParam and dispatches through a switch, say. The runtime extraction can be custom; the documented request body should still be model-derived.
requestBody.for
For more granular specification, you can take advantage of the for option, which enables you to specify a different model class for request body generation:
export default class UsersController extends PsychicController {
@OpenAPI({
status: 204,
requestBody: {
for: User,
params: ['email'],
},
})
public async create() {
await User.create(this.extractParams(User, ['email']))
this.noContent()
}
}
If the for argument is left off, the allowed fields will fall back to the base model provided to the @OpenAPI decorator:
export default class UsersController extends PsychicController {
@OpenAPI(User, {
status: 204,
requestBody: {
params: ['email'],
},
})
public async create() {
await User.create(this.extractParams(User, ['email']))
this.noContent()
}
}
requestBody.params
The params option names the model columns the request body documents:
export default class UsersController extends PsychicController {
@OpenAPI(User, {
status: 204,
requestBody: {
params: ['email'],
},
})
public async create() {
await User.create(this.extractParams(User, ['email']))
this.noContent()
}
}
requestBody.including
The including option enables you to explicitly provide params that would otherwise be excluded (see paramSafeColumns):
export default class HostPlacesRoomsController extends HostPlacesBaseController {
@OpenAPI(Room, {
status: 201,
tags: openApiTags,
description: 'Create a Room',
requestBody: {
including: ['type'],
},
})
public async create() {
const roomType = this.castParam('type', 'string', { enum: RoomTypesEnumValues })
// handle `roomType` explicitly
...
}
}
requestBody.combining
The combining attribute enables addition of fields unrelated to the model to the OpenAPI spec:
export default class PetsController extends PsychicController {
@OpenAPI(Pet, {
status: 204,
requestBody: {
combining: {
otherField: { type: 'boolean' },
},
},
})
public async create() {
const otherField = this.castParam('otherField', 'boolean')
// do something with `otherField`
}
}
combining is for fields that are genuinely not on the model. Don't list real model columns inside it — that either silently duplicates the model-derived shape or shadows it with a hand-typed copy that drifts (an { enum: [...] } redeclaration goes stale the moment the database enum changes, for example). When the value you're adding is itself a Dream-model shape — typically a parent create request that bundles a one-shot array of children — use the for: Model sentinel inside combining instead (or combining.<key>.items for arrays). It produces an inline object schema of the columns its params and including name, each typed from the model; with neither, it renders an empty object:
import { OpenAPI } from '@rvoh/psychic'
@OpenAPI(Place, {
status: 201,
requestBody: {
params: ['name', 'style', 'sleeps'],
combining: {
rooms: {
type: 'array',
items: OpenAPI.forDream(Room, {
params: ['type', 'name'],
}),
},
},
},
})
public async create() {
// extract place params, then extract each room's params with its own allowlist
}
OpenAPI.forDream(Model, opts) is the typed wrapper for this: params / including / required are constrained at compile time to that model's column names, so a misspelled column raises a TypeScript error instead of silently dropping out of the OpenAPI shape. The nested for: sentinel is request-only — response shapes for a bundled payload are modeled as a serializer and declared with @OpenAPI(SerializerFn) instead of hand-written; see Custom Response Envelopes below.
params and including (including inside OpenAPI.forDream) are an explicit allowlist: write the column names out, or share one hand-written const paramSafeColumns: DreamParamSafeColumnNames<Place>[] = ['name', 'style'] between extractParams and @OpenAPI, as the generator does. Deriving them from a model's own paramSafeColumns, or from Model.columns(), dumps that model's writable column surface into the request body: the documented shape then widens every time the model gains a column, advertises fields the action's extractParams allowlist drops, and publishes the model's column set in the OpenAPI document — the leak Psychic's no-params, no-body default exists to prevent.
headers
Provide the headers option to specify which headers are expected for this endpoint:
export default class PostsController extends PsychicController {
@OpenAPI({
headers: {
Authorization: { description: 'Bearer token', required: true },
},
})
public async helloWorld() {
this.noContent()
}
}
responses
Use the responses option to document additional statuses. Psychic already documents 400, 401, 403, 404, 409 and 500 on every operation; naming one of those here replaces its default entry for this operation.
export default class PlacesController extends PsychicController {
@OpenAPI(Place, {
status: 201,
responses: {
429: { description: 'Too many requests' },
},
})
public async create() {
// ...
}
}
When a status carries a body, describe it with a serializer rather than hand-written schema: { $serializer: SomeObjectSerializer } documents the body an error helper renders when passed that serializer, e.g. this.conflict(BookingConflictSerializer({ ... })) (see object-shaped error bodies). Hand-write schema only for a shape no serializer can represent, such as a string marker's { type: 'string', enum: [...] }.
Custom Response Envelopes
When an action returns a custom or compound shape — an envelope like { place, nearby }, or a computed array alongside serialized models — don't reach for a hand-written responses block. Model the whole shape as one composing ObjectSerializer and pass it to @OpenAPI; see A compound response is still one serializer for how to build it. The action invokes that composing serializer and renders the result:
export default class PlacesController extends PsychicController {
@OpenAPI(PlaceWithNearbySerializer, { status: 200 })
public async show() {
const place = await this.currentHost.associationQuery('places').findOrFail(this.castParam('id', 'bigint'))
const nearby = await place.nearbyPlaces()
this.ok(PlaceWithNearbySerializer(place, nearby))
}
}
The schema is derived and validated under test, with no hand-maintained JSON Schema to drift. Even a one-off envelope is worth this — a composing serializer stays the single source of truth where a hand-written responses block does not.
Customizing default error responses
Every operation gets the same default error-response set — 400, 401, 403, 404, 409, 500 — merged in uniformly regardless of the controller's auth base. So an operation that genuinely can't return one of those (a truly public GET that never 401s or 403s) still advertises it in the spec unless you intervene.
Psychic converts validation failures — param, request-body, and model validation — to a bare 400, and every operation already documents the default BadRequest (400) response, so you don't add anything per-action just to document a validation error. If your actions render this.badRequest({ errors }) (surfacing field-level validation errors) and you want that body in the spec, reshape the shared BadRequest component once at conf level (defaults.components.responses.BadRequest — see The levers below) rather than repeating a schema per action.
Precedence
For any status, the value comes from the first source that defines it:
- per-action
@OpenAPIresponses[status] - conf
defaults.responses[status](see Conf-level configuration) - Psychic's built-in default responses
Defaults fill a status only when nothing above already set it. Declaring a status replaces that whole status entry — there is no deep merge within a status.
The levers
-
Override one status, keep the rest. Declare the status in a per-action
responsesblock, or in confdefaults.responsesto apply it spec-wide. Your value wins for that status; every other default stays. NoomitDefaultResponsesneeded. -
Reshape a shared response once. When a response shape is cross-cutting — every authed endpoint can return it — redefine the component the default
$refpoints at instead of repeating it per action. Setdefaults.components.responses.Forbidden(or.Unauthorized,.BadRequest, etc.) at conf level. Your key replaces the whole component, so every defaulted403's$ref: '#/components/responses/Forbidden'resolves to your redefined body across the spec:// conf/app.ts — give the shared 403 a typed marker body oncepsy.set('openapi', {outputFilepath: path.join('src', 'openapi', 'openapi.json'),defaults: {components: {responses: {Forbidden: {description: 'Forbidden',content: {'application/json': {schema: { type: 'string', enum: ['terms_of_service_required'] },},},},},},},})Conf
defaultsapply to the whole spec, not "authed endpoints only." To scope a marker to the authed surface, give those controllers their own named spec viaopenapiNames(see Multiple specs) and redefineForbiddenonly there — otherwise public endpoints that default a403advertise the marker body too. -
Remove one status, keep the rest. There's no direct mechanism for this.
omitDefaultResponsesis a boolean, all-or-nothing — it drops every default — so re-list each keeper as a description-only response. Use it on a public action that can't401/403:// a truly public GET — drop the auth defaults, re-add the ones it can still return@OpenAPI(Place, {omitDefaultResponses: true,responses: {404: { description: 'Not Found' },500: { description: 'Internal Server Error' },},})
omitDefaultResponses and omitDefaultHeaders are not conf-level options — they live only on the per-action @OpenAPI decorator and on a controller's static openapiConfig getter, which every controller under it inherits (see openapiConfig below). There is no spec-wide switch. There is no per-controller "add a response to every action" knob: a controller-wide response — say a 403 that a shared @BeforeAction can return — is declared per-action, or spec-wide via conf defaults.responses.
Run pnpm psy sync after any of these changes so the spec files and generated clients update.
openapiConfig
In addition to the per-action omitDefaultHeaders and omitDefaultResponses options, a controller can set these — plus tags — for every action on the controller at once, via a static openapiConfig getter:
export default class PublicPagesController extends PsychicController {
public static get openapiConfig() {
return {
omitDefaultResponses: true,
tags: ['public'],
}
}
}
openapiConfig only toggles { omitDefaultHeaders, omitDefaultResponses, tags } — it is not a place to add responses. Like any static getter, it is inherited by every controller that extends this one. A per-action @OpenAPI option always overrides the controller-wide openapiConfig value for that action.
query
The query option enables you to specify the shape of custom query params for your endpoint. By default, all query params are automatically assumed to be strings, but you can customize their shape if desired, using the schema suboption:
export default class PostsController extends PsychicController {
@OpenAPI({
query: {
searchTerm: {
required: false,
},
searchTerms: {
required: false,
schema: {
type: 'string[]',
},
},
},
})
public async helloWorld() {
this.noContent()
}
}
A query param backed by a database enum declares the generated values in its schema and is cast against the same values, so the documented and accepted sets can't drift:
@OpenAPI(Place, {
status: 200,
many: true,
serializerKey: 'summary',
query: {
search: { required: false, schema: 'string' },
style: { required: false, schema: { type: 'string', enum: PlaceStylesEnumValues } },
},
})
public async index() {
let query = this.currentHost.associationQuery('places').preloadFor('summary')
const search = this.castParam('search', 'string', { allowNull: true })
const style = this.castParam('style', 'string', { enum: PlaceStylesEnumValues, allowNull: true })
// escape the user's input so `%` and `_` match literally
if (search) query = query.where({ name: ops.like(`${ops.like.escape(search)}%`) })
if (style) query = query.where({ style })
this.ok(await query.all())
}