mirror of
https://github.com/elysiajs/elysia
synced 2026-08-09 12:35:48 +00:00
Merge pull request #1697 from Teyik0/main
feat: extend complex formdata support to StandardSchema
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { Elysia, t } from '../src'
|
||||
|
||||
/**
|
||||
* Example: Nested File Uploads with Multipart Forms
|
||||
*
|
||||
* Elysia supports nested file uploads using dot notation in multipart forms.
|
||||
* This allows you to organize files and data in a nested structure while
|
||||
* still using standard multipart/form-data encoding.
|
||||
*
|
||||
* How it works:
|
||||
* 1. Client sends files with dot notation keys (e.g., "user.avatar")
|
||||
* 2. Elysia automatically reconstructs the nested object structure
|
||||
* 3. Your handler receives a properly nested object
|
||||
*/
|
||||
|
||||
const app = new Elysia()
|
||||
// Basic nested file upload
|
||||
.post(
|
||||
'/user/profile',
|
||||
({ body }) => ({
|
||||
message: 'Profile created!',
|
||||
user: {
|
||||
name: body.user.name,
|
||||
avatarSize: body.user.avatar.size
|
||||
}
|
||||
}),
|
||||
{
|
||||
body: t.Object({
|
||||
user: t.Object({
|
||||
name: t.String(),
|
||||
avatar: t.File()
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Deeply nested files
|
||||
.post(
|
||||
'/user/portfolio',
|
||||
({ body }) => ({
|
||||
bio: body.user.profile.bio,
|
||||
photoCount: body.user.profile.photos.length
|
||||
}),
|
||||
{
|
||||
body: t.Object({
|
||||
user: t.Object({
|
||||
profile: t.Object({
|
||||
bio: t.String(),
|
||||
photos: t.Files()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Mixed flat and nested fields
|
||||
.post(
|
||||
'/post',
|
||||
({ body }) => ({
|
||||
title: body.title,
|
||||
authorName: body.author.name,
|
||||
imageSize: body.author.avatar.size
|
||||
}),
|
||||
{
|
||||
body: t.Object({
|
||||
title: t.String(),
|
||||
author: t.Object({
|
||||
name: t.String(),
|
||||
avatar: t.File()
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
.listen(3000)
|
||||
|
||||
console.log(`🦊 Server running at http://${app.server?.hostname}:${app.server?.port}`)
|
||||
|
||||
/**
|
||||
* Client-side usage (with fetch):
|
||||
*
|
||||
* const formData = new FormData()
|
||||
* formData.append('user.name', 'John')
|
||||
* formData.append('user.avatar', fileBlob)
|
||||
*
|
||||
* await fetch('http://localhost:3000/user/profile', {
|
||||
* method: 'POST',
|
||||
* body: formData
|
||||
* })
|
||||
*
|
||||
*
|
||||
* Eden client usage (future):
|
||||
*
|
||||
* await client.user.profile.post({
|
||||
* user: {
|
||||
* name: 'John',
|
||||
* avatar: fileBlob // Eden will flatten this automatically
|
||||
* }
|
||||
* })
|
||||
*/
|
||||
@@ -48,12 +48,79 @@ export const WebStandardAdapter: ElysiaAdapter = {
|
||||
|
||||
return (
|
||||
fnLiteral +
|
||||
`const dangerousKeys=new Set(['__proto__','constructor','prototype'])\n` +
|
||||
`const isDangerousKey=(k)=>{` +
|
||||
`if(dangerousKeys.has(k))return true;` +
|
||||
`const m=k.match(/^(.+)\\[(\\d+)\\]$/);` +
|
||||
`return m?dangerousKeys.has(m[1]):false` +
|
||||
`}\n` +
|
||||
`const parseArrayKey=(k)=>{` +
|
||||
`const m=k.match(/^(.+)\\[(\\d+)\\]$/);` +
|
||||
`return m?{name:m[1],index:parseInt(m[2],10)}:null` +
|
||||
`}\n` +
|
||||
`for(const key of form.keys()){` +
|
||||
`if(c.body[key]) continue\n` +
|
||||
`if(c.body[key])continue\n` +
|
||||
`const value=form.getAll(key)\n` +
|
||||
`if(value.length===1)` +
|
||||
`c.body[key]=value[0]\n` +
|
||||
`else c.body[key]=value` +
|
||||
`let finalValue\n` +
|
||||
`if(value.length===1){\n` +
|
||||
`const sv=value[0]\n` +
|
||||
`if(typeof sv==='string'&&(sv.charCodeAt(0)===123||sv.charCodeAt(0)===91)){\n` +
|
||||
`try{\n` +
|
||||
`const p=JSON.parse(sv)\n` +
|
||||
`if(p&&typeof p==='object')finalValue=p\n` +
|
||||
`}catch{}\n` +
|
||||
`}\n` +
|
||||
`if(finalValue===undefined)finalValue=sv\n` +
|
||||
`}else finalValue=value\n` +
|
||||
`if(Array.isArray(finalValue)){\n` +
|
||||
`const stringValue=finalValue.find((entry)=>typeof entry==='string')\n` +
|
||||
`const files=typeof File==='undefined'?[]:finalValue.filter((entry)=>entry instanceof File)\n` +
|
||||
`if(stringValue&&files.length&&stringValue.charCodeAt(0)===123){\n` +
|
||||
`try{\n` +
|
||||
`const parsed=JSON.parse(stringValue)\n` +
|
||||
`if(parsed&&typeof parsed==='object'&&!Array.isArray(parsed)){\n` +
|
||||
`if(!('file' in parsed)&&files.length===1)parsed.file=files[0]\n` +
|
||||
`else if(!('files' in parsed)&&files.length>1)parsed.files=files\n` +
|
||||
`finalValue=parsed\n` +
|
||||
`}\n` +
|
||||
`}catch{}\n` +
|
||||
`}\n` +
|
||||
`}\n` +
|
||||
`if(key.includes('.')||key.includes('[')){` +
|
||||
`const keys=key.split('.')\n` +
|
||||
`const lastKey=keys.pop()\n` +
|
||||
`if(isDangerousKey(lastKey)||keys.some(isDangerousKey))continue\n` +
|
||||
`let current=c.body\n` +
|
||||
`for(const k of keys){` +
|
||||
`const arrayInfo=parseArrayKey(k)\n` +
|
||||
`if(arrayInfo){` +
|
||||
`if(!Array.isArray(current[arrayInfo.name]))current[arrayInfo.name]=[]\n` +
|
||||
`const existing=current[arrayInfo.name][arrayInfo.index]\n` +
|
||||
`const isFile=typeof File!=='undefined'&&existing instanceof File\n` +
|
||||
`if(!existing||typeof existing!=='object'||Array.isArray(existing)||isFile){\n` +
|
||||
`let parsed\n` +
|
||||
`if(typeof existing==='string'&&existing.charCodeAt(0)===123){\n` +
|
||||
`try{` +
|
||||
`parsed=JSON.parse(existing)\n` +
|
||||
`if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))parsed=undefined` +
|
||||
`}catch{}\n` +
|
||||
`}\n` +
|
||||
`current[arrayInfo.name][arrayInfo.index]=parsed||{}\n` +
|
||||
`}\n` +
|
||||
`current=current[arrayInfo.name][arrayInfo.index]` +
|
||||
`}else{` +
|
||||
`if(!current[k]||typeof current[k]!=='object')current[k]={}\n` +
|
||||
`current=current[k]` +
|
||||
`}` +
|
||||
`}\n` +
|
||||
`const arrayInfo=parseArrayKey(lastKey)\n` +
|
||||
`if(arrayInfo){` +
|
||||
`if(!Array.isArray(current[arrayInfo.name]))current[arrayInfo.name]=[]\n` +
|
||||
`current[arrayInfo.name][arrayInfo.index]=finalValue` +
|
||||
`}else{` +
|
||||
`current[lastKey]=finalValue` +
|
||||
`}` +
|
||||
`}else c.body[key]=finalValue` +
|
||||
`}`
|
||||
)
|
||||
}
|
||||
@@ -125,7 +192,10 @@ export const WebStandardAdapter: ElysiaAdapter = {
|
||||
},
|
||||
error404(hasEventHook, hasErrorHook, afterHandle = '') {
|
||||
let findDynamicRoute =
|
||||
`if(route===null){` + afterHandle + (hasErrorHook ? '' : 'c.set.status=404') + '\nreturn '
|
||||
`if(route===null){` +
|
||||
afterHandle +
|
||||
(hasErrorHook ? '' : 'c.set.status=404') +
|
||||
'\nreturn '
|
||||
|
||||
if (hasErrorHook)
|
||||
findDynamicRoute += `app.handleError(c,notFound,false,${this.parameters})`
|
||||
|
||||
+168
-12
@@ -24,6 +24,150 @@ export type DynamicHandler = {
|
||||
route: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches array index notation in property paths
|
||||
* Examples:
|
||||
* "users[0]" → Group 1: "users", Group 2: "0"
|
||||
* "items[42]" → Group 1: "items", Group 2: "42"
|
||||
* "a[123]" → Group 1: "a", Group 2: "123"
|
||||
*
|
||||
* Does not match:
|
||||
* "users" → no brackets
|
||||
* "users[]" → no index
|
||||
* "users[ab]" → non-numeric index
|
||||
*/
|
||||
const ARRAY_INDEX_REGEX = /^(.+)\[(\d+)\]$/
|
||||
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
||||
|
||||
const isDangerousKey = (key: string): boolean => {
|
||||
if (DANGEROUS_KEYS.has(key)) return true
|
||||
|
||||
const match = key.match(ARRAY_INDEX_REGEX)
|
||||
return match ? DANGEROUS_KEYS.has(match[1]) : false
|
||||
}
|
||||
|
||||
const parseArrayKey = (key: string) => {
|
||||
const match = key.match(ARRAY_INDEX_REGEX)
|
||||
if (!match) return null
|
||||
|
||||
return {
|
||||
name: match[1],
|
||||
index: parseInt(match[2], 10)
|
||||
}
|
||||
}
|
||||
|
||||
const parseObjectString = (entry: unknown) => {
|
||||
if (typeof entry !== 'string' || entry.charCodeAt(0) !== 123) return
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(entry)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
||||
return parsed
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const setNestedValue = (obj: Record<string, any>, path: string, value: any) => {
|
||||
const keys = path.split('.')
|
||||
const lastKey = keys.pop() as string
|
||||
|
||||
// Validate all keys upfront
|
||||
if (isDangerousKey(lastKey) || keys.some(isDangerousKey)) return
|
||||
|
||||
let current = obj
|
||||
|
||||
// Traverse intermediate keys
|
||||
for (const key of keys) {
|
||||
const arrayInfo = parseArrayKey(key)
|
||||
|
||||
if (arrayInfo) {
|
||||
// Initialize array if needed
|
||||
if (!Array.isArray(current[arrayInfo.name]))
|
||||
current[arrayInfo.name] = []
|
||||
|
||||
const existing = current[arrayInfo.name][arrayInfo.index]
|
||||
const isFile =
|
||||
typeof File !== 'undefined' && existing instanceof File
|
||||
|
||||
// Initialize object at index if needed
|
||||
if (
|
||||
!existing ||
|
||||
typeof existing !== 'object' ||
|
||||
Array.isArray(existing) ||
|
||||
isFile
|
||||
)
|
||||
current[arrayInfo.name][arrayInfo.index] =
|
||||
parseObjectString(existing) ?? {}
|
||||
|
||||
current = current[arrayInfo.name][arrayInfo.index]
|
||||
} else {
|
||||
// Initialize object property if needed
|
||||
if (!current[key] || typeof current[key] !== 'object')
|
||||
current[key] = {}
|
||||
|
||||
current = current[key]
|
||||
}
|
||||
}
|
||||
|
||||
// Set final value
|
||||
const arrayInfo = parseArrayKey(lastKey)
|
||||
|
||||
if (arrayInfo) {
|
||||
if (!Array.isArray(current[arrayInfo.name]))
|
||||
current[arrayInfo.name] = []
|
||||
|
||||
current[arrayInfo.name][arrayInfo.index] = value
|
||||
} else {
|
||||
current[lastKey] = value
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeFormValue = (value: unknown[]) => {
|
||||
if (value.length === 1) {
|
||||
const stringValue = value[0]
|
||||
if (typeof stringValue === 'string') {
|
||||
// Try to parse JSON objects (starting with '{') or arrays (starting with '[')
|
||||
if (stringValue.charCodeAt(0) === 123 || stringValue.charCodeAt(0) === 91) {
|
||||
try {
|
||||
const parsed = JSON.parse(stringValue)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return parsed
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return value[0]
|
||||
}
|
||||
|
||||
const stringValue = value.find(
|
||||
(entry): entry is string => typeof entry === 'string'
|
||||
)
|
||||
if (!stringValue) return value
|
||||
|
||||
if (typeof File === 'undefined') return value
|
||||
const files = value.filter((entry): entry is File => entry instanceof File)
|
||||
if (!files.length) return value
|
||||
|
||||
if (stringValue.charCodeAt(0) !== 123) return value
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(stringValue)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null) return value
|
||||
|
||||
if (!('file' in parsed) && files.length === 1)
|
||||
(parsed as Record<string, unknown>).file = files[0]
|
||||
else if (!('files' in parsed) && files.length > 1)
|
||||
(parsed as Record<string, unknown>).files = files
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
const injectDefaultValues = (
|
||||
typeChecker: TypeCheck<any> | ElysiaTypeCheck<any>,
|
||||
obj: Record<string, any>
|
||||
@@ -147,8 +291,11 @@ export const createDynamicHandler = (app: AnyElysia) => {
|
||||
if (body[key]) continue
|
||||
|
||||
const value = form.getAll(key)
|
||||
if (value.length === 1) body[key] = value[0]
|
||||
else body[key] = value
|
||||
const finalValue = normalizeFormValue(value)
|
||||
|
||||
if (key.includes('.') || key.includes('['))
|
||||
setNestedValue(body, key, finalValue)
|
||||
else body[key] = finalValue
|
||||
}
|
||||
|
||||
break
|
||||
@@ -199,15 +346,16 @@ export const createDynamicHandler = (app: AnyElysia) => {
|
||||
case 'multipart/form-data': {
|
||||
body = {}
|
||||
|
||||
const form =
|
||||
await request.formData()
|
||||
const form = await request.formData()
|
||||
for (const key of form.keys()) {
|
||||
if (body[key]) continue
|
||||
|
||||
const value = form.getAll(key)
|
||||
if (value.length === 1)
|
||||
body[key] = value[0]
|
||||
else body[key] = value
|
||||
const finalValue = normalizeFormValue(value)
|
||||
|
||||
if (key.includes('.') || key.includes('['))
|
||||
setNestedValue(body, key, finalValue)
|
||||
else body[key] = finalValue
|
||||
}
|
||||
|
||||
break
|
||||
@@ -273,9 +421,11 @@ export const createDynamicHandler = (app: AnyElysia) => {
|
||||
if (body[key]) continue
|
||||
|
||||
const value = form.getAll(key)
|
||||
if (value.length === 1)
|
||||
body[key] = value[0]
|
||||
else body[key] = value
|
||||
const finalValue = normalizeFormValue(value)
|
||||
|
||||
if (key.includes('.') || key.includes('['))
|
||||
setNestedValue(body, key, finalValue)
|
||||
else body[key] = finalValue
|
||||
}
|
||||
|
||||
break
|
||||
@@ -458,8 +608,14 @@ export const createDynamicHandler = (app: AnyElysia) => {
|
||||
|
||||
if (validator.createBody?.()?.Check(body) === false)
|
||||
throw new ValidationError('body', validator.body!, body)
|
||||
else if (validator.body?.Decode)
|
||||
context.body = validator.body.Decode(body) as any
|
||||
else if (validator.body?.Decode) {
|
||||
let decoded = validator.body.Decode(body) as any
|
||||
if (decoded instanceof Promise)
|
||||
decoded = await decoded
|
||||
|
||||
// Zod returns { value: ... } wrapper
|
||||
context.body = decoded?.value ?? decoded
|
||||
}
|
||||
}
|
||||
|
||||
if (hooks.beforeHandle)
|
||||
|
||||
+17
-12
@@ -153,7 +153,10 @@ export const ElysiaType = {
|
||||
.Encode((value) => value) as any as TNumber
|
||||
},
|
||||
|
||||
NumericEnum<T extends AssertNumericEnum<T>>(item: T, property?: SchemaOptions) {
|
||||
NumericEnum<T extends AssertNumericEnum<T>>(
|
||||
item: T,
|
||||
property?: SchemaOptions
|
||||
) {
|
||||
const schema = Type.Enum(item, property)
|
||||
const compiler = compile(schema)
|
||||
|
||||
@@ -301,7 +304,7 @@ export const ElysiaType = {
|
||||
[
|
||||
t.String({
|
||||
format: 'ObjectString',
|
||||
default: options?.default
|
||||
default: options?.default
|
||||
}),
|
||||
schema
|
||||
],
|
||||
@@ -365,16 +368,18 @@ export const ElysiaType = {
|
||||
|
||||
return t
|
||||
.Transform(
|
||||
t.Union([
|
||||
t.String({
|
||||
format: 'ArrayString',
|
||||
default: options?.default
|
||||
}),
|
||||
schema
|
||||
],
|
||||
{
|
||||
elysiaMeta: 'ArrayString'
|
||||
})
|
||||
t.Union(
|
||||
[
|
||||
t.String({
|
||||
format: 'ArrayString',
|
||||
default: options?.default
|
||||
}),
|
||||
schema
|
||||
],
|
||||
{
|
||||
elysiaMeta: 'ArrayString'
|
||||
}
|
||||
)
|
||||
)
|
||||
.Decode((value) => {
|
||||
if (Array.isArray(value)) {
|
||||
|
||||
+224
-203
@@ -670,237 +670,258 @@ describe('Model reference with File and nested Object', () => {
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = (await response.json()) as any
|
||||
expect(data.name).toBe('John')
|
||||
expect(data.metadata).toEqual({ age: 25 })
|
||||
const result = await response.json()
|
||||
expect(result).toMatchObject({
|
||||
name: 'John',
|
||||
metadata: { age: 25 }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// describe.skip('Zod (for standard schema) with File and nested Object', () => {
|
||||
// const bunFilePath6 = `test/images/aris-yuzu.jpg`
|
||||
// const bunFile = Bun.file(bunFilePath6) as File
|
||||
describe('Zod (for standard schema) with File and nested Object', () => {
|
||||
const bunFilePath6 = `test/images/aris-yuzu.jpg`
|
||||
const bunFile = Bun.file(bunFilePath6) as File
|
||||
|
||||
// it('should handle Zod schema with File and nested object (without manual coercion)', async () => {
|
||||
// const app = new Elysia().post('/upload', ({ body }) => body, {
|
||||
// body: z.object({
|
||||
// name: z.string(),
|
||||
// file: z.file().refine((file) => fileType(file, 'image/jpeg')),
|
||||
// metadata: z.object({
|
||||
// age: z.number()
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
it('should handle Zod schema with File and nested object (without manual coercion)', async () => {
|
||||
const app = new Elysia().post('/upload', ({ body }) => body, {
|
||||
body: z.object({
|
||||
name: z.string(),
|
||||
file: z.file().refine((file) => fileType(file, 'image/jpeg')),
|
||||
metadata: z.object({
|
||||
age: z.coerce.number()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// const formData = new FormData()
|
||||
// formData.append('name', 'John')
|
||||
// formData.append('file', bunFile)
|
||||
// formData.append('metadata', JSON.stringify({ age: 25 }))
|
||||
const formData = new FormData()
|
||||
formData.append('name', 'John')
|
||||
formData.append('file', bunFile)
|
||||
formData.append('metadata', JSON.stringify({ age: '25' }))
|
||||
|
||||
// const response = await app.handle(
|
||||
// new Request('http://localhost/upload', {
|
||||
// method: 'POST',
|
||||
// body: formData
|
||||
// })
|
||||
// )
|
||||
const response = await app.handle(
|
||||
new Request('http://localhost/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
)
|
||||
|
||||
// expect(response.status).toBe(200)
|
||||
// const data = (await response.json()) as any
|
||||
// expect(data.name).toBe('John')
|
||||
// expect(data.metadata).toEqual({ age: 25 })
|
||||
// })
|
||||
const result = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(result).toMatchObject({
|
||||
name: 'John',
|
||||
metadata: { age: 25 }
|
||||
})
|
||||
})
|
||||
|
||||
// it('should handle array JSON strings in FormData', async () => {
|
||||
// const app = new Elysia().post('/upload', ({ body }) => body, {
|
||||
// body: z.object({
|
||||
// file: z.file().refine((file) => fileType(file, 'image/jpeg')),
|
||||
// tags: z.array(z.string())
|
||||
// })
|
||||
// })
|
||||
it('should handle array JSON strings in FormData', async () => {
|
||||
const app = new Elysia().post('/upload', ({ body }) => body, {
|
||||
body: z.object({
|
||||
file: z.file().refine((file) => fileType(file, 'image/jpeg')),
|
||||
tags: z.array(z.string())
|
||||
})
|
||||
})
|
||||
|
||||
// const formData = new FormData()
|
||||
// formData.append('file', bunFile)
|
||||
// formData.append('tags', JSON.stringify(['tag1', 'tag2', 'tag3']))
|
||||
const formData = new FormData()
|
||||
formData.append('file', bunFile)
|
||||
formData.append('tags', 'tag1')
|
||||
formData.append('tags', 'tag2')
|
||||
formData.append('tags', 'tag3')
|
||||
|
||||
// const response = await app.handle(
|
||||
// new Request('http://localhost/upload', {
|
||||
// method: 'POST',
|
||||
// body: formData
|
||||
// })
|
||||
// )
|
||||
const response = await app.handle(
|
||||
new Request('http://localhost/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
)
|
||||
|
||||
// expect(response.status).toBe(200)
|
||||
// const data = (await response.json()) as any
|
||||
// expect(data.tags).toEqual(['tag1', 'tag2', 'tag3'])
|
||||
// })
|
||||
const result = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(result).toMatchObject({
|
||||
tags: ['tag1', 'tag2', 'tag3']
|
||||
})
|
||||
})
|
||||
|
||||
// it('should keep invalid JSON as string', async () => {
|
||||
// const app = new Elysia().post('/upload', ({ body }) => body, {
|
||||
// body: z.object({
|
||||
// file: z.file().refine((file) => fileType(file, 'image/jpeg')),
|
||||
// description: z.string()
|
||||
// })
|
||||
// })
|
||||
it('should keep invalid JSON as string', async () => {
|
||||
const app = new Elysia().post('/upload', ({ body }) => body, {
|
||||
body: z.object({
|
||||
file: z.file().refine((file) => fileType(file, 'image/jpeg')),
|
||||
description: z.string()
|
||||
})
|
||||
})
|
||||
|
||||
// const formData = new FormData()
|
||||
// formData.append('file', bunFile)
|
||||
// formData.append('description', '{invalid json}')
|
||||
const formData = new FormData()
|
||||
formData.append('file', bunFile)
|
||||
formData.append('description', '{invalid json}')
|
||||
|
||||
// const response = await app.handle(
|
||||
// new Request('http://localhost/upload', {
|
||||
// method: 'POST',
|
||||
// body: formData
|
||||
// })
|
||||
// )
|
||||
const response = await app.handle(
|
||||
new Request('http://localhost/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
)
|
||||
|
||||
// expect(response.status).toBe(200)
|
||||
// const data = (await response.json()) as any
|
||||
// expect(data.description).toBe('{invalid json}')
|
||||
// })
|
||||
const result = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(result).toMatchObject({
|
||||
description: '{invalid json}'
|
||||
})
|
||||
})
|
||||
|
||||
// it('should keep plain strings that are not JSON', async () => {
|
||||
// const app = new Elysia().post('/upload', ({ body }) => body, {
|
||||
// body: z.object({
|
||||
// file: z.file().refine((file) => fileType(file, 'image/jpeg')),
|
||||
// comment: z.string()
|
||||
// })
|
||||
// })
|
||||
it('should keep plain strings that are not JSON', async () => {
|
||||
const app = new Elysia().post('/upload', ({ body }) => body, {
|
||||
body: z.object({
|
||||
file: z.file().refine((file) => fileType(file, 'image/jpeg')),
|
||||
comment: z.string()
|
||||
})
|
||||
})
|
||||
|
||||
// const formData = new FormData()
|
||||
// formData.append('file', bunFile)
|
||||
// formData.append('comment', 'This is a plain comment')
|
||||
const formData = new FormData()
|
||||
formData.append('file', bunFile)
|
||||
formData.append('comment', 'This is a plain comment')
|
||||
|
||||
// const response = await app.handle(
|
||||
// new Request('http://localhost/upload', {
|
||||
// method: 'POST',
|
||||
// body: formData
|
||||
// })
|
||||
// )
|
||||
const response = await app.handle(
|
||||
new Request('http://localhost/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
)
|
||||
|
||||
// expect(response.status).toBe(200)
|
||||
// const data = (await response.json()) as any
|
||||
// expect(data.comment).toBe('This is a plain comment')
|
||||
// })
|
||||
const result = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(result).toMatchObject({
|
||||
comment: 'This is a plain comment'
|
||||
})
|
||||
})
|
||||
|
||||
// it('should handle nested objects in JSON', async () => {
|
||||
// const app = new Elysia().post('/upload', ({ body }) => body, {
|
||||
// body: z.object({
|
||||
// file: z.file().refine((file) => fileType(file, 'image/jpeg')),
|
||||
// profile: z.object({
|
||||
// user: z.object({
|
||||
// name: z.string(),
|
||||
// age: z.number()
|
||||
// }),
|
||||
// settings: z.object({
|
||||
// notifications: z.boolean()
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
it('should handle nested objects in JSON', async () => {
|
||||
const app = new Elysia().post('/upload', ({ body }) => body, {
|
||||
body: z.object({
|
||||
file: z.file().refine((file) => fileType(file, 'image/jpeg')),
|
||||
profile: z.object({
|
||||
user: z.object({
|
||||
name: z.string(),
|
||||
age: z.coerce.number()
|
||||
}),
|
||||
settings: z.object({
|
||||
notifications: z.coerce.boolean()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// const formData = new FormData()
|
||||
// formData.append('file', bunFile)
|
||||
// formData.append(
|
||||
// 'profile',
|
||||
// JSON.stringify({
|
||||
// user: { name: 'Alice', age: 30 },
|
||||
// settings: { notifications: true }
|
||||
// })
|
||||
// )
|
||||
const formData = new FormData()
|
||||
formData.append('file', bunFile)
|
||||
formData.append(
|
||||
'profile',
|
||||
JSON.stringify({
|
||||
user: {
|
||||
name: 'Alice',
|
||||
age: 30
|
||||
},
|
||||
settings: { notifications: true }
|
||||
})
|
||||
)
|
||||
|
||||
// const response = await app.handle(
|
||||
// new Request('http://localhost/upload', {
|
||||
// method: 'POST',
|
||||
// body: formData
|
||||
// })
|
||||
// )
|
||||
const response = await app.handle(
|
||||
new Request('http://localhost/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
)
|
||||
|
||||
// expect(response.status).toBe(200)
|
||||
// const data = (await response.json()) as any
|
||||
// expect(data.profile).toEqual({
|
||||
// user: { name: 'Alice', age: 30 },
|
||||
// settings: { notifications: true }
|
||||
// })
|
||||
// })
|
||||
const result = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(result).toMatchObject({
|
||||
profile: {
|
||||
user: { name: 'Alice', age: 30 },
|
||||
settings: { notifications: true }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// it('should handle Zod schema with optional fields', async () => {
|
||||
// const app = new Elysia().post('/upload', ({ body }) => body, {
|
||||
// body: z.object({
|
||||
// file: z.file().refine((file) => fileType(file, 'image/jpeg')),
|
||||
// name: z.string(),
|
||||
// description: z.string().optional(),
|
||||
// metadata: z
|
||||
// .object({
|
||||
// category: z.string(),
|
||||
// tags: z.array(z.string()).optional(),
|
||||
// featured: z.boolean().optional()
|
||||
// })
|
||||
// .optional()
|
||||
// })
|
||||
// })
|
||||
it('should handle Zod schema with optional fields', async () => {
|
||||
const app = new Elysia().post('/upload', ({ body }) => body, {
|
||||
body: z.object({
|
||||
file: z.file().refine((file) => fileType(file, 'image/jpeg')),
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
metadata: z
|
||||
.object({
|
||||
category: z.string(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
featured: z.boolean().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
})
|
||||
|
||||
// const formData = new FormData()
|
||||
// formData.append('file', bunFile)
|
||||
// formData.append('name', 'Test Product')
|
||||
// // Omit optional fields
|
||||
const formData = new FormData()
|
||||
formData.append('file', bunFile)
|
||||
formData.append('name', 'Test Product')
|
||||
// Omit optional fields
|
||||
|
||||
// const response = await app.handle(
|
||||
// new Request('http://localhost/upload', {
|
||||
// method: 'POST',
|
||||
// body: formData
|
||||
// })
|
||||
// )
|
||||
const response = await app.handle(
|
||||
new Request('http://localhost/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
)
|
||||
|
||||
// expect(response.status).toBe(200)
|
||||
// const data = (await response.json()) as any
|
||||
// expect(data.name).toBe('Test Product')
|
||||
// expect(data.description).toBeUndefined()
|
||||
// expect(data.metadata).toBeUndefined()
|
||||
// })
|
||||
const result = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(result).toMatchObject({
|
||||
name: 'Test Product'
|
||||
})
|
||||
expect(result).not.toHaveProperty('description')
|
||||
expect(result).not.toHaveProperty('metadata')
|
||||
})
|
||||
|
||||
// it('should handle Zod schema with optional fields provided', async () => {
|
||||
// const app = new Elysia().post('/upload', ({ body }) => body, {
|
||||
// body: z.object({
|
||||
// file: z.file().refine((file) => fileType(file, 'image/jpeg')),
|
||||
// name: z.string(),
|
||||
// description: z.string().optional(),
|
||||
// metadata: z
|
||||
// .object({
|
||||
// category: z.string(),
|
||||
// tags: z.array(z.string()).optional(),
|
||||
// featured: z.boolean().optional()
|
||||
// })
|
||||
// .optional()
|
||||
// })
|
||||
// })
|
||||
it('should handle Zod schema with optional fields provided', async () => {
|
||||
const app = new Elysia().post('/upload', ({ body }) => body, {
|
||||
body: z.object({
|
||||
file: z.file().refine((file) => fileType(file, 'image/jpeg')),
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
metadata: z
|
||||
.object({
|
||||
category: z.string(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
featured: z.coerce.boolean().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
})
|
||||
|
||||
// const formData = new FormData()
|
||||
// formData.append('file', bunFile)
|
||||
// formData.append('name', 'Test Product')
|
||||
// formData.append('description', 'A test description')
|
||||
// formData.append(
|
||||
// 'metadata',
|
||||
// JSON.stringify({
|
||||
// category: 'electronics',
|
||||
// tags: ['phone', 'mobile'],
|
||||
// featured: true
|
||||
// })
|
||||
// )
|
||||
const formData = new FormData()
|
||||
formData.append('file', bunFile)
|
||||
formData.append('name', 'Test Product')
|
||||
formData.append('description', 'A test description')
|
||||
formData.append(
|
||||
'metadata',
|
||||
JSON.stringify({
|
||||
category: 'electronics',
|
||||
tags: ['phone', 'mobile'],
|
||||
featured: true
|
||||
})
|
||||
)
|
||||
|
||||
// const response = await app.handle(
|
||||
// new Request('http://localhost/upload', {
|
||||
// method: 'POST',
|
||||
// body: formData
|
||||
// })
|
||||
// )
|
||||
const response = await app.handle(
|
||||
new Request('http://localhost/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
)
|
||||
|
||||
// expect(response.status).toBe(200)
|
||||
// const data = (await response.json()) as any
|
||||
// expect(data.name).toBe('Test Product')
|
||||
// expect(data.description).toBe('A test description')
|
||||
// expect(data.metadata).toEqual({
|
||||
// category: 'electronics',
|
||||
// tags: ['phone', 'mobile'],
|
||||
// featured: true
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
const result = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(result).toMatchObject({
|
||||
name: 'Test Product',
|
||||
description: 'A test description',
|
||||
metadata: {
|
||||
category: 'electronics',
|
||||
tags: ['phone', 'mobile'],
|
||||
featured: true
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1156,4 +1156,539 @@ describe('Body Validator', () => {
|
||||
|
||||
expect(err instanceof ValidationError).toBe(true)
|
||||
})
|
||||
|
||||
it('handle nested file upload with dot notation', async () => {
|
||||
const app = new Elysia().post(
|
||||
'/',
|
||||
({ body }) => ({
|
||||
userName: body.user.name,
|
||||
fileSize: body.user.avatar.size
|
||||
}),
|
||||
{
|
||||
body: t.Object({
|
||||
user: t.Object({
|
||||
name: t.String(),
|
||||
avatar: t.File()
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('user.name', 'John')
|
||||
formData.append('user.avatar', Bun.file('test/images/millenium.jpg'))
|
||||
|
||||
const response = await app.handle(
|
||||
new Request('http://localhost/', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
)
|
||||
|
||||
const result = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(result).toMatchObject({
|
||||
userName: 'John',
|
||||
fileSize: expect.any(Number)
|
||||
})
|
||||
})
|
||||
|
||||
it('handle nested files upload with dot notation', async () => {
|
||||
const app = new Elysia().post(
|
||||
'/',
|
||||
({ body }) => ({
|
||||
productName: body.product.name,
|
||||
fileSizes: body.product.images.map((f) => f.size)
|
||||
}),
|
||||
{
|
||||
body: t.Object({
|
||||
product: t.Object({
|
||||
name: t.String(),
|
||||
images: t.Files()
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('product.name', 'Chair')
|
||||
formData.append('product.images', Bun.file('test/images/millenium.jpg'))
|
||||
formData.append('product.images', Bun.file('test/images/aris-yuzu.jpg'))
|
||||
|
||||
const response = await app.handle(
|
||||
new Request('http://localhost/', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
)
|
||||
|
||||
const result = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(result).toMatchObject({
|
||||
productName: 'Chair',
|
||||
fileSizes: expect.arrayContaining([
|
||||
expect.any(Number),
|
||||
expect.any(Number)
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('handle deeply nested file upload', async () => {
|
||||
const app = new Elysia().post(
|
||||
'/',
|
||||
({ body }) => ({
|
||||
bio: body.user.profile.bio,
|
||||
country: body.user.profile.country,
|
||||
photoSize: body.user.profile.photo.size
|
||||
}),
|
||||
{
|
||||
body: t.Object({
|
||||
user: t.Object({
|
||||
profile: t.Object({
|
||||
bio: t.String(),
|
||||
country: t.String(),
|
||||
photo: t.File()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('user.profile.bio', 'Hello World')
|
||||
formData.append('user.profile.country', 'France')
|
||||
formData.append(
|
||||
'user.profile.photo',
|
||||
Bun.file('test/images/millenium.jpg')
|
||||
)
|
||||
|
||||
const response = await app.handle(
|
||||
new Request('http://localhost/', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
)
|
||||
|
||||
const result = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(result).toMatchObject({
|
||||
bio: 'Hello World',
|
||||
country: 'France',
|
||||
photoSize: expect.any(Number)
|
||||
})
|
||||
})
|
||||
|
||||
it('handle multiple nested files', async () => {
|
||||
const app = new Elysia().post(
|
||||
'/',
|
||||
({ body }) => ({
|
||||
avatarSize: body.user.avatar.size,
|
||||
coverSize: body.user.cover.size
|
||||
}),
|
||||
{
|
||||
body: t.Object({
|
||||
user: t.Object({
|
||||
avatar: t.File(),
|
||||
cover: t.File()
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('user.avatar', Bun.file('test/images/millenium.jpg'))
|
||||
formData.append('user.cover', Bun.file('test/images/kozeki-ui.webp'))
|
||||
|
||||
const response = await app.handle(
|
||||
new Request('http://localhost/', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
)
|
||||
|
||||
const result = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(result).toMatchObject({
|
||||
avatarSize: expect.any(Number),
|
||||
coverSize: expect.any(Number)
|
||||
})
|
||||
})
|
||||
|
||||
it('handle mixed nested and flat fields', async () => {
|
||||
const app = new Elysia().post(
|
||||
'/',
|
||||
({ body }) => ({
|
||||
flatValue: body.flat,
|
||||
nestedName: body.user.name,
|
||||
nestedFileSize: body.user.avatar.size
|
||||
}),
|
||||
{
|
||||
body: t.Object({
|
||||
flat: t.String(),
|
||||
user: t.Object({
|
||||
name: t.String(),
|
||||
avatar: t.File()
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('flat', 'I am flat')
|
||||
formData.append('user.name', 'Jane')
|
||||
formData.append('user.avatar', Bun.file('test/images/millenium.jpg'))
|
||||
|
||||
const response = await app.handle(
|
||||
new Request('http://localhost/', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
)
|
||||
|
||||
const result = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(result).toMatchObject({
|
||||
flatValue: 'I am flat',
|
||||
nestedName: 'Jane',
|
||||
nestedFileSize: expect.any(Number)
|
||||
})
|
||||
})
|
||||
|
||||
it('handle complex nested array with files', async () => {
|
||||
const app = new Elysia().post(
|
||||
'/',
|
||||
({ body }) => ({
|
||||
productName: body.name,
|
||||
createFilesCount: body.images.create.length,
|
||||
updateCount: body.images.update.length,
|
||||
images: {
|
||||
create: body.images.create.map((f) => f.size),
|
||||
update: body.images.update.map((f) => ({
|
||||
id: f.id,
|
||||
altText: f.altText,
|
||||
imgSize: f.img.size
|
||||
}))
|
||||
}
|
||||
}),
|
||||
{
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
images: t.Object({
|
||||
create: t.Files(),
|
||||
update: t.Array(
|
||||
t.Object({
|
||||
id: t.String(),
|
||||
img: t.File(),
|
||||
altText: t.String()
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('name', 'Test Product')
|
||||
formData.append('images.create', Bun.file('test/images/millenium.jpg'))
|
||||
formData.append('images.create', Bun.file('test/images/kozeki-ui.webp'))
|
||||
formData.append('images.update[0].id', '123')
|
||||
formData.append(
|
||||
'images.update[0].img',
|
||||
Bun.file('test/images/midori.png')
|
||||
)
|
||||
formData.append('images.update[0].altText', 'an image')
|
||||
|
||||
const response = await app.handle(
|
||||
new Request('http://localhost/', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
)
|
||||
|
||||
const result = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(result).toMatchObject({
|
||||
productName: 'Test Product',
|
||||
createFilesCount: 2,
|
||||
updateCount: 1,
|
||||
images: {
|
||||
create: [expect.any(Number), expect.any(Number)],
|
||||
update: [
|
||||
{
|
||||
id: '123',
|
||||
altText: 'an image',
|
||||
imgSize: expect.any(Number)
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('handle dot notation for standard schema with array and nested file', async () => {
|
||||
const { z } = await import('zod')
|
||||
|
||||
const app = new Elysia().post(
|
||||
'/',
|
||||
({ body }) => ({
|
||||
updateCount: body.images.update.length,
|
||||
updates: body.images.update.map((item) => ({
|
||||
id: item.id,
|
||||
altText: item.altText,
|
||||
imgSize: item.img.size
|
||||
}))
|
||||
}),
|
||||
{
|
||||
body: z.object({
|
||||
images: z.object({
|
||||
update: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
img: z.file(),
|
||||
altText: z.string()
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append(
|
||||
'images.update[0]',
|
||||
JSON.stringify({ id: '123', altText: 'an image' })
|
||||
)
|
||||
formData.append(
|
||||
'images.update[0].img',
|
||||
Bun.file('test/images/midori.png')
|
||||
)
|
||||
|
||||
const response = await app.handle(
|
||||
new Request('http://localhost/', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
)
|
||||
|
||||
const result = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(result).toMatchObject({
|
||||
updateCount: 1,
|
||||
updates: [
|
||||
{
|
||||
id: '123',
|
||||
altText: 'an image',
|
||||
imgSize: expect.any(Number)
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('handle mix of stringify and dot notation', async () => {
|
||||
const app = new Elysia().post(
|
||||
'/',
|
||||
({ body }) => ({
|
||||
productName: body.name,
|
||||
metadata: body.metadata,
|
||||
createFilesCount: body.images.create.length,
|
||||
updateCount: body.images.update.length,
|
||||
images: {
|
||||
create: body.images.create.map((f) => f.size),
|
||||
update: body.images.update.map((f) => ({
|
||||
id: f.id,
|
||||
altText: f.altText,
|
||||
imgSize: f.img.size
|
||||
}))
|
||||
}
|
||||
}),
|
||||
{
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
metadata: t.Object({
|
||||
description: t.String(),
|
||||
price: t.Number(),
|
||||
inStock: t.Boolean(),
|
||||
tags: t.Array(t.String()),
|
||||
category: t.String()
|
||||
}),
|
||||
images: t.Object({
|
||||
create: t.Files(),
|
||||
update: t.Array(
|
||||
t.Object({
|
||||
id: t.String(),
|
||||
img: t.File(),
|
||||
altText: t.String()
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('name', 'Test Product')
|
||||
formData.append(
|
||||
'metadata',
|
||||
JSON.stringify({
|
||||
description: 'A high-quality product',
|
||||
price: 29.99,
|
||||
inStock: true,
|
||||
tags: ['electronics', 'featured', 'sale'],
|
||||
category: 'gadgets'
|
||||
})
|
||||
)
|
||||
formData.append('images.create', Bun.file('test/images/millenium.jpg'))
|
||||
formData.append('images.create', Bun.file('test/images/kozeki-ui.webp'))
|
||||
formData.append(
|
||||
'images.update[0]',
|
||||
JSON.stringify({ id: '123', altText: 'an image' })
|
||||
)
|
||||
formData.append(
|
||||
'images.update[0].img',
|
||||
Bun.file('test/images/midori.png')
|
||||
)
|
||||
|
||||
const response = await app.handle(
|
||||
new Request('http://localhost/', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
)
|
||||
|
||||
const result = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(result).toMatchObject({
|
||||
productName: 'Test Product',
|
||||
createFilesCount: 2,
|
||||
updateCount: 1,
|
||||
metadata: {
|
||||
description: 'A high-quality product',
|
||||
price: 29.99,
|
||||
inStock: true,
|
||||
tags: ['electronics', 'featured', 'sale'],
|
||||
category: 'gadgets'
|
||||
},
|
||||
images: {
|
||||
create: [expect.any(Number), expect.any(Number)],
|
||||
update: [
|
||||
{
|
||||
id: '123',
|
||||
altText: 'an image',
|
||||
imgSize: expect.any(Number)
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should parse sub-array correctly', async () => {
|
||||
const app = new Elysia().post('/', ({ body }) => body, {
|
||||
body: t.Object({
|
||||
imagesOps: t.Object({
|
||||
options: t.Array(
|
||||
t.Object({
|
||||
id: t.String(),
|
||||
value: t.String()
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append(
|
||||
'imagesOps.options',
|
||||
JSON.stringify([{ id: 'test-id', value: 'test-value' }])
|
||||
)
|
||||
|
||||
const response = await app.handle(
|
||||
new Request('http://localhost/', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const result = (await response.json()) as any
|
||||
expect(result.imagesOps.options).toEqual([
|
||||
{ id: 'test-id', value: 'test-value' }
|
||||
])
|
||||
})
|
||||
|
||||
it('prevent prototype pollution with __proto__ in nested multipart', async () => {
|
||||
const app = new Elysia().post('/', ({ body }) => body)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('user.name', 'John')
|
||||
formData.append('__proto__.isAdmin', 'true')
|
||||
formData.append('user.__proto__.isAdmin', 'true')
|
||||
|
||||
const response = await app.handle(
|
||||
new Request('http://localhost/', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
)
|
||||
|
||||
const result = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(result).toMatchObject({
|
||||
user: { name: 'John' }
|
||||
})
|
||||
|
||||
// Check that Object.prototype wasn't polluted
|
||||
const testObj = {}
|
||||
expect('isAdmin' in testObj).toBe(false)
|
||||
expect('isAdmin' in {}).toBe(false)
|
||||
})
|
||||
|
||||
it('prevent prototype pollution with constructor in nested multipart', async () => {
|
||||
const app = new Elysia().post('/', ({ body }) => body)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('user.name', 'John')
|
||||
formData.append('constructor.prototype.isAdmin', 'true')
|
||||
formData.append('user.constructor', 'bad')
|
||||
|
||||
const response = await app.handle(
|
||||
new Request('http://localhost/', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
)
|
||||
|
||||
const result = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(result).toMatchObject({
|
||||
user: { name: 'John' }
|
||||
})
|
||||
|
||||
// Check that Object.prototype wasn't polluted
|
||||
expect('isAdmin' in {}).toBe(false)
|
||||
})
|
||||
|
||||
it('prevent prototype pollution in array notation', async () => {
|
||||
const app = new Elysia().post('/', ({ body }) => body)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('items[0].name', 'Item 1')
|
||||
formData.append('items[__proto__].isAdmin', 'true')
|
||||
formData.append('__proto__[0]', 'bad')
|
||||
|
||||
const response = await app.handle(
|
||||
new Request('http://localhost/', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
)
|
||||
|
||||
const result = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(result).toMatchObject({
|
||||
items: [{ name: 'Item 1' }]
|
||||
})
|
||||
|
||||
// Check that Object.prototype wasn't polluted
|
||||
const testObj = {}
|
||||
expect('isAdmin' in testObj).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user