diff --git a/bun.lock b/bun.lock index b68f016..b2dbb08 100644 --- a/bun.lock +++ b/bun.lock @@ -165,7 +165,7 @@ "@sinclair/typebox": ["@sinclair/typebox@0.34.33", "", {}, "sha512-5HAV9exOMcXRUxo+9iYB5n09XxzCXnfy4VTNW4xnDv+FgjzAGY989C28BIdljKqmF+ZltUwujE3aossvcVtq6g=="], - "@tokenizer/inflate": ["@tokenizer/inflate@0.3.1", "", { "dependencies": { "debug": "^4.4.1", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-4oeoZEBQdLdt5WmP/hx1KZ6D3/Oid/0cUb2nk4F0pTDAWy+KCH3/EnAkZF/bvckWo8I33EqBm01lIPgmgc8rCA=="], + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], @@ -271,11 +271,9 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], - "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], - "file-type": ["file-type@21.1.0", "", { "dependencies": { "@tokenizer/inflate": "^0.3.1", "strtok3": "^10.3.1", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-boU4EHmP3JXkwDo4uhyBhTt5pPstxB6eEXKJBu2yu2l7aAMMm7QQYQEzssJmKReZYrFdFOJS8koVo6bXIBGDqA=="], + "file-type": ["file-type@21.1.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-ifJXo8zUqbQ/bLbl9sFoqHNTNWbnPY1COImFfM6CCy7z+E+jC1eY9YfOKkx0fckIg+VljAy2/87T61fp0+eEkg=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], diff --git a/src/treaty2/index.ts b/src/treaty2/index.ts index d479411..c2c3b24 100644 --- a/src/treaty2/index.ts +++ b/src/treaty2/index.ts @@ -317,20 +317,63 @@ const createProxy = ( if (hasFile(body)) { const formData = new FormData() + const shouldStringify = (value: any): boolean => { + if (typeof value === 'string') return false + if (isFile(value)) return false + + // Objects and Arrays should be stringified + if (typeof value === 'object' && value !== null) { + return true + } + + return false + } + + const prepareValue = async (value: any): Promise => { + if (value instanceof File) { + return await createNewFile(value) + } + + if (shouldStringify(value)) { + return JSON.stringify(value) + } + + return value + } + // FormData is 1 level deep for (const [key, field] of Object.entries( fetchInit.body )) { if (Array.isArray(field)) { - for (let i = 0; i < field.length; i++) { - const value = (field as any)[i] + // Check if array contains non-file objects + // If so, stringify the entire array (for t.ArrayString()) + // Otherwise, append each element separately (for t.Files()) + const hasNonFileObjects = field.some( + (item) => + typeof item === 'object' && + item !== null && + !isFile(item) + ) + if (hasNonFileObjects) { + // ArrayString case: stringify the whole array formData.append( key as any, - value instanceof File - ? await createNewFile(value) - : value + JSON.stringify(field) ) + } else { + // Files case: append each element separately + for (let i = 0; i < field.length; i++) { + const value = (field as any)[i] + const preparedValue = + await prepareValue(value) + + formData.append( + key as any, + preparedValue + ) + } } continue @@ -338,9 +381,17 @@ const createProxy = ( if (isServer) { if (Array.isArray(field)) - for (const f of field) - formData.append(key, f) - else formData.append(key, field as any) + for (const f of field) { + formData.append( + key, + await prepareValue(f) + ) + } + else + formData.append( + key, + await prepareValue(field) + ) continue } @@ -364,7 +415,7 @@ const createProxy = ( continue } - formData.append(key, field as string) + formData.append(key, await prepareValue(field)) } // We don't do this because we need to let the browser set the content type with the correct boundary diff --git a/src/treaty2/types.ts b/src/treaty2/types.ts index bd21178..34fcce0 100644 --- a/src/treaty2/types.ts +++ b/src/treaty2/types.ts @@ -3,6 +3,7 @@ import type { Elysia, ELYSIA_FORM_DATA } from 'elysia' import { EdenWS } from './ws' import type { IsNever, MaybeEmptyObject, Not, Prettify } from '../types' +import { BunFile } from 'bun' // type Files = File | FileList @@ -58,6 +59,15 @@ type IsSuccessCode = S extends SuccessCodeRange ? true : false type MaybeArray = T | T[] type MaybePromise = T | Promise +type MaybeArrayFile = T extends (File | BunFile)[] ? T | File | BunFile : T + +type RelaxFileArrays = + T extends Record + ? { + [K in keyof T]: MaybeArrayFile + } + : T + export namespace Treaty { interface TreatyParam { fetch?: RequestInit @@ -101,7 +111,7 @@ export namespace Treaty { > > : ( - body?: Body, + body?: RelaxFileArrays, options?: Prettify ) => Promise< TreatyResponse< @@ -118,7 +128,7 @@ export namespace Treaty { > : {} extends Body ? ( - body?: Body, + body?: RelaxFileArrays, options?: Prettify ) => Promise< TreatyResponse< @@ -126,7 +136,7 @@ export namespace Treaty { > > : ( - body: Body, + body: RelaxFileArrays, options?: Prettify ) => Promise< TreatyResponse< @@ -142,7 +152,7 @@ export namespace Treaty { > > : ( - body: Body, + body: RelaxFileArrays, options: Prettify ) => Promise< TreatyResponse< diff --git a/test/treaty2-files.test.ts b/test/treaty2-files.test.ts new file mode 100644 index 0000000..006c6fc --- /dev/null +++ b/test/treaty2-files.test.ts @@ -0,0 +1,183 @@ +import Elysia, { t } from 'elysia' +import { treaty } from '../src' +import { describe, expect, it } from 'bun:test' +import { expectTypeOf } from 'expect-type' +import { BunFile } from 'bun' + +const app = new Elysia() + .post('/files', ({ body: { files } }) => files.map((file) => file.name), { + body: t.Object({ + files: t.Files() + }) + }) + .post('/any/file', ({ body: { file } }) => file.name, { + body: t.Object({ + file: t.File({ type: 'image/*' }) + }) + }) + .post('/png/file', ({ body: { file } }) => file.name, { + body: t.Object({ + file: t.File({ type: 'image/png' }) + }) + }) + +const client = treaty(app) +type client = typeof client + +describe('Treaty2 - Using t.File() and t.Files() from server', async () => { + const filePath1 = `${import.meta.dir}/public/aris-yuzu.jpg` + const filePath2 = `${import.meta.dir}/public/midori.png` + const filePath3 = `${import.meta.dir}/public/kyuukurarin.mp4` + + const bunFile1 = Bun.file(filePath1) + const bunFile2 = Bun.file(filePath2) + const bunFile3 = Bun.file(filePath3) + + const file1 = new File([await bunFile2.arrayBuffer()], 'aris-yuzu.jpg', { + type: 'image/jpeg' + }) + const file2 = new File([await bunFile1.arrayBuffer()], 'midori.png', { + type: 'image/png' + }) + const file3 = new File([await bunFile3.arrayBuffer()], 'kyuukurarin.mp4', { + type: 'video/mp4' + }) + + const filesForm = new FormData() + filesForm.append('files', file1) + filesForm.append('files', file2) + filesForm.append('files', file3) + + const bunFilesForm = new FormData() + bunFilesForm.append('files', bunFile1) + bunFilesForm.append('files', bunFile2) + bunFilesForm.append('files', bunFile3) + + it('check route types', async () => { + type RouteFiles = client['files']['post'] + + expectTypeOf().parameter(0).toEqualTypeOf<{ + files: Array | File | BunFile + }>() + + type RouteFile = client['any']['file']['post'] + + expectTypeOf().parameter(0).toEqualTypeOf<{ + file: File | BunFile + }>() + + type RouteFileWithSpecific = client['any']['file']['post'] + + expectTypeOf().parameter(0).toEqualTypeOf<{ + file: File | BunFile + }>() + }) + + it('accept a single Bun.file', async () => { + const { data: files } = await client.files.post({ + files: bunFile1 + }) + + expect(files).not.toBeNull() + expect(files).not.toBeUndefined() + expect(files).toEqual([bunFile1.name]) + + const { data: filesbis } = await client.files.post({ + files: [bunFile1] + }) + + expect(filesbis).not.toBeNull() + expect(filesbis).not.toBeUndefined() + expect(filesbis).toEqual([bunFile1.name]) + + const { data: file } = await client.any.file.post({ + file: bunFile1 + }) + + expect(file).not.toBeNull() + expect(file).not.toBeUndefined() + expect(file).toEqual(bunFile1.name) + + const { data: pngFile } = await client.png.file.post({ + file: bunFile2 + }) + + expect(pngFile).not.toBeNull() + expect(pngFile).not.toBeUndefined() + expect(pngFile).toEqual(bunFile2.name) + + const { + data: notPngFile, + error, + status + } = await client.png.file.post({ + file: bunFile1 + }) + + expect(notPngFile).toBeNull() + expect(error?.status).toBe(422) + expect(status).toBe(422) + }) + + it('accept a single regular file', async () => { + const { data: files } = await client.files.post({ + files: file1 + }) + + expect(files).not.toBeNull() + expect(files).not.toBeUndefined() + expect(files).toEqual([file1.name]) + + const { data: filesbis } = await client.files.post({ + files: [file1] + }) + + expect(filesbis).not.toBeNull() + expect(filesbis).not.toBeUndefined() + expect(filesbis).toEqual([file1.name]) + + const { data: file } = await client.any.file.post({ + file: file1 + }) + + expect(file).not.toBeNull() + expect(file).not.toBeUndefined() + expect(file).toEqual(file1.name) + }) + + it('accept an array of multiple Bun.file', async () => { + const { data: files } = await client.files.post({ + files: [bunFile1, bunFile2, bunFile3] + }) + + expect(files).not.toBeNull() + expect(files).not.toBeUndefined() + expect(files).toEqual([bunFile1.name, bunFile2.name, bunFile3.name]) + + const { data: filesbis } = await client.files.post({ + files: bunFilesForm.getAll('files') as File[] + }) + + expect(filesbis).not.toBeNull() + expect(filesbis).not.toBeUndefined() + expect(filesbis).toEqual([bunFile1.name, bunFile2.name, bunFile3.name]) + }) + + it('accept an array of multiple regular file', async () => { + const { data: files } = await client.files.post({ + files: [file1, file2, file3] + }) + + expect(files).not.toBeNull() + expect(files).not.toBeUndefined() + expect(files).toEqual([file1.name, file2.name, file3.name]) + + const { data: filesbis } = await client.files.post({ + files: filesForm.getAll('files') as File[] + }) + + expect(filesbis).not.toBeNull() + expect(filesbis).not.toBeUndefined() + expect(filesbis).toEqual([file1.name, file2.name, file3.name]) + }) +}) diff --git a/test/treaty2-nested-form.test.ts b/test/treaty2-nested-form.test.ts new file mode 100644 index 0000000..ffb1824 --- /dev/null +++ b/test/treaty2-nested-form.test.ts @@ -0,0 +1,83 @@ +import { treaty } from '@elysiajs/eden' +import { describe, expect, it } from 'bun:test' +import { Elysia, t } from 'elysia' + +const productModel = t.Object({ + name: t.String(), + variants: t.ArrayString( + t.Object({ + price: t.Number({ minimum: 0 }), + weight: t.Number({ minimum: 0 }) + }) + ), + metadata: t.ObjectString({ + category: t.String(), + tags: t.Array(t.String()), + inStock: t.Boolean() + }), + image: t.File({ type: 'image' }) +}) +type productModel = typeof productModel.static + +const app = new Elysia() + .post('/product', ({ body, status }) => status("Created", body), { body: productModel }) + +const api = treaty(app) + +describe('Nested FormData with file(s) support', () => { + const filePath1 = `${import.meta.dir}/public/aris-yuzu.jpg` + + const testProduct: productModel = { + name: 'Test Product', + variants: [ + { + price: 10, + weight: 100 + }, + { + price: 2.7, + weight: 32 + } + ], + metadata: { + category: 'Electronics', + tags: ['new', 'featured', 'sale'], + inStock: true + }, + image: Bun.file(filePath1) + } + + it('should create a product using manual JSON.stringify (old way)', async () => { + const stringifiedVariants = JSON.stringify(testProduct.variants) + const stringifiedMetadata = JSON.stringify(testProduct.metadata) + + const { data, status } = await api.product.post({ + name: testProduct.name, + variants: stringifiedVariants as unknown as { + price: number + weight: number + }[], + metadata: stringifiedMetadata as unknown as { + category: string + tags: string[] + inStock: boolean + }, + image: testProduct.image + }) + + expect(status).toBe(201) + expect(data).toEqual(testProduct) + }) + + it('should auto-stringify ArrayString and ObjectString fields (new way - improved DX)', async () => { + const { data, status } = await api.product.post({ + name: testProduct.name, + variants: testProduct.variants, // No JSON.stringify needed! + metadata: testProduct.metadata, // No JSON.stringify needed! + image: testProduct.image + }) + + expect(status).toBe(201) + expect(data).toEqual(testProduct) + }) +}) diff --git a/test/treaty2.test.ts b/test/treaty2.test.ts index 4475c6b..cae8cd8 100644 --- a/test/treaty2.test.ts +++ b/test/treaty2.test.ts @@ -1,5 +1,5 @@ import { Elysia, form, sse, t } from 'elysia' -import { Treaty, treaty } from '../src' +import { treaty } from '../src' import { describe, expect, it, beforeAll, afterAll, mock, test } from 'bun:test' @@ -92,43 +92,98 @@ const app = new Elysia() alias: t.Literal('Kristen') }) }) - .group('/empty-test', (g) => g - .get('/with-maybe-empty', ({ query, headers }) => ({ query, headers }), { - query: t.MaybeEmpty(t.Object({ alias: t.String() })), - headers: t.MaybeEmpty(t.Object({ username: t.String() })) - }) - .get('/with-unknown', ({ query, headers }) => ({ query, headers }), { - query: t.Unknown(), - headers: t.Unknown(), - }) - .get('/with-empty-record', ({ query, headers }) => ({ query, headers }), { - query: t.Record(t.String(), t.Never()), - headers: t.Record(t.String(), t.Never()), - }) - .get('/with-empty-obj', ({ query, headers }) => ({ query, headers }), { - query: t.Object({}), - headers: t.Object({}), - }) - .get('/with-partial', ({ query, headers }) => ({ query, headers }), { - query: t.Partial(t.Object({ alias: t.String() })), - headers: t.Partial(t.Object({ username: t.String() })), - }) - .get('/with-optional', ({ query, headers }) => ({ query, headers }), { - query: t.Optional(t.Object({ alias: t.String() })), - headers: t.Optional(t.Object({ username: t.String() })), - }) - .get('/with-union-undefined', ({ query, headers }) => ({ query, headers }), { - query: t.Union([t.Object({ alias: t.String() }), t.Undefined()]), - headers: t.Union([t.Object({ username: t.String() }), t.Undefined()]) - }) - .get('/with-union-empty-obj', ({ query, headers }) => ({ query, headers }), { - query: t.Union([t.Object({ alias: t.String() }), t.Object({})]), - headers: t.Union([t.Object({ username: t.String() }), t.Object({})]), - }) - .get('/with-union-empty-record', ({ query, headers }) => ({ query, headers }), { - query: t.Union([t.Object({ alias: t.String() }), t.Record(t.String(), t.Never())]), - headers: t.Union([t.Object({ username: t.String() }), t.Record(t.String(), t.Never())]), - }) + .group('/empty-test', (g) => + g + .get( + '/with-maybe-empty', + ({ query, headers }) => ({ query, headers }), + { + query: t.MaybeEmpty(t.Object({ alias: t.String() })), + headers: t.MaybeEmpty(t.Object({ username: t.String() })) + } + ) + .get( + '/with-unknown', + ({ query, headers }) => ({ query, headers }), + { + query: t.Unknown(), + headers: t.Unknown() + } + ) + .get( + '/with-empty-record', + ({ query, headers }) => ({ query, headers }), + { + query: t.Record(t.String(), t.Never()), + headers: t.Record(t.String(), t.Never()) + } + ) + .get( + '/with-empty-obj', + ({ query, headers }) => ({ query, headers }), + { + query: t.Object({}), + headers: t.Object({}) + } + ) + .get( + '/with-partial', + ({ query, headers }) => ({ query, headers }), + { + query: t.Partial(t.Object({ alias: t.String() })), + headers: t.Partial(t.Object({ username: t.String() })) + } + ) + .get( + '/with-optional', + ({ query, headers }) => ({ query, headers }), + { + query: t.Optional(t.Object({ alias: t.String() })), + headers: t.Optional(t.Object({ username: t.String() })) + } + ) + .get( + '/with-union-undefined', + ({ query, headers }) => ({ query, headers }), + { + query: t.Union([ + t.Object({ alias: t.String() }), + t.Undefined() + ]), + headers: t.Union([ + t.Object({ username: t.String() }), + t.Undefined() + ]) + } + ) + .get( + '/with-union-empty-obj', + ({ query, headers }) => ({ query, headers }), + { + query: t.Union([ + t.Object({ alias: t.String() }), + t.Object({}) + ]), + headers: t.Union([ + t.Object({ username: t.String() }), + t.Object({}) + ]) + } + ) + .get( + '/with-union-empty-record', + ({ query, headers }) => ({ query, headers }), + { + query: t.Union([ + t.Object({ alias: t.String() }), + t.Record(t.String(), t.Never()) + ]), + headers: t.Union([ + t.Object({ username: t.String() }), + t.Record(t.String(), t.Never()) + ]) + } + ) ) .post('/queries', ({ query }) => query, { query: t.Object({ @@ -233,16 +288,6 @@ const app = new Elysia() return 'a' }) .get('/id/:id?', ({ params: { id = 'unknown' } }) => id) - .post('/files', ({ body: { files } }) => files.map((file) => file.name), { - body: t.Object({ - files: t.Files() - }) - }) - .post('/file', ({ body: { file } }) => file.name, { - body: t.Object({ - file: t.File() - }) - }) const client = treaty(app) @@ -398,7 +443,7 @@ describe('Treaty2', () => { 'with-unknown', 'with-empty-record', 'with-union-empty-obj', - 'with-union-empty-record', + 'with-union-empty-record' // 'with-maybe-empty', // 'with-optional', // 'with-union-undefined', @@ -927,128 +972,6 @@ describe('Treaty2 - Using endpoint URL', () => { expect(data).toBe('application/json!' as any) }) -}) - -describe('Treaty2 - Using t.File() and t.Files() from server', async () => { - const filePath1 = `${import.meta.dir}/public/aris-yuzu.jpg` - const filePath2 = `${import.meta.dir}/public/midori.png` - const filePath3 = `${import.meta.dir}/public/kyuukurarin.mp4` - - const bunFile1 = Bun.file(filePath1) - const bunFile2 = Bun.file(filePath2) - const bunFile3 = Bun.file(filePath3) - - const file1 = new File([await bunFile1.arrayBuffer()], 'cumin.webp', { - type: 'image/webp' - }) - const file2 = new File([await bunFile2.arrayBuffer()], 'curcuma.jpg', { - type: 'image/jpeg' - }) - const file3 = new File([await bunFile3.arrayBuffer()], 'kyuukurarin.mp4', { - type: 'video/mp4' - }) - - const filesForm = new FormData() - filesForm.append('files', file1) - filesForm.append('files', file2) - filesForm.append('files', file3) - - const bunFilesForm = new FormData() - bunFilesForm.append('files', bunFile1) - bunFilesForm.append('files', bunFile2) - bunFilesForm.append('files', bunFile3) - - it('accept a single Bun.file', async () => { - const { data: files } = await client.files.post({ - files: bunFile1 as unknown as File[] - }) - - expect(files).not.toBeNull() - expect(files).not.toBeUndefined() - expect(files).toEqual([bunFile1.name!]) - - const { data: filesbis } = await client.files.post({ - files: [bunFile1] as unknown as File[] - }) - - expect(filesbis).not.toBeNull() - expect(filesbis).not.toBeUndefined() - expect(filesbis).toEqual([bunFile1.name!]) - - const { data: file } = await client.file.post({ - file: bunFile1 as unknown as File - }) - - expect(file).not.toBeNull() - expect(file).not.toBeUndefined() - expect(file).toEqual(bunFile1.name!) - }) - - it('accept a single regular file', async () => { - const { data: files } = await client.files.post({ - files: file1 as unknown as File[] - }) - - expect(files).not.toBeNull() - expect(files).not.toBeUndefined() - expect(files).toEqual([file1.name!]) - - const { data: filesbis } = await client.files.post({ - files: [file1] as unknown as File[] - }) - - expect(filesbis).not.toBeNull() - expect(filesbis).not.toBeUndefined() - expect(filesbis).toEqual([file1.name!]) - - const { data: file } = await client.file.post({ - file: file1 as unknown as File - }) - - expect(file).not.toBeNull() - expect(file).not.toBeUndefined() - expect(file).toEqual(file1.name!) - }) - - it('accept an array of multiple Bun.file', async () => { - const { data: files } = await client.files.post({ - files: [bunFile1, bunFile2, bunFile3] as unknown as File[] - }) - - expect(files).not.toBeNull() - expect(files).not.toBeUndefined() - expect(files).toEqual([bunFile1.name!, bunFile2.name!, bunFile3.name!]) - - const { data: filesbis } = await client.files.post({ - files: bunFilesForm.getAll('files') as unknown as File[] - }) - - expect(filesbis).not.toBeNull() - expect(filesbis).not.toBeUndefined() - expect(filesbis).toEqual([ - bunFile1.name!, - bunFile2.name!, - bunFile3.name! - ]) - }) - - it('accept an array of multiple regular file', async () => { - const { data: files } = await client.files.post({ - files: [file1, file2, file3] as unknown as File[] - }) - - expect(files).not.toBeNull() - expect(files).not.toBeUndefined() - expect(files).toEqual([file1.name!, file2.name!, file3.name!]) - - const { data: filesbis } = await client.files.post({ - files: filesForm.getAll('files') as unknown as File[] - }) - - expect(filesbis).not.toBeNull() - expect(filesbis).not.toBeUndefined() - expect(filesbis).toEqual([file1.name!, file2.name!, file3.name!]) - }) it('handle root dynamic parameter', async () => { const app = new Elysia().get('/:id', ({ params: { id } }) => id, {