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()
}
}
Automatic request body inference
Request bodies will by default be inferred from the serializable argument, if it is provided and is pointing to a Dream model:
export default class UsersController extends PsychicController {
@OpenAPI(User, {
status: 204,
})
public async create() {
await User.create(this.extractParams(User, ['email', 'name']))
this.noContent()
}
}
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 restricts request body fields to a specific subset:
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), which produces an inline object schema derived from that model's param-safe columns:
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. Deriving them from a model's own paramSafeColumns, or from Model.columns(), dumps that model's entire writable column surface into the request body. For a model that declares no paramSafeColumns, that's every column on the table. This re-creates the implicit include-all default the params allowlist exists to prevent: the documented shape silently widens every time the model gains a column, the spec ends up advertising inputs the action's extractParams allowlist doesn't actually accept, and the full column surface leaks into the public OpenAPI shape.
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 specify the payload shape for specific response statuses. Before hand-writing a schema, check whether the response should instead be represented by a Dream serializer or an exported ObjectSerializer passed to @OpenAPI(SerializerFn, { status }).
export default class PostsController extends PsychicController {
@OpenAPI({
status: 204,
responses: {
400: {
type: 'object',
properties: {
errors: 'string[]',
},
},
},
})
public async helloWorld() {
if (something) {
this.badRequest({ errors: ['error 1', 'error 2'] })
}
this.noContent()
}
}
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 renders the plain data with this.ok(...):
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 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 you also want a specific { errors } body in the spec, reshape the shared BadRequest component once at conf level (defaults.components.responses.BadRequest — see The levers below) so every 400 carries it, 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 the keepers yourself. 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: { $ref: '#/components/responses/NotFound' },500: { $ref: '#/components/responses/InternalServerError' },},})
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 (see openapiConfig below). 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. 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()
}
}
defaultResponse
Use the defaultResponse option to specify attributes for the default response, such as description:
export default class PostsController extends PsychicController {
@OpenAPI({
defaultResponse: {
description: 'my description',
},
})
public async helloWorld() {
this.noContent()
}
}
security
Use the security option to provide security entries for OpenAPI. The shape provided here is identical to the shape required by OpenAPI itself, which is an array of objects, where the keys are the names, and the values are arrays of scopes required (or a blank array if there are no scopes):
export default class PostsController extends PsychicController {
@OpenAPI({
security: [
{
bearerToken: ['read', 'write'],
},
{
httpBasicAuth: [],
},
],
})
public async helloWorld() {
this.noContent()
}
}
omitDefaultHeaders
The omitDefaultHeaders option enables you to exclude all default-provided headers (which are configured in conf/app.ts):
export default class PostsController extends PsychicController {
@OpenAPI({
omitDefaultHeaders: true,
})
public async helloWorld() {
this.noContent()
}
}
omitDefaultResponses
Similar to the omitDefaultHeaders option, the omitDefaultResponses option enables you to exclude all default-provided responses (which are also configured in conf/app.ts):
export default class PostsController extends PsychicController {
@OpenAPI({
omitDefaultResponses: true,
})
public async helloWorld() {
this.noContent()
}
}
pathParams
Add descriptions and metadata for path parameters:
export default class PostsController extends PsychicController {
@OpenAPI({
pathParams: {
id: {
description: 'the id of the post',
},
},
})
public async show() {
this.ok(await this.currentHost.associationQuery('places').findOrFail(this.castParam('id', 'bigint')))
}
}
Debugging Unexpected 400s (and OpenAPI-triggered 500s in Specs)
When an endpoint returns an unexpected 400 — or a spec fails with a 500 thrown by OpenAPI response validation — and it isn't clear whether OpenAPI validation or controller logic is the cause, temporarily disable validation on that action to isolate it:
@OpenAPI(Place, {
status: 200,
validate: { all: false },
})
- Add
validate: { all: false }to the@OpenAPIdecorator for the failing endpoint. - Re-run the request or spec:
- For 400s on requests, this reveals whether the failure was in OpenAPI request validation (the problem stops) or in controller logic (the problem persists).
- For 500s in specs, this reveals whether OpenAPI response validation is rejecting a real shape mismatch versus something else failing.
- Remove the
validateline from the decorator once the problem is identified. Leaving it disabled defeats the protection OpenAPI validation provides.