From 3967c4fc578a2c90da45941bbe6f77e7f80af7a9 Mon Sep 17 00:00:00 2001 From: ZainUrRehmanKhan Date: Fri, 23 Dec 2022 12:25:07 +0500 Subject: [PATCH] feat: Support of ApiParam and ApiQuery for Controller Support added of ApiParam and ApiQuery for Controller, PR Reference #1332 Issue: #2200 --- lib/decorators/api-param.decorator.ts | 4 +- lib/decorators/api-query.decorator.ts | 4 +- lib/decorators/helpers.ts | 82 +++++++++---- lib/swagger-ui/helpers.ts | 4 +- test/decorators/api-param.decorator.spec.ts | 57 ++++++++++ test/decorators/api-query.decorator.spec.ts | 57 ++++++++++ test/explorer/swagger-explorer.spec.ts | 120 ++++++++++++++++++++ 7 files changed, 306 insertions(+), 22 deletions(-) create mode 100644 test/decorators/api-param.decorator.spec.ts create mode 100644 test/decorators/api-query.decorator.spec.ts diff --git a/lib/decorators/api-param.decorator.ts b/lib/decorators/api-param.decorator.ts index 4e1f48b4b..db7251c15 100644 --- a/lib/decorators/api-param.decorator.ts +++ b/lib/decorators/api-param.decorator.ts @@ -28,7 +28,9 @@ const defaultParamOptions: ApiParamOptions = { required: true }; -export function ApiParam(options: ApiParamOptions): MethodDecorator { +export function ApiParam( + options: ApiParamOptions +): MethodDecorator & ClassDecorator { const param: Record = { name: isNil(options.name) ? defaultParamOptions.name : options.name, in: 'path', diff --git a/lib/decorators/api-query.decorator.ts b/lib/decorators/api-query.decorator.ts index 7a41b81b1..60c235850 100644 --- a/lib/decorators/api-query.decorator.ts +++ b/lib/decorators/api-query.decorator.ts @@ -36,7 +36,9 @@ const defaultQueryOptions: ApiQueryOptions = { required: true }; -export function ApiQuery(options: ApiQueryOptions): MethodDecorator { +export function ApiQuery( + options: ApiQueryOptions +): MethodDecorator & ClassDecorator { const apiQueryMetadata = options as ApiQueryMetadata; const [type, isArray] = getTypeIsArrayTuple( apiQueryMetadata.type, diff --git a/lib/decorators/helpers.ts b/lib/decorators/helpers.ts index e700a6bee..0d1b0488a 100644 --- a/lib/decorators/helpers.ts +++ b/lib/decorators/helpers.ts @@ -1,6 +1,8 @@ import { isArray, isUndefined, negate, pickBy } from 'lodash'; import { DECORATORS } from '../constants'; import { METADATA_FACTORY_NAME } from '../plugin/plugin-constants'; +import { METHOD_METADATA } from '@nestjs/common/constants'; +import { isConstructor } from '@nestjs/common/utils/shared.utils'; export function createMethodDecorator( metakey: string, @@ -107,26 +109,68 @@ export function createMixedDecorator( export function createParamDecorator = any>( metadata: T, initial: Partial -): MethodDecorator { +): MethodDecorator & ClassDecorator { return ( - target: object, - key: string | symbol, - descriptor: PropertyDescriptor - ) => { - const parameters = - Reflect.getMetadata(DECORATORS.API_PARAMETERS, descriptor.value) || []; - Reflect.defineMetadata( - DECORATORS.API_PARAMETERS, - [ - ...parameters, - { - ...initial, - ...pickBy(metadata, negate(isUndefined)) - } - ], - descriptor.value - ); - return descriptor; + target: object | Function, + key?: string | symbol, + descriptor?: TypedPropertyDescriptor + ): any => { + const paramOptions = { + ...initial, + ...pickBy(metadata, negate(isUndefined)) + }; + + if (descriptor) { + const parameters = + Reflect.getMetadata(DECORATORS.API_PARAMETERS, descriptor.value) || []; + Reflect.defineMetadata( + DECORATORS.API_PARAMETERS, + [...parameters, paramOptions], + descriptor.value + ); + return descriptor; + } + + if (typeof target === 'object') { + return target; + } + + const propertyKeys = Object.getOwnPropertyNames(target.prototype); + + for (const propertyKey of propertyKeys) { + if (isConstructor(propertyKey)) { + continue; + } + + const methodDescriptor = Object.getOwnPropertyDescriptor( + target.prototype, + propertyKey + ); + + if (!methodDescriptor) { + continue; + } + + const isApiMethod = Reflect.hasMetadata( + METHOD_METADATA, + methodDescriptor.value + ); + + if (!isApiMethod) { + continue; + } + + const parameters = + Reflect.getMetadata( + DECORATORS.API_PARAMETERS, + methodDescriptor.value + ) || []; + Reflect.defineMetadata( + DECORATORS.API_PARAMETERS, + [...parameters, paramOptions], + methodDescriptor.value + ); + } }; } diff --git a/lib/swagger-ui/helpers.ts b/lib/swagger-ui/helpers.ts index 86cdc12ef..84ce78696 100644 --- a/lib/swagger-ui/helpers.ts +++ b/lib/swagger-ui/helpers.ts @@ -18,7 +18,9 @@ export function buildJSInitOptions(initOptions: SwaggerUIInitOptions) { 2 ); - json = json.replace(new RegExp('"' + functionPlaceholder + '"', 'g'), () => fns.shift()); + json = json.replace(new RegExp('"' + functionPlaceholder + '"', 'g'), () => + fns.shift() + ); return `let options = ${json};`; } diff --git a/test/decorators/api-param.decorator.spec.ts b/test/decorators/api-param.decorator.spec.ts new file mode 100644 index 000000000..973b0808e --- /dev/null +++ b/test/decorators/api-param.decorator.spec.ts @@ -0,0 +1,57 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { DECORATORS } from '../../lib/constants'; +import { ApiParam } from '../../lib/decorators'; + +describe('ApiParam', () => { + describe('class decorator', () => { + @ApiParam({ name: 'testId' }) + @Controller('tests/:testId') + class TestAppController { + @Get() + public get(@Param('testId') testId: string): string { + return testId; + } + + public noAPiMethod(): void { + } + } + + it('should get ApiParam options from api method', () => { + const controller = new TestAppController(); + expect( + Reflect.hasMetadata(DECORATORS.API_PARAMETERS, controller.get) + ).toBeTruthy(); + expect( + Reflect.getMetadata(DECORATORS.API_PARAMETERS, controller.get) + ).toEqual([{ in: 'path', name: 'testId', required: true }]); + }); + + it('should not get ApiParam options from non api method', () => { + const controller = new TestAppController(); + expect( + Reflect.hasMetadata(DECORATORS.API_PARAMETERS, controller.noAPiMethod) + ).toBeFalsy(); + }); + }); + + describe('method decorator', () => { + @Controller('tests/:testId') + class TestAppController { + @Get() + @ApiParam({ name: 'testId' }) + public get(@Param('testId') testId: string): string { + return testId; + } + } + + it('should get ApiParam options from api method', () => { + const controller = new TestAppController(); + expect( + Reflect.hasMetadata(DECORATORS.API_PARAMETERS, controller.get) + ).toBeTruthy(); + expect( + Reflect.getMetadata(DECORATORS.API_PARAMETERS, controller.get) + ).toEqual([{ in: 'path', name: 'testId', required: true }]); + }); + }); +}); diff --git a/test/decorators/api-query.decorator.spec.ts b/test/decorators/api-query.decorator.spec.ts new file mode 100644 index 000000000..2dcb55f2a --- /dev/null +++ b/test/decorators/api-query.decorator.spec.ts @@ -0,0 +1,57 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { DECORATORS } from '../../lib/constants'; +import { ApiQuery } from '../../lib/decorators'; + +describe('ApiQuery', () => { + describe('class decorator', () => { + @ApiQuery({ name: 'testId' }) + @Controller('test') + class TestAppController { + @Get() + public get(@Query('testId') testId: string): string { + return testId; + } + + public noAPiMethod(): void { + } + } + + it('should get ApiQuery options from api method', () => { + const controller = new TestAppController(); + expect( + Reflect.hasMetadata(DECORATORS.API_PARAMETERS, controller.get) + ).toBeTruthy(); + expect( + Reflect.getMetadata(DECORATORS.API_PARAMETERS, controller.get) + ).toEqual([{ in: 'query', name: 'testId', required: true }]); + }); + + it('should not get ApiQuery options from non api method', () => { + const controller = new TestAppController(); + expect( + Reflect.hasMetadata(DECORATORS.API_PARAMETERS, controller.noAPiMethod) + ).toBeFalsy(); + }); + }); + + describe('method decorator', () => { + @Controller('tests/:testId') + class TestAppController { + @Get() + @ApiQuery({ name: 'testId' }) + public get(@Query('testId') testId: string): string { + return testId; + } + } + + it('should get ApiQuery options from api method', () => { + const controller = new TestAppController(); + expect( + Reflect.hasMetadata(DECORATORS.API_PARAMETERS, controller.get) + ).toBeTruthy(); + expect( + Reflect.getMetadata(DECORATORS.API_PARAMETERS, controller.get) + ).toEqual([{ in: 'query', name: 'testId', required: true }]); + }); + }); +}); diff --git a/test/explorer/swagger-explorer.spec.ts b/test/explorer/swagger-explorer.spec.ts index 5e91b9e88..64ec0d2e0 100644 --- a/test/explorer/swagger-explorer.spec.ts +++ b/test/explorer/swagger-explorer.spec.ts @@ -1424,4 +1424,124 @@ describe('SwaggerExplorer', () => { }); }); }); + + describe('when params are defined', () => { + class Foo {} + + @ApiParam({ name: 'parentId', type: 'number' }) + @Controller(':parentId') + class FooController { + @ApiParam({ name: 'objectId', type: 'number' }) + @Get('foos/:objectId') + find(): Promise { + return Promise.resolve([]); + } + + @Post('foos') + create(): Promise { + return Promise.resolve(); + } + } + + it('should properly define params', () => { + const explorer = new SwaggerExplorer(schemaObjectFactory); + const routes = explorer.exploreController( + { + instance: new FooController(), + metatype: FooController + } as InstanceWrapper, + new ApplicationConfig(), + 'path' + ); + + expect(routes[0].root.parameters).toEqual([ + { + in: 'path', + name: 'objectId', + required: true, + schema: { + type: 'number' + } + }, + { + in: 'path', + name: 'parentId', + required: true, + schema: { + type: 'number' + } + } + ]); + expect(routes[1].root.parameters).toEqual([ + { + in: 'path', + name: 'parentId', + required: true, + schema: { + type: 'number' + } + } + ]); + }); + }); + + describe('when queries are defined', () => { + class Foo {} + + @ApiQuery({ name: 'parentId', type: 'number' }) + @Controller('') + class FooController { + @ApiQuery({ name: 'objectId', type: 'number' }) + @Get('foos') + find(): Promise { + return Promise.resolve([]); + } + + @Post('foos') + create(): Promise { + return Promise.resolve(); + } + } + + it('should properly define params', () => { + const explorer = new SwaggerExplorer(schemaObjectFactory); + const routes = explorer.exploreController( + { + instance: new FooController(), + metatype: FooController + } as InstanceWrapper, + new ApplicationConfig(), + 'path' + ); + + expect(routes[0].root.parameters).toEqual([ + { + in: 'query', + name: 'objectId', + required: true, + schema: { + type: 'number' + } + }, + { + in: 'query', + name: 'parentId', + required: true, + schema: { + type: 'number' + } + } + ]); + expect(routes[1].root.parameters).toEqual([ + { + in: 'query', + name: 'parentId', + required: true, + schema: { + type: 'number' + } + } + ]); + }); + }); });