Repository: lukeautry/tsoa
Pull request #674 (2020-04-29 09:30:52)

dimitor115/feature/handle-object-in-decorators
Implement passing object properties to decorators


Patch:
diff --git a/src/metadataGeneration/controllerGenerator.ts b/src/metadataGeneration/controllerGenerator.ts
index bb2793fd..fc539379 100644
--- a/src/metadataGeneration/controllerGenerator.ts
+++ b/src/metadataGeneration/controllerGenerator.ts
@@ -1,9 +1,8 @@
 import * as ts from 'typescript';
-import { getDecorators } from './../utils/decoratorUtils';
+import { getDecorators, getSecurites } from './../utils/decoratorUtils';
 import { GenerateMetadataError } from './exceptions';
 import { MetadataGenerator } from './metadataGenerator';
 import { MethodGenerator } from './methodGenerator';
-import { getSecurities } from './security';
 import { Tsoa } from './tsoa';
 
 export class ControllerGenerator {
@@ -85,7 +84,7 @@ export class ControllerGenerator {
       return [];
     }
 
-    return getSecurities(securityDecorators);
+    return securityDecorators.map(d => getSecurites(d, this.current.typeChecker));
   }
 
   private getIsHidden(): boolean {
diff --git a/src/metadataGeneration/initializer-value.ts b/src/metadataGeneration/initializer-value.ts
index 528a6e5c..63a50e94 100644
--- a/src/metadataGeneration/initializer-value.ts
+++ b/src/metadataGeneration/initializer-value.ts
@@ -1,15 +1,15 @@
 import * as ts from 'typescript';
 import { Tsoa } from './tsoa';
 
-export const getInitializerValue = (initializer?: ts.Expression, type?: Tsoa.Type) => {
-  if (!initializer) {
+export const getInitializerValue = (initializer?: ts.Expression, typeChecker?: ts.TypeChecker, type?: Tsoa.Type) => {
+  if (!initializer || !typeChecker) {
     return;
   }
 
   switch (initializer.kind as ts.SyntaxKind) {
     case ts.SyntaxKind.ArrayLiteralExpression:
       const arrayLiteral = initializer as ts.ArrayLiteralExpression;
-      return arrayLiteral.elements.map(element => getInitializerValue(element));
+      return arrayLiteral.elements.map(element => getInitializerValue(element, typeChecker));
     case ts.SyntaxKind.StringLiteral:
       return (initializer as ts.StringLiteral).text;
     case ts.SyntaxKind.TrueKeyword:
@@ -27,7 +27,7 @@ export const getInitializerValue = (initializer?: ts.Expression, type?: Tsoa.Typ
         let date = new Date();
         if (newExpression.arguments) {
           const newArguments = newExpression.arguments.filter(args => args.kind !== undefined);
-          const argsValue = newArguments.map(args => getInitializerValue(args));
+          const argsValue = newArguments.map(args => getInitializerValue(args, typeChecker));
           if (argsValue.length > 0) {
             date = new Date(argsValue as any);
           }
@@ -43,10 +43,14 @@ export const getInitializerValue = (initializer?: ts.Expression, type?: Tsoa.Typ
       const objectLiteral = initializer as ts.ObjectLiteralExpression;
       const nestedObject: any = {};
       objectLiteral.properties.forEach((p: any) => {
-        nestedObject[p.name.text] = getInitializerValue(p.initializer);
+        nestedObject[p.name.text] = getInitializerValue(p.initializer, typeChecker);
       });
       return nestedObject;
     default:
-      return;
+      const symbol = typeChecker.getSymbolAtLocation(initializer);
+      const extractedInitializer = symbol && symbol.valueDeclaration && hasInitializer(symbol.valueDeclaration) && (symbol.valueDeclaration.initializer as ts.Expression);
+      return extractedInitializer ? getInitializerValue(extractedInitializer, typeChecker) : undefined;
   }
 };
+
+const hasInitializer = (node: ts.Node): node is ts.HasInitializer => node.hasOwnProperty('initializer');
diff --git a/src/metadataGeneration/methodGenerator.ts b/src/metadataGeneration/methodGenerator.ts
index 16e43681..384ef2e9 100644
--- a/src/metadataGeneration/methodGenerator.ts
+++ b/src/metadataGeneration/methodGenerator.ts
@@ -1,12 +1,11 @@
 import * as ts from 'typescript';
 import { isVoidType } from '../utils/isVoidType';
-import { getDecorators } from './../utils/decoratorUtils';
+import { getDecorators, getDecoratorValues, getSecurites } from './../utils/decoratorUtils';
 import { getJSDocComment, getJSDocDescription, isExistJSDocTag } from './../utils/jsDocUtils';
 import { GenerateMetadataError } from './exceptions';
-import { getInitializerValue } from './initializer-value';
 import { MetadataGenerator } from './metadataGenerator';
 import { ParameterGenerator } from './parameterGenerator';
-import { getSecurities } from './security';
+
 import { Tsoa } from './tsoa';
 import { TypeResolver } from './typeResolver';
 
@@ -120,24 +119,12 @@ export class MethodGenerator {
     return decorators.map(decorator => {
       const expression = decorator.parent as ts.CallExpression;
 
-      let description = '';
-      let name = '200';
-      let examples;
-      if (expression.arguments.length > 0 && (expression.arguments[0] as any).text) {
-        name = (expression.arguments[0] as any).text;
-      }
-      if (expression.arguments.length > 1 && (expression.arguments[1] as any).text) {
-        description = (expression.arguments[1] as any).text;
-      }
-      if (expression.arguments.length > 2 && (expression.arguments[2] as any)) {
-        const argument = expression.arguments[2] as any;
-        examples = this.getExamplesValue(argument);
-      }
+      const [name, description, examples] = getDecoratorValues(decorator, this.current.typeChecker);
 
       return {
-        description,
+        description: description || '',
         examples,
-        name,
+        name: name || '200',
         schema: expression.typeArguments && expression.typeArguments.length > 0 ? new TypeResolver(expression.typeArguments[0], this.current).resolve() : undefined,
       } as Tsoa.Response;
     });
@@ -158,24 +145,13 @@ export class MethodGenerator {
       throw new GenerateMetadataError(`Only one SuccessResponse decorator allowed in '${this.getCurrentLocation}' method.`);
     }
 
-    const decorator = decorators[0];
-    const expression = decorator.parent as ts.CallExpression;
-
-    let description = '';
-    let name = '200';
+    const [name, description] = getDecoratorValues(decorators[0], this.current.typeChecker);
     const examples = this.getMethodSuccessExamples();
 
-    if (expression.arguments.length > 0 && (expression.arguments[0] as any).text) {
-      name = (expression.arguments[0] as any).text;
-    }
-    if (expression.arguments.length > 1 && (expression.arguments[1] as any).text) {
-      description = (expression.arguments[1] as any).text;
-    }
-
     return {
-      description,
+      description: description || '',
       examples,
-      name,
+      name: name || '200',
       schema: type,
     };
   }
@@ -188,26 +164,14 @@ export class MethodGenerator {
     if (exampleDecorators.length > 1) {
       throw new GenerateMetadataError(`Only one Example decorator allowed in '${this.getCurrentLocation}' method.`);
     }
-
-    const decorator = exampleDecorators[0];
-    const expression = decorator.parent as ts.CallExpression;
-    const argument = expression.arguments[0] as any;
-
-    return this.getExamplesValue(argument);
+    const values = getDecoratorValues(exampleDecorators[0], this.current.typeChecker);
+    return values && values[0];
   }
 
   private supportsPathMethod(method: string) {
     return ['get', 'post', 'put', 'patch', 'delete', 'head'].some(m => m === method.toLowerCase());
   }
 
-  private getExamplesValue(argument: any) {
-    const example: any = {};
-    argument.properties.forEach((p: any) => {
-      example[p.name.text] = getInitializerValue(p.initializer);
-    });
-    return example;
-  }
-
   private getIsDeprecated() {
     if (isExistJSDocTag(this.node, tag => tag.tagName.text === 'deprecated')) {
       return true;
@@ -232,10 +196,8 @@ export class MethodGenerator {
       throw new GenerateMetadataError(`Only one OperationId decorator allowed in '${this.getCurrentLocation}' method.`);
     }
 
-    const decorator = opDecorators[0];
-    const expression = decorator.parent as ts.CallExpression;
-    const ops = expression.arguments.map((a: any) => a.text as string);
-    return ops[0];
+    const values = getDecoratorValues(opDecorators[0], this.current.typeChecker);
+    return values && values[0];
   }
 
   private getTags() {
@@ -247,15 +209,22 @@ export class MethodGenerator {
       throw new GenerateMetadataError(`Only one Tags decorator allowed in '${this.getCurrentLocation}' method.`);
     }
 
-    const decorator = tagsDecorators[0];
-    const expression = decorator.parent as ts.CallExpression;
-    const tags = expression.arguments.map((a: any) => a.text as string);
-    if (this.parentTags) {
+    const tags = getDecoratorValues(tagsDecorators[0], this.current.typeChecker);
+    if (tags && this.parentTags) {
       tags.push(...this.parentTags);
     }
     return tags;
   }
 
+  private getSecurity(): Tsoa.Security[] {
+    const securityDecorators = this.getDecoratorsByIdentifier(this.node, 'Security');
+    if (!securityDecorators || !securityDecorators.length) {
+      return this.parentSecurity || [];
+    }
+
+    return securityDecorators.map(d => getSecurites(d, this.current.typeChecker));
+  }
+
   private getIsHidden() {
     const hiddenDecorators = this.getDecoratorsByIdentifier(this.node, 'Hidden');
     if (!hiddenDecorators || !hiddenDecorators.length) {
@@ -273,15 +242,6 @@ export class MethodGenerator {
     return true;
   }
 
-  private getSecurity(): Tsoa.Security[] {
-    const securityDecorators = this.getDecoratorsByIdentifier(this.node, 'Security');
-    if (!securityDecorators || !securityDecorators.length) {
-      return this.parentSecurity || [];
-    }
-
-    return getSecurities(securityDecorators);
-  }
-
   private getDecoratorsByIdentifier(node: ts.Node, id: string) {
     return getDecorators(node, identifier => identifier.text === id);
   }
diff --git a/src/metadataGeneration/parameterGenerator.ts b/src/metadataGeneration/parameterGenerator.ts
index 79c828ef..1e1e09d0 100644
--- a/src/metadataGeneration/parameterGenerator.ts
+++ b/src/metadataGeneration/parameterGenerator.ts
@@ -1,6 +1,6 @@
 import * as ts from 'typescript';
-import { getDecoratorName, getDecoratorTextValue } from './../utils/decoratorUtils';
 import { getJSDocTags } from './../utils/jsDocUtils';
+import { getNodeFirstDecoratorName, getNodeFirstDecoratorValue } from './../utils/decoratorUtils';
 import { getParameterValidators } from './../utils/validatorUtils';
 import { GenerateMetadataError } from './exceptions';
 import { getInitializerValue } from './initializer-value';
@@ -12,7 +12,7 @@ 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 {
-    const decoratorName = getDecoratorName(this.parameter, identifier => this.supportParameterDecorator(identifier.text));
+    const decoratorName = getNodeFirstDecoratorName(this.parameter, identifier => this.supportParameterDecorator(identifier.text));
 
     switch (decoratorName) {
       case 'Request':
@@ -54,11 +54,11 @@ export class ParameterGenerator {
     }
 
     return {
-      default: getInitializerValue(parameter.initializer, type),
+      default: getInitializerValue(parameter.initializer, this.current.typeChecker, type),
       description: this.getParameterDescription(parameter),
       example: this.getParameterExample(parameter, parameterName),
       in: 'body-prop',
-      name: getDecoratorTextValue(this.parameter, ident => ident.text === 'BodyProp') || parameterName,
+      name: getNodeFirstDecoratorValue(this.parameter, this.current.typeChecker, ident => ident.text === 'BodyProp') || parameterName,
       parameterName,
       required: !parameter.questionToken && !parameter.initializer,
       type,
@@ -95,11 +95,11 @@ export class ParameterGenerator {
     }
 
     return {
-      default: getInitializerValue(parameter.initializer, type),
+      default: getInitializerValue(parameter.initializer, this.current.typeChecker, type),
       description: this.getParameterDescription(parameter),
       example: this.getParameterExample(parameter, parameterName),
       in: 'header',
-      name: getDecoratorTextValue(this.parameter, ident => ident.text === 'Header') || parameterName,
+      name: getNodeFirstDecoratorValue(this.parameter, this.current.typeChecker, ident => ident.text === 'Header') || parameterName,
       parameterName,
       required: !parameter.questionToken && !parameter.initializer,
       type,
@@ -112,11 +112,11 @@ export class ParameterGenerator {
     const type = this.getValidatedType(parameter);
 
     const commonProperties = {
-      default: getInitializerValue(parameter.initializer, type),
+      default: getInitializerValue(parameter.initializer, this.current.typeChecker, type),
       description: this.getParameterDescription(parameter),
       example: this.getParameterExample(parameter, parameterName),
       in: 'query' as 'query',
-      name: getDecoratorTextValue(this.parameter, ident => ident.text === 'Query') || parameterName,
+      name: getNodeFirstDecoratorValue(this.parameter, this.current.typeChecker, ident => ident.text === 'Query') || parameterName,
       parameterName,
       required: !parameter.questionToken && !parameter.initializer,
       validators: getParameterValidators(this.parameter, parameterName),
@@ -146,8 +146,9 @@ export class ParameterGenerator {
 
   private getPathParameter(parameter: ts.ParameterDeclaration): Tsoa.Parameter {
     const parameterName = (parameter.name as ts.Identifier).text;
+
     const type = this.getValidatedType(parameter);
-    const pathName = getDecoratorTextValue(this.parameter, ident => ident.text === 'Path') || parameterName;
+    const pathName = getNodeFirstDecoratorValue(this.parameter, this.current.typeChecker, ident => ident.text === 'Path') || parameterName;
 
     if (!this.supportPathDataType(type)) {
       throw new GenerateMetadataError(`@Path('${parameterName}') Can't support '${type.dataType}' type.`);
@@ -157,7 +158,7 @@ export class ParameterGenerator {
     }
 
     return {
-      default: getInitializerValue(parameter.initializer, type),
+      default: getInitializerValue(parameter.initializer, this.current.typeChecker, type),
       description: this.getParameterDescription(parameter),
       example: this.getParameterExample(parameter, parameterName),
       in: 'path',
diff --git a/src/metadataGeneration/security.ts b/src/metadataGeneration/security.ts
deleted file mode 100644
index 5cbfbced..00000000
--- a/src/metadataGeneration/security.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import * as ts from 'typescript';
-import { getInitializerValue } from './initializer-value';
-import { Tsoa } from './tsoa';
-
-export function getSecurities(decorators: ts.Identifier[]): Tsoa.Security[] {
-  const securities: Tsoa.Security[] = [];
-  for (const sec of decorators) {
-    const expression = sec.parent as ts.CallExpression;
-    const security: Tsoa.Security = {};
-
-    if (expression.arguments[0].kind === ts.SyntaxKind.StringLiteral) {
-      const name = (expression.arguments[0] as any).text;
-      security[name] = expression.arguments[1] ? (expression.arguments[1] as any).elements.map((e: any) => e.text) : [];
-    } else {
-      const properties = (expression.arguments[0] as any).properties;
-
-      for (const property of properties) {
-        const name = property.name.text;
-        const scopes = getInitializerValue(property.initializer);
-        security[name] = scopes;
-      }
-    }
-
-    securities.push(security);
-  }
-
-  return securities;
-}
diff --git a/src/metadataGeneration/typeResolver.ts b/src/metadataGeneration/typeResolver.ts
index b27f0408..c3c1ba2a 100644
--- a/src/metadataGeneration/typeResolver.ts
+++ b/src/metadataGeneration/typeResolver.ts
@@ -842,7 +842,7 @@ export class TypeResolver {
     }
 
     const property: Tsoa.Property = {
-      default: getInitializerValue(propertyDeclaration.initializer, type),
+      default: getInitializerValue(propertyDeclaration.initializer, this.current.typeChecker),
       description: this.getNodeDescription(propertyDeclaration),
       example: this.getNodeExample(propertyDeclaration),
       format: this.getNodeFormat(propertyDeclaration),
diff --git a/src/utils/decoratorUtils.ts b/src/utils/decoratorUtils.ts
index c46ad0a8..18d5d281 100644
--- a/src/utils/decoratorUtils.ts
+++ b/src/utils/decoratorUtils.ts
@@ -18,7 +18,7 @@ export function getDecorators(node: ts.Node, isMatching: (identifier: ts.Identif
     .filter(isMatching);
 }
 
-export function getDecoratorName(node: ts.Node, isMatching: (identifier: ts.Identifier) => boolean) {
+export function getNodeFirstDecoratorName(node: ts.Node, isMatching: (identifier: ts.Identifier) => boolean) {
   const decorators = getDecorators(node, isMatching);
   if (!decorators || !decorators.length) {
     return;
@@ -27,32 +27,30 @@ export function getDecoratorName(node: ts.Node, isMatching: (identifier: ts.Iden
   return decorators[0].text;
 }
 
-export function getDecoratorTextValue(node: ts.Node, isMatching: (identifier: ts.Identifier) => boolean) {
+export function getNodeFirstDecoratorValue(node: ts.Node, typeChecker: ts.TypeChecker, isMatching: (identifier: ts.Identifier) => boolean) {
   const decorators = getDecorators(node, isMatching);
   if (!decorators || !decorators.length) {
     return;
   }
+  const values = getDecoratorValues(decorators[0], typeChecker);
+  return values && values[0];
+}
 
-  const expression = decorators[0].parent as ts.CallExpression;
+export function getDecoratorValues(decorator: ts.Identifier, typeChecker: ts.TypeChecker) {
+  const expression = decorator.parent as ts.CallExpression;
   const expArguments = expression.arguments;
   if (!expArguments || !expArguments.length) {
     return;
   }
-  return (expArguments[0] as ts.StringLiteral).text;
+  return expArguments.map(a => getInitializerValue(a, typeChecker));
 }
 
-export function getDecoratorOptionValue(node: ts.Node, isMatching: (identifier: ts.Identifier) => boolean) {
-  const decorators = getDecorators(node, isMatching);
-  if (!decorators || !decorators.length) {
-    return;
+export function getSecurites(decorator: ts.Identifier, typeChecker: ts.TypeChecker) {
+  const [first, second] = getDecoratorValues(decorator, typeChecker);
+  if (isObject(first)) {
+    return first;
   }
-
-  const expression = decorators[0].parent as ts.CallExpression;
-  const expArguments = expression.arguments;
-  if (!expArguments || !expArguments.length) {
-    return;
-  }
-  return getInitializerValue(expArguments[0]);
+  return { [first]: second || [] };
 }
 
 export function isDecorator(node: ts.Node, isMatching: (identifier: ts.Identifier) => boolean) {
@@ -62,3 +60,7 @@ export function isDecorator(node: ts.Node, isMatching: (identifier: ts.Identifie
   }
   return true;
 }
+
+function isObject(v: any) {
+  return typeof v === 'object' && v !== null;
+}


Test patch:
diff --git a/tests/fixtures/controllers/methodController.ts b/tests/fixtures/controllers/methodController.ts
index d74fc7cb..636b922c 100644
--- a/tests/fixtures/controllers/methodController.ts
+++ b/tests/fixtures/controllers/methodController.ts
@@ -2,6 +2,31 @@ import { Controller, Delete, Get, Patch, Post, Put, Response, Route, Security, S
 import { ModelService } from '../services/modelService';
 import { ErrorResponseModel, TestModel } from '../testModel';
 
+const TEST_OBJECT_CONST = {
+  unAuthCode: '401',
+  unAuthText: 'Unauthorized',
+  success: 'Created',
+};
+
+enum TEST_ENUM_CODES {
+  BAD = 400,
+  NOT_FOUND = '404',
+  SUCCESS = 201,
+}
+enum TEST_ENUM {
+  BAD = 'Bad Request',
+  NOT_FOUND = 'Not Found',
+  SECURITY = 'JWT2',
+  ADMIN = 'permission:admin',
+  OWNER = 'permission:owner',
+  TAG = 'EnumTag1',
+}
+
+const TEST_SEC = {
+  firstSec: [],
+  secondSec: [TEST_ENUM.ADMIN, TEST_ENUM.OWNER],
+};
+
 @Route('MethodTest')
 export class MethodController extends Controller {
   @Get('Get')
@@ -51,6 +76,18 @@ export class MethodController extends Controller {
     return new ModelService().getModel();
   }
 
+  @Tags(TEST_ENUM.TAG)
+  @Security(TEST_ENUM.SECURITY, [TEST_ENUM.ADMIN, TEST_ENUM.OWNER])
+  @Security(TEST_SEC)
+  @Response<ErrorResponseModel>(TEST_OBJECT_CONST.unAuthCode, TEST_OBJECT_CONST.unAuthText)
+  @Response<ErrorResponseModel>(TEST_ENUM_CODES.BAD, TEST_ENUM.BAD)
+  @Response<ErrorResponseModel>(TEST_ENUM_CODES.NOT_FOUND, TEST_ENUM.NOT_FOUND)
+  @SuccessResponse(TEST_ENUM_CODES.SUCCESS, TEST_OBJECT_CONST.success)
+  @Get('DecoratorVariousValues')
+  public async decoratorVariousValues(): Promise<TestModel> {
+    return new ModelService().getModel();
+  }
+
   @SuccessResponse('201', 'Created')
   @Get('SuccessResponse')
   public async successResponse(): Promise<void> {
diff --git a/tests/unit/swagger/definitionsGeneration/metadata.spec.ts b/tests/unit/swagger/definitionsGeneration/metadata.spec.ts
index 2452301f..238a59e1 100644
--- a/tests/unit/swagger/definitionsGeneration/metadata.spec.ts
+++ b/tests/unit/swagger/definitionsGeneration/metadata.spec.ts
@@ -38,6 +38,7 @@ describe('Metadata generation', () => {
       'description',
       'tags',
       'multiResponse',
+      'decoratorVariousValues',
       'successResponse',
       'oauthOrAPIkeySecurity',
       'apiSecurity',
@@ -151,6 +152,47 @@ describe('Metadata generation', () => {
       expect(successResponse.description).to.equal('Ok');
     });
 
+    it('should get decorator values passed by different ways', () => {
+      const method = controller.methods.find(m => m.name === 'decoratorVariousValues');
+      if (!method) {
+        throw new Error('Method decoratorVariousValues not defined!');
+      }
+
+      expect(method.responses.length).to.equal(4);
+
+      const valuesFromObject = method.responses[0];
+      expect(valuesFromObject.name).to.equal('401');
+      expect(valuesFromObject.description).to.equal('Unauthorized');
+
+      const enumNumber = method.responses[1];
+      expect(enumNumber.name).to.equal(400);
+      expect(enumNumber.description).to.equal('Bad Request');
+
+      const enumString = method.responses[2];
+      expect(enumString.name).to.equal('404');
+      expect(enumString.description).to.equal('Not Found');
+
+      const success = method.responses[3];
+      expect(success.name).to.equal(201);
+      expect(success.description).to.equal('Created');
+
+      if (!method.security) {
+        throw new Error('Security decorator not defined!');
+      }
+
+      const security = method.security[0];
+      expect(security).to.haveOwnProperty('JWT2');
+      expect(security['JWT2']).to.deep.equal(['permission:admin', 'permission:owner']);
+
+      const objSecurity = method.security[1];
+      expect(objSecurity).to.deep.equal({
+        firstSec: [],
+        secondSec: ['permission:admin', 'permission:owner'],
+      });
+
+      expect(method.tags).to.deep.equal(['EnumTag1']);
+    });
+
     it('should generate success response', () => {
       const method = controller.methods.find(m => m.name === 'successResponse');
       if (!method) {