Skip to content

Commit 0758f84

Browse files
committed
fix(vine): accept File and Blob as input types for VineMultipartFile
The client sends a File or Blob (via FormData) that BodyParser converts into a MultipartFile at the request seam. Widen the InferInput type to reflect what a client can actually send, while keeping Infer as MultipartFile for the validated server-side value.
1 parent de78d97 commit 0758f84

2 files changed

Lines changed: 116 additions & 2 deletions

File tree

src/vine.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ const isMultipartFile = vine.createRule<FileRuleValidationOptions>((file, option
9898
* })
9999
*/
100100
export class VineMultipartFile extends BaseLiteralType<
101-
MultipartFile,
101+
MultipartFile | File | Blob,
102102
MultipartFile,
103103
MultipartFile
104104
> {

tests/bindings/vinejs.spec.ts

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,15 @@
88
*/
99

1010
import vine from '@vinejs/vine'
11+
import supertest from 'supertest'
1112
import { test } from '@japa/runner'
13+
import { createServer } from 'node:http'
14+
import type { InferInput, Infer } from '@vinejs/vine/types'
15+
import type { MultipartFile } from '@adonisjs/bodyparser/types'
1216

1317
import { IgnitorFactory } from '../../factories/core/ignitor.ts'
14-
import { MultipartFileFactory } from '../../factories/bodyparser.ts'
18+
import { TestUtilsFactory } from '../../factories/core/test_utils.ts'
19+
import { MultipartFileFactory, BodyParserMiddlewareFactory } from '../../factories/bodyparser.ts'
1520

1621
const BASE_URL = new URL('./tmp/', import.meta.url)
1722

@@ -251,4 +256,113 @@ test.group('Bindings | VineJS', (group) => {
251256
])
252257
}
253258
})
259+
260+
test('infer File and Blob as valid input types for the file schema', ({ expectTypeOf }) => {
261+
const schema = vine.object({
262+
avatar: vine.file(),
263+
})
264+
265+
/**
266+
* InferInput represents what the client is allowed to send. A browser
267+
* can only send a File or a Blob (via FormData), never a MultipartFile.
268+
*/
269+
expectTypeOf<InferInput<typeof schema>['avatar']>().toEqualTypeOf<MultipartFile | File | Blob>()
270+
271+
/**
272+
* Infer represents the validated value available on the server, which
273+
* is always a MultipartFile created by the BodyParser.
274+
*/
275+
expectTypeOf<Infer<typeof schema>['avatar']>().toEqualTypeOf<MultipartFile>()
276+
})
277+
})
278+
279+
test.group('Bindings | VineJS | multipart uploads over HTTP', (group) => {
280+
let testUtils: ReturnType<InstanceType<typeof TestUtilsFactory>['create']>
281+
282+
group.each.setup(async () => {
283+
const ignitor = new IgnitorFactory()
284+
.merge({
285+
rcFileContents: {
286+
providers: [
287+
() => import('../../providers/app_provider.js'),
288+
() => import('../../providers/hash_provider.js'),
289+
() => import('../../providers/vinejs_provider.js'),
290+
],
291+
},
292+
})
293+
.withCoreConfig()
294+
.create(BASE_URL, {
295+
importer(filePath: string) {
296+
return import(new URL(filePath, new URL('../', import.meta.url)).href)
297+
},
298+
})
299+
300+
testUtils = new TestUtilsFactory().create(ignitor)
301+
await testUtils.app.init()
302+
await testUtils.app.boot()
303+
await testUtils.boot()
304+
})
305+
306+
test('convert File and Blob sent over HTTP into MultipartFile instances', async ({ assert }) => {
307+
const bodyParser = new BodyParserMiddlewareFactory().create()
308+
const validator = vine.create(
309+
vine.object({
310+
avatar: vine.file(),
311+
document: vine.file(),
312+
})
313+
)
314+
315+
let validated: Infer<typeof validator> | undefined
316+
let serverError: any
317+
318+
/**
319+
* The server parses the incoming multipart request using the BodyParser
320+
* middleware and then validates it using the "vine.file" schema. This
321+
* mirrors exactly what happens during a real request lifecycle.
322+
*/
323+
const server = createServer(async (req, res) => {
324+
const ctx = await testUtils.createHttpContext({ req, res })
325+
try {
326+
await bodyParser.handle(ctx, async () => {
327+
validated = await validator.validate({
328+
avatar: ctx.request.file('avatar'),
329+
document: ctx.request.file('document'),
330+
})
331+
})
332+
} catch (error) {
333+
serverError = error
334+
}
335+
res.end('done')
336+
})
337+
338+
/**
339+
* Sending a File and a Blob over the wire the same way a browser would.
340+
* A File carries a filename ("avatar.jpg"), whereas a Blob is sent without
341+
* one (defaulting to "blob"), yet BodyParser converts both to a MultipartFile.
342+
*/
343+
await supertest(server)
344+
.post('/')
345+
.attach('avatar', Buffer.from('hello avatar'), 'avatar.jpg')
346+
.attach('document', Buffer.from('hello document'), 'blob')
347+
348+
assert.isUndefined(serverError)
349+
assert.isDefined(validated)
350+
351+
/**
352+
* The File uploaded under "avatar" is converted into a MultipartFile
353+
*/
354+
assert.isTrue(validated!.avatar.isMultipartFile)
355+
assert.equal(validated!.avatar.fieldName, 'avatar')
356+
assert.equal(validated!.avatar.clientName, 'avatar.jpg')
357+
assert.equal(validated!.avatar.size, Buffer.byteLength('hello avatar'))
358+
assert.isTrue(validated!.avatar.isValid)
359+
360+
/**
361+
* The Blob uploaded under "document" is also converted into a MultipartFile
362+
*/
363+
assert.isTrue(validated!.document.isMultipartFile)
364+
assert.equal(validated!.document.fieldName, 'document')
365+
assert.equal(validated!.document.size, Buffer.byteLength('hello document'))
366+
assert.isTrue(validated!.document.isValid)
367+
})
254368
})

0 commit comments

Comments
 (0)