Skip to main content

OpenAPI - Serializers

Serializers have the unique ability to transform the attributes and associations assigned to them into robust OpenAPI types. Dream and Psychic both work together to leverage the products of the types provided by serializers into the OpenAPI schemas produced. This enables you to compose OpenAPI types with ease, and will automatically interpret database-level types into fully-expanded OpenAPI types without any effort on your part, providing all of the OpenAPI types for your app out of the box without doing anything (unless your serializers need to deliver something more than what is in the database).

Automatic type inference​

Serializers will automatically infer types from the model when using the functional DreamSerializer API. The serializer automatically reads database column types and associations to generate accurate OpenAPI schemas:

Attribute type inference​

import { DreamSerializer } from '@rvoh/dream'
import Place from '../models/Place.js'

export const PlaceSerializer = (place: Place) =>
DreamSerializer(Place, place).attribute('name')

Dream will automatically read the database types for Place#name, returning an OpenAPI type like:

schema: {
Place: {
type: 'object',
required: ['name'],
properties: {
name: {
{ type: 'string', nullable: true }
}
}
}
}

We recommend that you utilize type inference where possible, since it will automatically respond to migrations to generate the most up-to-date types, leaving you with a completely hands-off approach to maintaining your OpenAPI types.

Date inference​

Unlike most JavaScript-based frameworks, Psychic makes a careful distinction between DateTime and CalendarDate, which it uses for datetimes and dates, respectively. Psychic will automatically instantiate instances of DateTime and CalendarDate as it reads PostgreSQL timestamp or date fields from database rows and instantiates model instances out of them, and will automatically convert them to iso8601 strings upon serializing to JSON, correctly delivering YYYY-MM-DD format for date fields, while delivering the full iso8601 timestamp spec for date-time fields, including TZ information. In the OpenAPI layer, Psychic will automatically type dates and date-times respectively, like so:

export const PlaceSerializer = (place: Place) =>
DreamSerializer(Place, place)
.attribute('createdOn')
.attribute('createdAt')

schema: {
Place: {
type: 'object',
required: ['createdOn', 'createdAt'],
properties: {
createdOn: {
{ type: 'string', format: 'date' },
},
createdAt: {
{ type: 'string', format: 'date-time' },
}
}
}
}

Association type inference​

With the functional API, we can render associations using rendersOne and rendersMany methods, which provide nested serializer usage without duplicating structures unnecessarily:

export const UserSerializer = (user: User) =>
DreamSerializer(User, user)
.rendersMany('places', (place) => PlaceSerializer(place))
.rendersOne(
'latestPlace',
(place) => (place ? PlaceSerializer(place) : null),
{ optional: true }
)

which would produce something like:

schema: {
User: {
type: 'object',
required: ['places', 'latestPlace'],
properties: {
places: {
type: 'array',
items: {
$ref: '#/components/schemas/Place'
}
}
latestPlace: {
anyOf: [
{
$ref: '#/components/schemas/Place'
},
{ null: true }
]
}
}
},
Place: {
type: 'object',
required: ['id','name'],
properties: {
id: { type: 'string' },
name: { type: 'string', nullable: true },
}
}
}

Custom attribute definitions​

If the inferred types will not work for your use case — for instance a @deco.Virtual() column, or a shape inference can't reach — override the OpenAPI type directly on the attribute call via the openapi option:

export const PlaceSerializer = (place: Place) =>
DreamSerializer(Place, place)
.attribute('description', {
openapi: {
type: 'object',
required: ['en-US', 'en-ES'],
properties: {
'en-US': { type: 'string' },
'en-ES': { type: 'string' },
},
},
})
.attribute('name', {
openapi: { type: 'string', nullable: true },
})

which would produce:

