Repository: lukeautry/tsoa
Pull request #75 (2017-03-09 23:25:49)

Namespaces support
Support for https://github.com/lukeautry/tsoa/issues/74


Patch:
diff --git a/src/metadataGeneration/resolveType.ts b/src/metadataGeneration/resolveType.ts
index ec333414..d6bac76a 100644
--- a/src/metadataGeneration/resolveType.ts
+++ b/src/metadataGeneration/resolveType.ts
@@ -32,20 +32,23 @@ export function ResolveType(typeNode: ts.TypeNode): Type {
     throw new Error(`Unknown type: ${ts.SyntaxKind[typeNode.kind]}`);
   }
   let typeReference: any = typeNode;
-  if (typeReference.typeName.text === 'Date') { return 'datetime'; }
-  if (typeReference.typeName.text === 'Buffer') { return 'buffer'; }
+  if (typeReference.typeName.kind === ts.SyntaxKind.Identifier) {
+    if (typeReference.typeName.text === 'Date') { return 'datetime'; }
+    if (typeReference.typeName.text === 'Buffer') { return 'buffer'; }
 
-  if (typeReference.typeName.text === 'Promise') {
-    typeReference = typeReference.typeArguments[0];
-    return ResolveType(typeReference);
+    if (typeReference.typeName.text === 'Promise') {
+      typeReference = typeReference.typeArguments[0];
+      return ResolveType(typeReference);
+    }
   }
 
-  const referenceType = generateReferenceType(typeReference.typeName.text);
+  const referenceType = generateReferenceType(typeReference.typeName as ts.EntityName);
   MetadataGenerator.current.AddReferenceType(referenceType);
   return referenceType;
 }
 
