Repository: lukeautry/tsoa (ts)

Issue:
Add support for @Hidden() decorator on Query Parameters
<!--- Provide a general summary of the issue in the Title above -->

## Sorting

* **I'm submitting a ...**
  - [ ] bug report
  - [X] feature request
  - [ ] support request

* I confirm that I
  - [X] used the [search](https://github.com/lukeautry/tsoa/search?type=Issues) to make sure that a similar issue hasn't already been submit

## Expected Behavior
<!--- Tell us what should happen -->

A developer should be able to hide a specific query parameter that they may not want the general public to know about but still need from their UI. Example:

The generated swagger would not show anything for `superSecretFlag`
<!--- If this is related to a TypeScript issue, then include the way that you expected to be able to write the code  -->
```ts
export class SampleController {
  @Get('id')
  public async find(@Path('id') id:string, @Hidden() superSecretFlag: boolean = false): Promise<any> {  }
}
```

## Current Behavior

Adding Hidden next to a parameter does not seem to do anything

## Context (Environment)
Version of the library: 2.3.8
Version of NodeJS: 10.16.3
* Confirm you were using yarn not npm: [X]

Pull request:
Add support for @Hidden() decorator on Query params
### All Submissions:

* [x] Have you followed the guidelines in our [Contributing](https://github.com/lukeautry/tsoa/tree/master/docs/CONTRIBUTING.md) document?
* [x] Have you checked to ensure there aren't other open [Pull Requests](https://github.com/lukeautry/tsoa/pulls) for the same update/change?
* [x] Have you written unit tests?
* [x] Have you written unit tests that cover the negative cases (i.e.: if bad data is submitted, does the library respond properly)?
* [x] This PR is associated with an existing issue?

**Closing issues**
Closes #582
Relates to #310 

### If this is a new feature submission:

* [x] Has the issue had a maintainer respond to the issue and clarify that the feature is something that aligns with the [goals](https://github.com/lukeautry/tsoa#goal) and [philosophy](https://github.com/lukeautry/tsoa#philosophy) of the project?

**Potential Problems With The Approach**
- Why this approach? Went with `@Hidden` because it's consistent with tsoa controllers and methods, and was the agreed approach from discussion on #582 
- Alternative 1: add a `hidden` property to `@Query`. This would align with how Swagger's Java annotations work `@ApiParam(hidden = true)`, see [here](http://docs.swagger.io/swagger-core/v1.5.X/apidocs/index.html?io/swagger/annotations/ApiParam.html)
- Alternative 2: don't put this in tsoa. Instead, allow users to configure arbitrary filters that extend tsoa, similar to how Swagger .NET works, see [here](https://github.com/domaindrivendev/Swashbuckle.AspNetCore#extend-generator-with-operation-schema--document-filters)

**Test plan**
- found all existing tests for `@Hidden` and added a query unit test to each

Patch:
diff --git a/src/metadataGeneration/methodGenerator.ts b/src/metadataGeneration/methodGenerator.ts
index 4f9e59df..e81e5b1f 100644
--- a/src/metadataGeneration/methodGenerator.ts
+++ b/src/metadataGeneration/methodGenerator.ts
@@ -62,15 +62,17 @@ export class MethodGenerator {
   }
 
   private buildParameters() {
-    const parameters = this.node.parameters.map(p => {
-      try {
-        return new ParameterGenerator(p, this.method, this.path, this.current).Generate();
-      } catch (e) {
-        const methodId = this.node.name as ts.Identifier;
-        const controllerId = (this.node.parent as ts.ClassDeclaration).name as ts.Identifier;
-        throw new GenerateMetadataError(`${e.message} \n in '${controllerId.text}.${methodId.text}'`);
-      }
-    });
+    const parameters = this.node.parameters
+      .map(p => {
+        try {
+          return new ParameterGenerator(p, this.method, this.path, this.current).Generate();
+        } catch (e) {
+          const methodId = this.node.name as ts.Identifier;
+          const controllerId = (this.node.parent as ts.ClassDeclaration).name as ts.Identifier;
+          throw new GenerateMetadataError(`${e.message} \n in '${controllerId.text}.${methodId.text}'`);
+        }
+      })
+      .filter((p): p is Tsoa.Parameter => p !== null);
 
     const bodyParameters = parameters.filter(p => p.in === 'body');
     const bodyProps = parameters.filter(p => p.in === 'body-prop');
diff --git a/src/metadataGeneration/parameterGenerator.ts b/src/metadataGeneration/parameterGenerator.ts
index 21f224d5..d42c421f 100644
--- a/src/metadataGeneration/parameterGenerator.ts
+++ b/src/metadataGeneration/parameterGenerator.ts
@@ -1,5 +1,5 @@
 import * as ts from 'typescript';
-import { getNodeFirstDecoratorName, getNodeFirstDecoratorValue } from './../utils/decoratorUtils';
+import { getDecorators, getNodeFirstDecoratorName, getNodeFirstDecoratorValue } from './../utils/decoratorUtils';
 import { getJSDocTags } from './../utils/jsDocUtils';
 import { getParameterValidators } from './../utils/validatorUtils';
 import { GenerateMetadataError } from './exceptions';
@@ -11,7 +11,7 @@ import { TypeResolver } from './typeResolver';
 export class ParameterGenerator {
   constructor(private readonly parameter: ts.ParameterDeclaration, private readonly method: string, private readonly path: string, private readonly current: MetadataGenerator) {}
 
-  public Generate(): Tsoa.Parameter {
+  public Generate(): Tsoa.Parameter | null {
     const decoratorName = getNodeFirstDecoratorName(this.parameter, identifier => this.supportParameterDecorator(identifier.text));
 
     switch (decoratorName) {
@@ -107,7 +107,7 @@ export class ParameterGenerator {
     };
   }
 
-  private getQueryParameter(parameter: ts.ParameterDeclaration): Tsoa.Parameter {
+  private getQueryParameter(parameter: ts.ParameterDeclaration): Tsoa.Parameter | null {
     const parameterName = (parameter.name as ts.Identifier).text;
     const type = this.getValidatedType(parameter);
 
@@ -122,6 +122,13 @@ export class ParameterGenerator {
       validators: getParameterValidators(this.parameter, parameterName),
     };
 
+    if (this.getQueryParamterIsHidden(parameter)) {
+      if (commonProperties.required) {
+        throw new GenerateMetadataError(`@Query('${parameterName}') Can't support @Hidden because it is required (does not allow undefined and does not have a default value).`);
+      }
+      return null;
+    }
+
     if (type.dataType === 'array') {
       const arrayType = type as Tsoa.ArrayType;
       if (!this.supportPathDataType(arrayType.elementType)) {
@@ -231,4 +238,18 @@ export class ParameterGenerator {
     }
     return new TypeResolver(typeNode, this.current, parameter).resolve();
   }
+
+  private getQueryParamterIsHidden(parameter: ts.ParameterDeclaration) {
+    const hiddenDecorators = getDecorators(parameter, identifier => identifier.text === 'Hidden');
+    if (!hiddenDecorators || !hiddenDecorators.length) {
+      return false;
+    }
+
+    if (hiddenDecorators.length > 1) {
+      const parameterName = (parameter.name as ts.Identifier).text;
+      throw new GenerateMetadataError(`Only one Hidden decorator allowed on @Query('${parameterName}').`);
+    }
+
+    return true;
+  }
 }


Test patch:
diff --git a/tests/fixtures/controllers/hiddenMethodController.ts b/tests/fixtures/controllers/hiddenMethodController.ts
index 3fbcb417..dddbb3cc 100644
--- a/tests/fixtures/controllers/hiddenMethodController.ts
+++ b/tests/fixtures/controllers/hiddenMethodController.ts
@@ -1,4 +1,4 @@
-import { Controller, Get, Hidden, Route } from '../../../src';
+import { Controller, Get, Hidden, Route, Query } from '../../../src';
 import { TestModel } from '../../fixtures/testModel';
 import { ModelService } from '../services/modelService';
 
@@ -14,4 +14,9 @@ export class HiddenMethodController extends Controller {
   public async hiddenGetMethod(): Promise<TestModel> {
     return Promise.resolve(new ModelService().getModel());
   }
+
+  @Get('hiddenQueryMethod')
+  public async hiddenQueryMethod(@Query() normalParam: string, @Query() @Hidden() defaultSecret = true, @Query() @Hidden() optionalSecret?: string): Promise<TestModel> {
+    return Promise.resolve(new ModelService().getModel());
+  }
 }
diff --git a/tests/unit/swagger/definitionsGeneration/metadata.spec.ts b/tests/unit/swagger/definitionsGeneration/metadata.spec.ts
index 0011d13f..880f6379 100644
--- a/tests/unit/swagger/definitionsGeneration/metadata.spec.ts
+++ b/tests/unit/swagger/definitionsGeneration/metadata.spec.ts
@@ -637,6 +637,28 @@ describe('Metadata generation', () => {
       expect(method.path).to.equal('hiddenGetMethod');
       expect(method.isHidden).to.equal(true);
     });
+
+    it('should mark query params as hidden', () => {
+      const method = controller.methods.find(m => m.name === 'hiddenQueryMethod');
+      if (!method) {
+        throw new Error('Method hiddenQueryMethod not defined!');
+      }
+
+      const defaultSecret = method.parameters.find(p => p.name === 'defaultSecret');
+      expect(defaultSecret).to.be.undefined;
+
+      const optionalSecret = method.parameters.find(p => p.name === 'optionalSecret');
+      expect(optionalSecret).to.be.undefined;
+
+      expect(method.parameters.length).to.equal(1);
+
+      const normalParam = method.parameters[0];
+      expect(normalParam.in).to.equal('query');
+      expect(normalParam.name).to.equal('normalParam');
+      expect(normalParam.parameterName).to.equal('normalParam');
+      expect(normalParam.required).to.be.true;
+      expect(normalParam.type.dataType).to.equal('string');
+    });
   });
 
   describe('HiddenControllerGenerator', () => {
diff --git a/tests/unit/swagger/schemaDetails.spec.ts b/tests/unit/swagger/schemaDetails.spec.ts
index 127bfb0a..cad37599 100644
--- a/tests/unit/swagger/schemaDetails.spec.ts
+++ b/tests/unit/swagger/schemaDetails.spec.ts
@@ -63,7 +63,31 @@ describe('Schema details generation', () => {
         const metadataHiddenMethod = new MetadataGenerator('./tests/fixtures/controllers/hiddenMethodController.ts').Generate();
         const specHiddenMethod = new SpecGenerator2(metadataHiddenMethod, getDefaultExtendedOptions()).GetSpec();
 
-        expect(specHiddenMethod.paths).to.have.keys(['/Controller/normalGetMethod']);
+        expect(specHiddenMethod.paths).to.have.keys(['/Controller/normalGetMethod', '/Controller/hiddenQueryMethod']);
+      });
+
+      it('should not contain hidden query params', () => {
+        const metadataHidden = new MetadataGenerator('./tests/fixtures/controllers/hiddenMethodController.ts').Generate();
+        const specHidden = new SpecGenerator2(metadataHidden, getDefaultExtendedOptions()).GetSpec();
+
+        if (!specHidden.paths) {
+          throw new Error('Paths are not defined.');
+        }
+        if (!specHidden.paths['/Controller/hiddenQueryMethod']) {
+          throw new Error('hiddenQueryMethod path not defined.');
+        }
+        if (!specHidden.paths['/Controller/hiddenQueryMethod'].get) {
+          throw new Error('hiddenQueryMethod get method not defined.');
+        }
+
+        const method = specHidden.paths['/Controller/hiddenQueryMethod'].get;
+        expect(method.parameters).to.have.lengthOf(1);
+
+        const normalParam = method.parameters![0];
+        expect(normalParam.in).to.equal('query');
+        expect(normalParam.name).to.equal('normalParam');
+        expect(normalParam.required).to.be.true;
+        expect(normalParam.type).to.equal('string');
       });
 
       it('should not contain paths for hidden controller', () => {
diff --git a/tests/unit/swagger/schemaDetails3.spec.ts b/tests/unit/swagger/schemaDetails3.spec.ts
index a84673e6..867e99b9 100644
--- a/tests/unit/swagger/schemaDetails3.spec.ts
+++ b/tests/unit/swagger/schemaDetails3.spec.ts
@@ -236,7 +236,31 @@ describe('Definition generation for OpenAPI 3.0.0', () => {
         const metadataHiddenMethod = new MetadataGenerator('./tests/fixtures/controllers/hiddenMethodController.ts').Generate();
         const specHiddenMethod = new SpecGenerator3(metadataHiddenMethod, JSON.parse(JSON.stringify(defaultOptions))).GetSpec();
 
-        expect(specHiddenMethod.paths).to.have.keys(['/Controller/normalGetMethod']);
+        expect(specHiddenMethod.paths).to.have.keys(['/Controller/normalGetMethod', '/Controller/hiddenQueryMethod']);
+      });
+
+      it('should not contain hidden query params', () => {
+        const metadataHidden = new MetadataGenerator('./tests/fixtures/controllers/hiddenMethodController.ts').Generate();
+        const specHidden = new SpecGenerator3(metadataHidden, JSON.parse(JSON.stringify(defaultOptions))).GetSpec();
+
+        if (!specHidden.paths) {
+          throw new Error('Paths are not defined.');
+        }
+        if (!specHidden.paths['/Controller/hiddenQueryMethod']) {
+          throw new Error('hiddenQueryMethod path not defined.');
+        }
+        if (!specHidden.paths['/Controller/hiddenQueryMethod'].get) {
+          throw new Error('hiddenQueryMethod get method not defined.');
+        }
+
+        const method = specHidden.paths['/Controller/hiddenQueryMethod'].get;
+        expect(method.parameters).to.have.lengthOf(1);
+
+        const normalParam = method.parameters![0];
+        expect(normalParam.in).to.equal('query');
+        expect(normalParam.name).to.equal('normalParam');
+        expect(normalParam.required).to.be.true;
+        expect(normalParam.schema.type).to.equal('string');
       });
 
       it('should not contain paths for hidden controller', () => {