Skip to content

Commit 4251ec5

Browse files
authored
✨support file upload decorator (#76)
* ✨support file upload decorator * add test for UploadedFile decorator * fix format for json * remove space and fix typo
1 parent ee07e4e commit 4251ec5

File tree

4 files changed

+248
-11
lines changed

4 files changed

+248
-11
lines changed

__tests__/fixtures/controllers.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
Put,
1919
QueryParam,
2020
QueryParams,
21+
UploadedFile,
22+
UploadedFiles,
2123
} from 'routing-controllers'
2224

2325
import { OpenAPI, ResponseSchema } from '../../src'
@@ -39,6 +41,11 @@ export class CreatePostBody {
3941
content: string[]
4042
}
4143

44+
export class CreateUserPostImagesBody {
45+
@IsString()
46+
description: string
47+
}
48+
4249
export class ListUsersQueryParams {
4350
@IsOptional()
4451
@IsEmail()
@@ -154,6 +161,11 @@ export class UsersController {
154161
) {
155162
return
156163
}
164+
165+
@Put('/:userId/avatar')
166+
putUserAvatar(@UploadedFile('image') _image: any) {
167+
return
168+
}
157169
}
158170

159171
@Controller('/users/:userId/posts')
@@ -170,6 +182,15 @@ export class UserPostsController {
170182
patchUserPost(@BodyParam('token') _token: string) {
171183
return
172184
}
185+
186+
@Post('/:postId/images')
187+
createUserPostImages(
188+
@Body({ required: true }) _body: CreateUserPostImagesBody,
189+
@BodyParam('token') _token: string,
190+
@UploadedFiles('images') _images: any[]
191+
) {
192+
return
193+
}
173194
}
174195

175196
@Controller()

__tests__/fixtures/spec.json

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,17 @@
5959
],
6060
"type": "object"
6161
},
62+
"CreateUserPostImagesBody": {
63+
"properties": {
64+
"description": {
65+
"type": "string"
66+
}
67+
},
68+
"required": [
69+
"description"
70+
],
71+
"type": "object"
72+
},
6273
"ListUsersHeaderParams": {
6374
"properties": {
6475
"Authorization": {
@@ -434,6 +445,72 @@
434445
]
435446
}
436447
},
448+
"/api/users/{userId}/posts/{postId}/images": {
449+
"post": {
450+
"operationId": "UserPostsController.createUserPostImages",
451+
"parameters": [
452+
{
453+
"in": "path",
454+
"name": "userId",
455+
"required": true,
456+
"schema": {
457+
"type": "string"
458+
}
459+
},
460+
{
461+
"in": "path",
462+
"name": "postId",
463+
"required": true,
464+
"schema": {
465+
"type": "string"
466+
}
467+
}
468+
],
469+
"requestBody": {
470+
"content": {
471+
"multipart/form-data": {
472+
"schema": {
473+
"allOf": [
474+
{
475+
"$ref": "#/components/schemas/CreateUserPostImagesBody"
476+
},
477+
{
478+
"properties": {
479+
"images": {
480+
"items": {
481+
"format": "binary",
482+
"type": "string"
483+
},
484+
"type": "array"
485+
},
486+
"token": {
487+
"type": "string"
488+
}
489+
},
490+
"required": [],
491+
"type": "object"
492+
}
493+
]
494+
}
495+
}
496+
},
497+
"description": "CreateUserPostImagesBody",
498+
"required": true
499+
},
500+
"responses": {
501+
"200": {
502+
"content": {
503+
"text/html; charset=utf-8": {}
504+
},
505+
"description": "Successful response"
506+
}
507+
},
508+
"summary": "Create user post images",
509+
"tags": [
510+
"User Posts"
511+
]
512+
}
513+
},
437514
"/api/users/{version}": {
438515
"delete": {
439516
"operationId": "UsersController.deleteUsersByVersion",
@@ -506,6 +583,49 @@
506583
]
507584
}
508585
},
586+
"/api/users/{userId}/avatar": {
587+
"put": {
588+
"operationId": "UsersController.putUserAvatar",
589+
"parameters": [
590+
{
591+
"in": "path",
592+
"name": "userId",
593+
"required": true,
594+
"schema": {
595+
"type": "string"
596+
}
597+
}
598+
],
599+
"requestBody": {
600+
"content": {
601+
"multipart/form-data": {
602+
"schema": {
603+
"properties": {
604+
"image": {
605+
"format": "binary",
606+
"type": "string"
607+
}
608+
},
609+
"required": [],
610+
"type": "object"
611+
}
612+
}
613+
}
614+
},
615+
"responses": {
616+
"200": {
617+
"content": {
618+
"application/json": {}
619+
},
620+
"description": "Successful response"
621+
}
622+
},
623+
"summary": "Put user avatar",
624+
"tags": [
625+
"Users"
626+
]
627+
}
628+
},
509629
"/api/users/{userId}/posts": {
510630
"post": {
511631
"operationId": "UsersController.createUserPost",
@@ -646,4 +766,4 @@
646766
}
647767
}
648768
}
649-
}
769+
}

