OperationsOpenAPI · NestJS

Describe each operation the way its callers experience it: a tag with a description, a summary, the header every route accepts, every response with its status and model, a global 500, a paginated response built with getSchemaPath, and a multipart upload body.

What you will learn

Read the theory for Operations

All OpenAPI lessons

All NestJS courses

loading types…

What you'll learn

  • Group operations with @ApiTags and describe the tag in the DocumentBuilder; add a summary with @ApiOperation and a shared header with @ApiHeader
  • Document responses with @ApiResponse and its status shorthands, with a model class as the type, and declare one response for every operation with addGlobalResponse
  • Describe a response the scanner cannot see, a generic page of results, with @ApiExtraModels, getSchemaPath and allOf, packaged as a reusable decorator with applyDecorators
  • Document a file upload as a multipart/form-data body with @ApiConsumes and a binary-string property

Operations

Schemas describe data; operations describe what happens. The admin dashboard's developer reads GET /cats and wants to know what it is for, what comes back on success, what a 404 looks like and whether there is a header they must send. The scanner knows the path and the method and, for a @Body(), the request schema, and that is all it knows: it cannot read a handler's return type (a Promise<Cat> is metadata for Promise, no more), it cannot see which exceptions a service throws, and it cannot guess what a route is for. The rest of the operation is yours to declare.

Tags, summaries, headers

@ApiTags('orders') on a controller (or a method) files every operation under that tag; the Swagger UI groups by tag, and the tag's description, given to addTag('orders', 'Everything a customer bought') in the DocumentBuilder, heads the group. Without @ApiTags the scanner tags by controller name, so OrdersController becomes Orders. @ApiOperation({ summary: 'Place an order' }) is the one line shown next to the method; a longer description can go with it. @ApiHeader({ name: 'X-Request-Id', description: '...' }) documents a request header, and on the controller it lands on every operation, ahead of the parameters the scanner found.

Responses

@ApiResponse({ status: 201, description: 'Placed' }) declares one response; the shorthands (@ApiCreatedResponse, @ApiNotFoundResponse, @ApiNoContentResponse, one per status) save the number. Give a response a type and the document references that class's schema, so the class needs @ApiProperty() on its properties like any DTO. An entity can carry both decorators at once, TypeORM's and the document's, and then it is the response model:

@Post()
@ApiCreatedResponse({ description: 'The order as stored', type: Order })
@ApiBadRequestResponse({ description: 'A line has no quantity' })
create(@Body() dto: CreateOrderDto): Promise<Order> {}

A response every operation shares, a 500, a 401, belongs in the builder: addGlobalResponse({ status: 500, description: '...' }) puts it on all of them.

A response the scanner cannot see

A paginated list returns { total, limit, offset, results: T[] }, and results differs per route. A generic class PaginatedDto<T> cannot say what T is at runtime. The docs' answer composes the schema by hand: @ApiExtraModels(PaginatedDto, Order) registers both classes so they appear under components.schemas even though no route references them directly, getSchemaPath(PaginatedDto) returns the $ref string to one, and an allOf joins the page's properties with a results array of the model:

@ApiOkResponse({
  schema: {
    allOf: [
      { $ref: getSchemaPath(PaginatedDto) },
      { properties: { results: { type: 'array', items: { $ref: getSchemaPath(Order) } } } },
    ],
  },
})

Written once per route that is a lot of decorator. applyDecorators() from @nestjs/common combines several into one, which is how a custom @ApiPaginatedResponse(Order) is made: a function taking the model, returning applyDecorators(ApiExtraModels(...), ApiOkResponse(...)). A title on the schema (PaginatedResponseOfOrder) gives generators a name for the composed type.

Uploads

A file upload is a multipart/form-data body. @ApiConsumes('multipart/form-data') sets the content type, and @ApiBody({ type: UploadDto }) names a DTO whose file property is @ApiProperty({ type: 'string', format: 'binary' }); the Swagger UI turns that into a file picker. The FileInterceptor from the Techniques course still does the receiving.

Your task

The cats API now pages its list (GET /cats?limit=&offset=), returns a PaginatedDto<Cat>, and accepts a photo at POST /cats/:id/photo. Nothing about the operations is documented.

  1. Describe the cats tag as The shelter's residents and declare, for every operation, that a 500 means Something broke on the shelter's side. Tag the controller cats and document the X-Shelter-Site header (Which shelter site is calling) on every route.
  2. Make Cat a model: every column visible, breed as the nullable Breed enum, photo an optional nullable string. Give the list, the fetch and the create their summaries (List the cats, a page at a time, Fetch one cat, Register a cat), limit and offset as optional numbers, and document the responses: 200 with a Cat on the fetch, 201 The cat, with its id on the create, 404 No cat has that id on the fetch and the delete, 204 Removed on the delete.
  3. Finish ApiPaginatedResponse in api-paginated-response.decorator.ts so the list's 200 is One page of results composed as above, and put it on the list route.
  4. Document the upload's body as multipart with PhotoUploadDto.

When it fails

  • "photo": { "type": "object", "nullable": true }: a string | null property's metadata is Object; say type: String.
  • The 200 of GET /cats references PaginatedDto but components.schemas has no such entry: @ApiExtraModels is missing. getSchemaPath builds a $ref string; it registers nothing.
  • The list's response has allOf but results is { "$ref": ... } without type: 'array': the composed schema is copied from the docs' JSON example, which shows the shape after the UI resolved it, not the decorator to write.
  • Every operation is tagged Cats, capital C: @ApiTags is missing and the scanner named the tag after the controller.

Remember

  • @ApiTags, @ApiOperation({ summary }) and @ApiHeader say what an operation is; addTag describes the group.
  • @ApiResponse and its status shorthands declare responses, with type for a model class; addGlobalResponse for one every operation shares.
  • What the scanner cannot see (a generic, a union) is composed by hand: @ApiExtraModels, getSchemaPath, allOf, packaged with applyDecorators.
  • An upload is @ApiConsumes('multipart/form-data') plus a DTO with a binary-string property.
Stuck? Show a hint

main.ts: .addTag('cats', "The shelter's residents") and .addGlobalResponse({ status: 500, description: '...' }). cat.entity.ts: @ApiProperty() on each column (@ApiPropertyOptional({ enum: Breed, enumName: 'Breed', nullable: true }) for breed, @ApiPropertyOptional({ type: String, nullable: true }) for photo). Controller: @ApiTags('cats') and @ApiHeader({ name: 'X-Shelter-Site', description: '...' }) on the class; @ApiOperation({ summary }) per route; @ApiOkResponse({ type: Cat }), @ApiCreatedResponse({ description, type: Cat }), @ApiNotFoundResponse({ description }), @ApiNoContentResponse({ description }); @ApiQuery({ name, required: false, type: Number }) for limit and offset; @ApiConsumes('multipart/form-data') + @ApiBody({ type: PhotoUploadDto }) on the upload. The decorator: applyDecorators(ApiExtraModels(PaginatedDto, model), ApiOkResponse({ schema: { allOf: [{ $ref: getSchemaPath(PaginatedDto) }, { properties: { results: { type: 'array', items: { $ref: getSchemaPath(model) } } } }] } })).