Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(): enable defining global responses #933

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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