Repository: lukeautry/tsoa (ts)

Issue:
Generate routes error.  Error: Unknown type: IndexedAccessType.
I would like to be able to set controller return type from useCase it is running inside:
<pre>
@Route('data')
@Tags('Data')
export class DataController extends Controller {

  @Get('tax-offices')
  @Security('jwt')
  public async getTaxOffices(@Request() request: AppRequest,
                             @Header('company-id')companyId: string): ReturnType&lt;GetTaxOfficesUseCase['run']&gt; {
    const ctx: Context = getContextFromRequest(request);
    const res = await getTaxOfficesUseCase.run({ ctx });
    this.setStatus(201);
    return res;
  }
}
</pre>

Now it is impossible, when using tsoa@2.3.8
<pre>
Generate routes error.
 Error: Unknown type: IndexedAccessType.
    at new GenerateMetadataError (/packages/web-server/node_modules/tsoa/dist/metadataGeneration/exceptions.js:20:28)
    at TypeResolver.getAnyTypeName (/packages/web-server/node_modules/tsoa/dist/metadataGeneration/typeResolver.js:290:19)
    at /packages/web-server/node_modules/tsoa/dist/metadataGeneration/typeResolver.js:275:72
    at Array.map (<anonymous>)
    at TypeResolver.getTypeName (/packages/web-server/node_modules/tsoa/dist/metadataGeneration/typeResolver.js:275:40)
    at TypeResolver.getReferenceType (/packages/web-server/node_modules/tsoa/dist/metadataGeneration/typeResolver.js:223:40)
    at TypeResolver.resolve (/packages/web-server/node_modules/tsoa/dist/metadataGeneration/typeResolver.js:99:34)
    at MethodGenerator.Generate (/packages/web-server/node_modules/tsoa/dist/metadataGeneration/methodGenerator.js:32:76)
    at /packages/web-server/node_modules/tsoa/dist/metadataGeneration/controllerGenerator.js:40:58
    at Array.map (<anonymous>)
</pre>

Pull request description:
feat: handle indexed types
### 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 #338 


Patch:
diff --git a/src/metadataGeneration/typeResolver.ts b/src/metadataGeneration/typeResolver.ts
index 71cfa42e..faee206d 100644
--- a/src/metadataGeneration/typeResolver.ts
+++ b/src/metadataGeneration/typeResolver.ts
@@ -217,6 +217,22 @@ export class TypeResolver {
       }
     }
 
+    if (
+      ts.isIndexedAccessTypeNode(this.typeNode) &&
+      ts.isLiteralTypeNode(this.typeNode.indexType) &&
+      (ts.isStringLiteral(this.typeNode.indexType.literal) || ts.isNumericLiteral(this.typeNode.indexType.literal))
+    ) {
+      const hasType = (node: ts.Node): node is ts.HasType => node.hasOwnProperty('type');
+      const symbol = this.current.typeChecker.getPropertyOfType(this.current.typeChecker.getTypeFromTypeNode(this.typeNode.objectType), this.typeNode.indexType.literal.text);
+      if (symbol === undefined || !hasType(symbol.valueDeclaration) || !symbol.valueDeclaration.type) {
+        throw new GenerateMetadataError(
+          `Could not determine the keys on ${this.current.typeChecker.typeToString(this.current.typeChecker.getTypeFromTypeNode(this.typeNode.objectType))}`,
+          this.typeNode,
+        );
+      }
+      return new TypeResolver(symbol.valueDeclaration.type, this.current, this.typeNode, this.context, this.referencer).resolve();
+    }
+
     if (this.typeNode.kind !== ts.SyntaxKind.TypeReference) {
       throw new GenerateMetadataError(`Unknown type: ${ts.SyntaxKind[this.typeNode.kind]}`, this.typeNode);
     }
@@ -492,7 +508,8 @@ export class TypeResolver {
         .replace(/\[\]/g, 'Array')
         .replace(/{|}/g, '_') // SuccessResponse_{indexesCreated-number}_ -> SuccessResponse__indexesCreated-number__
         .replace(/([a-z]+):([a-z]+)/gi, '$1-$2') // SuccessResponse_indexesCreated:number_ -> SuccessResponse_indexesCreated-number_
-        .replace(/;/g, '--'),
+        .replace(/;/g, '--')
+        .replace(/([a-z]+)\[([a-z]+)\]/gi, '$1~$2~'), // Partial_SerializedDatasourceWithVersion[format]_ -> Partial_SerializedDatasourceWithVersion~format~_,
     );
   }
 


