Skip to content

Commit eb053a9

Browse files
committed
feat(fetch): auto-serialize request bodies based on content type
1 parent bbaf29a commit eb053a9

File tree

4 files changed

+401
-7
lines changed

4 files changed

+401
-7
lines changed
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
/** biome-ignore-all lint/suspicious/noExplicitAny: any is used to allow for flexibility in the type */
2+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
3+
import type { UrlMapValue } from '@devup-api/core'
4+
5+
// Mock the url-map module to return custom bodyType values
6+
const mockUrlMap: Record<string, Record<string, UrlMapValue>> = {
7+
'openapi.json': {
8+
submitForm: { method: 'POST', url: '/submit', bodyType: 'form' },
9+
uploadFile: { method: 'POST', url: '/upload', bodyType: 'multipart' },
10+
jsonEndpoint: { method: 'POST', url: '/json', bodyType: 'json' },
11+
},
12+
}
13+
14+
mock.module('../url-map', () => ({
15+
DEVUP_API_URL_MAP: mockUrlMap,
16+
getApiEndpointInfo: (key: string, serverName: string): UrlMapValue => {
17+
const result = mockUrlMap[serverName]?.[key] ?? {
18+
method: 'GET' as const,
19+
url: key,
20+
}
21+
result.url ||= key
22+
return result
23+
},
24+
}))
25+
26+
// Import DevupApi AFTER mock.module so it picks up the mocked url-map
27+
const { DevupApi } = await import('../api')
28+
29+
const originalFetch = globalThis.fetch
30+
31+
beforeEach(() => {
32+
globalThis.fetch = mock(() =>
33+
Promise.resolve(
34+
new Response(JSON.stringify({ success: true }), {
35+
status: 200,
36+
headers: { 'Content-Type': 'application/json' },
37+
}),
38+
),
39+
) as unknown as typeof fetch
40+
})
41+
42+
afterEach(() => {
43+
globalThis.fetch = originalFetch
44+
})
45+
46+
describe('bodyType-aware serialization', () => {
47+
test('bodyType form: plain object body serialized as URLSearchParams', async () => {
48+
const api = new DevupApi(
49+
'https://api.example.com',
50+
undefined,
51+
'openapi.json',
52+
)
53+
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof mock>
54+
55+
await api.post(
56+
'submitForm' as never,
57+
{ body: { name: 'test', age: 25 } } as never,
58+
)
59+
60+
expect(mockFetch).toHaveBeenCalledTimes(1)
61+
const call = mockFetch.mock.calls[0]
62+
expect(call).toBeDefined()
63+
if (call) {
64+
const request = call[0] as Request
65+
expect(request.headers.get('Content-Type')).toBe(
66+
'application/x-www-form-urlencoded',
67+
)
68+
const body = await request.text()
69+
const params = new URLSearchParams(body)
70+
expect(params.get('name')).toBe('test')
71+
expect(params.get('age')).toBe('25')
72+
}
73+
})
74+
75+
test('bodyType multipart: plain object body serialized as FormData', async () => {
76+
const api = new DevupApi(
77+
'https://api.example.com',
78+
undefined,
79+
'openapi.json',
80+
)
81+
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof mock>
82+
83+
await api.post(
84+
'uploadFile' as never,
85+
{ body: { name: 'test', value: 123 } } as never,
86+
)
87+
88+
expect(mockFetch).toHaveBeenCalledTimes(1)
89+
const call = mockFetch.mock.calls[0]
90+
expect(call).toBeDefined()
91+
if (call) {
92+
const request = call[0] as Request
93+
// Content-Type should NOT be explicitly set for multipart
94+
// (browser auto-sets with boundary)
95+
const contentType = request.headers.get('Content-Type')
96+
expect(
97+
contentType === null || contentType.includes('multipart/form-data'),
98+
).toBe(true)
99+
expect(request.body).not.toBeNull()
100+
}
101+
})
102+
103+
test('bodyType undefined (default): plain object body serialized as JSON', async () => {
104+
const api = new DevupApi(
105+
'https://api.example.com',
106+
undefined,
107+
'openapi.json',
108+
)
109+
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof mock>
110+
111+
// Use a path that is NOT in the URL map, so bodyType will be undefined
112+
await api.post(
113+
'/test' as never,
114+
{ body: { name: 'test', value: 123 } } as never,
115+
)
116+
117+
expect(mockFetch).toHaveBeenCalledTimes(1)
118+
const call = mockFetch.mock.calls[0]
119+
expect(call).toBeDefined()
120+
if (call) {
121+
const request = call[0] as Request
122+
expect(request.headers.get('Content-Type')).toBe('application/json')
123+
const body = await request.text()
124+
expect(body).toBe(JSON.stringify({ name: 'test', value: 123 }))
125+
}
126+
})
127+
128+
test('bodyType json: plain object body serialized as JSON', async () => {
129+
const api = new DevupApi(
130+
'https://api.example.com',
131+
undefined,
132+
'openapi.json',
133+
)
134+
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof mock>
135+
136+
await api.post('jsonEndpoint' as never, { body: { name: 'test' } } as never)
137+
138+
expect(mockFetch).toHaveBeenCalledTimes(1)
139+
const call = mockFetch.mock.calls[0]
140+
expect(call).toBeDefined()
141+
if (call) {
142+
const request = call[0] as Request
143+
expect(request.headers.get('Content-Type')).toBe('application/json')
144+
const body = await request.text()
145+
expect(body).toBe(JSON.stringify({ name: 'test' }))
146+
}
147+
})
148+
149+
test('FormData body passthrough regardless of bodyType', async () => {
150+
const api = new DevupApi(
151+
'https://api.example.com',
152+
undefined,
153+
'openapi.json',
154+
)
155+
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof mock>
156+
const formData = new FormData()
157+
formData.append('file', 'test')
158+
159+
await api.post('submitForm' as never, { body: formData } as never)
160+
161+
expect(mockFetch).toHaveBeenCalledTimes(1)
162+
const call = mockFetch.mock.calls[0]
163+
expect(call).toBeDefined()
164+
if (call) {
165+
const request = call[0] as Request
166+
// FormData should be passed through as-is, not re-serialized
167+
expect(request.body).not.toBeNull()
168+
expect(request.body).toBeDefined()
169+
}
170+
})
171+
172+
test('bodyType form: nested objects are JSON.stringified in URLSearchParams', async () => {
173+
const api = new DevupApi(
174+
'https://api.example.com',
175+
undefined,
176+
'openapi.json',
177+
)
178+
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof mock>
179+
180+
await api.post(
181+
'submitForm' as never,
182+
{ body: { data: { nested: true }, items: [1, 2] } } as never,
183+
)
184+
185+
expect(mockFetch).toHaveBeenCalledTimes(1)
186+
const call = mockFetch.mock.calls[0]
187+
expect(call).toBeDefined()
188+
if (call) {
189+
const request = call[0] as Request
190+
const body = await request.text()
191+
const params = new URLSearchParams(body)
192+
expect(params.get('data')).toBe(JSON.stringify({ nested: true }))
193+
expect(params.get('items')).toBe(JSON.stringify([1, 2]))
194+
}
195+
})
196+
197+
test('bodyType form: null/undefined values are skipped', async () => {
198+
const api = new DevupApi(
199+
'https://api.example.com',
200+
undefined,
201+
'openapi.json',
202+
)
203+
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof mock>
204+
205+
await api.post(
206+
'submitForm' as never,
207+
{ body: { name: 'test', empty: null, missing: undefined } } as never,
208+
)
209+
210+
expect(mockFetch).toHaveBeenCalledTimes(1)
211+
const call = mockFetch.mock.calls[0]
212+
expect(call).toBeDefined()
213+
if (call) {
214+
const request = call[0] as Request
215+
const body = await request.text()
216+
const params = new URLSearchParams(body)
217+
expect(params.get('name')).toBe('test')
218+
expect(params.has('empty')).toBe(false)
219+
expect(params.has('missing')).toBe(false)
220+
}
221+
})
222+
})

