Skip to main content

status codes

Psychic encourages developers to think in terms of HTTP status code names, rather than manually setting status codes and rendering JSON. We provide methods which will automatically apply the given statuses, and will render JSON automatically:

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) // 200
}

@OpenAPI(Place, {
status: 200,
tags: openApiTags,
description: 'Fetch a Place',
})
public async show() {
const place = await this.place()
this.ok(place) // 200
}

@OpenAPI(Place, {
status: 201,
tags: openApiTags,
description: 'Create a Place',
})
public async create() {
let place = await this.currentHost.createAssociation(
'places',
this.extractParams(Place, ['name', 'description', 'style', 'sleeps'])
)
if (place.isPersisted) place = await place.loadFor('default').execute()
this.created(place) // 201
}

@OpenAPI(Place, {
status: 204,
tags: openApiTags,
description: 'Update a Place',
})
public async update() {
const place = await this.place()
await place.update(this.extractParams(Place, ['name', 'description', 'style', 'sleeps']))
this.noContent() // 204
}

private async place() {
return await this.currentHost
.associationQuery('places')
.findOrFail(this.castParam('id', 'string'))
}
}

Thinking in terms of status codes helps you to write a clearly-defined API, and will produce more consistent, standardized patterns for handling various operations. In addition to providing status codes for handling successes, Psychic also provides methods for handling non-success statuses, like so:

export default class V1HostPlacesController extends V1HostBaseController {
@OpenAPI(Place, {
status: 200,
tags: openApiTags,
description: 'Fetch a Place',
})
public async show() {
const place = await this.place()
this.ok(place)
}

private async place() {
return await this.currentHost
.associationQuery('places')
.findOrFail(this.castParam('id', 'string'))
}
}

Note that in the example above, there is no return statement encapsulating the this.notFound call. This may seem like a mistake, since this request looks like it would render both a 404 and a 200 response. However, calling the notFound method carefully raises an exception, which is then rescued within the request thread and rendered with the appropriate status. This is done so that any code after the notFound will not be run, guaranteeing that you don't mistakenly double-render your responses, which is a common problem in web applications.

Full list of response methods

// Success
this.ok(data) // 200 - serializes Dream models automatically
this.created(data) // 201
this.accepted(data) // 202
this.noContent() // 204

// Redirects
this.redirect(path) // 302
this.movedPermanently(path) // 301

// Client errors
this.badRequest() // 400
this.unauthorized() // 401
this.forbidden() // 403
this.notFound() // 404
this.conflict() // 409
this.unprocessableContent() // 422

// Server errors
this.serverError() // 500

A same-origin relative path (starting with a single /) always works with redirect/movedPermanently. An absolute-URL target is checked against redirectAllowedHosts — an empty allowlist by default — and throws a 500 (not a silent no-op) if the host isn't listed. Allowlist matching is host-only: case-insensitive and both port- and scheme-insensitive. Add external redirect targets explicitly:

psy.set('redirectAllowedHosts', ['oauth.example.com'])

Error markers: distinguishing two same-status causes

When one endpoint can return the same status for two different reasons, the message you pass to a response helper becomes a runtime discriminator the frontend can switch on.

The marker itself (untyped, zero-config). this.forbidden(msg) / this.unauthorized(msg) (and the rest) throw an error whose argument is JSON-stringified as the response body, so this.forbidden('not_your_place') produces the body "not_your_place". The default error responses are description-only — no schema — so the marker never appears in the generated OpenAPI spec or client; it's a runtime-only discriminator with no schema change and no regeneration needed:

// A Host editing a Place they don't own
if (!place.hostedBy(this.currentUser)) this.forbidden('not_your_place')
// frontend
if (err.response.status === 403 && err.response.data === 'not_your_place') { /* ... */ }

Two caveats. Pass a non-empty string — calling forbidden() with a falsy argument sends no body, so the discriminator check fails on the frontend. And the marker is always sent to the client verbatim at runtime, so keep it a coarse, stable cause code (not_your_place, terms_of_service_required) and never put sensitive detail in it.

The typed-enum upgrade. The body stays a string; attaching an enum to the action's @OpenAPI responses makes the generated client type it as a literal union instead of bare string:

@OpenAPI(Place, {
status: 204,
responses: {
403: { type: 'string', enum: ['not_your_place'] },
},
})
public async update() { /* ... this.forbidden('not_your_place') ... */ }

This changes only the spec and the generated types — it doesn't enforce the enum at runtime. You still write the forbidden('not_your_place') throw yourself and have to keep it in sync with the declared enum by hand.

Where the typed response lives depends on where the cause is raised. An action-specific cause (raised only inside one action) gets its typed responses override on that action's @OpenAPI. A cross-cutting cause raised from a shared @BeforeAction (e.g. terms_of_service_required, returnable by every authed endpoint) shouldn't be repeated per action — see OpenAPI: customizing default error responses for redefining the shared response component once at the conf level instead.

Implicit status codes

In addition to explicit status-code messages, Psychic provides reactive mechanisms to encapsulate Dream functionality, providing default status codes out the gate for you, such as in castParam and findOrFail:

export default class V1HostPlacesController extends V1HostBaseController {
@OpenAPI(Place, {
status: 200,
tags: openApiTags,
description: 'Fetch a Place',
})
public async show() {
// if the id is not present, or is not a valid string, this throws an error which Psychic converts to a 400
const id = this.castParam('id', 'string')

// if a place is not found by that id for this host, this throws an error which Psychic converts to a 404
const place = await this.currentHost
.associationQuery('places')
.findOrFail(id)

this.ok(place)
}
}