diff --git a/__tests__/fixtures/controllers.ts b/__tests__/fixtures/controllers.ts index f6a7452..ffd57c2 100644 --- a/__tests__/fixtures/controllers.ts +++ b/__tests__/fixtures/controllers.ts @@ -18,6 +18,8 @@ import { Put, QueryParam, QueryParams, + UploadedFile, + UploadedFiles, } from 'routing-controllers' import { OpenAPI, ResponseSchema } from '../../src' @@ -39,6 +41,11 @@ export class CreatePostBody { content: string[] } +export class CreateUserPostImagesBody { + @IsString() + description: string +} + export class ListUsersQueryParams { @IsOptional() @IsEmail() @@ -154,6 +161,11 @@ export class UsersController { ) { return } + + @Put('/:userId/avatar') + putUserAvatar(@UploadedFile('image') _image: any) { + return + } } @Controller('/users/:userId/posts') @@ -170,6 +182,15 @@ export class UserPostsController { patchUserPost(@BodyParam('token') _token: string) { return } + + @Post('/:postId/images') + createUserPostImages( + @Body({ required: true }) _body: CreateUserPostImagesBody, + @BodyParam('token') _token: string, + @UploadedFiles('images') _images: any[] + ) { + return + } } @Controller() diff --git a/__tests__/fixtures/spec.json b/__tests__/fixtures/spec.json index 65ccad8..77571ca 100644 --- a/__tests__/fixtures/spec.json +++ b/__tests__/fixtures/spec.json @@ -59,6 +59,17 @@ ], "type": "object" }, + "CreateUserPostImagesBody": { + "properties": { + "description": { + "type": "string" + } + }, + "required": [ + "description" + ], + "type": "object" + }, "ListUsersHeaderParams": { "properties": { "Authorization": { @@ -432,6 +443,72 @@ ] } }, + "/api/users/{userId}/posts/{postId}/images": { + "post": { + "operationId": "UserPostsController.createUserPostImages", + "parameters": [ + { + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "postId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/CreateUserPostImagesBody" + }, + { + "properties": { + "images": { + "items": { + "format": "binary", + "type": "string" + }, + "type": "array" + }, + "token": { + "type": "string" + } + }, + "required": [], + "type": "object" + } + ] + } + } + }, + "description": "CreateUserPostImagesBody", + "required": true + }, + "responses": { + "200": { + "content": { + "text/html; charset=utf-8": {} + }, + "description": "Successful response" + } + }, + "summary": "Create user post images", + "tags": [ + "User Posts" + ] + } + }, "/api/users/{version}": { "delete": { "operationId": "UsersController.deleteUsersByVersion", @@ -503,6 +580,49 @@ ] } }, + "/api/users/{userId}/avatar": { + "put": { + "operationId": "UsersController.putUserAvatar", + "parameters": [ + { + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "properties": { + "image": { + "format": "binary", + "type": "string" + } + }, + "required": [], + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": {} + }, + "description": "Successful response" + } + }, + "summary": "Put user avatar", + "tags": [ + "Users" + ] + } + }, "/api/users/{userId}/posts": { "post": { "operationId": "UsersController.createUserPost", @@ -638,4 +758,4 @@ } } } -} +} \ No newline at end of file diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index e03b01c..f708c04 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -116,6 +116,12 @@ describe('index', () => { target: UsersController, type: 'put', }, + { + method: 'putUserAvatar', + route: '/:userId/avatar', + target: UsersController, + type: 'put', + }, { method: 'getUserPost', route: '/:postId', @@ -128,6 +134,12 @@ describe('index', () => { target: UserPostsController, type: 'patch', }, + { + method: 'createUserPostImages', + route: '/:postId/images', + target: UserPostsController, + type: 'post', + }, { method: 'getDefaultPath', route: undefined, @@ -297,4 +309,62 @@ describe('getRequestBody', () => { required: true, }) }) + + it('parse a single `UploadedFile` metadata into a single `object` schema under content-type `multipart/form-data`', () => { + const route = routes.find((d) => d.action.method === 'putUserAvatar')! + expect(route).toBeDefined() + expect(getRequestBody(route)).toEqual({ + content: { + 'multipart/form-data': { + schema: { + properties: { + image: { + format: 'binary', + type: 'string', + }, + }, + required: [], + type: 'object', + }, + }, + }, + }) + }) + it('wrap `body` and others metadata containing `UploadedFiles` items under a single `allOf` schema under content-type `multipart/form-data`', () => { + const route = routes.find( + (d) => d.action.method === 'createUserPostImages' + )! + expect(route).toBeDefined() + expect(getRequestBody(route)).toEqual({ + content: { + 'multipart/form-data': { + schema: { + allOf: [ + { + $ref: '#/components/schemas/CreateUserPostImagesBody', + }, + { + properties: { + images: { + items: { + format: 'binary', + type: 'string', + }, + type: 'array', + }, + token: { + type: 'string', + }, + }, + required: [], + type: 'object', + }, + ], + }, + }, + }, + description: 'CreateUserPostImagesBody', + required: true, + }) + }) }) diff --git a/src/generateSpec.ts b/src/generateSpec.ts index e15602c..c0d40f8 100644 --- a/src/generateSpec.ts +++ b/src/generateSpec.ts @@ -191,19 +191,43 @@ export function getQueryParams( return queries } +function getNamedParamSchema( + param: ParamMetadataArgs +): oa.SchemaObject | oa.ReferenceObject { + const { type } = param + if (type === 'file') { + return { type: 'string', format: 'binary' } + } + if (type === 'files') { + return { + type: 'array', + items: { + type: 'string', + format: 'binary', + }, + } + } + return getParamSchema(param) +} + /** * Return OpenAPI requestBody of given route, if it has one. */ export function getRequestBody(route: IRoute): oa.RequestBodyObject | void { const bodyParamMetas = route.params.filter((d) => d.type === 'body-param') - const bodyParamsSchema: oa.SchemaObject | null = - bodyParamMetas.length > 0 - ? bodyParamMetas.reduce( + const uploadFileMetas = route.params.filter((d) => + ['file', 'files'].includes(d.type) + ) + const namedParamMetas = [...bodyParamMetas, ...uploadFileMetas] + + const namedParamsSchema: oa.SchemaObject | null = + namedParamMetas.length > 0 + ? namedParamMetas.reduce( (acc: oa.SchemaObject, d) => ({ ...acc, properties: { ...acc.properties, - [d.name!]: getParamSchema(d), + [d.name!]: getNamedParamSchema(d), }, required: isRequired(d, route) ? [...(acc.required || []), d.name!] @@ -213,8 +237,10 @@ export function getRequestBody(route: IRoute): oa.RequestBodyObject | void { ) : null - const bodyMeta = route.params.find((d) => d.type === 'body') + const contentType = + uploadFileMetas.length > 0 ? 'multipart/form-data' : 'application/json' + const bodyMeta = route.params.find((d) => d.type === 'body') if (bodyMeta) { const bodySchema = getParamSchema(bodyMeta) const { $ref } = @@ -222,18 +248,18 @@ export function getRequestBody(route: IRoute): oa.RequestBodyObject | void { return { content: { - 'application/json': { - schema: bodyParamsSchema - ? { allOf: [bodySchema, bodyParamsSchema] } + [contentType]: { + schema: namedParamsSchema + ? { allOf: [bodySchema, namedParamsSchema] } : bodySchema, }, }, description: ($ref || '').split('/').pop(), required: isRequired(bodyMeta, route), } - } else if (bodyParamsSchema) { + } else if (namedParamsSchema) { return { - content: { 'application/json': { schema: bodyParamsSchema } }, + content: { [contentType]: { schema: namedParamsSchema } }, } } }