Skip to main content

params

Requests to your server will often contain either a request body, path params, or query params. Psychic controllers provide special helper methods for interacting with these params. They enable the developer to both keep DRY, as well as providing implicit param validation.

castParam

The castParam method provides the developer with a unique way to both absorb and validate a specific param simultaneously. In the following example, the show action will find any places belonging to the currentHost with the id provided in the params. Psychic is param-location ambiguous, meaning that it will search the path, query, and body for a param with the key id, and will then cast it to a string type. However, if the param passed is not a valid string, Psychic will raise a 400 status code error.

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 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'))
}
}

Psychic supports many different data types for validation. Here are a few others that can be used:

export default class V1HostPlacesController extends V1HostBaseController {
public async show() {
const uuid = this.castParam('id', 'uuid')
const int = this.castParam('id', 'integer')
const bigint = this.castParam('id', 'bigint')
const date = this.castParam('id', 'date')
const datetime = this.castParam('id', 'datetime')
const number = this.castParam('id', 'number')
const string = this.castParam('id', 'string')
const enum = this.castParam('id', 'string', { enum: ['basic', 'premium']})
}
}

In addition to these primitive types, Psychic also supports array variants:

export default class V1HostPlacesController extends V1HostBaseController {
public async show() {
const uuids = this.castParam('id', 'uuid[]')
const ints = this.castParam('id', 'integer[]')
const bigints = this.castParam('id', 'bigint[]')
const dates = this.castParam('id', 'date[]')
const datetimes = this.castParam('id', 'datetime[]')
const numbers = this.castParam('id', 'number[]')
const strings = this.castParam('id', 'string[]')
const enums = this.castParam('id', 'string[]', {
enum: ['basic', 'premium'],
})
}
}

Bounding length and range

Beyond enum/allowNull, castParam also enforces size bounds: minLength/maxLength on a 'string', and minimum/maximum on a 'number'/'integer'/'bigint'. All four are inclusive and checked after type coercion; a violation throws and Psychic renders it as a 400. Bound untrusted request input at the boundary rather than validating it later in a model or service:

this.castParam('title', 'string', { minLength: 1, maxLength: 200 })
this.castParam('nightlyRateCents', 'integer', { minimum: 0, maximum: 100_000_000 })
this.castParam('sleeps', 'integer', { minimum: 1, maximum: 20, allowNull: true })

For array casts ('string[]', 'integer[]', …) the bounds apply element-wise — each element must satisfy them — and bigint range checks compare without precision loss.

An absent key and an explicit null differ

With { allowNull: true }, castParam returns undefined for a key absent from the body and null when the body carries an explicit null. The rule holds at every depth, including a null nested inside a present object.

That difference is what lets a partial update tell "leave this column alone" apart from "clear it":

const cityId = this.castParam('cityId', 'uuid', { allowNull: true })

// absent → leave the column alone; explicit null → clear it
if (cityId !== undefined) await place.update({ cityId })

Both values are falsy, so test === undefined wherever the two must stay apart, and collapse them freely where they need not — an index action's optional filters, say.

String params are trimmed automatically

Psychic strips leading and trailing whitespace from string params before validation and casting — ' Cozy Cabin ' arrives as 'Cozy Cabin'. This applies to scalar 'string' params, enum strings, and the elements of string and enum arrays. Both castParam and extractParams resolve strings through the same path, so there's no need to re-trim in a controller, a model setter, or a hook.

casting openapi shapes

The castParam method is also able to interpret OpenAPI shapes, and will return types data back to you based on the provided shape.

const data = this.castParam('myData', {
type: 'object',
properties: {
a: number,
b: { oneOf: [{ type: 'string' }, { type: 'number' }] },
},
})
data.a // number | undefined
data.b // string | number | undefined

When using castParam with OpenAPI shapes, params will automatically be coerced by the Ajv library.

extractParams

In addition to individual param casting, Psychic provides the ability to ingest a typed, allowlisted set of params for a given Dream model. extractParams takes the model class and an explicit list of permitted columns; the columns are visible at the call site so reviewers can see exactly which fields the action accepts from the request:

export default class V1HostPlacesController extends V1HostBaseController {
@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)
}
}

