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 a5089c5..feaca12 100644 --- a/packages/tspec/src/nestjs/openapiGenerator.ts +++ b/packages/tspec/src/nestjs/openapiGenerator.ts @@ -4,11 +4,13 @@ import { NestControllerMetadata, NestMethodMetadata, ParsedNestApp, + PropertyDefinition, } from './types'; import { buildSchemaRef as buildSchemaRefFromBuilder, buildPrimitiveSchema as buildPrimitiveSchemaFromBuilder, unwrapPromise as unwrapPromiseFromBuilder, + mergeJsDocAnnotations, createSchemaBuilderContext, SchemaBuilderContext, } from '../generator/schemaBuilder'; @@ -21,6 +23,25 @@ export interface GenerateOpenApiOptions { securitySchemes?: OpenAPIV3.ComponentsObject['securitySchemes']; } +/** + * Build OpenAPI schema for a DTO property, including JSDoc annotations. + * For query parameters, we need primitive schemas (not $ref). + */ +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 using shared utility + return mergeJsDocAnnotations(baseSchema, prop); +}; + export const generateOpenApiFromNest = ( app: ParsedNestApp, options: GenerateOpenApiOptions = {}, @@ -149,6 +170,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(); + }); + }); });