bxo 0.0.5-dev.54 → 0.0.5-dev.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bxo",
3
3
  "module": "index.ts",
4
- "version": "0.0.5-dev.54",
4
+ "version": "0.0.5-dev.56",
5
5
  "description": "A simple and lightweight web framework for Bun",
6
6
  "type": "module",
7
7
  "exports": {
@@ -78,6 +78,120 @@ export function validateResponse(
78
78
  return data;
79
79
  }
80
80
 
81
+ // Helper function to parse form keys with nested object and array notation (Axios-compatible)
82
+ function parseFormKey(key: string): {
83
+ baseKey: string;
84
+ path: string[];
85
+ isArray: boolean;
86
+ isJson: boolean;
87
+ hasIndexes: boolean;
88
+ } {
89
+ // Check for special endings like "{}" for JSON serialization FIRST
90
+ if (key.endsWith('{}')) {
91
+ const actualBaseKey = key.slice(0, -2);
92
+ return { baseKey: actualBaseKey, path: [], isArray: false, isJson: true, hasIndexes: false };
93
+ }
94
+
95
+ const bracketMatch = key.match(/^([^\[]+)(\[.*\])*$/);
96
+ if (!bracketMatch) {
97
+ return { baseKey: key, path: [], isArray: false, isJson: false, hasIndexes: false };
98
+ }
99
+
100
+ const baseKey = bracketMatch[1];
101
+ if (!baseKey) {
102
+ return { baseKey: key, path: [], isArray: false, isJson: false, hasIndexes: false };
103
+ }
104
+
105
+ const bracketPart = key.slice(baseKey.length);
106
+
107
+ if (!bracketPart) {
108
+ return { baseKey, path: [], isArray: false, isJson: false, hasIndexes: false };
109
+ }
110
+
111
+ // Check for special endings like "{}" for JSON serialization
112
+ if (baseKey.endsWith('{}')) {
113
+ const actualBaseKey = baseKey.slice(0, -2);
114
+ return { baseKey: actualBaseKey, path: [], isArray: false, isJson: true, hasIndexes: false };
115
+ }
116
+
117
+ // If no bracket part, return simple key
118
+ if (!bracketPart) {
119
+ return { baseKey, path: [], isArray: false, isJson: false, hasIndexes: false };
120
+ }
121
+
122
+ // Check if this is an array notation (e.g., "recordIds[]")
123
+ if (bracketPart === '[]') {
124
+ return { baseKey, path: [], isArray: true, isJson: false, hasIndexes: false };
125
+ }
126
+
127
+ // Extract all bracket contents
128
+ const path: string[] = [];
129
+ const bracketRegex = /\[([^\]]*)\]/g;
130
+ let match;
131
+ let hasIndexes = false;
132
+
133
+ while ((match = bracketRegex.exec(bracketPart)) !== null) {
134
+ if (match[1] !== undefined) {
135
+ path.push(match[1]);
136
+ // Check if this is a numeric index
137
+ if (/^\d+$/.test(match[1])) {
138
+ hasIndexes = true;
139
+ }
140
+ }
141
+ }
142
+
143
+ // Check if the last path element is empty (indicating array without indexes)
144
+ const isArray = path.length > 0 && path[path.length - 1] === '';
145
+
146
+ return { baseKey, path, isArray, isJson: false, hasIndexes };
147
+ }
148
+
149
+ // Helper function to set nested value in object (Axios-compatible)
150
+ function setNestedValue(obj: any, baseKey: string, path: string[], value: any, isArray: boolean = false): void {
151
+ if (!(baseKey in obj)) {
152
+ obj[baseKey] = isArray ? [] : {};
153
+ }
154
+
155
+ let current = obj[baseKey];
156
+
157
+ // Navigate to the parent of the target location
158
+ for (let i = 0; i < path.length - 1; i++) {
159
+ const key = path[i];
160
+ if (key && (!(key in current) || typeof current[key] !== 'object')) {
161
+ // Check if next key is numeric (array index)
162
+ const nextKey = path[i + 1];
163
+ const isNextKeyNumeric = nextKey && /^\d+$/.test(nextKey);
164
+ current[key] = isNextKeyNumeric ? [] : {};
165
+ }
166
+ if (key) {
167
+ current = current[key];
168
+ }
169
+ }
170
+
171
+ // Set the final value
172
+ const lastKey = path[path.length - 1];
173
+ if (lastKey) {
174
+ if (/^\d+$/.test(lastKey)) {
175
+ // Numeric key - treat as array index
176
+ const index = parseInt(lastKey, 10);
177
+ if (Array.isArray(current)) {
178
+ // Ensure array is large enough
179
+ while (current.length <= index) {
180
+ current.push(undefined);
181
+ }
182
+ current[index] = value;
183
+ } else {
184
+ // Convert to array if needed
185
+ const newArray = [];
186
+ newArray[index] = value;
187
+ current[lastKey] = newArray;
188
+ }
189
+ } else {
190
+ current[lastKey] = value;
191
+ }
192
+ }
193
+ }
194
+
81
195
  // Parse request body based on content type
82
196
  export async function parseRequestBody(request: Request): Promise<any> {
83
197
  const contentType = request.headers.get('content-type');
@@ -94,12 +208,47 @@ export async function parseRequestBody(request: Request): Promise<any> {
94
208
  const formBody: Record<string, any> = {};
95
209
 
96
210
  for (const [key, value] of formData.entries()) {
97
- if (value instanceof File) {
98
- // Return File instances directly
99
- formBody[key] = value;
211
+ // Parse the key to handle nested objects and arrays (Axios-compatible)
212
+ const parsedKey = parseFormKey(key);
213
+
214
+ if (parsedKey.isJson) {
215
+ // Handle JSON serialization (e.g., "obj{}")
216
+ if (typeof value === 'string') {
217
+ try {
218
+ formBody[parsedKey.baseKey] = JSON.parse(value);
219
+ } catch {
220
+ formBody[parsedKey.baseKey] = value;
221
+ }
222
+ } else {
223
+ formBody[parsedKey.baseKey] = value;
224
+ }
225
+ } else if (parsedKey.isArray) {
226
+ // Handle array notation like "recordIds[]"
227
+ if (parsedKey.baseKey in formBody) {
228
+ if (Array.isArray(formBody[parsedKey.baseKey])) {
229
+ formBody[parsedKey.baseKey].push(value);
230
+ } else {
231
+ formBody[parsedKey.baseKey] = [formBody[parsedKey.baseKey], value];
232
+ }
233
+ } else {
234
+ formBody[parsedKey.baseKey] = [value];
235
+ }
236
+ } else if (parsedKey.path.length > 0) {
237
+ // Handle nested object notation like "test[new]", "test[hi][hi]", "arr[0]", "users[0][name]"
238
+ setNestedValue(formBody, parsedKey.baseKey, parsedKey.path, value, parsedKey.hasIndexes);
100
239
  } else {
101
- // Handle regular form fields
102
- formBody[key] = value;
240
+ // Handle regular form fields - check if this key already exists
241
+ if (key in formBody) {
242
+ // If key already exists, convert to array or append to existing array
243
+ if (Array.isArray(formBody[key])) {
244
+ formBody[key].push(value);
245
+ } else {
246
+ formBody[key] = [formBody[key], value];
247
+ }
248
+ } else {
249
+ // First occurrence of this key
250
+ formBody[key] = value;
251
+ }
103
252
  }
104
253
  }
105
254
 
@@ -134,6 +134,40 @@ export function processResponse(
134
134
  });
135
135
  }
136
136
 
137
+ // Check if response contains File objects
138
+ const containsFiles = (obj: any): boolean => {
139
+ if (obj instanceof File) return true;
140
+ if (Array.isArray(obj)) {
141
+ return obj.some(item => containsFiles(item));
142
+ }
143
+ if (obj && typeof obj === 'object') {
144
+ return Object.values(obj).some(value => containsFiles(value));
145
+ }
146
+ return false;
147
+ };
148
+
149
+ if (containsFiles(response)) {
150
+ // For responses containing files, we need to handle them specially
151
+ // For now, we'll convert File objects to a serializable format
152
+ const serializableResponse = JSON.parse(JSON.stringify(response, (key, value) => {
153
+ if (value instanceof File) {
154
+ return {
155
+ type: 'File',
156
+ name: value.name,
157
+ size: value.size,
158
+ lastModified: value.lastModified
159
+ };
160
+ }
161
+ return value;
162
+ }));
163
+
164
+ headers.set('Content-Type', 'application/json');
165
+ return new Response(JSON.stringify(serializableResponse), {
166
+ status: ctx.set.status || 200,
167
+ headers: headers
168
+ });
169
+ }
170
+
137
171
  headers.set('Content-Type', 'application/json');
138
172
  return new Response(JSON.stringify(response), {
139
173
  status: ctx.set.status || 200,
@@ -157,6 +191,42 @@ export function processResponse(
157
191
  });
158
192
  }
159
193
 
194
+ // Check if response contains File objects (for the no-cookies case)
195
+ const containsFiles = (obj: any): boolean => {
196
+ if (obj instanceof File) return true;
197
+ if (Array.isArray(obj)) {
198
+ return obj.some(item => containsFiles(item));
199
+ }
200
+ if (obj && typeof obj === 'object') {
201
+ return Object.values(obj).some(value => containsFiles(value));
202
+ }
203
+ return false;
204
+ };
205
+
206
+ if (containsFiles(response)) {
207
+ // For responses containing files, we need to handle them specially
208
+ // For now, we'll convert File objects to a serializable format
209
+ const serializableResponse = JSON.parse(JSON.stringify(response, (key, value) => {
210
+ if (value instanceof File) {
211
+ return {
212
+ type: 'File',
213
+ name: value.name,
214
+ size: value.size,
215
+ lastModified: value.lastModified
216
+ };
217
+ }
218
+ return value;
219
+ }));
220
+
221
+ return new Response(JSON.stringify(serializableResponse), {
222
+ ...responseInit,
223
+ headers: {
224
+ 'Content-Type': 'application/json',
225
+ ...responseInit.headers
226
+ }
227
+ });
228
+ }
229
+
160
230
  return new Response(JSON.stringify(response), {
161
231
  ...responseInit,
162
232
  headers: {
@@ -196,6 +196,191 @@ describe('BXO Framework Integration', () => {
196
196
  expect(data.formData.email).toBe('john@example.com');
197
197
  expect(data.message).toBe('Form submitted');
198
198
  });
199
+
200
+ it('should handle form data with arrays', async () => {
201
+ app.post('/api/records', (ctx) => {
202
+ return { formData: ctx.body, message: 'Records submitted' };
203
+ });
204
+
205
+ const formData = new FormData();
206
+ formData.append('app', 'zodula');
207
+ formData.append('model', 'zodula_User');
208
+ formData.append('recordIds[]', 'asdfasdfdsa');
209
+ formData.append('recordIds[]', 'jarupak.sri@gmail.com');
210
+ formData.append('fields', '*');
211
+
212
+ const response = await fetch(`${baseUrl}/api/records`, {
213
+ method: 'POST',
214
+ body: formData
215
+ });
216
+
217
+ const data = await response.json() as {
218
+ formData: {
219
+ app: string,
220
+ model: string,
221
+ recordIds: string[],
222
+ fields: string
223
+ },
224
+ message: string
225
+ };
226
+
227
+ expect(response.status).toBe(200);
228
+ expect(data.formData.app).toBe('zodula');
229
+ expect(data.formData.model).toBe('zodula_User');
230
+ expect(Array.isArray(data.formData.recordIds)).toBe(true);
231
+ expect(data.formData.recordIds).toEqual(['asdfasdfdsa', 'jarupak.sri@gmail.com']);
232
+ expect(data.formData.fields).toBe('*');
233
+ expect(data.message).toBe('Records submitted');
234
+ });
235
+
236
+ it('should handle form data with nested objects', async () => {
237
+ app.post('/api/nested', (ctx) => {
238
+ return { formData: ctx.body, message: 'Nested data submitted' };
239
+ });
240
+
241
+ const formData = new FormData();
242
+ formData.append('test[test]', 'test');
243
+ formData.append('test[new]', 'new');
244
+ formData.append('test[hi][hi]', 'hi');
245
+
246
+ const response = await fetch(`${baseUrl}/api/nested`, {
247
+ method: 'POST',
248
+ body: formData
249
+ });
250
+
251
+ const data = await response.json() as {
252
+ formData: {
253
+ test: {
254
+ test: string,
255
+ new: string,
256
+ hi: {
257
+ hi: string
258
+ }
259
+ }
260
+ },
261
+ message: string
262
+ };
263
+
264
+ expect(response.status).toBe(200);
265
+ expect(data.formData.test).toEqual({
266
+ test: 'test',
267
+ new: 'new',
268
+ hi: {
269
+ hi: 'hi'
270
+ }
271
+ });
272
+ expect(data.message).toBe('Nested data submitted');
273
+ });
274
+
275
+ it('should handle form data with nested arrays and files', async () => {
276
+ app.post('/api/records-with-files', (ctx) => {
277
+ return { formData: ctx.body, message: 'Records with files submitted' };
278
+ });
279
+
280
+ const formData = new FormData();
281
+ formData.append('records[0][qrPayment]', new File(['test file content'], 'test.png', { type: 'image/png' }));
282
+ formData.append('records[1][qrPayment]', new File(['test file content 2'], 'test2.png', { type: 'image/png' }));
283
+ formData.append('records[0][name]', 'Record 1');
284
+ formData.append('records[1][name]', 'Record 2');
285
+
286
+ const response = await fetch(`${baseUrl}/api/records-with-files`, {
287
+ method: 'POST',
288
+ body: formData
289
+ });
290
+
291
+ const data = await response.json() as {
292
+ formData: {
293
+ records: Array<{
294
+ qrPayment: {
295
+ type: string;
296
+ name: string;
297
+ size: number;
298
+ lastModified: number;
299
+ },
300
+ name: string
301
+ }>
302
+ },
303
+ message: string
304
+ };
305
+
306
+ expect(response.status).toBe(200);
307
+ expect(Array.isArray(data.formData.records)).toBe(true);
308
+ expect(data.formData.records).toHaveLength(2);
309
+ expect(data.formData.records[0]?.name).toBe('Record 1');
310
+ expect(data.formData.records[1]?.name).toBe('Record 2');
311
+ expect(data.formData.records[0]?.qrPayment).toEqual({
312
+ type: 'File',
313
+ name: 'test.png',
314
+ size: 17,
315
+ lastModified: 0
316
+ });
317
+ expect(data.formData.records[1]?.qrPayment).toEqual({
318
+ type: 'File',
319
+ name: 'test2.png',
320
+ size: 19,
321
+ lastModified: 0
322
+ });
323
+ expect(data.message).toBe('Records with files submitted');
324
+ });
325
+
326
+ it('should have File objects in ctx.body', async () => {
327
+ app.post('/api/files-test', (ctx) => {
328
+ // Check if the files are actual File instances
329
+ const body = ctx.body as {
330
+ records: Array<{
331
+ qrPayment: File;
332
+ name: string;
333
+ }>;
334
+ };
335
+ const records = body.records;
336
+ console.log('File instance check:');
337
+ console.log('records[0].qrPayment instanceof File:', records[0]?.qrPayment instanceof File);
338
+ console.log('records[1].qrPayment instanceof File:', records[1]?.qrPayment instanceof File);
339
+ console.log('File name:', records[0]?.qrPayment.name);
340
+ console.log('File size:', records[0]?.qrPayment.size);
341
+ console.log('File type:', records[0]?.qrPayment.type);
342
+
343
+ return {
344
+ message: 'Files received',
345
+ fileInfo: {
346
+ isFile0: records[0]?.qrPayment instanceof File,
347
+ isFile1: records[1]?.qrPayment instanceof File,
348
+ name0: records[0]?.qrPayment.name,
349
+ size0: records[0]?.qrPayment.size,
350
+ type0: records[0]?.qrPayment.type
351
+ }
352
+ };
353
+ });
354
+
355
+ const formData = new FormData();
356
+ formData.append('records[0][qrPayment]', new File(['test file content'], 'test.png', { type: 'image/png' }));
357
+ formData.append('records[1][qrPayment]', new File(['test file content 2'], 'test2.png', { type: 'image/png' }));
358
+ formData.append('records[0][name]', 'Record 1');
359
+ formData.append('records[1][name]', 'Record 2');
360
+
361
+ const response = await fetch(`${baseUrl}/api/files-test`, {
362
+ method: 'POST',
363
+ body: formData
364
+ });
365
+
366
+ const data = await response.json() as {
367
+ message: string;
368
+ fileInfo: {
369
+ isFile0: boolean;
370
+ isFile1: boolean;
371
+ name0: string;
372
+ size0: number;
373
+ type0: string;
374
+ };
375
+ };
376
+
377
+ expect(response.status).toBe(200);
378
+ expect(data.fileInfo.isFile0).toBe(true);
379
+ expect(data.fileInfo.isFile1).toBe(true);
380
+ expect(data.fileInfo.name0).toBe('test.png');
381
+ expect(data.fileInfo.size0).toBe(17);
382
+ expect(data.fileInfo.type0).toBe('image/png');
383
+ });
199
384
  });
200
385
 
201
386
  describe('Response Handling', () => {
@@ -5,6 +5,7 @@ import {
5
5
  parseCookies,
6
6
  validateData,
7
7
  validateResponse,
8
+ parseRequestBody,
8
9
  cookiesToHeaders,
9
10
  mergeHeadersWithCookies,
10
11
  createRedirectResponse,
@@ -200,6 +201,165 @@ describe('Utility Functions', () => {
200
201
  });
201
202
  });
202
203
 
204
+ describe('parseRequestBody', () => {
205
+ it('should parse JSON body correctly', async () => {
206
+ const jsonData = { name: 'john', age: 25 };
207
+ const request = new Request('http://localhost/test', {
208
+ method: 'POST',
209
+ headers: { 'Content-Type': 'application/json' },
210
+ body: JSON.stringify(jsonData)
211
+ });
212
+
213
+ const result = await parseRequestBody(request);
214
+ expect(result).toEqual(jsonData);
215
+ });
216
+
217
+ it('should parse form data with arrays correctly', async () => {
218
+ const formData = new FormData();
219
+ formData.append('app', 'zodula');
220
+ formData.append('model', 'zodula_User');
221
+ formData.append('recordIds[]', 'asdfasdfdsa');
222
+ formData.append('recordIds[]', 'jarupak.sri@gmail.com');
223
+ formData.append('fields', '*');
224
+
225
+ const request = new Request('http://localhost/test', {
226
+ method: 'POST',
227
+ body: formData
228
+ });
229
+
230
+ const result = await parseRequestBody(request);
231
+
232
+ expect(result.app).toBe('zodula');
233
+ expect(result.model).toBe('zodula_User');
234
+ expect(Array.isArray(result.recordIds)).toBe(true);
235
+ expect(result.recordIds).toEqual(['asdfasdfdsa', 'jarupak.sri@gmail.com']);
236
+ expect(result.fields).toBe('*');
237
+ });
238
+
239
+ it('should handle single form field correctly', async () => {
240
+ const formData = new FormData();
241
+ formData.append('name', 'john');
242
+ formData.append('email', 'john@example.com');
243
+
244
+ const request = new Request('http://localhost/test', {
245
+ method: 'POST',
246
+ body: formData
247
+ });
248
+
249
+ const result = await parseRequestBody(request);
250
+
251
+ expect(result.name).toBe('john');
252
+ expect(result.email).toBe('john@example.com');
253
+ });
254
+
255
+ it('should handle file uploads in form data', async () => {
256
+ const file = new File(['test content'], 'test.txt', { type: 'text/plain' });
257
+ const formData = new FormData();
258
+ formData.append('name', 'john');
259
+ formData.append('file', file);
260
+
261
+ const request = new Request('http://localhost/test', {
262
+ method: 'POST',
263
+ body: formData
264
+ });
265
+
266
+ const result = await parseRequestBody(request);
267
+
268
+ expect(result.name).toBe('john');
269
+ expect(result.file).toBeInstanceOf(File);
270
+ expect(result.file.name).toBe('test.txt');
271
+ });
272
+
273
+ it('should parse nested object form data correctly', async () => {
274
+ const formData = new FormData();
275
+ formData.append('test[test]', 'test');
276
+ formData.append('test[new]', 'new');
277
+ formData.append('test[hi][hi]', 'hi');
278
+
279
+ const request = new Request('http://localhost/test', {
280
+ method: 'POST',
281
+ body: formData
282
+ });
283
+
284
+ const result = await parseRequestBody(request);
285
+
286
+ expect(result.test).toEqual({
287
+ test: 'test',
288
+ new: 'new',
289
+ hi: {
290
+ hi: 'hi'
291
+ }
292
+ });
293
+ });
294
+
295
+ it('should parse Axios-compatible multipart data with arrays and objects', async () => {
296
+ const formData = new FormData();
297
+ formData.append('x', '1');
298
+ formData.append('arr[]', '1');
299
+ formData.append('arr[]', '2');
300
+ formData.append('arr[]', '3');
301
+ formData.append('arr2[0]', '1');
302
+ formData.append('arr2[1][0]', '2');
303
+ formData.append('arr2[2]', '3');
304
+ formData.append('users[0][name]', 'Peter');
305
+ formData.append('users[0][surname]', 'Griffin');
306
+ formData.append('users[1][name]', 'Thomas');
307
+ formData.append('users[1][surname]', 'Anderson');
308
+ formData.append('obj2{}', '[{"x":1}]');
309
+
310
+ const request = new Request('http://localhost/test', {
311
+ method: 'POST',
312
+ body: formData
313
+ });
314
+
315
+ const result = await parseRequestBody(request);
316
+
317
+ expect(result.x).toBe('1');
318
+ expect(result.arr).toEqual(['1', '2', '3']);
319
+ expect(result.arr2).toEqual(['1', ['2'], '3']);
320
+ expect(result.users).toEqual([
321
+ { name: 'Peter', surname: 'Griffin' },
322
+ { name: 'Thomas', surname: 'Anderson' }
323
+ ]);
324
+ expect(result.obj2).toEqual([{ x: 1 }]);
325
+ });
326
+
327
+ it('should handle JSON serialization with special endings', async () => {
328
+ const formData = new FormData();
329
+ formData.append('myObj{}', '{"x": 1, "s": "foo"}');
330
+ formData.append('settings{}', '{"theme": "dark", "notifications": true}');
331
+
332
+ const request = new Request('http://localhost/test', {
333
+ method: 'POST',
334
+ body: formData
335
+ });
336
+
337
+ const result = await parseRequestBody(request);
338
+
339
+ expect(result.myObj).toEqual({ x: 1, s: 'foo' });
340
+ expect(result.settings).toEqual({ theme: 'dark', notifications: true });
341
+ });
342
+
343
+ it('should handle mixed array indexing styles', async () => {
344
+ const formData = new FormData();
345
+ formData.append('tags[]', 'javascript');
346
+ formData.append('tags[]', 'typescript');
347
+ formData.append('scores[0]', '100');
348
+ formData.append('scores[1]', '95');
349
+ formData.append('scores[2]', '88');
350
+
351
+ const request = new Request('http://localhost/test', {
352
+ method: 'POST',
353
+ body: formData
354
+ });
355
+
356
+ const result = await parseRequestBody(request);
357
+
358
+ expect(result.tags).toEqual(['javascript', 'typescript']);
359
+ expect(result.scores).toEqual(['100', '95', '88']);
360
+ });
361
+ });
362
+
203
363
  describe('File Upload Utilities', () => {
204
364
  it('should identify file uploads correctly', () => {
205
365
  const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' });