Skip to content
Merged
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
57 changes: 34 additions & 23 deletions packages/tspec/src/generator/schemaBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,38 @@ export interface SchemaBuilderContext {
enumDefinitions: Map<string, EnumDefinition>;
}

/**
* 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<string, unknown>)[key] === undefined) {
delete (schema as Record<string, unknown>)[key];
}
});

return schema;
};

export const buildPrimitiveSchema = (typeName: string): OpenAPIV3.SchemaObject => {
switch (typeName.toLowerCase()) {
case 'string':
Expand Down Expand Up @@ -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<string, unknown>)[key] === undefined) {
delete (propSchema as Record<string, unknown>)[key];
}
});
}
: mergeJsDocAnnotations(baseSchema, prop);

properties[prop.name] = propSchema;
if (prop.required) {
Expand Down
40 changes: 40 additions & 0 deletions packages/tspec/src/nestjs/openapiGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 = {},
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion packages/tspec/src/nestjs/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/tspec/src/nestjs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
37 changes: 36 additions & 1 deletion packages/tspec/src/test/nestjs/fixtures/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ function Param(name?: string): ParameterDecorator {
function Body(): ParameterDecorator {
return () => {};
}
function Query(): ParameterDecorator {
return () => {};
}
function ApiTags(...tags: string[]): ClassDecorator {
return () => {};
}
Expand Down Expand Up @@ -139,6 +142,38 @@ export class PaginatedResponse<T> {
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 컨트롤러
*/
Expand All @@ -150,7 +185,7 @@ export class UsersController {
* @summary Get all users
*/
@Get()
findAll(): Promise<PaginatedResponse<UserDto>> {
findAll(@Query() query: ListUsersQueryDto): Promise<PaginatedResponse<UserDto>> {
return Promise.resolve({ data: [], nextToken: null, totalCount: 0 });
}

Expand Down
56 changes: 56 additions & 0 deletions packages/tspec/src/test/nestjs/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,67 @@

// meta property should be object with additionalProperties
expect(createUserDto.properties.meta).toBeDefined();
expect(createUserDto.properties.meta.type).toBe('object');

Check failure on line 176 in packages/tspec/src/test/nestjs/schema.test.ts

View workflow job for this annotation

GitHub Actions / Test & Build (Node 22)

src/test/nestjs/schema.test.ts > NestJS Schema Generation > Record types (Issue #89) > should handle Record<string, unknown> without creating invalid schema name

AssertionError: expected undefined to be 'object' // Object.is equality - Expected: "object" + Received: undefined ❯ src/test/nestjs/schema.test.ts:176:50

Check failure on line 176 in packages/tspec/src/test/nestjs/schema.test.ts

View workflow job for this annotation

GitHub Actions / Test & Build (Node 24)

src/test/nestjs/schema.test.ts > NestJS Schema Generation > Record types (Issue #89) > should handle Record<string, unknown> without creating invalid schema name

AssertionError: expected undefined to be 'object' // Object.is equality - Expected: "object" + Received: undefined ❯ src/test/nestjs/schema.test.ts:176:50
expect(createUserDto.properties.meta.additionalProperties).toBe(true);

// Should NOT have a schema with invalid name containing comma and space
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();
});
});
});
Loading