feat: swagger annotations (#713)

This commit is contained in:
Vitalii Samofal 2023-08-24 12:47:45 +01:00 committed by GitHub
parent fe357574bd
commit 8eede4b3c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 1475 additions and 220 deletions

View File

@ -458,6 +458,43 @@ is resolved to:
`WHERE ... AND (id = 5 OR id = 7) AND name = 'Milo' AND ...` `WHERE ... AND (id = 5 OR id = 7) AND name = 'Milo' AND ...`
## Swagger
You can use two default decorators @ApiOkResponsePaginated and @ApiPagination to generate swagger documentation for your endpoints
`@ApiOkPaginatedResponse` is for response body, return http[](https://) status is 200
`@ApiPaginationQuery` is for query params
```typescript
@Get()
@ApiOkPaginatedResponse(
UserDto,
USER_PAGINATION_CONFIG,
)
@ApiPaginationQuery(USER_PAGINATION_CONFIG)
async findAll(
@Paginate()
query: PaginateQuery,
): Promise<Paginated<UserEntity>> {
}
```
There is also some syntax sugar for this, and you can use only one decorator `@PaginatedSwaggerDocs` for both response body and query params
```typescript
@Get()
@PaginatedSwaggerDocs(UserDto, USER_PAGINATION_CONFIG)
async findAll(
@Paginate()
query: PaginateQuery,
): Promise<Paginated<UserEntity>> {
}
```
## Troubleshooting ## Troubleshooting
The package does not report error reasons in the response bodies. They are instead The package does not report error reasons in the response bodies. They are instead

964
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -32,6 +32,8 @@
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand" "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/testing": "^10.2.1",
"@nestjs/platform-express": "^10.2.1",
"@nestjs/common": "^10.2.1", "@nestjs/common": "^10.2.1",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/jest": "^29.5.4", "@types/jest": "^29.5.4",
@ -59,6 +61,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"@nestjs/common": "^10.2.1", "@nestjs/common": "^10.2.1",
"@nestjs/swagger": "^7.1.8",
"express": "^4.18.2", "express": "^4.18.2",
"fastify": "^4.21.0", "fastify": "^4.21.0",
"typeorm": "^0.3.17" "typeorm": "^0.3.17"

View File

@ -1,2 +1,3 @@
export * from './decorator' export * from './decorator'
export * from './paginate' export * from './paginate'
export * from './swagger'

View File

@ -0,0 +1,65 @@
import { applyDecorators, Type } from '@nestjs/common'
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger'
import { ReferenceObject, SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface'
import { PaginateConfig } from '../paginate'
import { PaginatedDocumented } from './paginated-swagger.type'
export const ApiOkPaginatedResponse = <DTO extends Type<unknown>>(
dataDto: DTO,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
paginatedConfig: PaginateConfig<any>
) => {
const cols = paginatedConfig?.filterableColumns || {}
return applyDecorators(
ApiExtraModels(PaginatedDocumented, dataDto),
ApiOkResponse({
schema: {
allOf: [
{ $ref: getSchemaPath(PaginatedDocumented) },
{
properties: {
data: {
type: 'array',
items: { $ref: getSchemaPath(dataDto) },
},
meta: {
properties: {
select: {
type: 'array',
items: {
type: 'string',
enum: paginatedConfig?.select,
},
},
filter: {
type: 'object',
properties: Object.keys(cols).reduce(
(acc, key) => {
acc[key] = {
oneOf: [
{
type: 'string',
},
{
type: 'array',
items: {
type: 'string',
},
},
],
}
return acc
},
{} as Record<string, SchemaObject | ReferenceObject>
),
},
},
},
},
},
],
},
})
)
}

View File

@ -0,0 +1,158 @@
import { DEFAULT_LIMIT, DEFAULT_MAX_LIMIT, FilterOperator, FilterSuffix, PaginateConfig } from '../paginate'
import { ApiQuery } from '@nestjs/swagger'
import { FilterComparator } from '../filter'
import { applyDecorators } from '@nestjs/common'
const DEFAULT_VALUE_KEY = 'Default Value'
function p(key: string | 'Format' | 'Example' | 'Default Value' | 'Max Value', value: string) {
return `<p>
<b>${key}: </b> ${value}
</p>`
}
function li(key: string | 'Available Fields', values: string[]) {
return `<h4>${key}</h4><ul>${values.map((v) => `<li>${v}</li>`).join('\n')}</ul>`
}
export function SortBy(paginationConfig: PaginateConfig<any>) {
const defaultSortMessage = paginationConfig.defaultSortBy
? paginationConfig.defaultSortBy.map(([col, order]) => `${col}:${order}`).join(',')
: 'No default sorting specified, the result order is not guaranteed'
return ApiQuery({
name: 'sortBy',
isArray: true,
description: `Parameter to sort by.
<p>To sort by multiple fields, just provide query param multiple types. The order in url defines an order of sorting</p>
${p('Format', 'fieldName:DIRECTION')}
${p('Example', 'sortBy=id:DESC&sortBy=createdAt:ASC')}
${p('Default Value', defaultSortMessage)}
${li('Available Fields', paginationConfig.sortableColumns)}
`,
required: false,
type: 'string',
})
}
function Limit(paginationConfig: PaginateConfig<any>) {
return ApiQuery({
name: 'limit',
description: `Number of records per page.
${p('Example', '20')}
${p(DEFAULT_VALUE_KEY, paginationConfig?.defaultLimit?.toString() || DEFAULT_LIMIT.toString())}
${p('Max Value', paginationConfig.maxLimit?.toString() || DEFAULT_MAX_LIMIT.toString())}
If provided value is greater than max value, max value will be applied.
`,
required: false,
type: 'number',
})
}
function Select(paginationConfig: PaginateConfig<any>) {
if (!paginationConfig.select) {
return
}
return ApiQuery({
name: 'select',
description: `List of fields to select.
${p('Example', paginationConfig.select.slice(0, Math.min(5, paginationConfig.select.length)).join(','))}
${p(
DEFAULT_VALUE_KEY,
'By default all fields returns. If you want to select only some fields, provide them in query param'
)}
`,
required: false,
type: 'string',
})
}
function Where(paginationConfig: PaginateConfig<any>) {
if (!paginationConfig.filterableColumns) return
const allColumnsDecorators = Object.entries(paginationConfig.filterableColumns)
.map(([fieldName, filterOperations]) => {
const operations =
filterOperations === true || filterOperations === undefined
? [
...Object.values(FilterComparator),
...Object.values(FilterSuffix),
...Object.values(FilterOperator),
]
: filterOperations.map((fo) => fo.toString())
return ApiQuery({
name: `filter.${fieldName}`,
description: `Filter by ${fieldName} query param.
${p('Format', `filter.${fieldName}={$not}:OPERATION:VALUE`)}
${p('Example', `filter.${fieldName}=$not:$like:John Doe&filter.${fieldName}=like:John`)}
${li('Available Operations', operations)}`,
required: false,
type: 'string',
isArray: true,
})
})
.filter((v) => v !== undefined)
return applyDecorators(...allColumnsDecorators)
}
function Page() {
return ApiQuery({
name: 'page',
description: `Page number to retrieve.If you provide invalid value the default page number will applied
${p('Example', '1')}
${p(DEFAULT_VALUE_KEY, '1')}
`,
required: false,
type: 'number',
})
}
function Search(paginateConfig: PaginateConfig<any>) {
if (!paginateConfig.searchableColumns) return
return ApiQuery({
name: 'search',
description: `Search term to filter result values
${p('Example', 'John')}
${p(DEFAULT_VALUE_KEY, 'No default value')}
`,
required: false,
type: 'string',
})
}
function SearchBy(paginateConfig: PaginateConfig<any>) {
if (!paginateConfig.searchableColumns) return
return ApiQuery({
name: 'searchBy',
description: `List of fields to search by term to filter result values
${p(
'Example',
paginateConfig.searchableColumns.slice(0, Math.min(5, paginateConfig.searchableColumns.length)).join(',')
)}
${p(DEFAULT_VALUE_KEY, 'By default all fields mentioned below will be used to search by term')}
${li('Available Fields', paginateConfig.searchableColumns)}
`,
required: false,
type: 'string',
})
}
export const ApiPaginationQuery = (paginationConfig: PaginateConfig<any>) => {
return applyDecorators(
...[
Page(),
Limit(paginationConfig),
Where(paginationConfig),
SortBy(paginationConfig),
Search(paginationConfig),
SearchBy(paginationConfig),
Select(paginationConfig),
].filter((v): v is MethodDecorator => v !== undefined)
)
}

View File

@ -0,0 +1,8 @@
import { applyDecorators, Type } from '@nestjs/common'
import { PaginateConfig } from '../paginate'
import { ApiPaginationQuery } from './api-paginated-query.decorator'
import { ApiOkPaginatedResponse } from './api-ok-paginated-response.decorator'
export function PaginatedSwaggerDocs<DTO extends Type<unknown>>(dto: DTO, paginatedConfig: PaginateConfig<any>) {
return applyDecorators(ApiOkPaginatedResponse(dto, paginatedConfig), ApiPaginationQuery(paginatedConfig))
}

4
src/swagger/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * from './api-paginated-query.decorator'
export * from './api-ok-paginated-response.decorator'
export * from './paginated-swagger.type'
export * from './api-paginated-swagger-docs.decorator'

View File

@ -0,0 +1,145 @@
import { ApiProperty } from '@nestjs/swagger'
import { Column, SortBy } from '../helper'
import { Paginated } from '../paginate'
class PaginatedLinksDocumented {
@ApiProperty({
title: 'Link to first page',
required: false,
type: 'string',
})
first?: string
@ApiProperty({
title: 'Link to previous page',
required: false,
type: 'string',
})
previous?: string
@ApiProperty({
title: 'Link to current page',
required: false,
type: 'string',
})
current!: string
@ApiProperty({
title: 'Link to next page',
required: false,
type: 'string',
})
next?: string
@ApiProperty({
title: 'Link to last page',
required: false,
type: 'string',
})
last?: string
}
export class PaginatedMetaDocumented<T> {
@ApiProperty({
title: 'Number of items per page',
required: true,
type: 'number',
})
itemsPerPage!: number
@ApiProperty({
title: 'Total number of items',
required: true,
type: 'number',
})
totalItems!: number
@ApiProperty({
title: 'Current requested page',
required: true,
type: 'number',
})
currentPage!: number
@ApiProperty({
title: 'Total number of pages',
required: true,
type: 'number',
})
totalPages!: number
@ApiProperty({
title: 'Sorting by columns',
required: false,
type: 'array',
items: {
type: 'array',
items: {
oneOf: [
{
type: 'string',
},
{
type: 'string',
enum: ['ASC', 'DESC'],
},
],
},
},
})
sortBy!: SortBy<T>
@ApiProperty({
title: 'Search by fields',
required: false,
isArray: true,
type: 'string',
})
searchBy!: Column<T>[]
@ApiProperty({
title: 'Search term',
required: false,
type: 'string',
})
search!: string
@ApiProperty({
title: 'List of selected fields',
required: false,
isArray: true,
type: 'string',
})
select!: string[]
@ApiProperty({
title: 'Filters that applied to the query',
required: false,
isArray: false,
type: 'object',
})
filter?: {
[p: string]: string | string[]
}
}
export class PaginatedDocumented<T> extends Paginated<T> {
@ApiProperty({
isArray: true,
required: true,
title: 'Array of entities',
})
override data!: T[]
@ApiProperty({
title: 'Pagination Metadata',
required: true,
})
override meta!: PaginatedMetaDocumented<T>
@ApiProperty({
title: 'Links to pages',
required: true,
})
override links!: PaginatedLinksDocumented
}

View File

@ -0,0 +1,310 @@
import { Get, Post, Type } from '@nestjs/common'
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'
import { FilterOperator, FilterSuffix, PaginateConfig } from '../paginate'
import { Test } from '@nestjs/testing'
import { PaginatedSwaggerDocs } from './api-paginated-swagger-docs.decorator'
import { ApiPaginationQuery } from './api-paginated-query.decorator'
import { ApiOkPaginatedResponse } from './api-ok-paginated-response.decorator'
const BASE_PAGINATION_CONFIG = {
sortableColumns: ['id'],
} satisfies PaginateConfig<TestDto>
const FULL_CONFIG = {
...BASE_PAGINATION_CONFIG,
defaultSortBy: [['id', 'DESC']],
defaultLimit: 20,
maxLimit: 100,
filterableColumns: {
id: true,
name: [FilterOperator.EQ, FilterSuffix.NOT],
},
searchableColumns: ['name'],
select: ['id', 'name'],
} satisfies PaginateConfig<TestDto>
class TestDto {
id: string
name: string
}
// eslint-disable-next-line @typescript-eslint/ban-types
async function getSwaggerDefinitionForEndpoint<T>(entityType: Type<T>, config: PaginateConfig<T>) {
class TestController {
@PaginatedSwaggerDocs(entityType, config)
@Get('/test')
public test(): void {
//
}
@ApiPaginationQuery(config)
@ApiOkPaginatedResponse(entityType, config)
@Post('/test')
public testPost(): void {
//
}
}
const fakeAppModule = await Test.createTestingModule({
controllers: [TestController],
}).compile()
const fakeApp = fakeAppModule.createNestApplication()
return SwaggerModule.createDocument(fakeApp, new DocumentBuilder().build())
}
describe('PaginatedEndpoint decorator', () => {
it('post and get definition should be the same', async () => {
const openApiDefinition = await getSwaggerDefinitionForEndpoint(TestDto, BASE_PAGINATION_CONFIG)
expect(openApiDefinition.paths['/test'].get.parameters).toStrictEqual(
openApiDefinition.paths['/test'].post.parameters
)
})
it('should annotate endpoint with OpenApi documentation with limited config', async () => {
const openApiDefinition = await getSwaggerDefinitionForEndpoint(TestDto, BASE_PAGINATION_CONFIG)
const params = openApiDefinition.paths['/test'].get.parameters
expect(params).toStrictEqual([
{
name: 'page',
required: false,
in: 'query',
description:
'Page number to retrieve.If you provide invalid value the default page number will applied\n <p>\n <b>Example: </b> 1\n </p>\n <p>\n <b>Default Value: </b> 1\n </p>\n ',
schema: {
type: 'number',
},
},
{
name: 'limit',
required: false,
in: 'query',
description:
'Number of records per page.\n <p>\n <b>Example: </b> 20\n </p>\n <p>\n <b>Default Value: </b> 20\n </p>\n <p>\n <b>Max Value: </b> 100\n </p>\n\n If provided value is greater than max value, max value will be applied.\n ',
schema: {
type: 'number',
},
},
{
name: 'sortBy',
required: false,
in: 'query',
description:
'Parameter to sort by.\n <p>To sort by multiple fields, just provide query param multiple types. The order in url defines an order of sorting</p>\n <p>\n <b>Format: </b> fieldName:DIRECTION\n </p>\n <p>\n <b>Example: </b> sortBy=id:DESC&sortBy=createdAt:ASC\n </p>\n <p>\n <b>Default Value: </b> No default sorting specified, the result order is not guaranteed\n </p>\n <h4>Available Fields</h4><ul><li>id</li></ul>\n ',
schema: {
type: 'array',
items: {
type: 'string',
},
},
},
])
expect(openApiDefinition.paths['/test'].get.responses).toEqual({
'200': {
description: '',
content: {
'application/json': {
schema: {
allOf: [
{
$ref: '#/components/schemas/PaginatedDocumented',
},
{
properties: {
data: {
type: 'array',
items: {
$ref: '#/components/schemas/TestDto',
},
},
meta: {
properties: {
select: {
type: 'array',
items: {
type: 'string',
},
},
filter: {
type: 'object',
properties: {},
},
},
},
},
},
],
},
},
},
},
})
})
it('should annotate endpoint with OpenApi documentation with full config', async () => {
const openApiDefinition = await getSwaggerDefinitionForEndpoint(TestDto, FULL_CONFIG)
const params = openApiDefinition.paths['/test'].get.parameters
expect(params).toStrictEqual([
{
name: 'page',
required: false,
in: 'query',
description:
'Page number to retrieve.If you provide invalid value the default page number will applied\n <p>\n <b>Example: </b> 1\n </p>\n <p>\n <b>Default Value: </b> 1\n </p>\n ',
schema: {
type: 'number',
},
},
{
name: 'limit',
required: false,
in: 'query',
description:
'Number of records per page.\n <p>\n <b>Example: </b> 20\n </p>\n <p>\n <b>Default Value: </b> 20\n </p>\n <p>\n <b>Max Value: </b> 100\n </p>\n\n If provided value is greater than max value, max value will be applied.\n ',
schema: {
type: 'number',
},
},
{
name: 'filter.id',
required: false,
in: 'query',
description:
'Filter by id query param.\n <p>\n <b>Format: </b> filter.id={$not}:OPERATION:VALUE\n </p>\n <p>\n <b>Example: </b> filter.id=$not:$like:John Doe&filter.id=like:John\n </p>\n <h4>Available Operations</h4><ul><li>$and</li>\n<li>$or</li>\n<li>$not</li>\n<li>$eq</li>\n<li>$gt</li>\n<li>$gte</li>\n<li>$in</li>\n<li>$null</li>\n<li>$lt</li>\n<li>$lte</li>\n<li>$btw</li>\n<li>$ilike</li>\n<li>$sw</li>\n<li>$contains</li></ul>',
schema: {
type: 'array',
items: {
type: 'string',
},
},
},
{
name: 'filter.name',
required: false,
in: 'query',
description:
'Filter by name query param.\n <p>\n <b>Format: </b> filter.name={$not}:OPERATION:VALUE\n </p>\n <p>\n <b>Example: </b> filter.name=$not:$like:John Doe&filter.name=like:John\n </p>\n <h4>Available Operations</h4><ul><li>$eq</li>\n<li>$not</li></ul>',
schema: {
type: 'array',
items: {
type: 'string',
},
},
},
{
name: 'sortBy',
required: false,
in: 'query',
description:
'Parameter to sort by.\n <p>To sort by multiple fields, just provide query param multiple types. The order in url defines an order of sorting</p>\n <p>\n <b>Format: </b> fieldName:DIRECTION\n </p>\n <p>\n <b>Example: </b> sortBy=id:DESC&sortBy=createdAt:ASC\n </p>\n <p>\n <b>Default Value: </b> id:DESC\n </p>\n <h4>Available Fields</h4><ul><li>id</li></ul>\n ',
schema: {
type: 'array',
items: {
type: 'string',
},
},
},
{
name: 'search',
required: false,
in: 'query',
description:
'Search term to filter result values\n <p>\n <b>Example: </b> John\n </p>\n <p>\n <b>Default Value: </b> No default value\n </p>\n ',
schema: {
type: 'string',
},
},
{
name: 'searchBy',
required: false,
in: 'query',
description:
'List of fields to search by term to filter result values\n <p>\n <b>Example: </b> name\n </p>\n <p>\n <b>Default Value: </b> By default all fields mentioned below will be used to search by term\n </p>\n <h4>Available Fields</h4><ul><li>name</li></ul>\n ',
schema: {
type: 'string',
},
},
{
name: 'select',
required: false,
in: 'query',
description:
'List of fields to select.\n <p>\n <b>Example: </b> id,name\n </p>\n <p>\n <b>Default Value: </b> By default all fields returns. If you want to select only some fields, provide them in query param\n </p>\n ',
schema: {
type: 'string',
},
},
])
expect(openApiDefinition.paths['/test'].get.responses).toEqual({
'200': {
description: '',
content: {
'application/json': {
schema: {
allOf: [
{
$ref: '#/components/schemas/PaginatedDocumented',
},
{
properties: {
data: {
type: 'array',
items: {
$ref: '#/components/schemas/TestDto',
},
},
meta: {
properties: {
select: {
type: 'array',
items: {
type: 'string',
enum: ['id', 'name'],
},
},
filter: {
type: 'object',
properties: {
id: {
oneOf: [
{
type: 'string',
},
{
type: 'array',
items: {
type: 'string',
},
},
],
},
name: {
oneOf: [
{
type: 'string',
},
{
type: 'array',
items: {
type: 'string',
},
},
],
},
},
},
},
},
},
},
],
},
},
},
},
})
})
})