diff --git a/packages/tspec/src/generator/index.ts b/packages/tspec/src/generator/index.ts index ab7361b..0da2284 100644 --- a/packages/tspec/src/generator/index.ts +++ b/packages/tspec/src/generator/index.ts @@ -249,7 +249,7 @@ export const generateTspec = async ( } logger.log('Generating OpenAPI spec...'); - const openapi = generateOpenApiFromNest(app, { + const openapi = await generateOpenApiFromNest(app, { title: params.openapi?.title, version: params.openapi?.version, description: params.openapi?.description, diff --git a/packages/tspec/src/generator/schemaBuilder.ts b/packages/tspec/src/generator/schemaBuilder.ts index 5f9374f..58bf14b 100644 --- a/packages/tspec/src/generator/schemaBuilder.ts +++ b/packages/tspec/src/generator/schemaBuilder.ts @@ -21,6 +21,21 @@ export interface TypeDefinition { name: string; properties: PropertyDefinition[]; description?: string; + /** + * Index signature type for Record, Map, or { [key: string]: T } + * TODO(cleanup): This field can be removed once TJS is fully integrated. + * @deprecated - Will be removed when TJS fallback is no longer needed + */ + indexSignature?: { + keyType: string; + valueType: string; + }; + /** + * Type parameter names for generic types (e.g., ['T'] for DataResponse, ['K', 'V'] for Map) + * TODO(cleanup): This field can be removed once TJS is fully integrated. + * @deprecated - Will be removed when TJS fallback is no longer needed + */ + typeParameters?: string[]; } export interface PropertyDefinition { @@ -40,6 +55,15 @@ export interface PropertyDefinition { maxLength?: number; pattern?: string; default?: unknown; + /** + * Index signature for Record, Map, or { [key: string]: T } properties + * TODO(cleanup): This field can be removed once TJS is fully integrated. + * @deprecated - Will be removed when TJS fallback is no longer needed + */ + indexSignature?: { + keyType: string; + valueType: string; + }; } export interface EnumDefinition { @@ -52,6 +76,8 @@ export interface SchemaBuilderContext { schemas: Record; typeDefinitions: Map; enumDefinitions: Map; + /** TJS-generated schemas as fallback for types not found in typeDefinitions */ + tjsSchemas?: Record; } /** @@ -160,26 +186,45 @@ export const buildSchemaRef = ( } // Handle generic types like DataResponse, PaginatedResponse, Record + // TODO(cleanup): This entire generic handling block can be removed once TJS is fully integrated. + // TJS already resolves generics, so this manual parsing is only needed as fallback. + // @deprecated - Will be removed when TJS fallback is no longer needed const genericMatch = typeName.match(/^(\w+)<(.+)>$/); if (genericMatch) { const [, wrapperType, innerType] = genericMatch; - // Look up the wrapper type definition and substitute the type parameter + // Look up the wrapper type definition const wrapperDef = typeDefinitions.get(wrapperType); + + // TODO(cleanup): indexSignature handling - TJS handles this automatically + // @deprecated - Will be removed when TJS fallback is no longer needed + // If type definition has indexSignature info (from TypeScript compiler), use it + if (wrapperDef?.indexSignature) { + const valueSchema = buildSchemaRef(wrapperDef.indexSignature.valueType, context); + const isUnknownValue = wrapperDef.indexSignature.valueType === 'unknown' || + wrapperDef.indexSignature.valueType === 'any' || + Object.keys(valueSchema).length === 0; + return { + type: 'object', + additionalProperties: isUnknownValue ? true : valueSchema, + }; + } + + // TODO(cleanup): Type parameter substitution - TJS handles this automatically + // @deprecated - Will be removed when TJS fallback is no longer needed + // Substitute type parameter for wrapper types with properties if (wrapperDef && wrapperDef.properties.length > 0) { const properties: Record = {}; const required: string[] = []; + + // Parse type arguments from innerType (e.g., "User" or "string, number") + const typeArgs = parseTypeArguments(innerType); + // Get type parameter names from wrapper definition (e.g., ['T'] or ['K', 'V']) + const typeParams = wrapperDef.typeParameters || ['T']; // fallback to 'T' for backward compatibility for (const prop of wrapperDef.properties) { - // Substitute type parameter T with the actual inner type - let propType = prop.type; - if (propType === 'T') { - propType = innerType; - } else if (propType === 'T[]') { - propType = `${innerType}[]`; - } else if (propType.includes('')) { - propType = propType.replace('', `<${innerType}>`); - } + // Substitute type parameters with actual type arguments + let propType = substituteTypeParameters(prop.type, typeParams, typeArgs); properties[prop.name] = buildSchemaRef(propType, context); if (prop.required) { @@ -194,8 +239,28 @@ export const buildSchemaRef = ( }; } - // Fallback: just resolve the inner type if wrapper definition not found - return buildSchemaRef(innerType, context); + // TODO(cleanup): Record/Map string parsing - TJS handles this automatically + // @deprecated - Will be removed when TJS fallback is no longer needed + // Fallback: parse Record and Map from string when no TypeScript info available + if (wrapperType === 'Record' || wrapperType === 'Map') { + const commaIndex = innerType.indexOf(','); + if (commaIndex !== -1) { + const valueType = innerType.slice(commaIndex + 1).trim(); + const valueSchema = buildSchemaRef(valueType, context); + const isUnknownValue = valueType === 'unknown' || valueType === 'any' || + Object.keys(valueSchema).length === 0; + return { + type: 'object', + additionalProperties: isUnknownValue ? true : valueSchema, + }; + } + } + + // Fallback for other unknown generic types + return { + type: 'object', + additionalProperties: true, + }; } // Handle Date type @@ -222,13 +287,43 @@ export const buildSchemaRef = ( // Register as a reference schema with resolved properties if (!schemas[schemaName]) { + const { tjsSchemas } = context; const typeDef = typeDefinitions.get(typeName); - if (typeDef && typeDef.properties.length > 0) { + + // Priority 1: Handle types with index signature (Record, Map, { [key: string]: T }) + if (typeDef?.indexSignature) { + const valueSchema = buildSchemaRef(typeDef.indexSignature.valueType, context); + const isUnknownValue = typeDef.indexSignature.valueType === 'unknown' || + typeDef.indexSignature.valueType === 'any' || + Object.keys(valueSchema).length === 0; + schemas[schemaName] = { + type: 'object', + description: typeDef.description, + additionalProperties: isUnknownValue ? true : valueSchema, + }; + } + // Priority 2: Use manual parsing with typeDefinitions (preserves JSDoc tags) + else if (typeDef && typeDef.properties.length > 0) { const properties: Record = {}; const required: string[] = []; for (const prop of typeDef.properties) { - const baseSchema = buildSchemaRef(prop.type, context); + let baseSchema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject; + + // If property has index signature info (from TypeScript's type checker), + // build schema with additionalProperties + if (prop.indexSignature) { + const valueSchema = buildSchemaRef(prop.indexSignature.valueType, context); + const isUnknownValue = prop.indexSignature.valueType === 'unknown' || + prop.indexSignature.valueType === 'any' || + Object.keys(valueSchema).length === 0; + baseSchema = { + type: 'object', + additionalProperties: isUnknownValue ? true : valueSchema, + }; + } else { + baseSchema = buildSchemaRef(prop.type, context); + } // Merge JSDoc tags into the schema (skip for $ref schemas) const propSchema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject = '$ref' in baseSchema @@ -247,7 +342,13 @@ export const buildSchemaRef = ( properties, required: required.length > 0 ? required : undefined, }; - } else { + } + // Priority 3: Use TJS schema as fallback for types not found in typeDefinitions + else if (tjsSchemas && (tjsSchemas[typeName] || tjsSchemas[schemaName])) { + schemas[schemaName] = tjsSchemas[typeName] || tjsSchemas[schemaName]; + } + // Priority 4: Empty object schema as last resort + else { schemas[schemaName] = { type: 'object', description: `Schema for ${typeName}`, @@ -258,6 +359,81 @@ export const buildSchemaRef = ( return { $ref: `#/components/schemas/${schemaName}` }; }; +/** + * Parse type arguments from a comma-separated string. + * Handles nested generics like "Map, User" + * + * TODO(cleanup): This function can be removed once TJS is fully integrated. + * TJS already resolves generics, so this manual parsing is only needed as fallback. + * @deprecated - Will be removed when TJS fallback is no longer needed + */ +const parseTypeArguments = (innerType: string): string[] => { + const args: string[] = []; + let depth = 0; + let current = ''; + + for (const char of innerType) { + if (char === '<') { + depth++; + current += char; + } else if (char === '>') { + depth--; + current += char; + } else if (char === ',' && depth === 0) { + args.push(current.trim()); + current = ''; + } else { + current += char; + } + } + + if (current.trim()) { + args.push(current.trim()); + } + + return args; +}; + +/** + * Substitute type parameters with actual type arguments. + * e.g., substituteTypeParameters("T", ["T"], ["User"]) => "User" + * substituteTypeParameters("T[]", ["T"], ["User"]) => "User[]" + * substituteTypeParameters("Array", ["T"], ["User"]) => "Array" + * + * TODO(cleanup): This function can be removed once TJS is fully integrated. + * TJS already resolves generics, so this manual parsing is only needed as fallback. + * @deprecated - Will be removed when TJS fallback is no longer needed + */ +const substituteTypeParameters = ( + propType: string, + typeParams: string[], + typeArgs: string[], +): string => { + let result = propType; + + for (let i = 0; i < typeParams.length && i < typeArgs.length; i++) { + const param = typeParams[i]; + const arg = typeArgs[i]; + + // Replace exact match (e.g., "T" -> "User") + if (result === param) { + return arg; + } + + // Replace array type (e.g., "T[]" -> "User[]") + if (result === `${param}[]`) { + return `${arg}[]`; + } + + // Replace in generic (e.g., "Array" -> "Array", "Map" -> "Map") + // Use word boundary to avoid replacing partial matches + const regex = new RegExp(`\\b${param}\\b`, 'g'); + result = result.replace(regex, arg); + } + + return result; +}; + // Parse inline object types like { status: string; message: string; } const parseInlineObjectType = ( typeName: string, @@ -302,8 +478,10 @@ const parseInlineObjectType = ( export const createSchemaBuilderContext = ( typeDefinitions?: Map, enumDefinitions?: Map, + tjsSchemas?: Record, ): SchemaBuilderContext => ({ schemas: {}, typeDefinitions: typeDefinitions || new Map(), enumDefinitions: enumDefinitions || new Map(), + tjsSchemas, }); diff --git a/packages/tspec/src/nestjs/openapiGenerator.ts b/packages/tspec/src/nestjs/openapiGenerator.ts index feaca12..49e2cef 100644 --- a/packages/tspec/src/nestjs/openapiGenerator.ts +++ b/packages/tspec/src/nestjs/openapiGenerator.ts @@ -14,6 +14,7 @@ import { createSchemaBuilderContext, SchemaBuilderContext, } from '../generator/schemaBuilder'; +import { convertToOpenapiSchemas } from '../generator/openapiSchemaConverter'; export interface GenerateOpenApiOptions { title?: string; @@ -42,12 +43,20 @@ const buildPropertySchema = ( return mergeJsDocAnnotations(baseSchema, prop); }; -export const generateOpenApiFromNest = ( +export const generateOpenApiFromNest = async ( app: ParsedNestApp, options: GenerateOpenApiOptions = {}, -): OpenAPIV3.Document => { +): Promise => { const paths: OpenAPIV3.PathsObject = {}; - const context = createSchemaBuilderContext(app.typeDefinitions, app.enumDefinitions); + + // If TJS schemas are available, convert them to OpenAPI format and use them + // This gives us fully resolved schemas without manual parsing + let tjsOpenApiSchemas: Record | undefined; + if (app.tjsSchemas) { + tjsOpenApiSchemas = await convertToOpenapiSchemas(app.tjsSchemas); + } + + const context = createSchemaBuilderContext(app.typeDefinitions, app.enumDefinitions, tjsOpenApiSchemas); for (const controller of app.controllers) { const basePath = controller.path ? `/${controller.path}`.replace(/\/+/g, '/') : ''; diff --git a/packages/tspec/src/nestjs/parser.ts b/packages/tspec/src/nestjs/parser.ts index f9afc59..7aa6311 100644 --- a/packages/tspec/src/nestjs/parser.ts +++ b/packages/tspec/src/nestjs/parser.ts @@ -1,5 +1,6 @@ import * as ts from 'typescript'; import { globSync } from 'glob'; +import * as TJS from 'typescript-json-schema'; import { NestControllerMetadata, @@ -196,7 +197,82 @@ export const parseNestControllers = (options: NestParserOptions): ParsedNestApp // Continue resolving nested types } - return { controllers, imports, typeDefinitions, enumDefinitions }; + // Generate TJS schemas as fallback for types not found in typeDefinitions + // Filter out generic type strings (e.g., "DataResponse") - TJS only accepts symbol names + const baseTypeNames = new Set(); + for (const typeName of typesToResolve) { + if (typeName.includes('<') || typeName.includes('[') || typeName.includes('|')) continue; + const primitives = ['string', 'number', 'boolean', 'void', 'undefined', 'null', 'any', 'unknown', 'never', 'object']; + if (primitives.includes(typeName.toLowerCase())) continue; + baseTypeNames.add(typeName); + } + const tjsSchemas = generateTjsSchemas(files, parsedConfig.options, baseTypeNames); + + return { controllers, imports, typeDefinitions, enumDefinitions, tjsSchemas }; +}; + +/** + * Generate JSON schemas for types using typescript-json-schema. + * TJS handles all the complex type resolution (generics, Record, Map, etc.) automatically. + */ +const generateTjsSchemas = ( + files: string[], + compilerOptions: ts.CompilerOptions, + typeNames: Set, +): Record | undefined => { + if (typeNames.size === 0) { + return undefined; + } + + try { + const tjsProgram = TJS.getProgramFromFiles(files, { + ...compilerOptions, + noEmit: true, + }); + + const tjsSettings: TJS.PartialArgs = { + required: true, + noExtraProps: true, + strictNullChecks: true, + ignoreErrors: true, + esModuleInterop: compilerOptions.esModuleInterop, + constAsEnum: true, + validationKeywords: [ + 'title', 'pattern', + 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf', + 'minLength', 'maxLength', + 'minItems', 'maxItems', 'uniqueItems', + 'minProperties', 'maxProperties', + 'format', 'description', 'default', + 'deprecated', 'example', 'nullable', + ], + }; + + const generator = TJS.buildGenerator(tjsProgram, tjsSettings); + if (!generator) { + return undefined; + } + + // Get schemas for all type names + // Filter to only include types that actually exist as symbols in the program + const typeNamesArray = Array.from(typeNames).filter((name) => { + const symbols = generator.getSymbols(name); + return symbols.length > 0; + }); + + if (typeNamesArray.length === 0) { + return undefined; + } + + const result = generator.getSchemaForSymbols(typeNamesArray); + + // Deep clone to break references to TypeScript compiler internals + return result.definitions ? JSON.parse(JSON.stringify(result.definitions)) : undefined; + } catch (error) { + // If TJS fails, fall back to manual parsing (existing behavior) + console.warn('TJS schema generation failed, falling back to manual parsing:', error); + return undefined; + } }; const parseController = ( @@ -551,6 +627,144 @@ const getTypeString = ( return checker.typeToString(type); }; +/** + * Extract index signature info from a type using TypeScript's type checker + * + * TODO(cleanup): This function can be removed once TJS is fully integrated. + * TJS already handles Record, Map, and index signatures automatically. + * @deprecated - Will be removed when TJS fallback is no longer needed + */ +const getIndexSignatureInfo = ( + typeNode: ts.TypeNode | undefined, + checker: ts.TypeChecker, +): { keyType: string; valueType: string } | undefined => { + if (!typeNode) return undefined; + + const type = checker.getTypeFromTypeNode(typeNode); + return getIndexSignatureInfoFromType(type, checker); +}; + +/** + * Extract index signature info from a resolved Type object + * + * TODO(cleanup): This function can be removed once TJS is fully integrated. + * TJS already handles Record, Map, and index signatures automatically. + * @deprecated - Will be removed when TJS fallback is no longer needed + */ +const getIndexSignatureInfoFromType = ( + type: ts.Type, + checker: ts.TypeChecker, +): { keyType: string; valueType: string } | undefined => { + // Skip primitive types - they have built-in index signatures but shouldn't be treated as Record/Map + // e.g., string[number] returns string, but we don't want to treat string as { [key: number]: string } + const typeString = checker.typeToString(type); + const primitives = ['string', 'number', 'boolean', 'symbol', 'bigint', 'void', 'undefined', 'null', 'any', 'unknown', 'never']; + if (primitives.includes(typeString.toLowerCase())) { + return undefined; + } + + // Skip array types - they have index signatures but should be treated as arrays, not Record/Map + // e.g., T[] or Array should become { type: 'array', items: ... } + if (typeString.endsWith('[]') || typeString.startsWith('Array<')) { + return undefined; + } + // Also check using TypeChecker's method for tuple/array detection + if (checker.isArrayType(type) || checker.isTupleType(type)) { + return undefined; + } + + // Skip enum types - they also have index signatures but shouldn't be treated as Record/Map + // Check if the type is an enum by checking its flags + if (type.isUnion()) { + // Enum types are represented as unions of literal types + const allLiterals = type.types.every((t) => + (t.flags & ts.TypeFlags.StringLiteral) !== 0 || + (t.flags & ts.TypeFlags.NumberLiteral) !== 0 + ); + if (allLiterals) { + return undefined; + } + } + + // Also skip if it's a string/number enum + if ((type.flags & ts.TypeFlags.Enum) !== 0 || (type.flags & ts.TypeFlags.EnumLiteral) !== 0) { + return undefined; + } + + const indexInfos = checker.getIndexInfosOfType(type); + + if (indexInfos.length > 0) { + // Get the first index signature (typically string index) + const indexInfo = indexInfos[0]; + const keyType = checker.typeToString(indexInfo.keyType); + const valueType = checker.typeToString(indexInfo.type); + return { keyType, valueType }; + } + + return undefined; +}; + +/** + * Resolve a type from TypeNode and extract its properties. + * This uses TypeScript's type checker to get the instantiated type, + * so generics like DataResponse are already resolved. + * + * TODO(cleanup): This function can be removed once TJS is fully integrated. + * TJS already resolves all types including generics automatically. + * @deprecated - Will be removed when TJS fallback is no longer needed + */ +const resolveTypeDefinition = ( + typeNode: ts.TypeNode, + checker: ts.TypeChecker, +): TypeDefinition | null => { + const type = checker.getTypeFromTypeNode(typeNode); + const typeName = checker.typeToString(type); + + // Check for index signature (Record, Map, { [key: string]: T }) + const indexSignature = getIndexSignatureInfoFromType(type, checker); + if (indexSignature) { + return { name: typeName, properties: [], indexSignature }; + } + + // Get properties from the resolved type + const properties: PropertyDefinition[] = []; + const typeProperties = type.getProperties(); + + for (const prop of typeProperties) { + const propName = prop.getName(); + const propType = checker.getTypeOfSymbolAtLocation(prop, typeNode); + const propTypeString = checker.typeToString(propType); + + // Check if property is optional + const declarations = prop.getDeclarations(); + const isOptional = declarations?.some((d) => + (ts.isPropertySignature(d) || ts.isPropertyDeclaration(d)) && !!d.questionToken + ) ?? false; + + // Get JSDoc from declaration + const declaration = declarations?.[0]; + const propDescription = declaration ? getJsDocDescription(declaration) : undefined; + const jsDocTags = declaration ? getJsDocTags(declaration) : {}; + + // Check for index signature on property type + const propIndexSignature = getIndexSignatureInfoFromType(propType, checker); + + const isArray = propTypeString.endsWith('[]') || propTypeString.startsWith('Array<'); + + properties.push({ + name: propName, + type: propTypeString, + required: !isOptional, + description: propDescription, + isArray, + indexSignature: propIndexSignature, + ...jsDocTags, + }); + } + + return { name: typeName, properties }; +}; + const getJsDocDescription = (node: ts.Node): string | undefined => { const jsDocComments = (node as any).jsDoc as ts.JSDoc[] | undefined; if (!jsDocComments?.length) return undefined; @@ -680,6 +894,14 @@ const collectTypeNames = (typeStr: string, typesToResolve: Set): void => } }; +// Extract type parameter names from a class or interface declaration +const getTypeParameters = (node: ts.ClassDeclaration | ts.InterfaceDeclaration): string[] | undefined => { + if (!node.typeParameters || node.typeParameters.length === 0) { + return undefined; + } + return node.typeParameters.map((tp) => tp.name.text); +}; + // Parse class or interface declaration to TypeDefinition const parseTypeDefinition = ( node: ts.ClassDeclaration | ts.InterfaceDeclaration, @@ -690,6 +912,7 @@ const parseTypeDefinition = ( const properties: PropertyDefinition[] = []; const description = getJsDocDescription(node); + const typeParameters = getTypeParameters(node); node.members.forEach((member) => { if (ts.isPropertyDeclaration(member) || ts.isPropertySignature(member)) { @@ -703,6 +926,9 @@ const parseTypeDefinition = ( // Check if it's an array type const isArray = propType.endsWith('[]') || propType.startsWith('Array<'); + + // Extract index signature info for Record, Map, etc. + const indexSignature = getIndexSignatureInfo(member.type, checker); properties.push({ name: propName, @@ -710,12 +936,13 @@ const parseTypeDefinition = ( required, description: propDescription, isArray, + indexSignature, ...jsDocTags, }); } }); - return { name: typeName, properties, description }; + return { name: typeName, properties, description, typeParameters }; }; // Parse type alias declaration to TypeDefinition @@ -739,6 +966,7 @@ const parseTypeAliasDefinition = ( const required = !member.questionToken; const propDescription = getJsDocDescription(member); const isArray = propType.endsWith('[]') || propType.startsWith('Array<'); + const indexSignature = getIndexSignatureInfo(member.type, checker); properties.push({ name: propName, @@ -746,6 +974,7 @@ const parseTypeAliasDefinition = ( required, description: propDescription, isArray, + indexSignature, }); } }); @@ -753,6 +982,12 @@ const parseTypeAliasDefinition = ( return { name: typeName, properties, description }; } + // Check if the type alias itself is a Record/Map type (e.g., type MyRecord = Record) + const indexSignature = getIndexSignatureInfo(node.type, checker); + if (indexSignature) { + return { name: typeName, properties: [], description, indexSignature }; + } + // For other type aliases, return empty properties return { name: typeName, properties: [], description }; }; diff --git a/packages/tspec/src/nestjs/types.ts b/packages/tspec/src/nestjs/types.ts index 1cd8c8c..0feff14 100644 --- a/packages/tspec/src/nestjs/types.ts +++ b/packages/tspec/src/nestjs/types.ts @@ -49,6 +49,8 @@ export interface ParsedNestApp { imports: Map; // typeName -> import path typeDefinitions: Map; // typeName -> type definition enumDefinitions: Map; // enumName -> enum definition + /** TJS-generated schemas for all types (already resolved, no manual parsing needed) */ + tjsSchemas?: Record; } export interface EnumDefinition { @@ -61,6 +63,21 @@ export interface TypeDefinition { name: string; properties: PropertyDefinition[]; description?: string; + /** + * Index signature type for Record, Map, or { [key: string]: T } + * TODO(cleanup): This field can be removed once TJS is fully integrated. + * @deprecated - Will be removed when TJS fallback is no longer needed + */ + indexSignature?: { + keyType: string; + valueType: string; + }; + /** + * Type parameter names for generic types (e.g., ['T'] for DataResponse, ['K', 'V'] for Map) + * TODO(cleanup): This field can be removed once TJS is fully integrated. + * @deprecated - Will be removed when TJS fallback is no longer needed + */ + typeParameters?: string[]; } export interface PropertyDefinition { @@ -80,4 +97,13 @@ export interface PropertyDefinition { maxLength?: number; pattern?: string; default?: unknown; + /** + * Index signature for Record, Map, or { [key: string]: T } properties + * TODO(cleanup): This field can be removed once TJS is fully integrated. + * @deprecated - Will be removed when TJS fallback is no longer needed + */ + indexSignature?: { + keyType: string; + valueType: string; + }; } diff --git a/packages/tspec/src/test/nestjs/fixtures/users.controller.ts b/packages/tspec/src/test/nestjs/fixtures/users.controller.ts index 77c72f3..0933e3a 100644 --- a/packages/tspec/src/test/nestjs/fixtures/users.controller.ts +++ b/packages/tspec/src/test/nestjs/fixtures/users.controller.ts @@ -142,6 +142,21 @@ export class PaginatedResponse { totalCount?: number | null; } +/** + * 사용자 목록 응답 DTO (배열 프로퍼티 테스트용) + */ +export class UserListResponseDto { + /** + * 사용자 목록 + */ + users!: UserDto[]; + + /** + * 총 개수 + */ + totalCount!: number; +} + /** * 사용자 목록 조회 쿼리 DTO */ @@ -215,4 +230,12 @@ export class UsersController { ): Promise> { return Promise.resolve({ data: {} as UserDto }); } + + /** + * 사용자 목록 조회 (배열 프로퍼티 테스트용) + */ + @Get('list') + getList(): Promise { + return Promise.resolve({ users: [], totalCount: 0 }); + } } diff --git a/packages/tspec/src/test/nestjs/parser.test.ts b/packages/tspec/src/test/nestjs/parser.test.ts index 21adcac..36477c2 100644 --- a/packages/tspec/src/test/nestjs/parser.test.ts +++ b/packages/tspec/src/test/nestjs/parser.test.ts @@ -81,13 +81,13 @@ describe('NestJS Parser', () => { }); describe('generateOpenApiFromNest', () => { - it('should generate valid OpenAPI spec', () => { + it('should generate valid OpenAPI spec', async () => { const result = parseNestControllers({ tsconfigPath: path.join(fixturesPath, 'tsconfig.json'), controllerGlobs: [path.join(fixturesPath, 'books.controller.ts')], }); - const openapi = generateOpenApiFromNest(result, { + const openapi = await generateOpenApiFromNest(result, { title: 'Books API', version: '1.0.0', }); @@ -103,13 +103,13 @@ describe('NestJS Parser', () => { expect(openapi.paths['/books/{id}']?.delete).toBeDefined(); }); - it('should generate multipart/form-data for file uploads', () => { + it('should generate multipart/form-data for file uploads', async () => { const result = parseNestControllers({ tsconfigPath: path.join(fixturesPath, 'tsconfig.json'), controllerGlobs: [path.join(fixturesPath, 'files.controller.ts')], }); - const openapi = generateOpenApiFromNest(result, { + const openapi = await generateOpenApiFromNest(result, { title: 'Files API', version: '1.0.0', }); @@ -143,13 +143,13 @@ describe('NestJS Parser', () => { expect(metadataSchema.properties.document).toEqual({ type: 'string', format: 'binary' }); }); - it('should include DTO fields in multipart/form-data schema (Issue #87)', () => { + it('should include DTO fields in multipart/form-data schema (Issue #87)', async () => { const result = parseNestControllers({ tsconfigPath: path.join(fixturesPath, 'tsconfig.json'), controllerGlobs: [path.join(fixturesPath, 'files.controller.ts')], }); - const openapi = generateOpenApiFromNest(result, { + const openapi = await generateOpenApiFromNest(result, { title: 'Files API', version: '1.0.0', }); @@ -169,13 +169,13 @@ describe('NestJS Parser', () => { expect(fromImageSchema.properties.memo).toBeDefined(); }); - it('should generate 200 response when only error responses are defined via @ApiResponse (Issue #87)', () => { + it('should generate 200 response when only error responses are defined via @ApiResponse (Issue #87)', async () => { const result = parseNestControllers({ tsconfigPath: path.join(fixturesPath, 'tsconfig.json'), controllerGlobs: [path.join(fixturesPath, 'files.controller.ts')], }); - const openapi = generateOpenApiFromNest(result, { + const openapi = await generateOpenApiFromNest(result, { title: 'Files API', version: '1.0.0', }); diff --git a/packages/tspec/src/test/nestjs/schema.test.ts b/packages/tspec/src/test/nestjs/schema.test.ts index 38f2edb..d84e518 100644 --- a/packages/tspec/src/test/nestjs/schema.test.ts +++ b/packages/tspec/src/test/nestjs/schema.test.ts @@ -6,7 +6,7 @@ import { generateOpenApiFromNest } from '../../nestjs/openapiGenerator'; describe('NestJS Schema Generation', () => { const fixturesPath = path.join(__dirname, 'fixtures'); - const getOpenApiSpec = () => { + const getOpenApiSpec = async () => { const result = parseNestControllers({ tsconfigPath: path.join(fixturesPath, 'tsconfig.json'), controllerGlobs: [path.join(fixturesPath, 'users.controller.ts')], @@ -19,8 +19,8 @@ describe('NestJS Schema Generation', () => { }; describe('JSDoc parsing', () => { - it('should parse @example tag', () => { - const openapi = getOpenApiSpec(); + it('should parse @example tag', async () => { + const openapi = await getOpenApiSpec(); const userDto = openapi.components?.schemas?.UserDto as any; expect(userDto).toBeDefined(); @@ -29,16 +29,16 @@ describe('NestJS Schema Generation', () => { expect(userDto.properties.name.example).toBe('홍길동'); }); - it('should parse @minimum and @maximum tags', () => { - const openapi = getOpenApiSpec(); + it('should parse @minimum and @maximum tags', async () => { + const openapi = await getOpenApiSpec(); const userDto = openapi.components?.schemas?.UserDto as any; expect(userDto.properties.age.minimum).toBe(0); expect(userDto.properties.age.maximum).toBe(150); }); - it('should parse @minLength and @maxLength tags', () => { - const openapi = getOpenApiSpec(); + it('should parse @minLength and @maxLength tags', async () => { + const openapi = await getOpenApiSpec(); const createUserDto = openapi.components?.schemas?.CreateUserDto as any; expect(createUserDto.properties.name.minLength).toBe(2); @@ -46,8 +46,8 @@ describe('NestJS Schema Generation', () => { expect(createUserDto.properties.password.minLength).toBe(8); }); - it('should parse @pattern tag', () => { - const openapi = getOpenApiSpec(); + it('should parse @pattern tag', async () => { + const openapi = await getOpenApiSpec(); const createUserDto = openapi.components?.schemas?.CreateUserDto as any; expect(createUserDto.properties.email.pattern).toBe( @@ -55,8 +55,8 @@ describe('NestJS Schema Generation', () => { ); }); - it('should parse @deprecated tag', () => { - const openapi = getOpenApiSpec(); + it('should parse @deprecated tag', async () => { + const openapi = await getOpenApiSpec(); const userDto = openapi.components?.schemas?.UserDto as any; expect(userDto.properties.legacyField.deprecated).toBe(true); @@ -64,8 +64,8 @@ describe('NestJS Schema Generation', () => { }); describe('Enum parsing', () => { - it('should parse enum types with values', () => { - const openapi = getOpenApiSpec(); + it('should parse enum types with values', async () => { + const openapi = await getOpenApiSpec(); const genderSchema = openapi.components?.schemas?.Gender as any; const activityLevelSchema = openapi.components?.schemas?.ActivityLevel as any; @@ -78,8 +78,8 @@ describe('NestJS Schema Generation', () => { expect(activityLevelSchema.enum).toEqual(['SEDENTARY', 'MODERATELY_ACTIVE', 'VERY_ACTIVE']); }); - it('should reference enum in property schema', () => { - const openapi = getOpenApiSpec(); + it('should reference enum in property schema', async () => { + const openapi = await getOpenApiSpec(); const userDto = openapi.components?.schemas?.UserDto as any; // Gender property should reference the enum schema @@ -94,8 +94,8 @@ describe('NestJS Schema Generation', () => { }); describe('Nullable types', () => { - it('should handle nullable union types', () => { - const openapi = getOpenApiSpec(); + it('should handle nullable union types', async () => { + const openapi = await getOpenApiSpec(); const userDto = openapi.components?.schemas?.UserDto as any; expect(userDto.properties.email.nullable).toBe(true); @@ -104,8 +104,8 @@ describe('NestJS Schema Generation', () => { }); describe('Generic wrapper types', () => { - it('should resolve DataResponse wrapper', () => { - const openapi = getOpenApiSpec(); + it('should resolve DataResponse wrapper', async () => { + const openapi = await getOpenApiSpec(); const findOnePath = openapi.paths['/users/{id}']?.get; expect(findOnePath).toBeDefined(); @@ -117,8 +117,8 @@ describe('NestJS Schema Generation', () => { expect(responseSchema.properties.data.$ref).toBe('#/components/schemas/UserDto'); }); - it('should resolve PaginatedResponse wrapper', () => { - const openapi = getOpenApiSpec(); + it('should resolve PaginatedResponse wrapper', async () => { + const openapi = await getOpenApiSpec(); const findAllPath = openapi.paths['/users']?.get; expect(findAllPath).toBeDefined(); @@ -135,8 +135,8 @@ describe('NestJS Schema Generation', () => { }); describe('Date types', () => { - it('should convert Date to string with date-time format', () => { - const openapi = getOpenApiSpec(); + it('should convert Date to string with date-time format', async () => { + const openapi = await getOpenApiSpec(); const userDto = openapi.components?.schemas?.UserDto as any; expect(userDto.properties.createdAt.type).toBe('string'); @@ -145,8 +145,8 @@ describe('NestJS Schema Generation', () => { }); describe('API Tags', () => { - it('should parse @ApiTags decorator', () => { - const openapi = getOpenApiSpec(); + it('should parse @ApiTags decorator', async () => { + const openapi = await getOpenApiSpec(); const findAllPath = openapi.paths['/users']?.get; expect(findAllPath?.tags).toContain('Users'); @@ -154,8 +154,8 @@ describe('NestJS Schema Generation', () => { }); describe('Required properties', () => { - it('should mark non-optional properties as required', () => { - const openapi = getOpenApiSpec(); + it('should mark non-optional properties as required', async () => { + const openapi = await getOpenApiSpec(); const userDto = openapi.components?.schemas?.UserDto as any; expect(userDto.required).toContain('id'); @@ -167,8 +167,8 @@ describe('NestJS Schema Generation', () => { }); describe('Record types (Issue #89)', () => { - it('should handle Record without creating invalid schema name', () => { - const openapi = getOpenApiSpec(); + it('should handle Record without creating invalid schema name', async () => { + const openapi = await getOpenApiSpec(); const createUserDto = openapi.components?.schemas?.CreateUserDto as any; // meta property should be object with additionalProperties @@ -181,9 +181,30 @@ describe('NestJS Schema Generation', () => { }); }); + describe('Array property types', () => { + it('should handle array properties correctly (not as Record/Map)', async () => { + const openapi = await getOpenApiSpec(); + const userListResponseDto = openapi.components?.schemas?.UserListResponseDto as any; + + expect(userListResponseDto).toBeDefined(); + expect(userListResponseDto.properties.users).toBeDefined(); + + // users property should be an array, NOT an object with additionalProperties + expect(userListResponseDto.properties.users.type).toBe('array'); + expect(userListResponseDto.properties.users.items).toBeDefined(); + expect(userListResponseDto.properties.users.items.$ref).toBe('#/components/schemas/UserDto'); + + // Should NOT have additionalProperties (which would indicate Record/Map treatment) + expect(userListResponseDto.properties.users.additionalProperties).toBeUndefined(); + + // totalCount should be a number + expect(userListResponseDto.properties.totalCount.type).toBe('number'); + }); + }); + describe('@Query() DTO expansion (Issue #91)', () => { - it('should expand @Query() DTO into individual query parameters', () => { - const openapi = getOpenApiSpec(); + it('should expand @Query() DTO into individual query parameters', async () => { + const openapi = await getOpenApiSpec(); const findAllPath = openapi.paths['/users']?.get; expect(findAllPath).toBeDefined(); @@ -225,8 +246,8 @@ describe('NestJS Schema Generation', () => { expect(nameParam.example).toBe('홍길동'); }); - it('should not have a single "query" parameter for DTO', () => { - const openapi = getOpenApiSpec(); + it('should not have a single "query" parameter for DTO', async () => { + const openapi = await getOpenApiSpec(); const findAllPath = openapi.paths['/users']?.get; const params = findAllPath?.parameters as any[]; diff --git a/packages/tspec/src/test/schemaBuilder.test.ts b/packages/tspec/src/test/schemaBuilder.test.ts index b206448..a6e9b59 100644 --- a/packages/tspec/src/test/schemaBuilder.test.ts +++ b/packages/tspec/src/test/schemaBuilder.test.ts @@ -79,13 +79,13 @@ describe('schemaBuilder', () => { expect(context.schemas['string, unknown']).toBeUndefined(); }); - it('should handle unknown generic types as object with additionalProperties', () => { + it('should handle Map with typed additionalProperties', () => { const context = createSchemaBuilderContext(); const schema = buildSchemaRef('Map', context); expect(schema).toEqual({ type: 'object', - additionalProperties: true, + additionalProperties: { type: 'number' }, }); }); });