Repository: nestjs/swagger
Pull request #2213 (2022-12-23 07:52:24)

feat: Support of ApiParam and ApiQuery for Controller
Support added of ApiParam and ApiQuery for Controller, PR Reference #1332

## PR Checklist
Please check if your PR fulfills the following requirements:

- [x] The commit message follows our guidelines: https://github.com/nestjs/nest/blob/master/CONTRIBUTING.md
- [x] Tests for the changes have been added (for bug fixes / features)
- [x] Docs have been added / updated (for bug fixes / features)


## PR Type
What kind of change does this PR introduce?

<!-- Please check the one that applies to this PR using "x". -->
- [ ] Bugfix
- [x] Feature
- [ ] Code style update (formatting, local variables)
- [ ] Refactoring (no functional changes, no api changes)
- [ ] Build related changes
- [ ] CI related changes
- [ ] Other... Please describe:

## What is the current behavior?
Put ApiParam on every route method in controller.
```
@Controller('tests/:testId')
class TestAppController {
  @ApiParam({ name: 'testId' })
  @ApiQuery({ name: 'testQuery'})
  @Get()
  public method1(@Param('testId') testId: string, @Query('testQuery') testQuery: string) {
  }

  @ApiParam({ name: 'testId' })
  @ApiQuery({ name: 'testQuery'})
  @Get()
  public method2(@Param('testId') testId: string, @Query('testQuery') testQuery: string) {
  }
}
```

Issue Number: #2200


## What is the new behavior?
I would like ApiParam to support the class decorator to reduce the waste of time.
```
@ApiParam({ name: 'testId' }) //new
@ApiQuery({ name: 'testQuery'}) //new
@Controller('tests/:testId')
class TestAppController {
  @Get()
  public method1(@Param('testId') testId: string, @Query('testQuery') testQuery: string) {
  }

  @Get()
  public method2(@Param('testId') testId: string, @Query('testQuery') testQuery: string) {
  }
}
```

## Does this PR introduce a breaking change?
- [ ] Yes
- [x] No


## Other information
NestJs Docs updated: https://github.com/nestjs/docs.nestjs.com/pull/2570


Patch:
diff --git a/lib/decorators/api-param.decorator.ts b/lib/decorators/api-param.decorator.ts
index 4e1f48b4..db7251c1 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<string, any> = {
     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 7a41b81b..60c23585 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 e700a6be..0d1b0488 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<T = any>(
   metakey: string,
@@ -107,26 +109,68 @@ export function createMixedDecorator<T = any>(
 export function createParamDecorator<T extends Record<string, any> = any>(
   metadata: T,
   initial: Partial<T>
-): 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>
+  ): 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 86cdc12e..84ce7869 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};`;
 }


Test patch:
diff --git a/test/decorators/api-param.decorator.spec.ts b/test/decorators/api-param.decorator.spec.ts
new file mode 100644
index 00000000..973b0808
--- /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 00000000..2dcb55f2
--- /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 5e91b9e8..64ec0d2e 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<Foo[]> {
+        return Promise.resolve([]);
+      }
+
+      @Post('foos')
+      create(): Promise<any> {
+        return Promise.resolve();
+      }
+    }
+
+    it('should properly define params', () => {
+      const explorer = new SwaggerExplorer(schemaObjectFactory);
+      const routes = explorer.exploreController(
+        {
+          instance: new FooController(),
+          metatype: FooController
+        } as InstanceWrapper<FooController>,
+        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<Foo[]> {
+        return Promise.resolve([]);
+      }
+
+      @Post('foos')
+      create(): Promise<any> {
+        return Promise.resolve();
+      }
+    }
+
+    it('should properly define params', () => {
+      const explorer = new SwaggerExplorer(schemaObjectFactory);
+      const routes = explorer.exploreController(
+        {
+          instance: new FooController(),
+          metatype: FooController
+        } as InstanceWrapper<FooController>,
+        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'
+          }
+        }
+      ]);
+    });
+  });
 });