diff --git a/example/nested-multipart-files.ts b/example/nested-multipart-files.ts new file mode 100644 index 00000000..dd8a6331 --- /dev/null +++ b/example/nested-multipart-files.ts @@ -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 + * } + * }) + */ diff --git a/src/adapter/web-standard/index.ts b/src/adapter/web-standard/index.ts index c6d482e1..43701d0d 100644 --- a/src/adapter/web-standard/index.ts +++ b/src/adapter/web-standard/index.ts @@ -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})` diff --git a/src/dynamic-handle.ts b/src/dynamic-handle.ts index e34bafa6..342ac713 100644 --- a/src/dynamic-handle.ts +++ b/src/dynamic-handle.ts @@ -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, 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).file = files[0] + else if (!('files' in parsed) && files.length > 1) + (parsed as Record).files = files + + return parsed +} + const injectDefaultValues = ( typeChecker: TypeCheck | ElysiaTypeCheck, obj: Record @@ -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 @@ -456,8 +606,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) diff --git a/src/type-system/index.ts b/src/type-system/index.ts index 9ad9e040..7203711a 100644 --- a/src/type-system/index.ts +++ b/src/type-system/index.ts @@ -153,7 +153,10 @@ export const ElysiaType = { .Encode((value) => value) as any as TNumber }, - NumericEnum>(item: T, property?: SchemaOptions) { + NumericEnum>( + 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)) { diff --git a/test/type-system/formdata.test.ts b/test/type-system/formdata.test.ts index be5d0c63..3e264ce1 100644 --- a/test/type-system/formdata.test.ts +++ b/test/type-system/formdata.test.ts @@ -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 - -// 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() -// }) -// }) -// }) - -// 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 -// }) -// ) - -// expect(response.status).toBe(200) -// const data = (await response.json()) as any -// expect(data.name).toBe('John') -// expect(data.metadata).toEqual({ 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()) -// }) -// }) - -// const formData = new FormData() -// formData.append('file', bunFile) -// formData.append('tags', JSON.stringify(['tag1', 'tag2', 'tag3'])) - -// 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']) -// }) - -// 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 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}') -// }) - -// 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 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') -// }) - -// 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() -// }) -// }) -// }) -// }) - -// 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 -// }) -// ) - -// expect(response.status).toBe(200) -// const data = (await response.json()) as any -// expect(data.profile).toEqual({ -// 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() -// }) -// }) - -// 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 -// }) -// ) - -// 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() -// }) - -// 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() -// }) -// }) - -// 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 -// }) -// ) - -// 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 -// }) -// }) -// }) +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.coerce.number() + }) + }) + }) + + 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 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()) + }) + }) + + 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 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() + }) + }) + + 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 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() + }) + }) + + 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 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.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 response = await app.handle( + new Request('http://localhost/upload', { + method: 'POST', + body: formData + }) + ) + + 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() + }) + }) + + 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 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.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 response = await app.handle( + new Request('http://localhost/upload', { + method: 'POST', + body: formData + }) + ) + + 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 + } + }) + }) +}) diff --git a/test/validator/body.test.ts b/test/validator/body.test.ts index 74cbe283..2aee3253 100644 --- a/test/validator/body.test.ts +++ b/test/validator/body.test.ts @@ -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) + }) })