bxo 0.0.5-dev.53 → 0.0.5-dev.55

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.53",
4
+ "version": "0.0.5-dev.55",
5
5
  "description": "A simple and lightweight web framework for Bun",
6
6
  "type": "module",
7
7
  "exports": {
@@ -78,6 +78,116 @@ 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
+ current[index] = value;
179
+ } else {
180
+ // Convert to array if needed
181
+ const newArray = [];
182
+ newArray[index] = value;
183
+ current[lastKey] = newArray;
184
+ }
185
+ } else {
186
+ current[lastKey] = value;
187
+ }
188
+ }
189
+ }
190
+
81
191
  // Parse request body based on content type
82
192
  export async function parseRequestBody(request: Request): Promise<any> {
83
193
  const contentType = request.headers.get('content-type');
@@ -90,25 +200,56 @@ export async function parseRequestBody(request: Request): Promise<any> {
90
200
  }
91
201
  } else if (contentType?.includes('multipart/form-data') || contentType?.includes('application/x-www-form-urlencoded')) {
92
202
  const formData = await request.formData();
93
- // Convert FormData to a structured object that preserves file information
203
+ // Convert FormData to a structured object
94
204
  const formBody: Record<string, any> = {};
95
205
 
96
206
  for (const [key, value] of formData.entries()) {
97
207
  if (value instanceof File) {
98
- // Handle file uploads
99
- formBody[key] = {
100
- type: 'file',
101
- name: value.name,
102
- size: value.size,
103
- lastModified: value.lastModified,
104
- file: value, // Keep the actual File object for access
105
- // Add convenience properties
106
- filename: value.name,
107
- mimetype: value.type || 'application/octet-stream'
108
- };
109
- } else {
110
- // Handle regular form fields
208
+ // Return File instances directly
111
209
  formBody[key] = value;
210
+ } else {
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);
239
+ } else {
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
+ }
252
+ }
112
253
  }
113
254
  }
114
255
 
@@ -186,21 +327,13 @@ export function createRedirectResponse(
186
327
  }
187
328
 
188
329
  // Check if a value is a file upload
189
- export function isFileUpload(value: any): value is {
190
- type: 'file';
191
- file: File;
192
- name: string;
193
- size: number;
194
- lastModified: number;
195
- filename: string;
196
- mimetype: string;
197
- } {
198
- return value && typeof value === 'object' && value.type === 'file' && value.file instanceof File;
330
+ export function isFileUpload(value: any): value is File {
331
+ return value instanceof File;
199
332
  }
200
333
 
201
334
  // Extract File object from upload value
202
335
  export function getFileFromUpload(value: any): File | null {
203
- return isFileUpload(value) ? value.file : null;
336
+ return isFileUpload(value) ? value : null;
204
337
  }
205
338
 
206
339
  // Get file metadata without the File object
@@ -209,7 +342,7 @@ export function getFileInfo(value: any): { name: string; size: number; mimetype:
209
342
  return {
210
343
  name: value.name,
211
344
  size: value.size,
212
- mimetype: value.mimetype,
345
+ mimetype: value.type || 'application/octet-stream',
213
346
  lastModified: value.lastModified
214
347
  };
215
348
  }
@@ -240,7 +373,7 @@ export function getFileUploads(formData: Record<string, any>): Record<string, Fi
240
373
  const files: Record<string, File> = {};
241
374
  for (const [key, value] of Object.entries(formData)) {
242
375
  if (isFileUpload(value)) {
243
- files[key] = value.file;
376
+ files[key] = value;
244
377
  }
245
378
  }
246
379
  return files;
@@ -196,6 +196,81 @@ 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
+ });
199
274
  });
200
275
 
201
276
  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,36 +201,178 @@ 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
- const fileUpload = {
206
- type: 'file',
207
- name: 'test.jpg',
208
- size: 1024,
209
- lastModified: 1234567890,
210
- file: new File(['test'], 'test.jpg'),
211
- filename: 'test.jpg',
212
- mimetype: 'image/jpeg'
213
- };
365
+ const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' });
214
366
 
215
- expect(isFileUpload(fileUpload)).toBe(true);
367
+ expect(isFileUpload(file)).toBe(true);
216
368
  expect(isFileUpload('not a file')).toBe(false);
217
369
  expect(isFileUpload({ type: 'text' })).toBe(false);
218
370
  });
219
371
 
220
372
  it('should extract file from upload', () => {
221
- const file = new File(['test'], 'test.jpg');
222
- const fileUpload = {
223
- type: 'file',
224
- name: 'test.jpg',
225
- size: 1024,
226
- lastModified: 1234567890,
227
- file,
228
- filename: 'test.jpg',
229
- mimetype: 'image/jpeg'
230
- };
373
+ const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' });
231
374
 
232
- const result = getFileFromUpload(fileUpload);
375
+ const result = getFileFromUpload(file);
233
376
  expect(result).toBe(file);
234
377
  });
235
378
 
@@ -239,22 +382,14 @@ describe('Utility Functions', () => {
239
382
  });
240
383
 
241
384
  it('should get file info', () => {
242
- const fileUpload = {
243
- type: 'file',
244
- name: 'test.jpg',
245
- size: 1024,
246
- lastModified: 1234567890,
247
- file: new File(['test'], 'test.jpg'),
248
- filename: 'test.jpg',
249
- mimetype: 'image/jpeg'
250
- };
385
+ const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' });
251
386
 
252
- const result = getFileInfo(fileUpload);
387
+ const result = getFileInfo(file);
253
388
  expect(result).toEqual({
254
389
  name: 'test.jpg',
255
- size: 1024,
390
+ size: 4,
256
391
  mimetype: 'image/jpeg',
257
- lastModified: 1234567890
392
+ lastModified: expect.any(Number)
258
393
  });
259
394
  });
260
395
 
@@ -264,17 +399,10 @@ describe('Utility Functions', () => {
264
399
  });
265
400
 
266
401
  it('should get all file uploads from form data', () => {
402
+ const file = new File(['test'], 'avatar.jpg', { type: 'image/jpeg' });
267
403
  const formData = {
268
404
  name: 'john',
269
- avatar: {
270
- type: 'file',
271
- name: 'avatar.jpg',
272
- size: 1024,
273
- lastModified: 1234567890,
274
- file: new File(['test'], 'avatar.jpg'),
275
- filename: 'avatar.jpg',
276
- mimetype: 'image/jpeg'
277
- },
405
+ avatar: file,
278
406
  email: 'john@example.com'
279
407
  };
280
408
 
@@ -285,17 +413,10 @@ describe('Utility Functions', () => {
285
413
  });
286
414
 
287
415
  it('should get all non-file fields from form data', () => {
416
+ const file = new File(['test'], 'avatar.jpg', { type: 'image/jpeg' });
288
417
  const formData = {
289
418
  name: 'john',
290
- avatar: {
291
- type: 'file',
292
- name: 'avatar.jpg',
293
- size: 1024,
294
- lastModified: 1234567890,
295
- file: new File(['test'], 'avatar.jpg'),
296
- filename: 'avatar.jpg',
297
- mimetype: 'image/jpeg'
298
- },
419
+ avatar: file,
299
420
  email: 'john@example.com'
300
421
  };
301
422