Skip to content

Commit

Permalink
feat(): enable defining global responses
Browse files Browse the repository at this point in the history
Enables defining global responses which are inherited by all server
routes.

fixes nestjs#884
  • Loading branch information
razvanz committed Nov 24, 2020
1 parent 1d28c1c commit 223c53c
Show file tree
Hide file tree
Showing 12 changed files with 201 additions and 33 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ The following methods have been added:
- `addBasicAuth`
- `addSecurity`
- `addSecurityRequirements`
- `addResponse`

## Support

Expand Down
47 changes: 47 additions & 0 deletions e2e/api-spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
"name": "connect.sid"
}
},
"responses": {
"502": {
"description": "Bad gateway"
}
},
"schemas": {
"ExtraModel": {
"type": "object",
Expand Down Expand Up @@ -274,6 +279,12 @@
},
"403": {
"description": "Forbidden."
},
"500": {
"description": "Internal server error"
},
"502": {
"$ref": "#/components/responses/502"
}
},
"tags": [
Expand Down Expand Up @@ -368,6 +379,12 @@
"responses": {
"200": {
"description": ""
},
"500": {
"description": "Internal server error"
},
"502": {
"$ref": "#/components/responses/502"
}
},
"tags": [
Expand Down Expand Up @@ -417,6 +434,12 @@
}
}
}
},
"500": {
"description": "Internal server error"
},
"502": {
"$ref": "#/components/responses/502"
}
},
"tags": [
Expand Down Expand Up @@ -545,6 +568,12 @@
"responses": {
"200": {
"description": ""
},
"500": {
"description": "Internal server error"
},
"502": {
"$ref": "#/components/responses/502"
}
},
"tags": [
Expand Down Expand Up @@ -589,6 +618,12 @@
"responses": {
"201": {
"description": ""
},
"500": {
"description": "Internal server error"
},
"502": {
"$ref": "#/components/responses/502"
}
},
"tags": [
Expand Down Expand Up @@ -643,6 +678,12 @@
},
"403": {
"description": "Forbidden."
},
"500": {
"description": "Internal server error"
},
"502": {
"$ref": "#/components/responses/502"
}
},
"tags": [
Expand Down Expand Up @@ -676,6 +717,12 @@
"responses": {
"200": {
"description": ""
},
"500": {
"description": "Internal server error"
},
"502": {
"$ref": "#/components/responses/502"
}
},
"tags": [
Expand Down
1 change: 1 addition & 0 deletions e2e/src/cats/cats.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand Down
4 changes: 4 additions & 0 deletions e2e/validate-schema.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ describe('Validate OpenAPI schema', () => {
.addApiKey()
.addCookieAuth()
.addSecurityRequirements('bearer')
.addResponse({
status: 502,
description: 'Bad gateway'
})
.build();

document = SwaggerModule.createDocument(app, options);
Expand Down
13 changes: 12 additions & 1 deletion lib/document-builder.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<OpenAPIObject, 'components' | 'paths'> {
return this.document;
}
Expand Down
25 changes: 3 additions & 22 deletions lib/explorers/api-response.explorer.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>
) => {
const responses: ApiResponseMetadata[] = Reflect.getMetadata(
const responses: Record<string, ApiResponseMetadata> = Reflect.getMetadata(
DECORATORS.API_RESPONSE,
metatype
);
Expand Down Expand Up @@ -68,19 +65,3 @@ const getStatusCode = (method: Function) => {
return HttpStatus.OK;
}
};

const omitParamType = (param: Record<string, any>) => 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);
};
23 changes: 18 additions & 5 deletions lib/swagger-explorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand All @@ -65,6 +68,7 @@ export class SwaggerExplorer {
wrapper: InstanceWrapper<Controller>,
modulePath?: string,
globalPrefix?: string,
globalResponses?: ResponsesObject,
operationIdFactory?: (controllerKey: string, methodKey: string) => string
) {
if (operationIdFactory) {
Expand Down Expand Up @@ -92,7 +96,8 @@ export class SwaggerExplorer {
instance,
documentResolvers,
modulePath,
globalPrefix
globalPrefix,
globalResponses
);
}

Expand All @@ -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) {
Expand All @@ -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<OpenAPIObject>,
metatype
);
const ctrlExtraModels = exploreGlobalApiExtraModelsMetadata(metatype);
this.registerExtraModels(ctrlExtraModels);

Expand Down Expand Up @@ -183,6 +194,7 @@ export class SwaggerExplorer {
}

private exploreGlobalMetadata(
metadataBase: Partial<OpenAPIObject>,
metatype: Type<unknown>
): Partial<OpenAPIObject> {
const globalExplorers = [
Expand All @@ -201,8 +213,9 @@ export class SwaggerExplorer {
chunks: (curr.chunks || []).concat(next)
};
}
return { ...curr, ...next };
}, {});

return merge({}, curr, next);
}, metadataBase);

return globalMetadata;
}
Expand Down
2 changes: 1 addition & 1 deletion lib/swagger-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions lib/swagger-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,22 @@ 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';
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();
Expand All @@ -26,7 +30,8 @@ export class SwaggerScanner {

public scanApplication(
app: INestApplication,
options: SwaggerDocumentOptions
options: SwaggerDocumentOptions,
config: Omit<OpenAPIObject, 'paths'>
): Omit<OpenAPIObject, 'openapi' | 'info'> {
const {
deepScanRoutes,
Expand All @@ -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(
Expand All @@ -44,6 +50,10 @@ export class SwaggerScanner {
const globalPrefix = !ignoreGlobalPrefix
? stripLastSlash(this.getGlobalPrefix(app))
: '';
const globalResponses = mapResponsesToSwaggerResponses(
config.components.responses as Record<string, ApiResponseOptions>,
schemas
);

const denormalizedPaths = modules.map(
({ routes, metatype, relatedModules }) => {
Expand All @@ -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
Expand All @@ -92,13 +103,15 @@ export class SwaggerScanner {
routes: Map<string, InstanceWrapper>,
modulePath?: string,
globalPrefix?: string,
globalResponses?: ResponsesObject,
operationIdFactory?: (controllerKey: string, methodKey: string) => string
): Array<Omit<OpenAPIObject, 'openapi' | 'info'> & Record<'root', any>> {
const denormalizedArray = [...routes.values()].map((ctrl) =>
this.explorer.exploreController(
ctrl,
modulePath,
globalPrefix,
globalResponses,
operationIdFactory
)
);
Expand Down
Loading

0 comments on commit 223c53c

Please sign in to comment.