diff --git a/README.md b/README.md index 57334deda..d8d1933d2 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ The following methods have been added: - `addBasicAuth` - `addSecurity` - `addSecurityRequirements` +- `addResponse` ## Support diff --git a/e2e/api-spec.json b/e2e/api-spec.json index 1899cf8c6..df1f415e2 100644 --- a/e2e/api-spec.json +++ b/e2e/api-spec.json @@ -39,6 +39,11 @@ "name": "connect.sid" } }, + "responses": { + "502": { + "description": "Bad gateway" + } + }, "schemas": { "ExtraModel": { "type": "object", @@ -274,6 +279,12 @@ }, "403": { "description": "Forbidden." + }, + "500": { + "description": "Internal server error" + }, + "502": { + "$ref": "#/components/responses/502" } }, "tags": [ @@ -368,6 +379,12 @@ "responses": { "200": { "description": "" + }, + "500": { + "description": "Internal server error" + }, + "502": { + "$ref": "#/components/responses/502" } }, "tags": [ @@ -417,6 +434,12 @@ } } } + }, + "500": { + "description": "Internal server error" + }, + "502": { + "$ref": "#/components/responses/502" } }, "tags": [ @@ -545,6 +568,12 @@ "responses": { "200": { "description": "" + }, + "500": { + "description": "Internal server error" + }, + "502": { + "$ref": "#/components/responses/502" } }, "tags": [ @@ -589,6 +618,12 @@ "responses": { "201": { "description": "" + }, + "500": { + "description": "Internal server error" + }, + "502": { + "$ref": "#/components/responses/502" } }, "tags": [ @@ -643,6 +678,12 @@ }, "403": { "description": "Forbidden." + }, + "500": { + "description": "Internal server error" + }, + "502": { + "$ref": "#/components/responses/502" } }, "tags": [ @@ -676,6 +717,12 @@ "responses": { "200": { "description": "" + }, + "500": { + "description": "Internal server error" + }, + "502": { + "$ref": "#/components/responses/502" } }, "tags": [ diff --git a/e2e/src/cats/cats.controller.ts b/e2e/src/cats/cats.controller.ts index 923abbff8..819841fa5 100644 --- a/e2e/src/cats/cats.controller.ts +++ b/e2e/src/cats/cats.controller.ts @@ -24,6 +24,7 @@ import { PaginationQuery } from './dto/pagination-query.dto'; description: 'Test', schema: { default: 'test' } }) +@ApiResponse({ status: 500, description: 'Internal server error' }) @Controller('cats') export class CatsController { constructor(private readonly catsService: CatsService) {} diff --git a/e2e/validate-schema.e2e-spec.ts b/e2e/validate-schema.e2e-spec.ts index 1df73bc39..8b17098dd 100644 --- a/e2e/validate-schema.e2e-spec.ts +++ b/e2e/validate-schema.e2e-spec.ts @@ -26,6 +26,10 @@ describe('Validate OpenAPI schema', () => { .addApiKey() .addCookieAuth() .addSecurityRequirements('bearer') + .addResponse({ + status: 502, + description: 'Bad gateway' + }) .build(); document = SwaggerModule.createDocument(app, options); diff --git a/lib/document-builder.ts b/lib/document-builder.ts index 6417a12f7..ee9f01f06 100644 --- a/lib/document-builder.ts +++ b/lib/document-builder.ts @@ -1,9 +1,11 @@ import { Logger } from '@nestjs/common'; -import { isUndefined, negate, pickBy } from 'lodash'; +import { isUndefined, negate, omit, pickBy } from 'lodash'; import { buildDocumentBase } from './fixtures/document.base'; import { OpenAPIObject } from './interfaces'; +import { ApiResponseOptions } from './decorators/'; import { ExternalDocumentationObject, + ResponseObject, SecuritySchemeObject, ServerVariableObject, TagObject @@ -173,6 +175,15 @@ export class DocumentBuilder { return this; } + public addResponse(options: ApiResponseOptions): this { + this.document.components.responses = { + ...(this.document.components.responses || {}), + [options.status]: omit(options, 'status') as ResponseObject + }; + + return this; + } + public build(): Omit { return this.document; } diff --git a/lib/explorers/api-response.explorer.ts b/lib/explorers/api-response.explorer.ts index 64c89128b..a3c70cb4b 100644 --- a/lib/explorers/api-response.explorer.ts +++ b/lib/explorers/api-response.explorer.ts @@ -1,20 +1,17 @@ import { HttpStatus, RequestMethod, Type } from '@nestjs/common'; import { HTTP_CODE_METADATA, METHOD_METADATA } from '@nestjs/common/constants'; -import { isEmpty } from '@nestjs/common/utils/shared.utils'; -import { get, mapValues, omit } from 'lodash'; +import { get } from 'lodash'; import { DECORATORS } from '../constants'; import { ApiResponseMetadata } from '../decorators'; import { SchemaObject } from '../interfaces/open-api-spec.interface'; -import { ResponseObjectFactory } from '../services/response-object-factory'; +import { mapResponsesToSwaggerResponses } from '../utils/map-responses-to-swagger-responses.util'; import { mergeAndUniq } from '../utils/merge-and-uniq.util'; -const responseObjectFactory = new ResponseObjectFactory(); - export const exploreGlobalApiResponseMetadata = ( schemas: SchemaObject[], metatype: Type ) => { - const responses: ApiResponseMetadata[] = Reflect.getMetadata( + const responses: Record = Reflect.getMetadata( DECORATORS.API_RESPONSE, metatype ); @@ -68,19 +65,3 @@ const getStatusCode = (method: Function) => { return HttpStatus.OK; } }; - -const omitParamType = (param: Record) => omit(param, 'type'); -const mapResponsesToSwaggerResponses = ( - responses: ApiResponseMetadata[], - schemas: SchemaObject[], - produces: string[] = ['application/json'] -) => { - produces = isEmpty(produces) ? ['application/json'] : produces; - - const openApiResponses = mapValues( - responses, - (response: ApiResponseMetadata) => - responseObjectFactory.create(response, produces, schemas) - ); - return mapValues(openApiResponses, omitParamType); -}; diff --git a/lib/swagger-explorer.ts b/lib/swagger-explorer.ts index 9c9010370..4397df3c9 100644 --- a/lib/swagger-explorer.ts +++ b/lib/swagger-explorer.ts @@ -14,12 +14,14 @@ import { isArray, isEmpty, mapValues, + merge, omit, omitBy, pick } from 'lodash'; import * as pathToRegexp from 'path-to-regexp'; import { DECORATORS } from './constants'; +import { ApiResponseOptions } from './decorators/'; import { exploreApiExcludeEndpointMetadata } from './explorers/api-exclude-endpoint.explorer'; import { exploreApiExtraModelsMetadata, @@ -44,6 +46,7 @@ import { DenormalizedDocResolvers } from './interfaces/denormalized-doc-resolver import { DenormalizedDoc } from './interfaces/denormalized-doc.interface'; import { OpenAPIObject, + ResponsesObject, SchemaObject } from './interfaces/open-api-spec.interface'; import { MimetypeContentWrapper } from './services/mimetype-content-wrapper'; @@ -65,6 +68,7 @@ export class SwaggerExplorer { wrapper: InstanceWrapper, modulePath?: string, globalPrefix?: string, + globalResponses?: ResponsesObject, operationIdFactory?: (controllerKey: string, methodKey: string) => string ) { if (operationIdFactory) { @@ -92,7 +96,8 @@ export class SwaggerExplorer { instance, documentResolvers, modulePath, - globalPrefix + globalPrefix, + globalResponses ); } @@ -106,7 +111,8 @@ export class SwaggerExplorer { instance: object, documentResolvers: DenormalizedDocResolvers, modulePath?: string, - globalPrefix?: string + globalPrefix?: string, + globalResponses?: ResponsesObject ): DenormalizedDoc[] { let path = this.validateRoutePath(this.reflectControllerPath(metatype)); if (modulePath) { @@ -120,7 +126,12 @@ export class SwaggerExplorer { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; - const globalMetadata = this.exploreGlobalMetadata(metatype); + const globalMetadata = this.exploreGlobalMetadata( + // Responses is not a property of `OpenAPIObject`, but that's what's + // being returned (incorrect type) by this.exploreGlobalMetadata(...). + { responses: globalResponses } as Partial, + metatype + ); const ctrlExtraModels = exploreGlobalApiExtraModelsMetadata(metatype); this.registerExtraModels(ctrlExtraModels); @@ -183,6 +194,7 @@ export class SwaggerExplorer { } private exploreGlobalMetadata( + metadataBase: Partial, metatype: Type ): Partial { const globalExplorers = [ @@ -201,8 +213,9 @@ export class SwaggerExplorer { chunks: (curr.chunks || []).concat(next) }; } - return { ...curr, ...next }; - }, {}); + + return merge({}, curr, next); + }, metadataBase); return globalMetadata; } diff --git a/lib/swagger-module.ts b/lib/swagger-module.ts index 89fd3ddc6..3f9c2ff14 100644 --- a/lib/swagger-module.ts +++ b/lib/swagger-module.ts @@ -15,7 +15,7 @@ export class SwaggerModule { options: SwaggerDocumentOptions = {} ): OpenAPIObject { const swaggerScanner = new SwaggerScanner(); - const document = swaggerScanner.scanApplication(app, options); + const document = swaggerScanner.scanApplication(app, options, config); document.components = { ...(config.components || {}), ...document.components diff --git a/lib/swagger-scanner.ts b/lib/swagger-scanner.ts index 17c1fc849..c214484f4 100644 --- a/lib/swagger-scanner.ts +++ b/lib/swagger-scanner.ts @@ -3,10 +3,12 @@ import { MODULE_PATH } from '@nestjs/common/constants'; import { NestContainer } from '@nestjs/core/injector/container'; import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper'; import { Module } from '@nestjs/core/injector/module'; -import { extend, flatten, isEmpty, reduce } from 'lodash'; +import { extend, flatten, isEmpty, mapValues, reduce } from 'lodash'; +import { ApiResponseOptions } from './decorators/'; import { OpenAPIObject, SwaggerDocumentOptions } from './interfaces'; import { ReferenceObject, + ResponsesObject, SchemaObject } from './interfaces/open-api-spec.interface'; import { ModelPropertiesAccessor } from './services/model-properties-accessor'; @@ -14,7 +16,9 @@ import { SchemaObjectFactory } from './services/schema-object-factory'; import { SwaggerTypesMapper } from './services/swagger-types-mapper'; import { SwaggerExplorer } from './swagger-explorer'; import { SwaggerTransformer } from './swagger-transformer'; +import { mapResponsesToSwaggerResponses } from './utils/map-responses-to-swagger-responses.util'; import { stripLastSlash } from './utils/strip-last-slash.util'; +import { transformResponsesToRefs } from './utils/transform-responses-to-refs.util'; export class SwaggerScanner { private readonly transfomer = new SwaggerTransformer(); @@ -26,7 +30,8 @@ export class SwaggerScanner { public scanApplication( app: INestApplication, - options: SwaggerDocumentOptions + options: SwaggerDocumentOptions, + config: Omit ): Omit { const { deepScanRoutes, @@ -35,6 +40,7 @@ export class SwaggerScanner { ignoreGlobalPrefix = false, operationIdFactory } = options; + const schemas = this.explorer.getSchemas(); const container: NestContainer = (app as any).container; const modules: Module[] = this.getModules( @@ -44,6 +50,10 @@ export class SwaggerScanner { const globalPrefix = !ignoreGlobalPrefix ? stripLastSlash(this.getGlobalPrefix(app)) : ''; + const globalResponses = mapResponsesToSwaggerResponses( + config.components.responses as Record, + schemas + ); const denormalizedPaths = modules.map( ({ routes, metatype, relatedModules }) => { @@ -69,17 +79,18 @@ export class SwaggerScanner { allRoutes, path, globalPrefix, + transformResponsesToRefs(globalResponses), operationIdFactory ); } ); - const schemas = this.explorer.getSchemas(); this.addExtraModels(schemas, extraModels); return { ...this.transfomer.normalizePaths(flatten(denormalizedPaths)), components: { + responses: globalResponses as ResponsesObject, schemas: reduce(this.explorer.getSchemas(), extend) as Record< string, SchemaObject | ReferenceObject @@ -92,6 +103,7 @@ export class SwaggerScanner { routes: Map, modulePath?: string, globalPrefix?: string, + globalResponses?: ResponsesObject, operationIdFactory?: (controllerKey: string, methodKey: string) => string ): Array & Record<'root', any>> { const denormalizedArray = [...routes.values()].map((ctrl) => @@ -99,6 +111,7 @@ export class SwaggerScanner { ctrl, modulePath, globalPrefix, + globalResponses, operationIdFactory ) ); diff --git a/lib/utils/map-responses-to-swagger-responses.util.ts b/lib/utils/map-responses-to-swagger-responses.util.ts new file mode 100644 index 000000000..62d86b13a --- /dev/null +++ b/lib/utils/map-responses-to-swagger-responses.util.ts @@ -0,0 +1,23 @@ +import { mapValues, omit } from 'lodash'; +import { isEmpty } from '@nestjs/common/utils/shared.utils'; +import { ApiResponseMetadata } from '../decorators'; +import { SchemaObject } from '../interfaces/open-api-spec.interface'; +import { ResponseObjectFactory } from '../services/response-object-factory'; + +const responseObjectFactory = new ResponseObjectFactory(); +const omitParamType = (param: Record) => omit(param, 'type'); + +export function mapResponsesToSwaggerResponses( + responses: Record, + schemas: SchemaObject[], + produces: string[] = ['application/json'] +) { + produces = isEmpty(produces) ? ['application/json'] : produces; + + const openApiResponses = mapValues( + responses, + (response: ApiResponseMetadata) => + responseObjectFactory.create(response, produces, schemas) + ); + return mapValues(openApiResponses, omitParamType); +} diff --git a/lib/utils/transform-responses-to-refs.util.ts b/lib/utils/transform-responses-to-refs.util.ts new file mode 100644 index 000000000..8f69282fd --- /dev/null +++ b/lib/utils/transform-responses-to-refs.util.ts @@ -0,0 +1,11 @@ +import { mapValues } from 'lodash'; +import { ApiResponseOptions } from '../decorators'; +import { ReferenceObject } from '../interfaces/open-api-spec.interface'; + +export function transformResponsesToRefs( + globalResponses: Record +): Record { + return mapValues(globalResponses, (value, key) => ({ + $ref: `#/components/responses/${key}` + })); +} diff --git a/test/explorer/swagger-explorer.spec.ts b/test/explorer/swagger-explorer.spec.ts index 2fd9b6b62..26216cd19 100644 --- a/test/explorer/swagger-explorer.spec.ts +++ b/test/explorer/swagger-explorer.spec.ts @@ -12,7 +12,8 @@ import { ApiParam, ApiProduces, ApiProperty, - ApiQuery + ApiQuery, + ApiResponse } from '../../lib/decorators'; import { DenormalizedDoc } from '../../lib/interfaces/denormalized-doc.interface'; import { ResponseObject } from '../../lib/interfaces/open-api-spec.interface'; @@ -116,6 +117,7 @@ describe('SwaggerExplorer', () => { } as InstanceWrapper, 'path', undefined, + undefined, methodKeyOperationIdFactory ); const operationPrefix = ''; @@ -132,6 +134,7 @@ describe('SwaggerExplorer', () => { } as InstanceWrapper, 'path', undefined, + undefined, controllerKeyMethodKeyOperationIdFactory ); const operationPrefix = 'FooController.'; @@ -323,6 +326,7 @@ describe('SwaggerExplorer', () => { } as InstanceWrapper, 'path', undefined, + undefined, methodKeyOperationIdFactory ); const prefix = ''; @@ -339,6 +343,7 @@ describe('SwaggerExplorer', () => { } as InstanceWrapper, 'path', undefined, + undefined, controllerKeyMethodKeyOperationIdFactory ); const prefix = 'FooController.'; @@ -491,6 +496,7 @@ describe('SwaggerExplorer', () => { } as InstanceWrapper, 'path', undefined, + undefined, methodKeyOperationIdFactory ); const operationPrefix = ''; @@ -507,6 +513,7 @@ describe('SwaggerExplorer', () => { } as InstanceWrapper, 'path', undefined, + undefined, controllerKeyMethodKeyOperationIdFactory ); const operationPrefix = 'FooController.'; @@ -971,4 +978,60 @@ describe('SwaggerExplorer', () => { ]); }); }); + describe('when global responses defined', () => { + @Controller('') + @ApiResponse({ + status: 500, + description: '500 - controller error response' + }) + @ApiResponse({ + status: 502, + description: '502 - controller error response' + }) + class FooController { + @Post('foos') + @ApiResponse({ + status: 200, + description: '200 - method response' + }) + @ApiResponse({ + status: 500, + description: '500 - method error response' + }) + get(): Promise { + return Promise.resolve({}); + } + } + + it('should merge global responses with explicit ones', () => { + const explorer = new SwaggerExplorer(schemaObjectFactory); + const routes = explorer.exploreController( + { + instance: new FooController(), + metatype: FooController + } as InstanceWrapper, + 'path', + '', + { + '500': { description: '500 - global error response' }, + '502': { description: '502 - global error response' }, + '504': { description: '504 - global error response' } + } + ); + + // GET + expect( + (routes[0].responses['200'] as ResponseObject).description + ).toEqual('200 - method response'); + expect( + (routes[0].responses['500'] as ResponseObject).description + ).toEqual('500 - method error response'); + expect( + (routes[0].responses['502'] as ResponseObject).description + ).toEqual('502 - controller error response'); + expect( + (routes[0].responses['504'] as ResponseObject).description + ).toEqual('504 - global error response'); + }); + }); });