diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..fee72d99c --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "bierner.comment-tagged-templates", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode" + ] +} \ No newline at end of file diff --git a/packages/cli/src/metadataGeneration/typeResolver.ts b/packages/cli/src/metadataGeneration/typeResolver.ts index 315854b38..476e8daae 100644 --- a/packages/cli/src/metadataGeneration/typeResolver.ts +++ b/packages/cli/src/metadataGeneration/typeResolver.ts @@ -303,6 +303,87 @@ export class TypeResolver { return new TypeResolver(this.typeNode.type, this.current, this.typeNode, this.context, this.referencer).resolve(); } + if (ts.isTypeQueryNode(this.typeNode)) { + const isIntrinsicType = (type: ts.Type): boolean => { + const flags = ts.TypeFlags; + return ( + this.hasFlag(type, flags.Number) || + this.hasFlag(type, flags.String) || + this.hasFlag(type, flags.Boolean) || + this.hasFlag(type, flags.BigInt) || + this.hasFlag(type, flags.ESSymbol) || + this.hasFlag(type, flags.Undefined) || + this.hasFlag(type, flags.Null) + ); + }; + const symbol = this.current.typeChecker.getSymbolAtLocation(this.typeNode.exprName); + if (symbol) { + // Access the first declaration of the symbol + const declaration = symbol.declarations?.[0]; + throwUnless(declaration, new GenerateMetadataError(`Could not find declaration for symbol: ${symbol.name}`, this.typeNode)); + + if (ts.isVariableDeclaration(declaration)) { + const initializer = declaration.initializer; + const declarationType = this.current.typeChecker.getTypeAtLocation(declaration); + if (isIntrinsicType(declarationType)) { + return { dataType: this.current.typeChecker.typeToString(declarationType) as Tsoa.PrimitiveType['dataType'] }; + } + if (initializer && (ts.isStringLiteral(initializer) || ts.isNumericLiteral(initializer) || ts.isBigIntLiteral(initializer))) { + return { dataType: 'enum', enums: [initializer.text] }; + } else if (initializer && ts.isObjectLiteralExpression(initializer)) { + const getOneOrigDeclaration = (prop: ts.Symbol): ts.Declaration | undefined => { + if (prop.declarations) { + return prop.declarations[0]; + } + const syntheticOrigin: ts.Symbol = (prop as any).links?.syntheticOrigin; + if (syntheticOrigin && syntheticOrigin.name === prop.name) { + return syntheticOrigin.declarations?.[0]; + } + return undefined; + }; + + const isIgnored = (prop: ts.Symbol) => { + const declaration = getOneOrigDeclaration(prop); + return declaration !== undefined && getJSDocTagNames(declaration).some(tag => tag === 'ignore') && !ts.isPropertyAssignment(declaration); + }; + + const typeProperties: ts.Symbol[] = this.current.typeChecker.getPropertiesOfType(this.current.typeChecker.getTypeAtLocation(initializer)); + const properties: Tsoa.Property[] = typeProperties + .filter(property => isIgnored(property) === false) + .map(property => { + const propertyType = this.current.typeChecker.getTypeOfSymbolAtLocation(property, this.typeNode); + const typeNode = this.current.typeChecker.typeToTypeNode(propertyType, undefined, ts.NodeBuilderFlags.NoTruncation)!; + const parent = getOneOrigDeclaration(property); + const type = new TypeResolver(typeNode, this.current, parent, this.context, propertyType).resolve(); + + const required = !this.hasFlag(property, ts.SymbolFlags.Optional); + const comments = property.getDocumentationComment(this.current.typeChecker); + const description = comments.length ? ts.displayPartsToString(comments) : undefined; + + return { + name: property.getName(), + required, + deprecated: parent ? isExistJSDocTag(parent, tag => tag.tagName.text === 'deprecated') || isDecorator(parent, identifier => identifier.text === 'Deprecated') : false, + type, + default: undefined, + validators: (parent ? getPropertyValidators(parent) : {}) || {}, + description, + format: parent ? this.getNodeFormat(parent) : undefined, + example: parent ? this.getNodeExample(parent) : undefined, + extensions: parent ? this.getNodeExtension(parent) : undefined, + }; + }); + + const objectLiteral: Tsoa.NestedObjectLiteralType = { + dataType: 'nestedObjectLiteral', + properties, + }; + return objectLiteral; + } + } + } + } + throwUnless(this.typeNode.kind === ts.SyntaxKind.TypeReference, new GenerateMetadataError(`Unknown type: ${ts.SyntaxKind[this.typeNode.kind]}`, this.typeNode)); return this.resolveTypeReferenceNode(this.typeNode as ts.TypeReferenceNode, this.current, this.context, this.parentNode); @@ -573,7 +654,7 @@ export class TypeResolver { if (this.context[name]) { //resolve name only interesting if entity is not qualifiedName name = this.context[name].name; //Not needed to check unicity, because generic parameters are checked previously - } else { + } else if (type.parent && !ts.isTypeQueryNode(type.parent)) { const declarations = this.getModelTypeDeclarations(type); //Two possible solutions for recognizing different types: diff --git a/tests/fixtures/controllers/parameterController.ts b/tests/fixtures/controllers/parameterController.ts index 64ff6517b..59347d292 100644 --- a/tests/fixtures/controllers/parameterController.ts +++ b/tests/fixtures/controllers/parameterController.ts @@ -1,5 +1,5 @@ import { Body, BodyProp, Get, Header, Path, Post, Query, Request, Route, Res, TsoaResponse, Deprecated, Queries, RequestProp, FormField } from '@tsoa/runtime'; -import { Gender, ParameterTestModel } from '../testModel'; +import { Gender, ParameterTestModel, ValueType } from '../testModel'; @Route('ParameterTest') export class ParameterController { @@ -402,4 +402,9 @@ export class ParameterController { public async inline1(@Body() body: { requestString: string; requestNumber: number }): Promise<{ resultString: string; responseNumber: number }> { return { resultString: 'a', responseNumber: 1 }; } + + @Post('TypeInference') + public async typeInference(@Body() body: ValueType): Promise { + return { a: 'a', b: 1 }; + } } diff --git a/tests/fixtures/testModel.ts b/tests/fixtures/testModel.ts index 4cf58b56b..5bc796986 100644 --- a/tests/fixtures/testModel.ts +++ b/tests/fixtures/testModel.ts @@ -1289,3 +1289,7 @@ type OrderDirection = 'asc' | 'desc'; type OrderOptions = `${keyof E & string}:${OrderDirection}`; type TemplateLiteralString = OrderOptions; + +const value = { a: 'a', b: 1 }; +type Infer = T; +export type ValueType = Infer; diff --git a/tests/unit/swagger/definitionsGeneration/metadata.spec.ts b/tests/unit/swagger/definitionsGeneration/metadata.spec.ts index 878d105e6..7f8b6f079 100644 --- a/tests/unit/swagger/definitionsGeneration/metadata.spec.ts +++ b/tests/unit/swagger/definitionsGeneration/metadata.spec.ts @@ -825,6 +825,23 @@ describe('Metadata generation', () => { const deprecatedParam2 = method.parameters[2]; expect(deprecatedParam2.deprecated).to.be.true; }); + + it('should handle type inference params', () => { + const method = controller.methods.find(m => m.name === 'typeInference'); + if (!method) { + throw new Error('Method typeInference not defined!'); + } + const parameter = method.parameters.find(param => param.parameterName === 'body'); + if (!parameter) { + throw new Error('Parameter firstname not defined!'); + } + + expect(method.parameters.length).to.equal(1); + expect(parameter.in).to.equal('body'); + expect(parameter.name).to.equal('body'); + expect(parameter.parameterName).to.equal('body'); + expect(parameter.required).to.be.true; + }); }); describe('HiddenMethodGenerator', () => { diff --git a/tests/unit/swagger/definitionsGeneration/typeResolver.spec.ts b/tests/unit/swagger/definitionsGeneration/typeResolver.spec.ts new file mode 100644 index 000000000..e6411a540 --- /dev/null +++ b/tests/unit/swagger/definitionsGeneration/typeResolver.spec.ts @@ -0,0 +1,564 @@ +import { expect } from 'chai'; +import * as ts from 'typescript'; +import { TypeResolver } from '@tsoa/cli/metadataGeneration/typeResolver'; +import { MetadataGenerator } from '@tsoa/cli/metadataGeneration/metadataGenerator'; + +const refAliasProperties = { + default: undefined, + description: undefined, + format: undefined, + validators: {}, +}; + +const defaultProperties = { + default: undefined, + deprecated: false, + description: undefined, + example: undefined, + extensions: [], + format: undefined, + validators: {}, +}; + +function createProgramFromSource(source: string, fileName = 'test.ts') { + const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest); + + const defaultCompilerHost = ts.createCompilerHost({}); + const compilerHost = ts.createCompilerHost({}); + compilerHost.getSourceFile = (fileName, languageVersion) => { + if (fileName === sourceFile.fileName) { + return sourceFile; + } + return defaultCompilerHost.getSourceFile(fileName, languageVersion); + }; + compilerHost.fileExists = fileName => { + if (fileName === sourceFile.fileName) { + return true; + } + return defaultCompilerHost.fileExists(fileName); + }; + + const program = ts.createProgram([sourceFile.fileName], {}, compilerHost); + const metadataGenerator = new MetadataGenerator(fileName); + // @ts-expect-error: 'program' is a private and read-only property of MetadataGenerator + metadataGenerator.program = program; + // @ts-expect-error: 'program' is a private and read-only property of MetadataGenerator + metadataGenerator.typeChecker = program.getTypeChecker(); + + const typeAliasDeclaration = sourceFile.statements.filter(ts.isTypeAliasDeclaration).pop(); + if (!typeAliasDeclaration) { + throw new Error('No type alias declaration found in source'); + } + + return { + metadataGenerator, + typeNode: typeAliasDeclaration.type, + }; +} + +describe('TypeResolver.resolve', () => { + it('should resolve an object type', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + type ObjectType = { a: string; b: number; }; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ + dataType: 'nestedObjectLiteral', + properties: [ + { + name: 'b', + type: { dataType: 'double' }, + required: true, + ...defaultProperties, + }, + { + name: 'a', + type: { dataType: 'string' }, + required: true, + ...defaultProperties, + }, + ], + additionalProperties: undefined, + }); + }); + + it('should resolve conditional types', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + type ConditionalType = T extends string ? string : number; + type Concrete = ConditionalType; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + + expect(result).to.deep.equal({ + refName: 'ConditionalType_boolean_', + dataType: 'refAlias', + type: { dataType: 'double' }, + ...refAliasProperties, + }); + }); + + it('should resolve indexed access types', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + type IndexedAccessType = { a: string }["a"]; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ dataType: 'string' }); + }); + + it('should resolve inferred types', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + type InferredType = T extends infer U ? U : never; + type Concrete = InferredType; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ + refName: 'InferredType_string_', + dataType: 'refAlias', + type: { dataType: 'string' }, + ...refAliasProperties, + }); + }); + + it('should resolve typeof with literal', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + const hello = "hello" + type InferredType = typeof hello; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ + dataType: 'enum', + enums: ['hello'], + }); + }); + + it('should resolve typeof with object', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + const concrete = {a: "a", b: 1}; + type InferredType = typeof concrete; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ + dataType: 'nestedObjectLiteral', + properties: [ + { + name: 'a', + type: { dataType: 'string' }, + required: true, + ...defaultProperties, + }, + { + name: 'b', + type: { dataType: 'double' }, + required: true, + ...defaultProperties, + }, + ], + }); + }); + + it('should resolve intersection types', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + type IntersectionType = { a: string } & { b: number }; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ + dataType: 'intersection', + types: [ + { + additionalProperties: undefined, + dataType: 'nestedObjectLiteral', + properties: [ + { + name: 'a', + type: { dataType: 'string' }, + required: true, + ...defaultProperties, + }, + ], + }, + { + additionalProperties: undefined, + dataType: 'nestedObjectLiteral', + properties: [ + { + name: 'b', + type: { dataType: 'double' }, + required: true, + ...defaultProperties, + }, + ], + }, + ], + }); + }); + + it('should resolve intrinsic types', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + type IntrinsicType = string; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ dataType: 'string' }); + }); + + it('should resolve literal types', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + type LiteralType = "literal"; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ dataType: 'enum', enums: ['literal'] }); + }); + + it('should resolve mapped types', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + type MappedType = { [P in keyof T]: T[P] }; + type Concrete = MappedType<{ a: string; b: number }>; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ + dataType: 'refAlias', + refName: 'MappedType__a-string--b-number__', + type: { + dataType: 'nestedObjectLiteral', + properties: [ + { + name: 'a', + type: { dataType: 'string' }, + required: true, + ...defaultProperties, + }, + { + name: 'b', + type: { dataType: 'double' }, + required: true, + ...defaultProperties, + }, + ], + }, + ...refAliasProperties, + }); + }); + + it('should resolve mapped types with contraints', () => { + const source = ` + type Flatten> = { + [k in keyof T]: T[k]; + }; + + type ValueType = Flatten<{ name: string }>; + `; + const { metadataGenerator, typeNode } = createProgramFromSource(source); + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ + dataType: 'refAlias', + refName: 'Flatten__name-string__', + type: { + dataType: 'nestedObjectLiteral', + properties: [ + { + name: 'name', + type: { dataType: 'string' }, + required: true, + ...defaultProperties, + }, + ], + }, + ...refAliasProperties, + }); + }); + + it('should resolve optional types', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + type OptionalType = { a?: string }; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ + dataType: 'nestedObjectLiteral', + properties: [ + { + name: 'a', + type: { dataType: 'string' }, + required: false, + ...defaultProperties, + }, + ], + additionalProperties: undefined, + }); + }); + + it('should resolve predicate types', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + type PredicateType = (x: any) => x is string; + type Concrete = ReturnType; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ + dataType: 'refAlias', + refName: 'ReturnType_PredicateType_', + type: { dataType: 'boolean' }, + ...refAliasProperties, + description: 'Obtain the return type of a function type', + }); + }); + + it('should resolve keyof query types', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + type QueryType = keyof { a: string }; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ dataType: 'enum', enums: ['a'] }); + }); + + it('should resolve reference types', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + type ReferenceType = { a: string }; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ + dataType: 'nestedObjectLiteral', + properties: [ + { + name: 'a', + type: { dataType: 'string' }, + required: true, + ...defaultProperties, + }, + ], + additionalProperties: undefined, + }); + }); + + // works with nested types, but not with interfaces + it.skip('should resolve nested interface types', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + interface Address { line1: string; line2?: string; postalCode: string; }; + type Person = { + first: string; + last: string; + address: Address; + }; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ + dataType: 'nestedObjectLiteral', + properties: [ + { + name: 'a', + type: { dataType: 'string' }, + required: true, + ...defaultProperties, + }, + ], + additionalProperties: undefined, + }); + }); + + it('should resolve rest types', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + type Sum = (...numbers: number[]) => number; + type RestType = Parameters; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ + dataType: 'refAlias', + refName: 'Parameters_Sum_', + type: { + dataType: 'array', + elementType: { + dataType: 'double', + }, + }, + ...refAliasProperties, + description: 'Obtain the parameters of a function type in a tuple', + }); + }); + + // Only template literal types with literal string unions are supported + it('should resolve template literal types', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + type TemplateLiteralType = \`prefix_\${"a" | "b"}\`; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ dataType: 'enum', enums: ['prefix_a', 'prefix_b'] }); + }); + + it('should resolve type operator types', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + type TypeOperatorType = keyof { a: string }; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ dataType: 'enum', enums: ['a'] }); + }); + + it('should resolve union types', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + type UnionType = string | number; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ dataType: 'union', types: [{ dataType: 'string' }, { dataType: 'double' }] }); + }); + + it('should resolve unknown types', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + type UnknownType = unknown; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ dataType: 'any' }); + }); + + it('should resolve simple generic types', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + type Wrapper = { data: T }; + type Concrete = Wrapper; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ + dataType: 'refAlias', + refName: 'Wrapper_string_', + type: { + dataType: 'nestedObjectLiteral', + properties: [{ name: 'data', type: { dataType: 'string' }, required: true, ...defaultProperties }], + additionalProperties: undefined, + }, + ...refAliasProperties, + }); + }); + + // One of the issues that blocks zod's z.infer from working as expected + // see https://github.com/lukeautry/tsoa/issues/1256#issuecomment-2649340573 + it.skip('should resolve indexed generic types', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + type Type = { _type: Output }; + type ValueType = Type["_type"]; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ + dataType: 'nestedObjectLiteral', + properties: [ + { name: 'a', type: { dataType: 'string' }, required: true, ...defaultProperties }, + { name: 'b', type: { dataType: 'double' }, required: true, ...defaultProperties }, + ], + additionalProperties: undefined, + }); + }); + + // Note: requires installing zod as a dependency as well + it.skip('should resolve Zod inferred types', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + import { z } from 'zod'; + const schema = z.object({ a: z.string(), b: z.number() }); + type InferredType = z.infer; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ + dataType: 'nestedObjectLiteral', + properties: [ + { name: 'a', type: { dataType: 'string' }, required: true, ...defaultProperties }, + { name: 'b', type: { dataType: 'double' }, required: true, ...defaultProperties }, + ], + additionalProperties: undefined, + }); + }); + + it('should resolve references to inferred types with intrinsic', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + const value: string = "a"; + type Infer = T; + type ValueType = Infer; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ + dataType: 'refAlias', + refName: 'Infer_typeofvalue_', + type: { dataType: 'string' }, + ...refAliasProperties, + }); + }); + + it('should resolve references to inferred types with literal', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + const value = "a"; + type Infer = T; + type ValueType = Infer; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ + dataType: 'refAlias', + refName: 'Infer_typeofvalue_', + type: { dataType: 'enum', enums: ['a'] }, + ...refAliasProperties, + }); + }); + it('should resolve references to inferred types with object', () => { + const { metadataGenerator, typeNode } = createProgramFromSource(/*ts*/ ` + const value = { a: "a", b: 1}; + type Infer = T; + type ValueType = Infer; + `); + + const resolver = new TypeResolver(typeNode, metadataGenerator); + const result = resolver.resolve(); + expect(result).to.deep.equal({ + dataType: 'refAlias', + refName: 'Infer_typeofvalue_', + type: { + dataType: 'nestedObjectLiteral', + properties: [ + { name: 'a', type: { dataType: 'string' }, required: true, ...defaultProperties }, + { name: 'b', type: { dataType: 'double' }, required: true, ...defaultProperties }, + ], + }, + ...refAliasProperties, + }); + }); +});