schema: {
Place: {
type: 'object',
required: ['description', 'name'],
properties: {
description: {
type: 'object',
required: ['en-US', 'en-ES'],
properties: {
'en-US': { type: 'string' },
'en-ES': { type: 'string' },
}
},
name: {
type: 'string',
nullable: true,
}
}
}
}

Every attribute-defining method — attribute, customAttribute, delegatedAttribute — accepts openapi as an option alongside its other options (as, precision, default, flatten, and so on), rather than taking the OpenAPI shape as a bare second argument. For a @deco.Virtual() column that already carries an OpenAPI type annotation on the decorator, use plain .attribute('name') without an openapi option — the type is inferred from the Virtual decorator itself, and adding one here would be redundant hand-written schema for something Psychic can already derive.

Psychic and dream are currently fixed to OpenAPI 3.1.0, and plan to march forward with later versions. We attempt to support the full depth of their api (and if you notice anything that isn't, please open up an issue and we will correct it promptly!), which means you will be able to get typed support and autocomplete to aid you as you define your custom openapi definitions. This includes support for the following types:

  • string
  • number
  • integer
  • decimal
  • boolean
  • date
  • date-time
  • object
  • array
  • null

as well as conjuncting types, like:

  • anyOf
  • oneOf
  • allOf

Shorthand​

Manually typing OpenAPI specs can be pretty annoying after a while, so we have provided some helpful shorthand for those looking to save their wrists a little. For type primitives, rather than typing { type: 'string' }, you can simply type 'string'. The following shorthand strings are available:

  • 'string'
  • 'string[]'
  • 'number'
  • 'number[]'
  • 'integer'
  • 'integer[]'
  • 'decimal'
  • 'decimal[]'
  • 'boolean'
  • 'boolean[]'
  • 'date'
  • 'date[]'
  • 'date-time'
  • 'date-time[]'

Any of these shorthand representations can be wrapped in an array with 'null' to represent a nullable attribute, e.g., ['string', 'null']. Like the full object form, the shorthand is passed via the openapi option:

export const PlaceSerializer = (place: Place) =>
DreamSerializer(Place, place)
.attribute('name', { openapi: 'string' })
.attribute('amenities', { openapi: ['string[]', 'null'] })

which would produce:

schema: {
Place {
type: 'object',
required: ['name', 'amenities'],
properties: {
name: {
type: 'string',
},
amenities: {
// usually, we wouldn't recommend nullable arrays, because an empty array
// usually represents what we want, but this represents what the openapi
// shorthand can do
type: ['array', 'null'],
items: {
type: 'string',
}
}
}
}
}

Nullability on delegated attributes​

delegatedAttribute reads a property on a loaded association (.delegatedAttribute('profile', 'avatarUrl', ...) reads place.profile.avatarUrl). When the delegated-through path may resolve to undefined/null — a missing HasOne, an absent JSON sub-key — choose between optional: true and required: false. They are not aliases; they govern different layers and can be combined:

OptionRuntimeOpenAPI
optional: trueNo effect — key always rendered (null when missing).Schema wrapped in anyOf: [schema, { type: 'null' }].
required: falseKey omitted from the response when the resolved value is undefined.Field excluded from the containing schema's required[].
default: <value>Substitutes the value when the resolved path is undefined.(No effect.)
// Key always present; null when the association is missing.
.delegatedAttribute('profile', 'avatarUrl', { openapi: 'string', optional: true })

// Key omitted from the response when the association/path is missing.
.delegatedAttribute('profile', 'avatarUrl', { openapi: 'string', required: false })

A .attribute(...) for a @deco.BelongsTo('Foo', { optional: true }) column auto-infers OpenAPI nullability from that association metadata, so optional: true is most often needed explicitly for HasOne or other non-BelongsTo nullable paths.

rendersOne / rendersMany nullability​

Don't pass optional: true to rendersOne for a BelongsTo association. The BelongsTo declaration on the model — optional: true or the default false — is the canonical source of truth for whether the association can be null. The renderer auto-infers nullability from that metadata: a rendersOne('approver') whose BelongsTo('User', { optional: true }) declaration says nullable already produces the same anyOf: [{ $ref: ... }, { type: 'null' }] shape you'd get by passing optional: true explicitly.

optional on rendersOne is purely an OpenAPI nullability marker — the key is always rendered. rendersOne does not support required: false; if the key needs to be absent from the response, reshape the serializer (a custom attribute, or a different serializer variant) rather than reaching for an option that doesn't exist. optional: true also has no effect on an association that was never preloaded or loaded — rendering it still throws regardless of optional.

ObjectSerializer (for non-Dream objects)​

For plain objects — computed / view-model shapes with no backing Dream model — use ObjectSerializer instead of DreamSerializer. All attributes require an explicit openapi type, since there's no database column to infer from. When a controller returns a stable computed / view-model object, create an ObjectSerializer and pass that serializer function to @OpenAPI(SerializerFn, { status }) instead of hand-writing responses[status].properties:

import { ObjectSerializer } from '@rvoh/dream'

export const BedTypeSerializer = (bedType: BedTypesEnum, passthrough: { locale: LocalesEnum }) =>
ObjectSerializer({ bedType }, passthrough)
.attribute('bedType', {
as: 'value',
openapi: { type: 'string', enum: BedTypesEnumValues },
})
.customAttribute('label',
() => i18n(passthrough.locale, `rooms.Bedroom.bedTypes.${bedType}`),
{ openapi: 'string' }
)

For OpenAPI-visible nested computed/view-model response shapes, export every ObjectSerializer that is passed to another serializer's rendersOne or rendersMany. Exported serializers are registered as named OpenAPI schemas; local non-exported nested const serializers can generate anonymous Unnamed schemas, and multiple nested shapes can collapse into the same anonymous type, causing generated clients to lose fields.

Runtime serializer global names include the serializer's file path, so the same exported function name in different directories doesn't by itself cause a naming conflict. OpenAPI component names for named exports are based on the export name alone, though, so two OpenAPI-visible serializers with the same exported function name can still collide in the generated schema. Give computed/view-model serializers distinct export names — a ViewSerializer suffix, for instance — when the domain noun overlaps a Dream model serializer.

A compound response is still one serializer​

When an action returns a hand-shaped envelope — say a record plus a related collection, { place, nearby }, or a computed array alongside serialized models — model the whole envelope as one composing ObjectSerializer that renders each part, and pass it to @OpenAPI(SerializerFn). Don't reach for a hand-written responses block. rendersOne / rendersMany take a serializer function via { serializer }, or render a Dream-model field by key via { dreamClass, serializerKey }:

// Action returns { place: Place; nearby: Place[] }
export const PlaceWithNearbySerializer = (place: Place, nearby: Place[]) =>
ObjectSerializer({ place, nearby })
.rendersOne('place', { serializer: PlaceSummarySerializer })
.rendersMany('nearby', { serializer: PlaceSummarySerializer })

The action is then @OpenAPI(PlaceWithNearbySerializer, { status: 200 }) over 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. Forget to call PlaceWithNearbySerializer(...) — handing this.ok the raw { place, nearby } object instead — and OpenapiResponseValidationFailure lands on an ordinary column inside the nested place, which reads as a preload miss and is not one; a genuine preload miss throws NonLoadedAssociation during serialization and never reaches response validation. See Custom Response Envelopes for the controller side.

rendersOne / rendersMany also accept any declared property on a model, not only associations, so a shape that must be computed asynchronously can be assigned in the controller and rendered as a field of the model itself. Prefer the compound envelope above; reach for this only when the computed shape has to sit inside the model's own object — typically when those models render as a collection, where an envelope can't reach individual items. Either way, don't hand-write openapi for the nested shape — export the nested ObjectSerializer and reference it, and pass optional: true so actions that leave the field unassigned don't fail response validation.