From 2c66631002d56201e9f4816d31a47b2325b3a35d Mon Sep 17 00:00:00 2001 From: Hyeonss Date: Fri, 26 Dec 2025 01:21:17 +0900 Subject: [PATCH 1/2] fix: expand @Query() DTO parameters into individual query parameters Fixes #91 When using NestJS with @Query() decorator and a DTO class (without field name argument), the DTO properties are now properly expanded into individual OpenAPI query parameters with their JSDoc annotations (example, minimum, maximum, default, etc.) Changes: - Add isDto flag to NestParameterMetadata to identify DTO query parameters - Update parser to mark @Query() without field name as DTO - Update openapiGenerator to expand DTO properties into individual query parameters - Add tests for @Query() DTO expansion --- packages/tspec/src/nestjs/openapiGenerator.ts | 52 +++++++++++++++++ packages/tspec/src/nestjs/parser.ts | 7 ++- packages/tspec/src/nestjs/types.ts | 2 + .../test/nestjs/fixtures/users.controller.ts | 37 +++++++++++- packages/tspec/src/test/nestjs/schema.test.ts | 56 +++++++++++++++++++ 5 files changed, 152 insertions(+), 2 deletions(-) diff --git a/packages/tspec/src/nestjs/openapiGenerator.ts b/packages/tspec/src/nestjs/openapiGenerator.ts index a5089c5..c71a661 100644 --- a/packages/tspec/src/nestjs/openapiGenerator.ts +++ b/packages/tspec/src/nestjs/openapiGenerator.ts @@ -4,6 +4,7 @@ import { NestControllerMetadata, NestMethodMetadata, ParsedNestApp, + PropertyDefinition, } from './types'; import { buildSchemaRef as buildSchemaRefFromBuilder, @@ -21,6 +22,38 @@ export interface GenerateOpenApiOptions { securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes']; } +/** + * Build OpenAPI schema for a DTO property, including JSDoc annotations + */ +const buildPropertySchema = ( + prop: PropertyDefinition, + context: SchemaBuilderContext, +): OpenAPIV3.SchemaObject => { + const baseSchema = buildSchemaRefFromBuilder(prop.type, context); + + // If it's a reference, return primitive schema instead (for query params) + if ('$ref' in baseSchema) { + return buildPrimitiveSchemaFromBuilder(prop.type); + } + + // Merge JSDoc annotations into the schema + const schema: OpenAPIV3.SchemaObject = { + ...baseSchema, + }; + + // Add JSDoc annotations + if (prop.minimum !== undefined) schema.minimum = prop.minimum; + if (prop.maximum !== undefined) schema.maximum = prop.maximum; + if (prop.minLength !== undefined) schema.minLength = prop.minLength; + if (prop.maxLength !== undefined) schema.maxLength = prop.maxLength; + if (prop.pattern !== undefined) schema.pattern = prop.pattern; + if (prop.format !== undefined) schema.format = prop.format; + if (prop.default !== undefined) schema.default = prop.default; + if (prop.deprecated !== undefined) schema.deprecated = prop.deprecated; + + return schema; +}; + export const generateOpenApiFromNest = ( app: ParsedNestApp, options: GenerateOpenApiOptions = {}, @@ -149,6 +182,25 @@ const buildOperation = ( for (const param of method.parameters) { if (param.category !== 'body' && param.category !== 'file' && param.category !== 'files') { + // If this is a DTO query parameter, expand its properties into individual query parameters + if (param.isDto && param.category === 'query') { + const typeDef = context.typeDefinitions.get(param.type); + if (typeDef && typeDef.properties.length > 0) { + for (const prop of typeDef.properties) { + const propSchema = buildPropertySchema(prop, context); + parameters.push({ + name: prop.name, + in: 'query', + required: prop.required, + schema: propSchema, + description: prop.description, + example: prop.example as string | number | boolean | undefined, + }); + } + continue; + } + } + parameters.push({ name: param.name, in: param.category === 'param' ? 'path' : param.category, diff --git a/packages/tspec/src/nestjs/parser.ts b/packages/tspec/src/nestjs/parser.ts index 687966a..f9afc59 100644 --- a/packages/tspec/src/nestjs/parser.ts +++ b/packages/tspec/src/nestjs/parser.ts @@ -439,15 +439,20 @@ const parseParameters = ( const category = decoratorName.toLowerCase() as NestParameterMetadata['category']; const paramName = ts.isIdentifier(param.name) ? param.name.text : 'unknown'; - const fieldName = getDecoratorStringArg(decorator) || paramName; + const decoratorArg = getDecoratorStringArg(decorator); + const fieldName = decoratorArg || paramName; const paramType = getTypeString(param.type, checker); const required = !param.questionToken; + // For @Query() without a field name argument, mark as DTO to expand into individual parameters + const isDto = category === 'query' && !decoratorArg; + params.push({ name: category === 'body' ? paramName : fieldName, type: paramType, category, required, + isDto, }); break; } diff --git a/packages/tspec/src/nestjs/types.ts b/packages/tspec/src/nestjs/types.ts index 5a821d5..1cd8c8c 100644 --- a/packages/tspec/src/nestjs/types.ts +++ b/packages/tspec/src/nestjs/types.ts @@ -35,6 +35,8 @@ export interface NestParameterMetadata { required: boolean; /** Field name for file upload (from FileInterceptor) */ fieldName?: string; + /** Whether this is a DTO type that should be expanded into individual parameters (for @Query() without field name) */ + isDto?: boolean; } export interface NestParserOptions { diff --git a/packages/tspec/src/test/nestjs/fixtures/users.controller.ts b/packages/tspec/src/test/nestjs/fixtures/users.controller.ts index 01bae5f..77c72f3 100644 --- a/packages/tspec/src/test/nestjs/fixtures/users.controller.ts +++ b/packages/tspec/src/test/nestjs/fixtures/users.controller.ts @@ -17,6 +17,9 @@ function Param(name?: string): ParameterDecorator { function Body(): ParameterDecorator { return () => {}; } +function Query(): ParameterDecorator { + return () => {}; +} function ApiTags(...tags: string[]): ClassDecorator { return () => {}; } @@ -139,6 +142,38 @@ export class PaginatedResponse { totalCount?: number | null; } +/** + * 사용자 목록 조회 쿼리 DTO + */ +export class ListUsersQueryDto { + /** + * 페이지네이션 토큰 (다음 페이지 조회용) + * @example "eyJvZmZzZXQiOjAsImxpbWl0IjoyMH0=" + */ + nextToken?: string; + + /** + * 조회할 아이템 개수 (기본값: 20, 최대: 100) + * @minimum 1 + * @maximum 100 + * @default 20 + */ + limit?: number; + + /** + * 시작 위치 + * @minimum 0 + * @default 0 + */ + offset?: number; + + /** + * 이름 검색 + * @example "홍길동" + */ + name?: string; +} + /** * 사용자 API 컨트롤러 */ @@ -150,7 +185,7 @@ export class UsersController { * @summary Get all users */ @Get() - findAll(): Promise> { + findAll(@Query() query: ListUsersQueryDto): Promise> { return Promise.resolve({ data: [], nextToken: null, totalCount: 0 }); } diff --git a/packages/tspec/src/test/nestjs/schema.test.ts b/packages/tspec/src/test/nestjs/schema.test.ts index 440ef23..38f2edb 100644 --- a/packages/tspec/src/test/nestjs/schema.test.ts +++ b/packages/tspec/src/test/nestjs/schema.test.ts @@ -180,4 +180,60 @@ describe('NestJS Schema Generation', () => { expect(openapi.components?.schemas?.['string, unknown']).toBeUndefined(); }); }); + + describe('@Query() DTO expansion (Issue #91)', () => { + it('should expand @Query() DTO into individual query parameters', () => { + const openapi = getOpenApiSpec(); + const findAllPath = openapi.paths['/users']?.get; + + expect(findAllPath).toBeDefined(); + expect(findAllPath?.parameters).toBeDefined(); + + const params = findAllPath?.parameters as any[]; + + // Should have individual query parameters from ListUsersQueryDto + const nextTokenParam = params.find(p => p.name === 'nextToken'); + const limitParam = params.find(p => p.name === 'limit'); + const offsetParam = params.find(p => p.name === 'offset'); + const nameParam = params.find(p => p.name === 'name'); + + expect(nextTokenParam).toBeDefined(); + expect(nextTokenParam.in).toBe('query'); + expect(nextTokenParam.required).toBe(false); + expect(nextTokenParam.schema.type).toBe('string'); + expect(nextTokenParam.example).toBe('eyJvZmZzZXQiOjAsImxpbWl0IjoyMH0='); + + expect(limitParam).toBeDefined(); + expect(limitParam.in).toBe('query'); + expect(limitParam.required).toBe(false); + expect(limitParam.schema.type).toBe('number'); + expect(limitParam.schema.minimum).toBe(1); + expect(limitParam.schema.maximum).toBe(100); + expect(limitParam.schema.default).toBe(20); + + expect(offsetParam).toBeDefined(); + expect(offsetParam.in).toBe('query'); + expect(offsetParam.required).toBe(false); + expect(offsetParam.schema.type).toBe('number'); + expect(offsetParam.schema.minimum).toBe(0); + expect(offsetParam.schema.default).toBe(0); + + expect(nameParam).toBeDefined(); + expect(nameParam.in).toBe('query'); + expect(nameParam.required).toBe(false); + expect(nameParam.schema.type).toBe('string'); + expect(nameParam.example).toBe('홍길동'); + }); + + it('should not have a single "query" parameter for DTO', () => { + const openapi = getOpenApiSpec(); + const findAllPath = openapi.paths['/users']?.get; + + const params = findAllPath?.parameters as any[]; + + // Should NOT have a single "query" parameter + const queryParam = params.find(p => p.name === 'query'); + expect(queryParam).toBeUndefined(); + }); + }); }); From e5d03d5f676cde12c940ec2e8537c80540b493ec Mon Sep 17 00:00:00 2001 From: Hyeonss Date: Fri, 26 Dec 2025 01:30:43 +0900 Subject: [PATCH 2/2] refactor: extract mergeJsDocAnnotations utility for code reuse Extract JSDoc annotation merging logic into a shared utility function in schemaBuilder.ts and reuse it in both schemaBuilder.ts and nestjs/openapiGenerator.ts to avoid code duplication. --- packages/tspec/src/generator/schemaBuilder.ts | 57 +++++++++++-------- packages/tspec/src/nestjs/openapiGenerator.ts | 22 ++----- 2 files changed, 39 insertions(+), 40 deletions(-) diff --git a/packages/tspec/src/generator/schemaBuilder.ts b/packages/tspec/src/generator/schemaBuilder.ts index c67e218..5f9374f 100644 --- a/packages/tspec/src/generator/schemaBuilder.ts +++ b/packages/tspec/src/generator/schemaBuilder.ts @@ -54,6 +54,38 @@ export interface SchemaBuilderContext { enumDefinitions: Map; } +/** + * Merge JSDoc annotations from PropertyDefinition into an OpenAPI schema. + * This is a shared utility for building property schemas with JSDoc tags. + */ +export const mergeJsDocAnnotations = ( + baseSchema: OpenAPIV3.SchemaObject, + prop: PropertyDefinition, +): OpenAPIV3.SchemaObject => { + const schema: OpenAPIV3.SchemaObject = { + ...baseSchema, + description: prop.description || baseSchema.description, + example: prop.example, + format: prop.format || baseSchema.format, + deprecated: prop.deprecated, + minimum: prop.minimum, + maximum: prop.maximum, + minLength: prop.minLength, + maxLength: prop.maxLength, + pattern: prop.pattern, + default: prop.default, + }; + + // Clean up undefined values + Object.keys(schema).forEach((key) => { + if ((schema as Record)[key] === undefined) { + delete (schema as Record)[key]; + } + }); + + return schema; +}; + export const buildPrimitiveSchema = (typeName: string): OpenAPIV3.SchemaObject => { switch (typeName.toLowerCase()) { case 'string': @@ -198,31 +230,10 @@ export const buildSchemaRef = ( for (const prop of typeDef.properties) { const baseSchema = buildSchemaRef(prop.type, context); - // Merge JSDoc tags into the schema + // Merge JSDoc tags into the schema (skip for $ref schemas) const propSchema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject = '$ref' in baseSchema ? baseSchema - : { - ...baseSchema, - description: prop.description || baseSchema.description, - example: prop.example, - format: prop.format || baseSchema.format, - deprecated: prop.deprecated, - minimum: prop.minimum, - maximum: prop.maximum, - minLength: prop.minLength, - maxLength: prop.maxLength, - pattern: prop.pattern, - default: prop.default, - }; - - // Clean up undefined values - if (!('$ref' in propSchema)) { - Object.keys(propSchema).forEach((key) => { - if ((propSchema as Record)[key] === undefined) { - delete (propSchema as Record)[key]; - } - }); - } + : mergeJsDocAnnotations(baseSchema, prop); properties[prop.name] = propSchema; if (prop.required) { diff --git a/packages/tspec/src/nestjs/openapiGenerator.ts b/packages/tspec/src/nestjs/openapiGenerator.ts index c71a661..feaca12 100644 --- a/packages/tspec/src/nestjs/openapiGenerator.ts +++ b/packages/tspec/src/nestjs/openapiGenerator.ts @@ -10,6 +10,7 @@ import { buildSchemaRef as buildSchemaRefFromBuilder, buildPrimitiveSchema as buildPrimitiveSchemaFromBuilder, unwrapPromise as unwrapPromiseFromBuilder, + mergeJsDocAnnotations, createSchemaBuilderContext, SchemaBuilderContext, } from '../generator/schemaBuilder'; @@ -23,7 +24,8 @@ export interface GenerateOpenApiOptions { } /** - * Build OpenAPI schema for a DTO property, including JSDoc annotations + * Build OpenAPI schema for a DTO property, including JSDoc annotations. + * For query parameters, we need primitive schemas (not $ref). */ const buildPropertySchema = ( prop: PropertyDefinition, @@ -36,22 +38,8 @@ const buildPropertySchema = ( return buildPrimitiveSchemaFromBuilder(prop.type); } - // Merge JSDoc annotations into the schema - const schema: OpenAPIV3.SchemaObject = { - ...baseSchema, - }; - - // Add JSDoc annotations - if (prop.minimum !== undefined) schema.minimum = prop.minimum; - if (prop.maximum !== undefined) schema.maximum = prop.maximum; - if (prop.minLength !== undefined) schema.minLength = prop.minLength; - if (prop.maxLength !== undefined) schema.maxLength = prop.maxLength; - if (prop.pattern !== undefined) schema.pattern = prop.pattern; - if (prop.format !== undefined) schema.format = prop.format; - if (prop.default !== undefined) schema.default = prop.default; - if (prop.deprecated !== undefined) schema.deprecated = prop.deprecated; - - return schema; + // Merge JSDoc annotations using shared utility + return mergeJsDocAnnotations(baseSchema, prop); }; export const generateOpenApiFromNest = (