__tests__/index.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,12 @@ describe('index', () => {
116116
target: UsersController,
117117
type: 'put',
118118
},
119+
{
120+
method: 'putUserAvatar',
121+
route: '/:userId/avatar',
122+
target: UsersController,
123+
type: 'put',
124+
},
119125
{
120126
method: 'getUserPost',
121127
route: '/:postId',
@@ -128,6 +134,12 @@ describe('index', () => {
128134
target: UserPostsController,
129135
type: 'patch',
130136
},
137+
{
138+
method: 'createUserPostImages',
139+
route: '/:postId/images',
140+
target: UserPostsController,
141+
type: 'post',
142+
},
131143
{
132144
method: 'getDefaultPath',
133145
route: undefined,
@@ -297,4 +309,62 @@ describe('getRequestBody', () => {
297309
required: true,
298310
})
299311
})
312+
313+
it('parse a single `UploadedFile` metadata into a single `object` schema under content-type `multipart/form-data`', () => {
314+
const route = routes.find((d) => d.action.method === 'putUserAvatar')!
315+
expect(route).toBeDefined()
316+
expect(getRequestBody(route)).toEqual({
317+
content: {
318+
'multipart/form-data': {
319+
schema: {
320+
properties: {
321+
image: {
322+
format: 'binary',
323+
type: 'string',
324+
},
325+
},
326+
required: [],
327+
type: 'object',
328+
},
329+
},
330+
},
331+
})
332+
})
333+
it('wrap `body` and others metadata containing `UploadedFiles` items under a single `allOf` schema under content-type `multipart/form-data`', () => {
334+
const route = routes.find(
335+
(d) => d.action.method === 'createUserPostImages'
336+
)!
337+
expect(route).toBeDefined()
338+
expect(getRequestBody(route)).toEqual({
339+
content: {
340+
'multipart/form-data': {
341+
schema: {
342+
allOf: [
343+
{
344+
$ref: '#/components/schemas/CreateUserPostImagesBody',
345+
},
346+
{
347+
properties: {
348+
images: {
349+
items: {
350+
format: 'binary',
351+
type: 'string',
352+
},
353+
type: 'array',
354+
},
355+
token: {
356+
type: 'string',
357+
},
358+
},
359+
required: [],
360+
type: 'object',
361+
},
362+
],
363+
},
364+
},
365+
},
366+
description: 'CreateUserPostImagesBody',
367+
required: true,
368+
})
369+
})
300370
})

src/generateSpec.ts

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -191,19 +191,43 @@ export function getQueryParams(
191191
return queries
192192
}
193193

194+
function getNamedParamSchema(
195+
param: ParamMetadataArgs
196+
): oa.SchemaObject | oa.ReferenceObject {
197+
const { type } = param
198+
if (type === 'file') {
199+
return { type: 'string', format: 'binary' }
200+
}
201+
if (type === 'files') {
202+
return {
203+
type: 'array',
204+
items: {
205+
type: 'string',
206+
format: 'binary',
207+
},
208+
}
209+
}
210+
return getParamSchema(param)
211+
}
212+
194213
/**
195214
* Return OpenAPI requestBody of given route, if it has one.
196215
*/
197216
export function getRequestBody(route: IRoute): oa.RequestBodyObject | void {
198217
const bodyParamMetas = route.params.filter((d) => d.type === 'body-param')
199-
const bodyParamsSchema: oa.SchemaObject | null =
200-
bodyParamMetas.length > 0
201-
? bodyParamMetas.reduce(
218+
const uploadFileMetas = route.params.filter((d) =>
219+
['file', 'files'].includes(d.type)
220+
)
221+
const namedParamMetas = [...bodyParamMetas, ...uploadFileMetas]
222+
223+
const namedParamsSchema: oa.SchemaObject | null =
224+
namedParamMetas.length > 0
225+
? namedParamMetas.reduce(
202226
(acc: oa.SchemaObject, d) => ({
203227
...acc,
204228
properties: {
205229
...acc.properties,
206-
[d.name!]: getParamSchema(d),
230+
[d.name!]: getNamedParamSchema(d),
207231
},
208232
required: isRequired(d, route)
209233
? [...(acc.required || []), d.name!]
@@ -213,27 +237,29 @@ export function getRequestBody(route: IRoute): oa.RequestBodyObject | void {
213237
)
214238
: null
215239

216-
const bodyMeta = route.params.find((d) => d.type === 'body')
240+
const contentType =
241+
uploadFileMetas.length > 0 ? 'multipart/form-data' : 'application/json'
217242

243+
const bodyMeta = route.params.find((d) => d.type === 'body')
218244
if (bodyMeta) {
219245
const bodySchema = getParamSchema(bodyMeta)
220246
const { $ref } =
221247
'items' in bodySchema && bodySchema.items ? bodySchema.items : bodySchema
222248

223249
return {
224250
content: {
225-
'application/json': {
226-
schema: bodyParamsSchema
227-
? { allOf: [bodySchema, bodyParamsSchema] }
251+
[contentType]: {
252+
schema: namedParamsSchema
253+
? { allOf: [bodySchema, namedParamsSchema] }
228254
: bodySchema,
229255
},
230256
},
231257
description: ($ref || '').split('/').pop(),
232258
required: isRequired(bodyMeta, route),
233259
}
234-
} else if (bodyParamsSchema) {
260+
} else if (namedParamsSchema) {
235261
return {
236-
content: { 'application/json': { schema: bodyParamsSchema } },
262+
content: { [contentType]: { schema: namedParamsSchema } },
237263
}
238264
}
239265
}

0 commit comments

Comments
 (0)