Skip to main content

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.

warning

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:

  1. per-action @OpenAPI responses[status]
  2. conf defaults.responses[status] (see Conf-level configuration)
  3. 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 responses block, or in conf defaults.responses to apply it spec-wide. Your value wins for that status; every other default stays. No omitDefaultResponses needed.

  • Reshape a shared response once. When a response shape is cross-cutting — every authed endpoint can return it — redefine the component the default $ref points at instead of repeating it per action. Set defaults.components.responses.Forbidden (or .Unauthorized, .BadRequest, etc.) at conf level. Your key replaces the whole component, so every defaulted 403's $ref: '#/components/responses/Forbidden' resolves to your redefined body across the spec:

    // conf/app.ts — give the shared 403 a typed marker body once
    psy.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 defaults apply to the whole spec, not "authed endpoints only." To scope a marker to the authed surface, give those controllers their own named spec via openapiNames (see Multiple specs) and redefine Forbidden only there — otherwise public endpoints that default a 403 advertise the marker body too.

  • Remove one status, keep the rest. There's no direct mechanism for this. omitDefaultResponses is 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't 401/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())
}

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 },
})
  1. Add validate: { all: false } to the @OpenAPI decorator for the failing endpoint.
  2. 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.
    • If the decorator sets fastJsonStringify: true, comment it out too while debugging a response: it serializes through the response schema, coercing the very mismatch you're hunting (null renders as "" or 0).
  3. Remove the validate line (and restore fastJsonStringify) once the problem is identified. Leaving validation disabled defeats the protection it provides. See Debugging unexpected 400s for what a failure that persists with validation off points to.