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, i.e.

// 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.

@OpenAPI(PlaceSerializer, {
status: 200,
})

serializerKey

You can provide a serializerKey option, which will inform the 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
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. This can be done like so:

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

As expected, 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, using standard OpenAPI notation.

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

Request bodies will by default be inferred from the serializable argument, if it is provided and is pointing to a dream model, like so:

export default class UsersController extends PsychicController {
@OpenAPI(User, {
status: 204,
})
public async create() {
await User.create(this.paramsFor(User))
this.noContent()
}
}

for

For more granular specification, you can take advantage of the for, only, and including options, which enable you to explicitly provide certain params for a model class.

export default class UsersController extends PsychicController {
@OpenAPI({
status: 204,
requestBody: {
for: User,
only: ['email'],
},
})
public async create() {
await User.create(this.paramsFor(User, { only: ['email'] }))
this.noContent()
}
}

If the for argument is left off, the allowed fields will fall back to the base model provided to the decorator, i.e.

export default class UsersController extends PsychicController {
@OpenAPI(User, {
status: 204,
requestBody: {
only: ['email'],
},
})
public async create() {
await User.create(this.paramsFor(User, { only: ['email'] }))
this.noContent()
}
}

Tapping into the including arg, we can explicitly provide params that would otherwise be excluded (i.e. foreign keys, primary keys, and timestamp fields).

export default class PetsController extends PsychicController {
@OpenAPI(Pet, {
status: 204,
requestBody: {
only: ['species'],
including: ['userId'],
},
})
public async create() {
await Pet.create(
this.paramsFor(Pet, { only: ['species'], including: ['userId'] })
)
this.noContent()
}
}

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 specify the payload shape for specific response statuses, like so:

export default class PostsController extends PsychicController {
@OpenAPI({
status: 204,
responses: {
400: {
type: 'object',
properties: {
errors: 'string[]',
},
},
},
})
public async helloWorld() {
if (something) {
this.badRequest({ errors: ['error 1', 'error 2'] })
}

this.noContent()
}
}

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, as shown below

export default class PostsController extends PsychicController {
@OpenAPI({
query: {
searchTerm: {
required: false,
},
searchTerms: {
required: false,
schema: {
type: 'string[]',
},
},
},
})
public async helloWorld() {
this.noContent()
}
}

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

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