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 ...`
## 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
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"
},
"devDependencies": {
"@nestjs/testing": "^10.2.1",
"@nestjs/platform-express": "^10.2.1",
"@nestjs/common": "^10.2.1",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.4",
@ -59,6 +61,7 @@
},
"peerDependencies": {
"@nestjs/common": "^10.2.1",
"@nestjs/swagger": "^7.1.8",
"express": "^4.18.2",
"fastify": "^4.21.0",
"typeorm": "^0.3.17"

View File

@ -1,2 +1,3 @@
export * from './decorator'
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',
},
},
],
},
},
},
},
},
},
},
],
},
},
},
},
})
})
})