The allowed array is compile-time constrained to the model's param-safe column names, so passing a protected column (primary key, timestamps, deletedAt, STI type, belongs-to foreign keys, polymorphic type fields, or any column declared in paramUnsafeColumns) is a TypeScript error. At runtime, the array is intersected against the model's paramSafeColumnsOrFallback() set, so anything that bypasses the type system still fails closed.

extractParams is the canonical primitive for controllers handling user-editable models. The generator emits it for every scaffolded create / update action.

Always-excluded columns

Some columns are stripped from extractParams regardless of what the allowlist names, because they should never be mass-assignable:

  • The primary key (defaults to id)
  • createdAt / updatedAt / deletedAt
  • The type field of STI models
  • Foreign keys of BelongsTo associations
  • The polymorphic type field of polymorphic BelongsTo associations

Since foreign keys are excluded, find the parent resource explicitly and pass it as an association instead of pulling the id through extractParams:

const place = await Place.findOrFail(this.castParam('placeId', 'uuid'))
const favoritePlace = await this.currentUser.createAssociation('favoritePlaces', {
...this.extractParams(FavoritePlace, ['rating', 'note']),
place,
})

Scope that lookup through the current user where you can (this.currentUser.associationQuery('places').findOrFail(...)) rather than a bare Place.findOrFail(...), so the record load and the authorization check are the same query.

paramSafeColumns and paramUnsafeColumns

The attributes that may be updated via params can be further restricted by providing a paramSafeColumns getter on any Dream model (see paramSafeColumns):

export default class User extends ApplicationModel {
public get paramSafeColumns(): DreamColumnNames<User>[] {
return ['email'] as const
}
}

extractParams(User, ['email']) intersects the explicit allowlist with the declared safe columns; passing a column the model doesn't permit is a no-op at runtime (and a TypeScript error at compile time).

Reach for paramSafeColumns when you want to enumerate a model's whole safe set. When you only need to block one or two columns from ever being bulk-assignable — an admin/role flag, an internal payout-account id — use paramUnsafeColumns instead; it layers on top of the always-excluded set above without you having to re-enumerate every other safe column:

public get paramUnsafeColumns(): DreamColumnNames<Friend>[] {
return ['bff']
}

Neither getter is something to add reflexively to every model — only where a column genuinely must never be assignable no matter what a controller's own allowlist says.

Generator output: a shared paramSafeColumns const

g:resource (and related generators) emit a shared paramSafeColumns const at the top of the controller file and reference it from every create / update action — and from those actions' @OpenAPI requestBody — so the allowlist stays visible at the call site without duplicating the array per action:

import { OpenAPI } from '@rvoh/psychic'
import { DreamParamSafeColumnNames } from '@rvoh/dream/types'
import Post from '@models/Post.js'

const paramSafeColumns: DreamParamSafeColumnNames<Post>[] = ['title', 'body']

export default class PostsController extends AuthedController {
@OpenAPI(Post, {
status: 201,
requestBody: { params: paramSafeColumns },
})
public async create() {
const post = await Post.create(this.extractParams(Post, paramSafeColumns))
this.created(post)
}

@OpenAPI(Post, {
status: 204,
requestBody: { params: paramSafeColumns },
})
public async update() {
await (await this.post()).update(this.extractParams(Post, paramSafeColumns))
this.noContent()
}
}

Narrowing paramSafeColumns this way updates both the runtime extractParams allowlist and the documented @OpenAPI request body in one place — the two can't silently drift apart. The const is emitted only when the controller has a create and/or update action and the model is known; when no columns survive the param-safe filter it's still emitted, typed and empty.

Request body size limits

@koa/bodyparser caps request bodies via jsonLimit and formLimit; a request over the limit is rejected with a 413 before reaching the action. Tune it in conf/app.ts to the largest legitimate payload your endpoints accept:

psy.set('json', {
jsonLimit: '5mb',
formLimit: '500kb',
})

See the @koa/bodyparser README for the full option surface. DoS protection against oversized payloads belongs at the edge (see the rate limiting guide), not at the body-parser layer.

Options

extractParams accepts an optional third argument:

// Extract from a nested key (e.g. `{ place: { name: '...' } }`)
this.extractParams(Place, ['name'], { key: 'place' })

// Extract an array of param objects
this.extractParams(Place, ['name'], { array: true })