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 307b206ce..f840295a5 100644 --- a/e2e/api-spec.json +++ b/e2e/api-spec.json @@ -109,6 +109,12 @@ }, "403": { "description": "Forbidden." + }, + "500": { + "description": "Internal server error" + }, + "502": { + "$ref": "#/components/responses/502" } }, "tags": [ @@ -223,6 +229,12 @@ "responses": { "200": { "description": "" + }, + "500": { + "description": "Internal server error" + }, + "502": { + "$ref": "#/components/responses/502" } }, "tags": [ @@ -276,6 +288,12 @@ } } } + }, + "500": { + "description": "Internal server error" + }, + "502": { + "$ref": "#/components/responses/502" } }, "tags": [ @@ -422,6 +440,12 @@ "responses": { "200": { "description": "" + }, + "500": { + "description": "Internal server error" + }, + "502": { + "$ref": "#/components/responses/502" } }, "tags": [ @@ -470,6 +494,12 @@ "responses": { "201": { "description": "" + }, + "500": { + "description": "Internal server error" + }, + "502": { + "$ref": "#/components/responses/502" } }, "tags": [ @@ -528,6 +558,12 @@ }, "403": { "description": "Forbidden." + }, + "500": { + "description": "Internal server error" + }, + "502": { + "$ref": "#/components/responses/502" } }, "tags": [ @@ -565,6 +601,12 @@ "responses": { "200": { "description": "" + }, + "500": { + "description": "Internal server error" + }, + "502": { + "$ref": "#/components/responses/502" } }, "tags": [ @@ -731,6 +773,11 @@ "name": "connect.sid" } }, + "responses": { + "502": { + "description": "Bad gateway" + } + }, "schemas": { "ExtraModel": { "type": "object", diff --git a/e2e/src/cats/cats.controller.ts b/e2e/src/cats/cats.controller.ts index 1ea981fea..89248bd60 100644 --- a/e2e/src/cats/cats.controller.ts +++ b/e2e/src/cats/cats.controller.ts @@ -26,6 +26,7 @@ import { LettersEnum, 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 fbdc4b1bf..a6a381da0 100644 --- a/e2e/validate-schema.e2e-spec.ts +++ b/e2e/validate-schema.e2e-spec.ts @@ -40,6 +40,10 @@ describe('Validate OpenAPI schema', () => { .addCookieAuth() .addSecurityRequirements('bearer') .addSecurityRequirements({ basic: [], cookie: [] }) + .addResponse({ + status: 502, + description: 'Bad gateway' + }) .build(); }); diff --git a/lib/document-builder.ts b/lib/document-builder.ts index 0be9a8c8f..58124c2f4 100644 --- a/lib/document-builder.ts +++ b/lib/document-builder.ts @@ -1,10 +1,12 @@ import { Logger } from '@nestjs/common'; -import { isString, isUndefined, negate, pickBy } from 'lodash'; +import { isString, isUndefined, negate, pickBy, omit } from 'lodash'; import { buildDocumentBase } from './fixtures/document.base'; import { OpenAPIObject } from './interfaces'; +import { ApiResponseOptions } from './decorators/'; import { ExternalDocumentationObject, SecurityRequirementObject, + ResponseObject, SecuritySchemeObject, ServerVariableObject, TagObject @@ -182,7 +184,16 @@ export class DocumentBuilder { return this; } - public build(): Omit { + 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 2eda59425..1476e10cf 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: Record, 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: Record, - 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 c539bf521..349fff381 100644 --- a/lib/swagger-explorer.ts +++ b/lib/swagger-explorer.ts @@ -26,12 +26,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 { exploreApiExcludeControllerMetadata } from './explorers/api-exclude-controller.explorer'; import { exploreApiExcludeEndpointMetadata } from './explorers/api-exclude-endpoint.explorer'; import { @@ -57,6 +59,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'; @@ -79,6 +82,7 @@ export class SwaggerExplorer { applicationConfig: ApplicationConfig, modulePath?: string | undefined, globalPrefix?: string | undefined, + globalResponses?: ResponsesObject, operationIdFactory?: (controllerKey: string, methodKey: string) => string ) { this.routePathFactory = new RoutePathFactory(applicationConfig); @@ -105,7 +109,8 @@ export class SwaggerExplorer { documentResolvers, applicationConfig, modulePath, - globalPrefix + globalPrefix, + globalResponses ); } @@ -120,7 +125,8 @@ export class SwaggerExplorer { documentResolvers: DenormalizedDocResolvers, applicationConfig: ApplicationConfig, modulePath?: string, - globalPrefix?: string + globalPrefix?: string, + globalResponses?: ResponsesObject ): DenormalizedDoc[] { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; @@ -129,7 +135,12 @@ export class SwaggerExplorer { if (excludeController) { return []; } - 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); @@ -225,6 +236,7 @@ export class SwaggerExplorer { } private exploreGlobalMetadata( + metadataBase: Partial, metatype: Type ): Partial { const globalExplorers = [ @@ -243,8 +255,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 adbde890f..9dc652404 100644 --- a/lib/swagger-module.ts +++ b/lib/swagger-module.ts @@ -19,7 +19,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 = assignTwoLevelsDeep( {}, diff --git a/lib/swagger-scanner.ts b/lib/swagger-scanner.ts index 104a3c02d..e2bff0ece 100644 --- a/lib/swagger-scanner.ts +++ b/lib/swagger-scanner.ts @@ -5,9 +5,11 @@ import { NestContainer } from '@nestjs/core/injector/container'; import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper'; import { InstanceToken, Module } from '@nestjs/core/injector/module'; import { flatten, isEmpty } 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'; @@ -15,8 +17,10 @@ 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 { getGlobalPrefix } from './utils/get-global-prefix'; 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(); @@ -28,7 +32,8 @@ export class SwaggerScanner { public scanApplication( app: INestApplication, - options: SwaggerDocumentOptions + options: SwaggerDocumentOptions, + config: Omit ): Omit { const { deepScanRoutes, @@ -37,6 +42,7 @@ export class SwaggerScanner { ignoreGlobalPrefix = false, operationIdFactory } = options; + const schemas = this.explorer.getSchemas(); const container = (app as any).container as NestContainer; const internalConfigRef = (app as any).config as ApplicationConfig; @@ -48,6 +54,10 @@ export class SwaggerScanner { const globalPrefix = !ignoreGlobalPrefix ? stripLastSlash(getGlobalPrefix(app)) : ''; + const globalResponses = mapResponsesToSwaggerResponses( + config.components.responses as Record, + schemas + ); const denormalizedPaths = modules.map( ({ routes, metatype, relatedModules }) => { @@ -71,6 +81,7 @@ export class SwaggerScanner { modulePath, globalPrefix, internalConfigRef, + transformResponsesToRefs(globalResponses), operationIdFactory ) ); @@ -83,18 +94,19 @@ export class SwaggerScanner { modulePath, globalPrefix, internalConfigRef, + transformResponsesToRefs(globalResponses), operationIdFactory ) ); } ); - const schemas = this.explorer.getSchemas(); this.addExtraModels(schemas, extraModels); return { ...this.transfomer.normalizePaths(flatten(denormalizedPaths)), components: { + responses: globalResponses as ResponsesObject, schemas: schemas as Record } }; @@ -105,6 +117,7 @@ export class SwaggerScanner { modulePath: string | undefined, globalPrefix: string | undefined, applicationConfig: ApplicationConfig, + globalResponses?: ResponsesObject, operationIdFactory?: (controllerKey: string, methodKey: string) => string ): Array & Record<'root', any>> { const denormalizedArray = [...routes.values()].map((ctrl) => @@ -113,6 +126,7 @@ export class SwaggerScanner { applicationConfig, 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..24c672bb3 --- /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: Record, + 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 3b703afe4..ceaab5de2 100644 --- a/test/explorer/swagger-explorer.spec.ts +++ b/test/explorer/swagger-explorer.spec.ts @@ -25,7 +25,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'; @@ -132,6 +133,7 @@ describe('SwaggerExplorer', () => { new ApplicationConfig(), 'modulePath', 'globalPrefix', + undefined, methodKeyOperationIdFactory ); const operationPrefix = ''; @@ -149,6 +151,7 @@ describe('SwaggerExplorer', () => { new ApplicationConfig(), 'modulePath', 'globalPrefix', + undefined, controllerKeyMethodKeyOperationIdFactory ); const operationPrefix = 'FooController.'; @@ -387,6 +390,7 @@ describe('SwaggerExplorer', () => { new ApplicationConfig(), undefined, 'globalPrefix', + undefined, methodKeyOperationIdFactory ); const prefix = ''; @@ -404,6 +408,7 @@ describe('SwaggerExplorer', () => { new ApplicationConfig(), undefined, 'globalPrefix', + undefined, controllerKeyMethodKeyOperationIdFactory ); const prefix = 'FooController.'; @@ -560,6 +565,7 @@ describe('SwaggerExplorer', () => { new ApplicationConfig(), 'modulePath', undefined, + undefined, methodKeyOperationIdFactory ); const operationPrefix = ''; @@ -577,6 +583,7 @@ describe('SwaggerExplorer', () => { new ApplicationConfig(), 'modulePath', undefined, + undefined, controllerKeyMethodKeyOperationIdFactory ); const operationPrefix = 'FooController.'; @@ -1343,4 +1350,62 @@ 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, + new ApplicationConfig(), + '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'); + }); + }); });