Test patch:
diff --git a/tests/fixtures/testModel.ts b/tests/fixtures/testModel.ts
index 7f57adc8..95e26978 100644
--- a/tests/fixtures/testModel.ts
+++ b/tests/fixtures/testModel.ts
@@ -61,6 +61,7 @@ export interface TestModel extends Model {
   anyType?: any;
   unknownType?: unknown;
   genericTypeObject?: Generic<{ foo: string; bar: boolean }>;
+  indexed?: Partial<Indexed['foo']>;
   // modelsObjectDirect?: {[key: string]: TestSubModel2;};
   modelsObjectIndirect?: TestSubModelContainer;
   modelsObjectIndirectNS?: TestSubModelContainerNamespace.TestSubModelContainer;
@@ -93,7 +94,7 @@ export interface TestModel extends Model {
     partial?: Partial<Account>;
     excludeToEnum?: Exclude<EnumUnion, EnumNumberValue>;
     excludeToAlias?: Exclude<ThreeOrFour, TypeAliasModel3>;
-    excludeLiteral?: Exclude<keyof TestClassModel, 'account' | 'defaultValue2'>;
+    excludeLiteral?: Exclude<keyof TestClassModel, 'account' | 'defaultValue2' | 'indexedTypeToInterface' | 'indexedTypeToClass' | 'indexedTypeToAlias'>;
     excludeToInterface?: Exclude<OneOrTwo, TypeAliasModel1>;
     excludeTypeToPrimitive?: NonNullable<number | null>;
 
@@ -151,6 +152,12 @@ interface Generic<T> {
   foo: T;
 }
 
+interface Indexed {
+  foo: {
+    bar: string;
+  };
+}
+
 type Maybe<T> = T | null;
 
 export interface TypeAliasModel1 {
@@ -575,6 +582,24 @@ enum MyEnum {
   KO,
 }
 
+interface IndexedInterface {
+  foo: 'bar';
+}
+type IndexedInterfaceAlias = IndexedInterface;
+class IndexedClass {
+  public foo: 'bar';
+}
+
+interface Indexed {
+  foo: {
+    bar: string;
+  };
+  interface: IndexedInterface;
+  alias: IndexedInterfaceAlias;
+  class: IndexedClass;
+}
+type IndexType = 'foo';
+
 /**
  * This is a description of TestClassModel
  */
@@ -583,6 +608,10 @@ export class TestClassModel extends TestClassBaseModel {
   public defaultValue2 = 'Default Value 2';
   public enumKeys: keyof typeof MyEnum;
   public keyInterface?: keyof Model;
+  public indexedType?: Indexed[IndexType]['bar'];
+  public indexedTypeToInterface?: Indexed['interface'];
+  public indexedTypeToClass?: Indexed['class'];
+  public indexedTypeToAlias?: Indexed['alias'];
   /**
    * This is a description of a public string property
    *
diff --git a/tests/unit/swagger/definitionsGeneration/definitions.spec.ts b/tests/unit/swagger/definitionsGeneration/definitions.spec.ts
index 58e83e00..36aa0494 100644
--- a/tests/unit/swagger/definitionsGeneration/definitions.spec.ts
+++ b/tests/unit/swagger/definitionsGeneration/definitions.spec.ts
@@ -402,6 +402,9 @@ describe('Definition generation', () => {
           genericTypeObject: (propertyName, propertySchema) => {
             expect(propertySchema.$ref).to.eq('#/definitions/Generic__foo-string--bar-boolean__');
           },
+          indexed: (propertyName, propertySchema) => {
+            expect(propertySchema.$ref).to.eq('#/definitions/Partial_Indexed~foo~_');
+          },
           modelsObjectIndirect: (propertyName, propertySchema) => {
             expect(propertySchema.$ref).to.eq('#/definitions/TestSubModelContainer', `for property ${propertyName}.$ref`);
             expect(propertySchema).to.not.haveOwnProperty('additionalProperties', `for property ${propertyName}`);
@@ -640,7 +643,12 @@ describe('Definition generation', () => {
                   partial: { $ref: '#/definitions/Partial_Account_', description: undefined, format: undefined, example: undefined },
                   excludeToEnum: { $ref: '#/definitions/Exclude_EnumUnion.EnumNumberValue_', description: undefined, format: undefined, example: undefined },
                   excludeToAlias: { $ref: '#/definitions/Exclude_ThreeOrFour.TypeAliasModel3_', description: undefined, format: undefined, example: undefined },
-                  excludeLiteral: { $ref: '#/definitions/Exclude_keyofTestClassModel.account~OR~defaultValue2_', description: undefined, format: undefined, example: undefined },
+                  excludeLiteral: {
+                    $ref: '#/definitions/Exclude_keyofTestClassModel.account~OR~defaultValue2~OR~indexedTypeToInterface~OR~indexedTypeToClass~OR~indexedTypeToAlias_',
+                    description: undefined,
+                    format: undefined,
+                    example: undefined,
+                  },
                   excludeToInterface: { $ref: '#/definitions/Exclude_OneOrTwo.TypeAliasModel1_', description: undefined, format: undefined, example: undefined },
                   excludeTypeToPrimitive: { $ref: '#/definitions/NonNullable_number~OR~null_', description: undefined, format: undefined, example: undefined },
                   pick: { $ref: '#/definitions/Pick_ThingContainerWithTitle_string_.list_', description: undefined, format: undefined, example: undefined },
@@ -763,7 +771,7 @@ describe('Definition generation', () => {
               `for definition linked by ${propertyName}`,
             );
 
-            const excludeLiteral = getValidatedDefinition('Exclude_keyofTestClassModel.account~OR~defaultValue2_', currentSpec);
+            const excludeLiteral = getValidatedDefinition('Exclude_keyofTestClassModel.account~OR~defaultValue2~OR~indexedTypeToInterface~OR~indexedTypeToClass~OR~indexedTypeToAlias_', currentSpec);
             expect(excludeLiteral).to.deep.eq(
               {
                 type: 'string',
@@ -771,6 +779,7 @@ describe('Definition generation', () => {
                   'id',
                   'enumKeys',
                   'keyInterface',
+                  'indexedType',
                   'publicStringProperty',
                   'optionalPublicStringProperty',
                   'emailPattern',
@@ -843,6 +852,10 @@ describe('Definition generation', () => {
                   defaultValue1: { type: 'string', default: 'Default Value 1', description: undefined, format: undefined, example: undefined },
                   enumKeys: { type: 'string', default: undefined, description: undefined, format: undefined, example: undefined, enum: ['OK', 'KO'], 'x-nullable': false },
                   id: { type: 'number', format: 'double', default: undefined, description: undefined, example: undefined },
+                  indexedType: { type: 'string', default: undefined, description: undefined, format: undefined, example: undefined },
+                  indexedTypeToAlias: { $ref: '#/definitions/IndexedInterfaceAlias', description: undefined, format: undefined, example: undefined },
+                  indexedTypeToClass: { $ref: '#/definitions/IndexedClass', description: undefined, format: undefined, example: undefined },
+                  indexedTypeToInterface: { $ref: '#/definitions/IndexedInterface', description: undefined, format: undefined, example: undefined },
                   keyInterface: { type: 'string', default: undefined, description: undefined, format: undefined, example: undefined, 'x-nullable': false, enum: ['id'] },
                   optionalPublicConstructorVar: { type: 'string', default: undefined, description: undefined, format: undefined, example: undefined },
                   readonlyConstructorArgument: { type: 'string', default: undefined, description: undefined, format: undefined, example: undefined },
@@ -873,6 +886,44 @@ describe('Definition generation', () => {
               `for definition linked by ${propertyName}`,
             );
 
+            const indexedTypeToClass = getValidatedDefinition('IndexedClass', currentSpec);
+            expect(indexedTypeToClass).to.deep.eq({
+              additionalProperties: currentSpec.specName === 'specWithNoImplicitExtras' || currentSpec.specName === 'dynamicSpecWithNoImplicitExtras' ? false : true,
+              description: undefined,
+              properties: {
+                foo: {
+                  default: undefined,
+                  description: undefined,
+                  enum: ['bar'],
+                  example: undefined,
+                  format: undefined,
+                  type: 'string',
+                  'x-nullable': false,
+                },
+              },
+              required: ['foo'],
+              type: 'object',
+            });
+
+            const indexedTypeToInterface = getValidatedDefinition('IndexedInterface', currentSpec);
+            expect(indexedTypeToInterface).to.deep.eq({
+              additionalProperties: currentSpec.specName === 'specWithNoImplicitExtras' || currentSpec.specName === 'dynamicSpecWithNoImplicitExtras' ? false : true,
+              description: undefined,
+              properties: {
+                foo: {
+                  default: undefined,
+                  description: undefined,
+                  enum: ['bar'],
+                  example: undefined,
+                  format: undefined,
+                  type: 'string',
+                  'x-nullable': false,
+                },
+              },
+              required: ['foo'],
+              type: 'object',
+            });
+
             const defaultArgs = getValidatedDefinition('DefaultTestModel', currentSpec);
             expect(defaultArgs).to.deep.eq(
               {
diff --git a/tests/unit/swagger/schemaDetails3.spec.ts b/tests/unit/swagger/schemaDetails3.spec.ts
index ca2e4e42..bf82238c 100644
--- a/tests/unit/swagger/schemaDetails3.spec.ts
+++ b/tests/unit/swagger/schemaDetails3.spec.ts
@@ -589,6 +589,9 @@ describe('Definition generation for OpenAPI 3.0.0', () => {
           genericTypeObject: (propertyName, propertySchema) => {
             expect(propertySchema.$ref).to.eq('#/components/schemas/Generic__foo-string--bar-boolean__');
           },
+          indexed: (propertyName, propertySchema) => {
+            expect(propertySchema.$ref).to.eq('#/components/schemas/Partial_Indexed~foo~_');
+          },
           modelsObjectIndirect: (propertyName, propertySchema) => {
             expect(propertySchema.$ref).to.eq('#/components/schemas/TestSubModelContainer', `for property ${propertyName}.$ref`);
             expect(propertySchema).to.not.haveOwnProperty('additionalProperties', `for property ${propertyName}`);
@@ -805,7 +808,12 @@ describe('Definition generation for OpenAPI 3.0.0', () => {
                   partial: { $ref: '#/components/schemas/Partial_Account_', description: undefined, format: undefined, example: undefined },
                   excludeToEnum: { $ref: '#/components/schemas/Exclude_EnumUnion.EnumNumberValue_', description: undefined, format: undefined, example: undefined },
                   excludeToAlias: { $ref: '#/components/schemas/Exclude_ThreeOrFour.TypeAliasModel3_', description: undefined, format: undefined, example: undefined },
-                  excludeLiteral: { $ref: '#/components/schemas/Exclude_keyofTestClassModel.account~OR~defaultValue2_', description: undefined, format: undefined, example: undefined },
+                  excludeLiteral: {
+                    $ref: '#/components/schemas/Exclude_keyofTestClassModel.account~OR~defaultValue2~OR~indexedTypeToInterface~OR~indexedTypeToClass~OR~indexedTypeToAlias_',
+                    description: undefined,
+                    format: undefined,
+                    example: undefined,
+                  },
                   excludeToInterface: { $ref: '#/components/schemas/Exclude_OneOrTwo.TypeAliasModel1_', description: undefined, format: undefined, example: undefined },
                   excludeTypeToPrimitive: { $ref: '#/components/schemas/NonNullable_number~OR~null_', description: undefined, format: undefined, example: undefined },
                   pick: { $ref: '#/components/schemas/Pick_ThingContainerWithTitle_string_.list_', description: undefined, format: undefined, example: undefined },
@@ -928,13 +936,14 @@ describe('Definition generation for OpenAPI 3.0.0', () => {
               `for a schema linked by property ${propertyName}`,
             );
 
-            const excludeLiteral = getComponentSchema('Exclude_keyofTestClassModel.account~OR~defaultValue2_', currentSpec);
+            const excludeLiteral = getComponentSchema('Exclude_keyofTestClassModel.account~OR~defaultValue2~OR~indexedTypeToInterface~OR~indexedTypeToClass~OR~indexedTypeToAlias_', currentSpec);
             expect(excludeLiteral).to.deep.eq(
               {
                 oneOf: [
                   { type: 'string', enum: ['id'], nullable: false },
                   { type: 'string', enum: ['enumKeys'], nullable: false },
                   { type: 'string', enum: ['keyInterface'], nullable: false },
+                  { type: 'string', enum: ['indexedType'], nullable: false },
                   { type: 'string', enum: ['publicStringProperty'], nullable: false },
                   { type: 'string', enum: ['optionalPublicStringProperty'], nullable: false },
                   { type: 'string', enum: ['emailPattern'], nullable: false },
@@ -1012,6 +1021,10 @@ describe('Definition generation for OpenAPI 3.0.0', () => {
                     oneOf: [{ enum: ['OK'], nullable: false, type: 'string' }, { enum: ['KO'], nullable: false, type: 'string' }],
                   },
                   id: { type: 'number', format: 'double', default: undefined, description: undefined, example: undefined },
+                  indexedType: { type: 'string', default: undefined, description: undefined, format: undefined, example: undefined },
+                  indexedTypeToClass: { $ref: '#/components/schemas/IndexedClass', description: undefined, format: undefined, example: undefined },
+                  indexedTypeToInterface: { $ref: '#/components/schemas/IndexedInterface', description: undefined, format: undefined, example: undefined },
+                  indexedTypeToAlias: { $ref: '#/components/schemas/IndexedInterfaceAlias', description: undefined, format: undefined, example: undefined },
                   keyInterface: { type: 'string', default: undefined, description: undefined, format: undefined, example: undefined, enum: ['id'], nullable: false },
                   optionalPublicConstructorVar: { type: 'string', default: undefined, description: undefined, format: undefined, example: undefined },
                   readonlyConstructorArgument: { type: 'string', default: undefined, description: undefined, format: undefined, example: undefined },