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
9 changes: 9 additions & 0 deletions packages/tspec/src/generator/schemaBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
78 changes: 62 additions & 16 deletions packages/tspec/src/nestjs/openapiGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, OpenAPIV3.SchemaObject> = {};
const required: string[] = [];
Expand All @@ -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);
}
}
}
}
Expand All @@ -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: {
Expand Down
63 changes: 63 additions & 0 deletions packages/tspec/src/test/nestjs/fixtures/files.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {};
Expand Down Expand Up @@ -134,4 +175,26 @@ export class FilesController {
): Promise<UploadResponse> {
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<UploadResponse> {
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<UploadResponse> {
return Promise.resolve({ fileName: '', url: '' });
}
}
73 changes: 72 additions & 1 deletion packages/tspec/src/test/nestjs/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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'),
Expand Down