diff --git a/packages/tspec/src/generator/schemaBuilder.ts b/packages/tspec/src/generator/schemaBuilder.ts index 58bf14b..7d7c85b 100644 --- a/packages/tspec/src/generator/schemaBuilder.ts +++ b/packages/tspec/src/generator/schemaBuilder.ts @@ -88,6 +88,15 @@ export const mergeJsDocAnnotations = ( baseSchema: OpenAPIV3.SchemaObject, prop: PropertyDefinition, ): OpenAPIV3.SchemaObject => { + // If @format binary is specified, treat as file upload regardless of original type + if (prop.format === 'binary') { + return { + type: 'string', + format: 'binary', + description: prop.description, + }; + } + const schema: OpenAPIV3.SchemaObject = { ...baseSchema, description: prop.description || baseSchema.description, diff --git a/packages/tspec/src/nestjs/openapiGenerator.ts b/packages/tspec/src/nestjs/openapiGenerator.ts index 49e2cef..d3eef4b 100644 --- a/packages/tspec/src/nestjs/openapiGenerator.ts +++ b/packages/tspec/src/nestjs/openapiGenerator.ts @@ -101,11 +101,20 @@ const buildOperation = ( const parameters: OpenAPIV3.ParameterObject[] = []; let requestBody: OpenAPIV3.RequestBodyObject | undefined; - // Check for file upload parameters + // Check for file upload parameters (from @UploadedFile/@UploadedFiles decorators) const fileParams = method.parameters.filter(p => p.category === 'file' || p.category === 'files'); const bodyParams = method.parameters.filter(p => p.category === 'body'); - if (fileParams.length > 0) { + // Check if body DTO contains file upload properties (@format binary JSDoc tag) + const bodyHasFileUpload = bodyParams.some(bodyParam => { + const typeDef = context.typeDefinitions.get(bodyParam.type); + if (typeDef) { + return typeDef.properties.some(prop => prop.format === 'binary'); + } + return false; + }); + + if (fileParams.length > 0 || bodyHasFileUpload) { // Build multipart/form-data request body for file uploads const properties: Record = {}; const required: string[] = []; @@ -127,22 +136,59 @@ const buildOperation = ( // Include body params in multipart form if present for (const bodyParam of bodyParams) { - const bodySchema = buildSchemaRefFromBuilder(bodyParam.type, context); + const typeDef = context.typeDefinitions.get(bodyParam.type); - // If it's a $ref, resolve it from context.schemas to get properties - if ('$ref' in bodySchema) { - const refName = bodySchema.$ref.replace('#/components/schemas/', ''); - const resolvedSchema = context.schemas[refName]; - if (resolvedSchema && 'properties' in resolvedSchema && resolvedSchema.properties) { - Object.assign(properties, resolvedSchema.properties); - if (resolvedSchema.required) { - required.push(...resolvedSchema.required); + if (typeDef) { + // Build properties directly from type definition to handle @format binary and MulterFile + for (const prop of typeDef.properties) { + // Check if property has @format binary JSDoc tag + if (prop.format === 'binary') { + // Handle array of files + if (prop.type.endsWith('[]')) { + properties[prop.name] = { + type: 'array', + items: { type: 'string', format: 'binary' }, + description: prop.description, + }; + } else { + properties[prop.name] = { + type: 'string', + format: 'binary', + description: prop.description, + }; + } + } else { + // Regular property - build schema with JSDoc annotations + const propSchema = buildSchemaRefFromBuilder(prop.type, context); + if ('$ref' in propSchema) { + properties[prop.name] = propSchema as OpenAPIV3.SchemaObject; + } else { + properties[prop.name] = mergeJsDocAnnotations(propSchema, prop); + } + } + + if (prop.required) { + required.push(prop.name); } } - } else if ('properties' in bodySchema && bodySchema.properties) { - Object.assign(properties, bodySchema.properties); - if (bodySchema.required) { - required.push(...bodySchema.required); + } else { + // Fallback: resolve from built schema + const bodySchema = buildSchemaRefFromBuilder(bodyParam.type, context); + + if ('$ref' in bodySchema) { + const refName = bodySchema.$ref.replace('#/components/schemas/', ''); + const resolvedSchema = context.schemas[refName]; + if (resolvedSchema && 'properties' in resolvedSchema && resolvedSchema.properties) { + Object.assign(properties, resolvedSchema.properties); + if (resolvedSchema.required) { + required.push(...resolvedSchema.required); + } + } + } else if ('properties' in bodySchema && bodySchema.properties) { + Object.assign(properties, bodySchema.properties); + if (bodySchema.required) { + required.push(...bodySchema.required); + } } } } @@ -151,7 +197,7 @@ const buildOperation = ( const uniqueRequired = [...new Set(required)]; requestBody = { - required: fileParams.some(p => p.required), + required: fileParams.some(p => p.required) || bodyHasFileUpload, content: { 'multipart/form-data': { schema: { diff --git a/packages/tspec/src/test/nestjs/fixtures/files.controller.ts b/packages/tspec/src/test/nestjs/fixtures/files.controller.ts index 3ec6f0a..40abedf 100644 --- a/packages/tspec/src/test/nestjs/fixtures/files.controller.ts +++ b/packages/tspec/src/test/nestjs/fixtures/files.controller.ts @@ -66,6 +66,47 @@ class CreateFromImageDto { memo?: string; } +/** + * DTO with file property using @format binary JSDoc tag + * Use @format binary to indicate file upload fields + */ +class CreateFoodIntakeFromImageDto { + /** + * 음식 이미지 파일 + * @format binary + */ + file!: any; + + /** + * 섭취 시간 (미입력 시 현재 시간) + * @format date-time + * @example "2024-11-24T12:30:00.000Z" + */ + intakeAt?: string; +} + +/** + * DTO with multiple file properties using @format binary + */ +class CreateWithMultipleFilesDto { + /** + * 메인 이미지 + * @format binary + */ + mainImage!: any; + + /** + * 썸네일 이미지 + * @format binary + */ + thumbnail!: any; + + /** + * 제목 + */ + title!: string; +} + // Mock ApiResponse decorator function ApiResponse(options: { status: number; description?: string; type?: any }): MethodDecorator { return () => {}; @@ -134,4 +175,26 @@ export class FilesController { ): Promise { return Promise.resolve({ fileName: '', url: '' }); } + + /** + * Upload file using @format binary in DTO (no @UploadedFile decorator) + * @summary Create food intake from image using DTO with @format binary + */ + @Post('food-intake-from-image') + createFoodIntakeFromImage( + @Body() dto: CreateFoodIntakeFromImageDto, + ): Promise { + return Promise.resolve({ fileName: '', url: '' }); + } + + /** + * Upload multiple files using @format binary in DTO + * @summary Create with multiple file fields + */ + @Post('with-multiple-files') + createWithMultipleFiles( + @Body() dto: CreateWithMultipleFilesDto, + ): Promise { + return Promise.resolve({ fileName: '', url: '' }); + } } diff --git a/packages/tspec/src/test/nestjs/parser.test.ts b/packages/tspec/src/test/nestjs/parser.test.ts index 36477c2..409f56e 100644 --- a/packages/tspec/src/test/nestjs/parser.test.ts +++ b/packages/tspec/src/test/nestjs/parser.test.ts @@ -50,7 +50,7 @@ describe('NestJS Parser', () => { const controller = result.controllers[0]; expect(controller.name).toBe('FilesController'); expect(controller.path).toBe('files'); - expect(controller.methods).toHaveLength(5); + expect(controller.methods).toHaveLength(7); // Single file upload const uploadFile = controller.methods.find((m) => m.name === 'uploadFile'); @@ -169,6 +169,77 @@ describe('NestJS Parser', () => { expect(fromImageSchema.properties.memo).toBeDefined(); }); + it('should generate multipart/form-data for DTO with @format binary properties', async () => { + const result = parseNestControllers({ + tsconfigPath: path.join(fixturesPath, 'tsconfig.json'), + controllerGlobs: [path.join(fixturesPath, 'files.controller.ts')], + }); + + const openapi = await generateOpenApiFromNest(result, { + title: 'Files API', + version: '1.0.0', + }); + + // Endpoint using @format binary in DTO (no @UploadedFile decorator) + const foodIntakePath = openapi.paths['/files/food-intake-from-image']; + expect(foodIntakePath?.post).toBeDefined(); + const foodIntakeOp = foodIntakePath?.post as any; + + // Should use multipart/form-data content type + expect(foodIntakeOp.requestBody.content['multipart/form-data']).toBeDefined(); + const schema = foodIntakeOp.requestBody.content['multipart/form-data'].schema; + + // File field should be binary + expect(schema.properties.file).toEqual({ + type: 'string', + format: 'binary', + description: '음식 이미지 파일', + }); + + // Other fields should be normal + expect(schema.properties.intakeAt).toBeDefined(); + expect(schema.properties.intakeAt.format).toBe('date-time'); + + // Required should include file + expect(schema.required).toContain('file'); + }); + + it('should handle multiple @format binary properties in DTO', async () => { + const result = parseNestControllers({ + tsconfigPath: path.join(fixturesPath, 'tsconfig.json'), + controllerGlobs: [path.join(fixturesPath, 'files.controller.ts')], + }); + + const openapi = await generateOpenApiFromNest(result, { + title: 'Files API', + version: '1.0.0', + }); + + // Endpoint with multiple file fields + const multiFilePath = openapi.paths['/files/with-multiple-files']; + expect(multiFilePath?.post).toBeDefined(); + const multiFileOp = multiFilePath?.post as any; + + expect(multiFileOp.requestBody.content['multipart/form-data']).toBeDefined(); + const schema = multiFileOp.requestBody.content['multipart/form-data'].schema; + + // Both file fields should be binary + expect(schema.properties.mainImage).toEqual({ + type: 'string', + format: 'binary', + description: '메인 이미지', + }); + expect(schema.properties.thumbnail).toEqual({ + type: 'string', + format: 'binary', + description: '썸네일 이미지', + }); + + // Non-file field should be normal + expect(schema.properties.title.type).toBe('string'); + expect(schema.properties.title.format).toBeUndefined(); + }); + 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'),