-function generateReferenceType(typeName: string): ReferenceType {
+function generateReferenceType(type: ts.EntityName): ReferenceType {
+  const typeName = resolveFqTypeName(type);
   try {
     const existingType = localReferenceTypeCache[typeName];
     if (existingType) { return existingType; }
@@ -56,7 +59,7 @@ function generateReferenceType(typeName: string): ReferenceType {
 
     inProgressTypes[typeName] = true;
 
-    const modelTypeDeclaration = getModelTypeDeclaration(typeName);
+    const modelTypeDeclaration = getModelTypeDeclaration(type);
     const properties = getModelTypeProperties(modelTypeDeclaration);
 
     const referenceType: ReferenceType = {
@@ -83,6 +86,15 @@ function generateReferenceType(typeName: string): ReferenceType {
   }
 }
 
+function resolveFqTypeName(type: ts.EntityName): string {
+  if (type.kind === ts.SyntaxKind.Identifier) {
+    return (type as ts.Identifier).text;
+  }
+
+  const qualifiedType = type as ts.QualifiedName;
+  return resolveFqTypeName(qualifiedType.left) + '.' + (qualifiedType.right as ts.Identifier).text;
+}
+
 function createCircularDependencyResolver(typeName: string) {
   const referenceType = {
     description: '',
@@ -101,19 +113,61 @@ function createCircularDependencyResolver(typeName: string) {
   return referenceType;
 }
 
-function getModelTypeDeclaration(typeName: string) {
-  const nodeIsNotUsable = (node: ts.Node) => {
-    switch (node.kind) {
-      case ts.SyntaxKind.InterfaceDeclaration:
-      case ts.SyntaxKind.ClassDeclaration:
-      case ts.SyntaxKind.TypeAliasDeclaration:
-        return false;
-      default: return true;
+function nodeIsUsable(node: ts.Node) {
+  switch (node.kind) {
+    case ts.SyntaxKind.InterfaceDeclaration:
+    case ts.SyntaxKind.ClassDeclaration:
+    case ts.SyntaxKind.TypeAliasDeclaration:
+      return true;
+    default: return false;
+  }
+}
+
+function resolveLeftmostIdentifier(type: ts.EntityName): ts.Identifier {
+  while (type.kind !== ts.SyntaxKind.Identifier) {
+    type = (type as ts.QualifiedName).left;
+  }
+  return type as ts.Identifier;
+}
+
+function resolveModelTypeScope(leftmost: ts.EntityName, statements: any[]): any[] {
+  while (leftmost.parent && leftmost.parent.kind === ts.SyntaxKind.QualifiedName) {
+    const leftmostName = leftmost.kind === ts.SyntaxKind.Identifier
+      ? (leftmost as ts.Identifier).text
+      : (leftmost as ts.QualifiedName).right.text;
+    const moduleDeclarations = statements
+      .filter(node => {
+        if (node.kind !== ts.SyntaxKind.ModuleDeclaration || !MetadataGenerator.current.IsExportedNode(node)) {
+          return false;
+        }
+
+        const moduleDeclaration = node as ts.ModuleDeclaration;
+        return (moduleDeclaration.name as ts.Identifier).text.toLowerCase() === leftmostName.toLowerCase();
+      }) as Array<ts.ModuleDeclaration>;
+
+      if (!moduleDeclarations.length) { throw new Error(`No matching module declarations found for ${leftmostName}`); }
+      if (moduleDeclarations.length > 1) { throw new Error(`Multiple matching module declarations found for ${leftmostName}; please make module declarations unique`); }
+
+      const moduleBlock = moduleDeclarations[0].body as ts.ModuleBlock;
+      if (moduleBlock === null || moduleBlock.kind !== ts.SyntaxKind.ModuleBlock) { throw new Error(`Module declaration found for ${leftmostName} has no body`); }
+
+      statements = moduleBlock.statements;
+      leftmost = leftmost.parent as ts.EntityName;
     }
-  };
-  const modelTypes = MetadataGenerator.current.nodes
+
+    return statements;
+}
+
+function getModelTypeDeclaration(type: ts.EntityName) {
+  const leftmostIdentifier = resolveLeftmostIdentifier(type);
+  const statements: any[] = resolveModelTypeScope(leftmostIdentifier, MetadataGenerator.current.nodes);
+
+  const typeName = type.kind === ts.SyntaxKind.Identifier
+    ? (type as ts.Identifier).text
+    : (type as ts.QualifiedName).right.text;
+  const modelTypes = statements
     .filter(node => {
-      if (nodeIsNotUsable(node) || !MetadataGenerator.current.IsExportedNode(node)) {
+      if (!nodeIsUsable(node) || !MetadataGenerator.current.IsExportedNode(node)) {
         return false;
       }
 
@@ -122,7 +176,10 @@ function getModelTypeDeclaration(typeName: string) {
     }) as Array<UsableDeclaration>;
 
   if (!modelTypes.length) { throw new Error(`No matching model found for referenced type ${typeName}`); }
-  if (modelTypes.length > 1) { throw new Error(`Multiple matching models found for referenced type ${typeName}; please make model names unique.`); }
+  if (modelTypes.length > 1) {
+    let conflicts = modelTypes.map(modelType => modelType.getSourceFile().fileName).join('"; "');
+    throw new Error(`Multiple matching models found for referenced type ${typeName}; please make model names unique. Conflicts found: "${conflicts}"`);
+  }
 
   return modelTypes[0];
 }
@@ -204,8 +261,8 @@ function getInheritedProperties(modelTypeDeclaration: UsableDeclaration): Proper
     if (!clause.types) { return; }
 
     clause.types.forEach(t => {
-      const baseIdentifier = t.expression as ts.Identifier;
-      generateReferenceType(baseIdentifier.text).properties
+      const baseEntityName = t.expression as ts.EntityName;
+      generateReferenceType(baseEntityName).properties
         .forEach(property => properties.push(property));
     });
   });


Test patch:
diff --git a/tests/fixtures/express/routes.ts b/tests/fixtures/express/routes.ts
index cc9f7e05..e10fe90c 100644
--- a/tests/fixtures/express/routes.ts
+++ b/tests/fixtures/express/routes.ts
@@ -24,6 +24,11 @@ const models: any = {
     'strLiteralArr': { typeName: 'array', required: true, arrayType: 'StrLiteral' },
     'dateValue': { typeName: 'datetime', required: false },
     'optionalString': { typeName: 'string', required: false },
+    'modelsObjectIndirect': { typeName: 'TestSubModelContainer', required: false },
+    'modelsObjectIndirectNS': { typeName: 'TestSubModelContainerNamespace.TestSubModelContainer', required: false },
+    'modelsObjectIndirectNS2': { typeName: 'TestSubModelContainerNamespace.InnerNamespace.TestSubModelContainer2', required: false },
+    'modelsObjectIndirectNS_Alias': { typeName: 'TestSubModelContainerNamespace_TestSubModelContainer', required: false },
+    'modelsObjectIndirectNS2_Alias': { typeName: 'TestSubModelContainerNamespace_InnerNamespace_TestSubModelContainer2', required: false },
     'id': { typeName: 'number', required: true },
   },
   'TestSubModel': {
@@ -33,6 +38,25 @@ const models: any = {
   },
   'StrLiteral': {
   },
+  'TestSubModel2': {
+    'testSubModel2': { typeName: 'boolean', required: true },
+    'email': { typeName: 'string', required: true },
+    'circular': { typeName: 'TestModel', required: false },
+    'id': { typeName: 'number', required: true },
+  },
+  'TestSubModelContainer': {
+    'simpleValue': { typeName: 'TestSubModel2', required: true },
+  },
+  'TestSubModelContainerNamespace.TestSubModelContainer': {
+    'simpleValue': { typeName: 'TestSubModel2', required: true },
+  },
+  'TestSubModelContainerNamespace.InnerNamespace.TestSubModelContainer2': {
+    'simpleValue': { typeName: 'TestSubModel2', required: true },
+  },
+  'TestSubModelContainerNamespace_TestSubModelContainer': {
+  },
+  'TestSubModelContainerNamespace_InnerNamespace_TestSubModelContainer2': {
+  },
   'TestClassModel': {
     'publicStringProperty': { typeName: 'string', required: true },
     'optionalPublicStringProperty': { typeName: 'string', required: false },
diff --git a/tests/fixtures/hapi/routes.ts b/tests/fixtures/hapi/routes.ts
index b0bb070a..629182cb 100644
--- a/tests/fixtures/hapi/routes.ts
+++ b/tests/fixtures/hapi/routes.ts
@@ -24,6 +24,11 @@ const models: any = {
     'strLiteralArr': { typeName: 'array', required: true, arrayType: 'StrLiteral' },
     'dateValue': { typeName: 'datetime', required: false },
     'optionalString': { typeName: 'string', required: false },
+    'modelsObjectIndirect': { typeName: 'TestSubModelContainer', required: false },
+    'modelsObjectIndirectNS': { typeName: 'TestSubModelContainerNamespace.TestSubModelContainer', required: false },
+    'modelsObjectIndirectNS2': { typeName: 'TestSubModelContainerNamespace.InnerNamespace.TestSubModelContainer2', required: false },
+    'modelsObjectIndirectNS_Alias': { typeName: 'TestSubModelContainerNamespace_TestSubModelContainer', required: false },
+    'modelsObjectIndirectNS2_Alias': { typeName: 'TestSubModelContainerNamespace_InnerNamespace_TestSubModelContainer2', required: false },
     'id': { typeName: 'number', required: true },
   },
   'TestSubModel': {
@@ -33,6 +38,25 @@ const models: any = {
   },
   'StrLiteral': {
   },
+  'TestSubModel2': {
+    'testSubModel2': { typeName: 'boolean', required: true },
+    'email': { typeName: 'string', required: true },
+    'circular': { typeName: 'TestModel', required: false },
+    'id': { typeName: 'number', required: true },
+  },
+  'TestSubModelContainer': {
+    'simpleValue': { typeName: 'TestSubModel2', required: true },
+  },
+  'TestSubModelContainerNamespace.TestSubModelContainer': {
+    'simpleValue': { typeName: 'TestSubModel2', required: true },
+  },
+  'TestSubModelContainerNamespace.InnerNamespace.TestSubModelContainer2': {
+    'simpleValue': { typeName: 'TestSubModel2', required: true },
+  },
+  'TestSubModelContainerNamespace_TestSubModelContainer': {
+  },
+  'TestSubModelContainerNamespace_InnerNamespace_TestSubModelContainer2': {
+  },
   'TestClassModel': {
     'publicStringProperty': { typeName: 'string', required: true },
     'optionalPublicStringProperty': { typeName: 'string', required: false },
@@ -1414,8 +1438,8 @@ export function RegisterRoutes(server: hapi.Server) {
       pre: [
         {
           method: authenticateMiddleware('api_key'
-          )        
-}
+          )
+        }
       ],
       handler: (request: any, reply) => {
         const args = {
diff --git a/tests/fixtures/inversify/routes.ts b/tests/fixtures/inversify/routes.ts
index 3849886d..ee182fcd 100644
--- a/tests/fixtures/inversify/routes.ts
+++ b/tests/fixtures/inversify/routes.ts
@@ -18,6 +18,11 @@ const models: any = {
     'strLiteralArr': { typeName: 'array', required: true, arrayType: 'StrLiteral' },
     'dateValue': { typeName: 'datetime', required: false },
     'optionalString': { typeName: 'string', required: false },
+    'modelsObjectIndirect': { typeName: 'TestSubModelContainer', required: false },
+    'modelsObjectIndirectNS': { typeName: 'TestSubModelContainerNamespace.TestSubModelContainer', required: false },
+    'modelsObjectIndirectNS2': { typeName: 'TestSubModelContainerNamespace.InnerNamespace.TestSubModelContainer2', required: false },
+    'modelsObjectIndirectNS_Alias': { typeName: 'TestSubModelContainerNamespace_TestSubModelContainer', required: false },
+    'modelsObjectIndirectNS2_Alias': { typeName: 'TestSubModelContainerNamespace_InnerNamespace_TestSubModelContainer2', required: false },
     'id': { typeName: 'number', required: true },
   },
   'TestSubModel': {
@@ -27,6 +32,25 @@ const models: any = {
   },
   'StrLiteral': {
   },
+  'TestSubModel2': {
+    'testSubModel2': { typeName: 'boolean', required: true },
+    'email': { typeName: 'string', required: true },
+    'circular': { typeName: 'TestModel', required: false },
+    'id': { typeName: 'number', required: true },
+  },
+  'TestSubModelContainer': {
+    'simpleValue': { typeName: 'TestSubModel2', required: true },
+  },
+  'TestSubModelContainerNamespace.TestSubModelContainer': {
+    'simpleValue': { typeName: 'TestSubModel2', required: true },
+  },
+  'TestSubModelContainerNamespace.InnerNamespace.TestSubModelContainer2': {
+    'simpleValue': { typeName: 'TestSubModel2', required: true },
+  },
+  'TestSubModelContainerNamespace_TestSubModelContainer': {
+  },
+  'TestSubModelContainerNamespace_InnerNamespace_TestSubModelContainer2': {
+  },
 };
 
 
diff --git a/tests/fixtures/koa/routes.ts b/tests/fixtures/koa/routes.ts
index ef10b35b..9d522f3e 100644
--- a/tests/fixtures/koa/routes.ts
+++ b/tests/fixtures/koa/routes.ts
@@ -24,6 +24,11 @@ const models: any = {
     'strLiteralArr': { typeName: 'array', required: true, arrayType: 'StrLiteral' },
     'dateValue': { typeName: 'datetime', required: false },
     'optionalString': { typeName: 'string', required: false },
+    'modelsObjectIndirect': { typeName: 'TestSubModelContainer', required: false },
+    'modelsObjectIndirectNS': { typeName: 'TestSubModelContainerNamespace.TestSubModelContainer', required: false },
+    'modelsObjectIndirectNS2': { typeName: 'TestSubModelContainerNamespace.InnerNamespace.TestSubModelContainer2', required: false },
+    'modelsObjectIndirectNS_Alias': { typeName: 'TestSubModelContainerNamespace_TestSubModelContainer', required: false },
+    'modelsObjectIndirectNS2_Alias': { typeName: 'TestSubModelContainerNamespace_InnerNamespace_TestSubModelContainer2', required: false },
     'id': { typeName: 'number', required: true },
   },
   'TestSubModel': {
@@ -33,6 +38,25 @@ const models: any = {
   },
   'StrLiteral': {
   },
+  'TestSubModel2': {
+    'testSubModel2': { typeName: 'boolean', required: true },
+    'email': { typeName: 'string', required: true },
+    'circular': { typeName: 'TestModel', required: false },
+    'id': { typeName: 'number', required: true },
+  },
+  'TestSubModelContainer': {
+    'simpleValue': { typeName: 'TestSubModel2', required: true },
+  },
+  'TestSubModelContainerNamespace.TestSubModelContainer': {
+    'simpleValue': { typeName: 'TestSubModel2', required: true },
+  },
+  'TestSubModelContainerNamespace.InnerNamespace.TestSubModelContainer2': {
+    'simpleValue': { typeName: 'TestSubModel2', required: true },
+  },
+  'TestSubModelContainerNamespace_TestSubModelContainer': {
+  },
+  'TestSubModelContainerNamespace_InnerNamespace_TestSubModelContainer2': {
+  },
   'TestClassModel': {
     'publicStringProperty': { typeName: 'string', required: true },
     'optionalPublicStringProperty': { typeName: 'string', required: false },
diff --git a/tests/fixtures/testModel.ts b/tests/fixtures/testModel.ts
index 54e496b9..d480fe26 100644
--- a/tests/fixtures/testModel.ts
+++ b/tests/fixtures/testModel.ts
@@ -19,17 +19,48 @@ export interface TestModel extends Model {
   strLiteralArr: StrLiteral[];
   dateValue?: Date;
   optionalString?: string;
+  // modelsObjectDirect?: {[key: string]: TestSubModel;};
+  modelsObjectIndirect?: TestSubModelContainer;
+  modelsObjectIndirectNS?: TestSubModelContainerNamespace.TestSubModelContainer;
+  modelsObjectIndirectNS2?: TestSubModelContainerNamespace.InnerNamespace.TestSubModelContainer2;
+  modelsObjectIndirectNS_Alias?: TestSubModelContainerNamespace_TestSubModelContainer;
+  modelsObjectIndirectNS2_Alias?: TestSubModelContainerNamespace_InnerNamespace_TestSubModelContainer2;
 }
 
 // shortened from StringLiteral to make the tslint enforced
 // alphabetical sorting cleaner
 export type StrLiteral = 'Foo' | 'Bar';
 
+export interface TestSubModelContainer {
+  // [key: string]: TestSubModel2;
+  simpleValue: TestSubModel2;
+}
+
+export namespace TestSubModelContainerNamespace {
+  export interface TestSubModelContainer {
+    // [key: string]: TestSubModel2;
+    simpleValue: TestSubModel2;
+  }
+
+  export namespace InnerNamespace {
+    export interface TestSubModelContainer2 {
+      // [key: string]: TestSubModel2;
+      simpleValue: TestSubModel2;
+    }
+  }
+}
+export type TestSubModelContainerNamespace_TestSubModelContainer = TestSubModelContainerNamespace.TestSubModelContainer;
+export type TestSubModelContainerNamespace_InnerNamespace_TestSubModelContainer2 = TestSubModelContainerNamespace.InnerNamespace.TestSubModelContainer2;
+
 export interface TestSubModel extends Model {
   email: string;
   circular?: TestModel;
 }
 
+export interface TestSubModel2 extends TestSubModel {
+  testSubModel2: boolean;
+}
+
 export interface BooleanResponseModel {
   success: boolean;
 }
diff --git a/tests/unit/swagger/definitionsGeneration/definitions.spec.ts b/tests/unit/swagger/definitionsGeneration/definitions.spec.ts
index a5e0bef3..0347bd29 100644
--- a/tests/unit/swagger/definitionsGeneration/definitions.spec.ts
+++ b/tests/unit/swagger/definitionsGeneration/definitions.spec.ts
@@ -25,7 +25,7 @@ describe('Definition generation', () => {
 
   describe('Interface-based generation', () => {
     it('should generate a definition for referenced models', () => {
-      const expectedModels = ['TestModel', 'TestSubModel', 'Result'];
+      const expectedModels = ['TestModel', 'TestSubModel', 'Result', 'TestSubModelContainer', 'TestSubModelContainerNamespace.InnerNamespace.TestSubModelContainer2', 'TestSubModel2'];
       expectedModels.forEach(modelName => {
         getValidatedDefinition(modelName);
       });