Repository: lukeautry/tsoa (ts)

Issue:
feature: class methods and getter
I'm using tsoa with a typegoose model and I have some virtuals fields with getter and some methods used in backend. 
I want to be able to display my virtuals in my swagger definitions and ignore my methods without adding any jsdoc comment to ignore it.

## 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

```yaml
        Datasource:
            properties:
                name:
                    type: string
                pausedAt:
                    type: string
                    format: date-time
                isPaused:
                    type: boolean
            required:
                - name
                - pausedAt
            type: object
```

```ts
export class Datasource {

  @prop({
    trim: true,
    required: [true, 'NameEmpty']
  })
  public name!: string

  @prop({
    default: () => null
  })
  public pausedAt!: Date | null

  public get isPaused () {
    return this.pausedAt !== null
  }
}
```

## Current Behavior

An error occur with the getter or the method without an ignore jsdoc comment.

Pull request:
feat: ignore class methods
### 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 #631 

### 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**

The ignore part seems to works fine.

But for the toJSON scan I'm having trouble to implement it, I've found the way to get the return type of the toJSON methods but I haven't found a way to properly export that type to the swagger definitions. So if you have any advice / ideas  I'd be glad to have them

Patch:
diff --git a/src/metadataGeneration/typeResolver.ts b/src/metadataGeneration/typeResolver.ts
index 4a40d7e0..8aadb625 100644
--- a/src/metadataGeneration/typeResolver.ts
+++ b/src/metadataGeneration/typeResolver.ts
@@ -144,7 +144,7 @@ export class TypeResolver {
     if (ts.isMappedTypeNode(this.typeNode) && this.referencer) {
       const isNotIgnored = (e: ts.Declaration) => {
         const ignore = isExistJSDocTag(e, tag => tag.tagName.text === 'ignore');
-        return !ignore;
+        return !ignore && (ts.isPropertyDeclaration(e) || ts.isPropertySignature(e) || ts.isParameter(e));
       };
 
       const type = this.current.typeChecker.getTypeFromTypeNode(this.referencer);
@@ -455,15 +455,42 @@ export class TypeResolver {
   }
 
   private getModelReference(modelType: ts.InterfaceDeclaration | ts.ClassDeclaration, name: string) {
+    const example = this.getNodeExample(modelType);
+    const description = this.getNodeDescription(modelType);
+
+    // Handle toJSON methods
+    if (!modelType.name) {
+      throw new GenerateMetadataError("Can't get Symbol from anonymous class", modelType);
+    }
+    const type = this.current.typeChecker.getTypeAtLocation(modelType.name);
+    const toJSON = this.current.typeChecker.getPropertyOfType(type, 'toJSON');
+    if (toJSON && toJSON.valueDeclaration && (ts.isMethodDeclaration(toJSON.valueDeclaration) || ts.isMethodSignature(toJSON.valueDeclaration))) {
+      let nodeType = toJSON.valueDeclaration.type;
+      if (!nodeType) {
+        const signature = this.current.typeChecker.getSignatureFromDeclaration(toJSON.valueDeclaration);
+        const implicitType = this.current.typeChecker.getReturnTypeOfSignature(signature!);
+        nodeType = this.current.typeChecker.typeToTypeNode(implicitType) as ts.TypeNode;
+      }
+      const type = new TypeResolver(nodeType, this.current).resolve();
+      const referenceType: Tsoa.ReferenceType = {
+        refName: this.getRefTypeName(name),
+        dataType: 'refAlias',
+        description,
+        type,
+        validators: {},
+        ...(example && { example }),
+      };
+      return referenceType;
+    }
+
     const properties = this.getModelProperties(modelType);
     const additionalProperties = this.getModelAdditionalProperties(modelType);
     const inheritedProperties = this.getModelInheritedProperties(modelType) || [];
-    const example = this.getNodeExample(modelType);
 
     const referenceType: Tsoa.ReferenceType = {
       additionalProperties,
       dataType: 'refObject',
-      description: this.getNodeDescription(modelType),
+      description,
       properties: inheritedProperties,
       refName: this.getRefTypeName(name),
       ...(example && { example }),


Test patch:
diff --git a/tests/fixtures/controllers/getController.ts b/tests/fixtures/controllers/getController.ts
index fdfc07b1..646486c1 100644
--- a/tests/fixtures/controllers/getController.ts
+++ b/tests/fixtures/controllers/getController.ts
@@ -1,7 +1,7 @@
 import { Readable } from 'stream';
 import { Controller, Example, Get, OperationId, Query, Request, Route, SuccessResponse, Tags } from '../../../src';
 import '../duplicateTestModel';
-import { GenericModel, TestClassModel, TestModel, TestSubModel } from '../testModel';
+import { GenericModel, GetterClass, GetterInterface, GetterInterfaceHerited, TestClassModel, TestModel, TestSubModel, SimpleClassWithToJSON } from '../testModel';
 import { ModelService } from './../services/modelService';
 
 @Route('GetTest')
@@ -58,6 +58,26 @@ export class GetTestController extends Controller {
     return new ModelService().getClassModel();
   }
 
+  @Get('GetterClass')
+  public async getGetterClass(): Promise<GetterClass> {
+    return new GetterClass();
+  }
+
+  @Get('SimpleClassWithToJSON')
+  public async simpleClassWithToJSON(): Promise<SimpleClassWithToJSON> {
+    return new SimpleClassWithToJSON('hello, world', true);
+  }
+
+  @Get('GetterInterface')
+  public async getGetterInterface(): Promise<GetterInterface> {
+    return {} as GetterInterface;
+  }
+
+  @Get('GetterInterfaceHerited')
+  public async getGetterInterfaceHerited(): Promise<GetterInterfaceHerited> {
+    return {} as GetterInterfaceHerited;
+  }
+
   @Get('Multi')
   public async getMultipleModels(): Promise<TestModel[]> {
     return [new ModelService().getModel(), new ModelService().getModel(), new ModelService().getModel()];
diff --git a/tests/fixtures/testModel.ts b/tests/fixtures/testModel.ts
index d60975bd..c3b0e877 100644
--- a/tests/fixtures/testModel.ts
+++ b/tests/fixtures/testModel.ts
@@ -612,6 +612,48 @@ export class TestClassModel extends TestClassBaseModel {
   ) {
     super();
   }
+
+  public myIgnoredMethod() {
+    return 'ignored';
+  }
+}
+
+type NonFunctionPropertyNames<T> = {
+  [K in keyof T]: T[K] extends Function ? never : K;
+}[keyof T];
+type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;
+export class GetterClass {
+  public a: 'b';
+
+  get foo() {
+    return 'bar';
+  }
+
+  public toJSON(): NonFunctionProperties<GetterClass> & { foo: string } {
+    return Object.assign({}, this, { foo: this.foo });
+  }
+}
+
+export class SimpleClassWithToJSON {
+  public a: string;
+  public b: boolean;
+
+  constructor(a: string, b: boolean) {
+    this.a = a;
+    this.b = b;
+  }
+
+  public toJSON(): { a: string } {
+    return { a: this.a };
+  }
+}
+
+export interface GetterInterface {
+  toJSON(): { foo: string };
+}
+
+export interface GetterInterfaceHerited extends GetterInterface {
+  foo: number;
 }
 
 export interface GenericModel<T = string> {
diff --git a/tests/integration/dynamic-controllers-express-server.spec.ts b/tests/integration/dynamic-controllers-express-server.spec.ts
index f2130db0..6fd993ff 100644
--- a/tests/integration/dynamic-controllers-express-server.spec.ts
+++ b/tests/integration/dynamic-controllers-express-server.spec.ts
@@ -47,6 +47,15 @@ describe('Express Server', () => {
     });
   });
 
+  it('respects toJSON for class serialization', () => {
+    return verifyGetRequest(basePath + '/GetTest/SimpleClassWithToJSON', (err, res) => {
+      const getterClass = res.body;
+      expect(getterClass).to.haveOwnProperty('a');
+      expect(getterClass.a).to.equal('hello, world');
+      expect(getterClass).to.not.haveOwnProperty('b');
+    });
+  });
+
   it('can handle get request with collection return value', () => {
     return verifyGetRequest(basePath + '/GetTest/Multi', (err, res) => {
       const models = res.body as TestModel[];
diff --git a/tests/integration/express-server.spec.ts b/tests/integration/express-server.spec.ts
index 85d74c45..97f1438e 100644
--- a/tests/integration/express-server.spec.ts
+++ b/tests/integration/express-server.spec.ts
@@ -47,6 +47,15 @@ describe('Express Server', () => {
     });
   });
 
+  it('respects toJSON for class serialization', () => {
+    return verifyGetRequest(basePath + '/GetTest/SimpleClassWithToJSON', (err, res) => {
+      const getterClass = res.body;
+      expect(getterClass).to.haveOwnProperty('a');
+      expect(getterClass.a).to.equal('hello, world');
+      expect(getterClass).to.not.haveOwnProperty('b');
+    });
+  });
+
   it('can handle get request with collection return value', () => {
     return verifyGetRequest(basePath + '/GetTest/Multi', (err, res) => {
       const models = res.body as TestModel[];
diff --git a/tests/integration/hapi-server.spec.ts b/tests/integration/hapi-server.spec.ts
index 9b72acc7..8e264bf1 100644
--- a/tests/integration/hapi-server.spec.ts
+++ b/tests/integration/hapi-server.spec.ts
@@ -35,6 +35,15 @@ describe('Hapi Server', () => {
     });
   });
 
+  it('respects toJSON for class serialization', () => {
+    return verifyGetRequest(basePath + '/GetTest/SimpleClassWithToJSON', (err, res) => {
+      const getterClass = res.body;
+      expect(getterClass).to.haveOwnProperty('a');
+      expect(getterClass.a).to.equal('hello, world');
+      expect(getterClass).to.not.haveOwnProperty('b');
+    });
+  });
+
   it('can handle get request with collection return value', () => {
     return verifyGetRequest(basePath + '/GetTest/Multi', (err, res) => {
       const models = res.body as TestModel[];
diff --git a/tests/integration/koa-server.spec.ts b/tests/integration/koa-server.spec.ts
index 157e9ed2..c26a775a 100644
--- a/tests/integration/koa-server.spec.ts
+++ b/tests/integration/koa-server.spec.ts
@@ -36,6 +36,15 @@ describe('Koa Server', () => {
     });
   });
 
+  it('respects toJSON for class serialization', () => {
+    return verifyGetRequest(basePath + '/GetTest/SimpleClassWithToJSON', (err, res) => {
+      const getterClass = res.body;
+      expect(getterClass).to.haveOwnProperty('a');
+      expect(getterClass.a).to.equal('hello, world');
+      expect(getterClass).to.not.haveOwnProperty('b');
+    });
+  });
+
   it('can handle get request with collection return value', () => {
     return verifyGetRequest(basePath + '/GetTest/Multi', (err, res) => {
       const models = res.body as TestModel[];
diff --git a/tests/unit/swagger/definitionsGeneration/definitions.spec.ts b/tests/unit/swagger/definitionsGeneration/definitions.spec.ts
index 2a9d7b3d..6e5c3f60 100644
--- a/tests/unit/swagger/definitionsGeneration/definitions.spec.ts
+++ b/tests/unit/swagger/definitionsGeneration/definitions.spec.ts
@@ -765,6 +765,7 @@ describe('Definition generation', () => {
                   'publicConstructorVar',
                   'readonlyConstructorArgument',
                   'optionalPublicConstructorVar',
+                  'myIgnoredMethod',
                   'defaultValue1',
                 ],
                 description: 'Exclude from T those types that are assignable to U',
diff --git a/tests/unit/swagger/schemaDetails3.spec.ts b/tests/unit/swagger/schemaDetails3.spec.ts
index 9113e790..dcaf5e7d 100644
--- a/tests/unit/swagger/schemaDetails3.spec.ts
+++ b/tests/unit/swagger/schemaDetails3.spec.ts
@@ -516,11 +516,7 @@ describe('Definition generation for OpenAPI 3.0.0', () => {
 
             const componentSchema = getComponentSchema('StrLiteral', currentSpec);
             expect(componentSchema).to.deep.eq({
-              oneOf: [
-                { type: 'string', enum: [''], nullable: false },
-                { type: 'string', enum: ['Foo'], nullable: false },
-                { type: 'string', enum: ['Bar'], nullable: false },
-              ],
+              oneOf: [{ type: 'string', enum: [''], nullable: false }, { type: 'string', enum: ['Foo'], nullable: false }, { type: 'string', enum: ['Bar'], nullable: false }],
               default: undefined,
               description: undefined,
               example: undefined,
@@ -814,6 +810,98 @@ describe('Definition generation for OpenAPI 3.0.0', () => {
               `for property ${propertyName}`,
             );
 
+            const getterClass = getComponentSchema('GetterClass', currentSpec);
+            expect(getterClass).to.deep.eq({
+              allOf: [
+                {
+                  $ref: '#/components/schemas/NonFunctionProperties_GetterClass_',
+                },
+                {
+                  properties: {
+                    foo: {
+                      type: 'string',
+                      description: undefined,
+                      example: undefined,
+                      format: undefined,
+                      default: undefined,
+                    },
+                  },
+                  required: ['foo'],
+                  type: 'object',
+                },
+              ],
+              default: undefined,
+              example: undefined,
+              format: undefined,
+              description: undefined,
+            });
+            const getterClass2 = getComponentSchema('NonFunctionProperties_GetterClass_', currentSpec);
+            expect(getterClass2).to.deep.eq({
+              $ref: '#/components/schemas/Pick_GetterClass.NonFunctionPropertyNames_GetterClass__',
+              description: undefined,
+              example: undefined,
+              format: undefined,
+              default: undefined,
+            });
+            const getterClass3 = getComponentSchema('Pick_GetterClass.NonFunctionPropertyNames_GetterClass__', currentSpec);
+            expect(getterClass3).to.deep.eq({
+              default: undefined,
+              description: 'From T, pick a set of properties whose keys are in the union K',
+              example: undefined,
+              format: undefined,
+              properties: {
+                a: {
+                  type: 'string',
+                  enum: ['b'],
+                  nullable: false,
+                  description: undefined,
+                  example: undefined,
+                  format: undefined,
+                  default: undefined,
+                },
+              },
+              required: ['a'],
+              type: 'object',
+            });
+
+            const getterInterface = getComponentSchema('GetterInterface', currentSpec);
+            expect(getterInterface).to.deep.eq({
+              properties: {
+                foo: {
+                  type: 'string',
+                  description: undefined,
+                  example: undefined,
+                  format: undefined,
+                  default: undefined,
+                },
+              },
+              required: ['foo'],
+              type: 'object',
+              default: undefined,
+              example: undefined,
+              format: undefined,
+              description: undefined,
+            });
+
+            const getterInterfaceHerited = getComponentSchema('GetterInterfaceHerited', currentSpec);
+            expect(getterInterfaceHerited).to.deep.eq({
+              properties: {
+                foo: {
+                  type: 'string',
+                  description: undefined,
+                  example: undefined,
+                  format: undefined,
+                  default: undefined,
+                },
+              },
+              required: ['foo'],
+              type: 'object',
+              default: undefined,
+              example: undefined,
+              format: undefined,
+              description: undefined,
+            });
+
             const omit = getComponentSchema('Omit_ErrorResponseModel.status_', currentSpec);
             expect(omit).to.deep.eq(
               {
@@ -934,6 +1022,7 @@ describe('Definition generation for OpenAPI 3.0.0', () => {
                   { type: 'string', enum: ['publicConstructorVar'], nullable: false },
                   { type: 'string', enum: ['readonlyConstructorArgument'], nullable: false },
                   { type: 'string', enum: ['optionalPublicConstructorVar'], nullable: false },
+                  { type: 'string', enum: ['myIgnoredMethod'], nullable: false },
                   { type: 'string', enum: ['defaultValue1'], nullable: false },
                 ],
                 description: 'Exclude from T those types that are assignable to U',
@@ -1001,10 +1090,7 @@ describe('Definition generation for OpenAPI 3.0.0', () => {
                     description: undefined,
                     format: undefined,
                     example: undefined,
-                    oneOf: [
-                      { enum: ['OK'], nullable: false, type: 'string' },
-                      { enum: ['KO'], nullable: false, type: 'string' },
-                    ],
+                    oneOf: [{ enum: ['OK'], nullable: false, type: 'string' }, { enum: ['KO'], nullable: false, type: 'string' }],
                   },
                   id: { type: 'number', format: 'double', default: undefined, description: undefined, example: undefined },
                   keyInterface: { type: 'string', default: undefined, description: undefined, format: undefined, example: undefined, enum: ['id'], nullable: false },
@@ -1083,10 +1169,7 @@ describe('Definition generation for OpenAPI 3.0.0', () => {
                 maybeString: { $ref: '#/components/schemas/Maybe_string_', description: undefined, format: undefined, example: undefined },
                 wordOrNull: { $ref: '#/components/schemas/Maybe_Word_', description: undefined, format: undefined, example: undefined },
                 numberOrNull: {
-                  oneOf: [
-                    { type: 'number', format: 'double' },
-                    { type: 'number', enum: ['null'], nullable: true },
-                  ],
+                  oneOf: [{ type: 'number', format: 'double' }, { type: 'number', enum: ['null'], nullable: true }],
                   description: undefined,
                   format: undefined,
                   default: undefined,
@@ -1177,10 +1260,7 @@ describe('Definition generation for OpenAPI 3.0.0', () => {
       // Assert
       expect(getComponentSchema(schemaName, { specName: 'specDefault', spec })).to.deep.eq({
         description: undefined,
-        oneOf: [
-          { type: 'number', enum: [1, 3] },
-          { type: 'string', enum: ['two', 'four'] },
-        ],
+        oneOf: [{ type: 'number', enum: [1, 3] }, { type: 'string', enum: ['two', 'four'] }],
       });
     });
   });