packages/fetch/src/__tests__/utils.test.ts

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
import { expect, test } from 'bun:test'
2-
import { getApiEndpoint, getQueryString, isPlainObject } from '../utils'
1+
import { describe, expect, test } from 'bun:test'
2+
import {
3+
getApiEndpoint,
4+
getQueryString,
5+
isPlainObject,
6+
objectToFormData,
7+
objectToURLSearchParams,
8+
} from '../utils'
39

410
test.each([
511
[{}, true],
@@ -160,3 +166,117 @@ test.each([
160166
)
161167
expect(result.toString()).toBe(expected)
162168
})
169+
170+
describe('objectToURLSearchParams', () => {
171+
test('converts simple key-value pairs', () => {
172+
const params = objectToURLSearchParams({ name: 'test', age: 25 })
173+
expect(params.get('name')).toBe('test')
174+
expect(params.get('age')).toBe('25')
175+
})
176+
177+
test('skips null values', () => {
178+
const params = objectToURLSearchParams({ name: 'test', skip: null })
179+
expect(params.get('name')).toBe('test')
180+
expect(params.has('skip')).toBe(false)
181+
})
182+
183+
test('skips undefined values', () => {
184+
const params = objectToURLSearchParams({ name: 'test', skip: undefined })
185+
expect(params.get('name')).toBe('test')
186+
expect(params.has('skip')).toBe(false)
187+
})
188+
189+
test('JSON.stringifies nested objects', () => {
190+
const nested = { key: 'value' }
191+
const params = objectToURLSearchParams({ data: nested })
192+
expect(params.get('data')).toBe(JSON.stringify(nested))
193+
})
194+
195+
test('JSON.stringifies arrays', () => {
196+
const arr = [1, 2, 3]
197+
const params = objectToURLSearchParams({ items: arr })
198+
expect(params.get('items')).toBe(JSON.stringify(arr))
199+
})
200+
201+
test('converts boolean values to string', () => {
202+
const params = objectToURLSearchParams({ active: true, deleted: false })
203+
expect(params.get('active')).toBe('true')
204+
expect(params.get('deleted')).toBe('false')
205+
})
206+
207+
test('converts number values to string', () => {
208+
const params = objectToURLSearchParams({ count: 42, price: 9.99 })
209+
expect(params.get('count')).toBe('42')
210+
expect(params.get('price')).toBe('9.99')
211+
})
212+
213+
test('handles empty object', () => {
214+
const params = objectToURLSearchParams({})
215+
expect(params.toString()).toBe('')
216+
})
217+
})
218+
219+
describe('objectToFormData', () => {
220+
test('converts simple key-value pairs', () => {
221+
const formData = objectToFormData({ name: 'test', age: 25 })
222+
expect(formData.get('name')).toBe('test')
223+
expect(formData.get('age')).toBe('25')
224+
})
225+
226+
test('skips null values', () => {
227+
const formData = objectToFormData({ name: 'test', skip: null })
228+
expect(formData.get('name')).toBe('test')
229+
expect(formData.has('skip')).toBe(false)
230+
})
231+
232+
test('skips undefined values', () => {
233+
const formData = objectToFormData({ name: 'test', skip: undefined })
234+
expect(formData.get('name')).toBe('test')
235+
expect(formData.has('skip')).toBe(false)
236+
})
237+
238+
test('JSON.stringifies nested objects', () => {
239+
const nested = { key: 'value' }
240+
const formData = objectToFormData({ data: nested })
241+
expect(formData.get('data')).toBe(JSON.stringify(nested))
242+
})
243+
244+
test('JSON.stringifies arrays', () => {
245+
const arr = [1, 2, 3]
246+
const formData = objectToFormData({ items: arr })
247+
expect(formData.get('items')).toBe(JSON.stringify(arr))
248+
})
249+
250+
test('appends Blob values directly', () => {
251+
const blob = new Blob(['hello'], { type: 'text/plain' })
252+
const formData = objectToFormData({ file: blob })
253+
const result = formData.get('file')
254+
expect(result).toBeInstanceOf(Blob)
255+
})
256+
257+
test('appends File values directly', () => {
258+
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
259+
const formData = objectToFormData({ document: file })
260+
const result = formData.get('document')
261+
expect(result).toBeInstanceOf(File)
262+
expect((result as File).name).toBe('test.txt')
263+
})
264+
265+
test('converts boolean values to string', () => {
266+
const formData = objectToFormData({ active: true, deleted: false })
267+
expect(formData.get('active')).toBe('true')
268+
expect(formData.get('deleted')).toBe('false')
269+
})
270+
271+
test('converts number values to string', () => {
272+
const formData = objectToFormData({ count: 42, price: 9.99 })
273+
expect(formData.get('count')).toBe('42')
274+
expect(formData.get('price')).toBe('9.99')
275+
})
276+
277+
test('handles empty object', () => {
278+
const formData = objectToFormData({})
279+
const entries = Array.from(formData.entries())
280+
expect(entries).toHaveLength(0)
281+
})
282+
})

0 commit comments

Comments
 (0)