Multiple specs
For some applications, it may be desirable to split your OpenAPI definitions into multiple, separate files. To do this, specify in conf/app.ts a second OpenAPI configuration under a different name (e.g. admin), like so:
export default (psy: PsychicApp) => {
psy.set('openapi', {
outputFilepath: path.join('src', 'openapi', 'openapi.json'),
// ...
})
psy.set('openapi', 'admin', {
// every non-default spec needs its own outputFilepath
outputFilepath: path.join('src', 'openapi', 'admin.openapi.json'),
// ...
})
}
A fresh application ships five specs out of the box: default (client/web), mobile, admin, internal, and tests. There are two distinct reasons to add or keep a spec split out like this:
- Separate by access domain. Publish admin, internal, and public endpoints as their own specs so endpoints meant for one audience never appear in a document meant for another. Add new specs as the app grows — for example a public server-to-server
partnerspec published alongside the client, admin, and internal specs, which stay private. - Shape the schema per consumer. The same endpoints can emit a different schema shape into different specs to fit how each client consumes them. The built-in
mobilespec below is this case: it documents the same endpoints asdefault, just shaped differently for a strongly-typed client.
The mobile spec and strongly-typed consumers
If a response payload contains a string enum, a strongly-typed mobile client compiles that enum into a closed type. This can be critical for you: when the backend later adds an enum value, an older app version that hasn't been rebuilt crashes on the unrecognized value, which makes the API rigid — every new value becomes a breaking change for anyone who hasn't updated.
suppressResponseEnums does away with this concern. With it set, a response enum is emitted as a plain string whose description lists the allowed values instead of a closed set — this lets a client ignore values it doesn't recognize, and often handle the set dynamically (rendering whatever options the backend sends into a select, for instance). Web front ends compile to JavaScript, which isn't strongly typed, so the default spec keeps real enums since front-end tooling benefits from them and production doesn't break when the backend adds a value.
export default (psy: PsychicApp) => {
psy.set('openapi', {
outputFilepath: path.join('src', 'openapi', 'openapi.json'),
})
psy.set('openapi', 'mobile', {
outputFilepath: path.join('src', 'openapi', 'mobile.openapi.json'),
// identical endpoints to `default`, except enums are emitted as
// described strings instead of a closed set of values
suppressResponseEnums: true,
})
}
Opting controllers into a spec
Each surface's controllers are routed to one or more OpenAPI specs via the openapiNames getter, so endpoints land in the specs meant for their audience. A controller is documented into every spec it lists:
class ApplicationController extends PsychicController {
public static override get openapiNames(): PsychicOpenapiNames<ApplicationController> {
return ['default', 'tests']
}
}
class AdminApplicationController extends ApplicationController {
public static override get openapiNames(): PsychicOpenapiNames<ApplicationController> {
return ['admin', 'tests']
}
}
All controllers inheriting from a base controller automatically inherit the same openapiNames. Run pnpm psy sync after adding a new namespace so the openapiNames types update.
Always keep 'tests' in any openapiNames override you write. A controller whose list omits 'tests' is left out of the aggregated tests spec (see below), so its endpoints get no generated request/response types — the typed request helper won't recognize their paths and the controller spec can't type-check. When adding a new surface or namespace, the list is ['<surface>', 'tests'], never ['<surface>'] alone.
The tests spec
Every base controller includes 'tests' in its openapiNames, so the tests spec aggregates endpoints from every surface (client, admin, internal, and any custom namespace) into one document:
psy.set('openapi', 'tests', {
outputFilepath: path.join('src', 'openapi', 'tests.openapi.json'),
syncTypes: true,
})
With syncTypes: true, pnpm psy sync runs this spec through openapi-typescript and writes a declaration file exporting a paths type. Those generated types make controller specs type-safe on both the request and the response, without hand-maintaining separate request/response types per surface.
This automatically compiles all OpenAPI decorators used in controllers that extend a given base into every spec that base's openapiNames lists.