Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 48 additions & 52 deletions packages/cli/src/metadataGeneration/parameterGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,58 +302,9 @@ export class ParameterGenerator {
const parameterName = (parameter.name as ts.Identifier).text;
const type = this.getValidatedType(parameter);

// Handle cases where TypeResolver doesn't properly resolve complex types
// like Zod's z.infer types to refObject or nestedObjectLiteral
if (type.dataType !== 'refObject' && type.dataType !== 'nestedObjectLiteral') {
// Try to resolve the type more aggressively for complex types
let typeNode = parameter.type;
if (!typeNode) {
const typeFromChecker = this.current.typeChecker.getTypeAtLocation(parameter);
typeNode = this.current.typeChecker.typeToTypeNode(typeFromChecker, undefined, ts.NodeBuilderFlags.NoTruncation) as ts.TypeNode;
}

// If it's a TypeReferenceNode (like z.infer), try to resolve it differently
if (ts.isTypeReferenceNode(typeNode)) {
try {
// Try to get the actual type from the type checker
const actualType = this.current.typeChecker.getTypeAtLocation(typeNode);
const typeNodeFromType = this.current.typeChecker.typeToTypeNode(actualType, undefined, ts.NodeBuilderFlags.NoTruncation) as ts.TypeNode;
const resolvedType = new TypeResolver(typeNodeFromType, this.current, parameter).resolve();

// Check if the resolved type is now acceptable
if (resolvedType.dataType === 'refObject' || resolvedType.dataType === 'nestedObjectLiteral') {
// Use the resolved type instead
for (const property of resolvedType.properties) {
this.validateQueriesProperties(property, parameterName);
}

const { examples: example, exampleLabels } = this.getParameterExample(parameter, parameterName);

return {
description: this.getParameterDescription(parameter),
in: 'queries',
name: parameterName,
example,
exampleLabels,
parameterName,
required: !parameter.questionToken && !parameter.initializer,
type: resolvedType,
validators: getParameterValidators(this.parameter, parameterName),
deprecated: this.getParameterDeprecation(parameter),
};
}
} catch (error) {
// If resolution fails, log the error for debugging but continue with the original error
// This helps developers understand why the type resolution failed
console.warn(`Failed to resolve complex type for @Queries('${parameterName}'):`, error);
// Continue with the original error below
}
}
const queriesType = this.resolveQueriesType(type, parameterName, parameter);

throw new GenerateMetadataError(`@Queries('${parameterName}') only support 'refObject' or 'nestedObjectLiteral' types. If you want only one query parameter, please use the '@Query' decorator.`);
}

for (const property of type.properties) {
for (const property of queriesType.properties) {
this.validateQueriesProperties(property, parameterName);
}

Expand All @@ -367,12 +318,57 @@ export class ParameterGenerator {
exampleLabels,
parameterName,
required: !parameter.questionToken && !parameter.initializer,
type,
type: queriesType,
validators: getParameterValidators(this.parameter, parameterName),
deprecated: this.getParameterDeprecation(parameter),
};
}

/**
* Resolves the type for @Queries, unwrapping refAlias types (TypeScript type aliases)
* to their underlying object type when possible.
*/
private resolveQueriesType(type: Tsoa.Type, parameterName: string, parameter: ts.ParameterDeclaration): Tsoa.RefObjectType | Tsoa.NestedObjectLiteralType {
if (type.dataType === 'refObject' || type.dataType === 'nestedObjectLiteral') {
return type;
}

// Unwrap refAlias (TypeScript `type` aliases) to check the underlying type
if (type.dataType === 'refAlias') {
let unwrapped: Tsoa.Type = type.type;
while (unwrapped.dataType === 'refAlias') {
unwrapped = unwrapped.type;
}
if (unwrapped.dataType === 'refObject' || unwrapped.dataType === 'nestedObjectLiteral') {
return unwrapped;
}
}

// Handle cases where TypeResolver doesn't properly resolve complex types
// like Zod's z.infer types to refObject or nestedObjectLiteral
let typeNode = parameter.type;
if (!typeNode) {
const typeFromChecker = this.current.typeChecker.getTypeAtLocation(parameter);
typeNode = this.current.typeChecker.typeToTypeNode(typeFromChecker, undefined, ts.NodeBuilderFlags.NoTruncation) as ts.TypeNode;
}

if (ts.isTypeReferenceNode(typeNode)) {
try {
const actualType = this.current.typeChecker.getTypeAtLocation(typeNode);
const typeNodeFromType = this.current.typeChecker.typeToTypeNode(actualType, undefined, ts.NodeBuilderFlags.NoTruncation) as ts.TypeNode;
const resolvedType = new TypeResolver(typeNodeFromType, this.current, parameter).resolve();

if (resolvedType.dataType === 'refObject' || resolvedType.dataType === 'nestedObjectLiteral') {
return resolvedType;
}
} catch (error) {
console.warn(`Failed to resolve complex type for @Queries('${parameterName}'):`, error);
}
}

throw new GenerateMetadataError(`@Queries('${parameterName}') only support 'refObject' or 'nestedObjectLiteral' types. If you want only one query parameter, please use the '@Query' decorator.`);
}

private validateQueriesProperties(property: Tsoa.Property, parentName: string) {
if (property.type.dataType === 'array') {
const arrayType = property.type;
Expand Down
18 changes: 18 additions & 0 deletions tests/fixtures/controllers/getController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,17 @@ export class GetTestController extends Controller {
return model;
}

@Get('AllQueriesInOneObjectWithTypeAlias')
public async getAllQueriesInOneObjectWithTypeAlias(@Queries() queryParams: QueryParamsType) {
const model = new ModelService().getModel();
model.optionalString = queryParams.optionalStringParam;
model.numberValue = queryParams.numberParam;
model.boolValue = queryParams.booleanParam;
model.stringValue = queryParams.stringParam;

return model;
}

@Get('ResponseWithUnionTypeProperty')
public async getResponseWithUnionTypeProperty(): Promise<Result> {
return {
Expand Down Expand Up @@ -402,3 +413,10 @@ export interface QueryParams {
booleanParam: boolean;
optionalStringParam?: string;
}

export type QueryParamsType = {
numberParam: number;
stringParam: string;
booleanParam: boolean;
optionalStringParam?: string;
};
19 changes: 19 additions & 0 deletions tests/integration/express-server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,25 @@ describe('Express Server', () => {
});
});

it('parses queries parameters with a type alias', () => {
const numberValue = 10;
const boolValue = true;
const stringValue = 'the-string';

return verifyGetRequest(
app,
basePath + `/GetTest/AllQueriesInOneObjectWithTypeAlias?booleanParam=${boolValue.toString()}&stringParam=${stringValue}&numberParam=${numberValue}`,
(_err, res) => {
const queryParams = res.body as TestModel;

expect(queryParams.numberValue).to.equal(numberValue);
expect(queryParams.boolValue).to.equal(boolValue);
expect(queryParams.stringValue).to.equal(stringValue);
expect(queryParams.optionalString).to.be.undefined;
},
);
});

it('accepts any parameter using a wildcard', () => {
const object = {
foo: 'foo',
Expand Down
12 changes: 12 additions & 0 deletions tests/unit/swagger/pathGeneration/getRoutes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,18 @@ describe('GET route generation', () => {
}).to.throw("@Queries('nestedQueries') nested property 'nestedObject' Can't support 'refObject' type. \n in 'InvalidNestedQueriesController.nestedQueriesMethod'");
});

it('should support TypeScript type aliases for @Queries', () => {
const operation = getValidatedGetOperation(`${baseRoute}/AllQueriesInOneObjectWithTypeAlias`);
expect(operation.parameters).to.have.length(4);

const paramNames = operation.parameters!.map((p: any) => p.name);
expect(paramNames).to.include.members(['numberParam', 'stringParam', 'booleanParam', 'optionalStringParam']);

operation.parameters!.forEach((param: any) => {
expect(param.in).to.equal('query');
});
});

it('should reject invalid header types', function () {
this.timeout(10_000);
expect(() => {
Expand Down
Loading