@onebun/core 0.1.12 → 0.1.14

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@onebun/core",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Core package for OneBun framework - decorators, DI, modules, controllers",
5
5
  "license": "LGPL-3.0",
6
6
  "author": "RemRyahirev",
@@ -1,3 +1,4 @@
1
+ import { type as arktype } from 'arktype';
1
2
  import {
2
3
  describe,
3
4
  test,
@@ -19,6 +20,7 @@ import {
19
20
  Param,
20
21
  Query,
21
22
  Body,
23
+ Header,
22
24
  } from '../decorators/decorators';
23
25
  import { Controller as BaseController } from '../module/controller';
24
26
  import { makeMockLoggerLayer } from '../testing/test-utils';
@@ -947,10 +949,722 @@ describe('OneBunApplication', () => {
947
949
 
948
950
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
949
951
  const response = await (mockServer as any).fetchHandler(request);
952
+ const body = await response.json();
950
953
 
951
- expect(response).toBeDefined();
952
- // Accept any successful response for now
953
- expect(response).toBeTruthy();
954
+ expect(response.status).toBe(200);
955
+ expect(body.result.query).toBe('test');
956
+ expect(body.result.limit).toBe(5);
957
+ expect(body.result.results).toEqual(['item1', 'item2']);
958
+ });
959
+
960
+ test('should handle URL-encoded query parameters', async () => {
961
+ @Controller('/api')
962
+ class ApiController extends BaseController {
963
+ @Get('/search')
964
+ async search(
965
+ @Query('name') name: string,
966
+ @Query('filter') filter?: string,
967
+ ) {
968
+ return { name, filter };
969
+ }
970
+ }
971
+
972
+ @Module({
973
+ controllers: [ApiController],
974
+ })
975
+ class TestModule {}
976
+
977
+ const app = createTestApp(TestModule);
978
+ await app.start();
979
+
980
+ // Test URL-encoded values: "John Doe" and "test&value"
981
+ const request = new Request('http://localhost:3000/api/search?name=John%20Doe&filter=test%26value', {
982
+ method: 'GET',
983
+ });
984
+
985
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
986
+ const response = await (mockServer as any).fetchHandler(request);
987
+ const body = await response.json();
988
+
989
+ expect(response.status).toBe(200);
990
+ expect(body.result.name).toBe('John Doe');
991
+ expect(body.result.filter).toBe('test&value');
992
+ });
993
+
994
+ test('should handle OAuth callback query string with special characters', async () => {
995
+ @Controller('/api/auth/google')
996
+ class AuthController extends BaseController {
997
+ @Get('/callback')
998
+ async callback(
999
+ @Query('state') state: string,
1000
+ @Query('code') code: string,
1001
+ @Query('scope') scope: string,
1002
+ @Query('authuser') authuser?: string,
1003
+ @Query('prompt') prompt?: string,
1004
+ ) {
1005
+ return {
1006
+ state, code, scope, authuser, prompt,
1007
+ };
1008
+ }
1009
+ }
1010
+
1011
+ @Module({
1012
+ controllers: [AuthController],
1013
+ })
1014
+ class TestModule {}
1015
+
1016
+ const app = createTestApp(TestModule);
1017
+ await app.start();
1018
+
1019
+ // Real OAuth callback URL from the user's example
1020
+ const queryString = 'state=b6d290537858f64d894a47480c5e3edd&code=4/0ASc3gC0o5UhWEjUTslteiiSpR6_NsLYXXdfCjDq0rPFYymqB7LMofianDqC1l4NHJXvA3A&scope=email%20profile%20https://www.googleapis.com/auth/userinfo.profile%20https://www.googleapis.com/auth/userinfo.email%20openid&authuser=0&prompt=consent';
1021
+ const request = new Request(`http://localhost:3000/api/auth/google/callback?${queryString}`, {
1022
+ method: 'GET',
1023
+ });
1024
+
1025
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1026
+ const response = await (mockServer as any).fetchHandler(request);
1027
+ const body = await response.json();
1028
+
1029
+ expect(response.status).toBe(200);
1030
+ expect(body.result.state).toBe('b6d290537858f64d894a47480c5e3edd');
1031
+ expect(body.result.code).toBe('4/0ASc3gC0o5UhWEjUTslteiiSpR6_NsLYXXdfCjDq0rPFYymqB7LMofianDqC1l4NHJXvA3A');
1032
+ expect(body.result.scope).toBe('email profile https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email openid');
1033
+ expect(body.result.authuser).toBe('0');
1034
+ expect(body.result.prompt).toBe('consent');
1035
+ });
1036
+
1037
+ test('should handle multiple query parameters with same key as array', async () => {
1038
+ @Controller('/api')
1039
+ class ApiController extends BaseController {
1040
+ @Get('/filter')
1041
+ async filter(@Query('tag') tag: string | string[]) {
1042
+ return { tags: Array.isArray(tag) ? tag : [tag] };
1043
+ }
1044
+ }
1045
+
1046
+ @Module({
1047
+ controllers: [ApiController],
1048
+ })
1049
+ class TestModule {}
1050
+
1051
+ const app = createTestApp(TestModule);
1052
+ await app.start();
1053
+
1054
+ // Multiple values with same key: ?tag=a&tag=b&tag=c
1055
+ const request = new Request('http://localhost:3000/api/filter?tag=a&tag=b&tag=c', {
1056
+ method: 'GET',
1057
+ });
1058
+
1059
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1060
+ const response = await (mockServer as any).fetchHandler(request);
1061
+ const body = await response.json();
1062
+
1063
+ expect(response.status).toBe(200);
1064
+ expect(body.result.tags).toEqual(['a', 'b', 'c']);
1065
+ });
1066
+
1067
+ test('should handle array notation query parameters (tag[]=a&tag[]=b)', async () => {
1068
+ @Controller('/api')
1069
+ class ApiController extends BaseController {
1070
+ @Get('/filter')
1071
+ async filter(@Query('tag') tag: string[]) {
1072
+ return { tags: tag };
1073
+ }
1074
+ }
1075
+
1076
+ @Module({
1077
+ controllers: [ApiController],
1078
+ })
1079
+ class TestModule {}
1080
+
1081
+ const app = createTestApp(TestModule);
1082
+ await app.start();
1083
+
1084
+ // Array notation: ?tag[]=a&tag[]=b&tag[]=c
1085
+ const request = new Request('http://localhost:3000/api/filter?tag[]=a&tag[]=b&tag[]=c', {
1086
+ method: 'GET',
1087
+ });
1088
+
1089
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1090
+ const response = await (mockServer as any).fetchHandler(request);
1091
+ const body = await response.json();
1092
+
1093
+ expect(response.status).toBe(200);
1094
+ expect(body.result.tags).toEqual(['a', 'b', 'c']);
1095
+ });
1096
+
1097
+ test('should handle single value with array notation (tag[]=a)', async () => {
1098
+ @Controller('/api')
1099
+ class ApiController extends BaseController {
1100
+ @Get('/filter')
1101
+ async filter(@Query('tag') tag: string[]) {
1102
+ return { tags: tag, isArray: Array.isArray(tag) };
1103
+ }
1104
+ }
1105
+
1106
+ @Module({
1107
+ controllers: [ApiController],
1108
+ })
1109
+ class TestModule {}
1110
+
1111
+ const app = createTestApp(TestModule);
1112
+ await app.start();
1113
+
1114
+ // Single value with array notation should still be an array
1115
+ const request = new Request('http://localhost:3000/api/filter?tag[]=single', {
1116
+ method: 'GET',
1117
+ });
1118
+
1119
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1120
+ const response = await (mockServer as any).fetchHandler(request);
1121
+ const body = await response.json();
1122
+
1123
+ expect(response.status).toBe(200);
1124
+ expect(body.result.tags).toEqual(['single']);
1125
+ expect(body.result.isArray).toBe(true);
1126
+ });
1127
+
1128
+ test('should handle empty query parameter values', async () => {
1129
+ @Controller('/api')
1130
+ class ApiController extends BaseController {
1131
+ @Get('/params')
1132
+ async params(
1133
+ @Query('empty') empty: string,
1134
+ @Query('other') other: string,
1135
+ ) {
1136
+ return { empty, other, emptyIsString: typeof empty === 'string' };
1137
+ }
1138
+ }
1139
+
1140
+ @Module({
1141
+ controllers: [ApiController],
1142
+ })
1143
+ class TestModule {}
1144
+
1145
+ const app = createTestApp(TestModule);
1146
+ await app.start();
1147
+
1148
+ // Empty value: ?empty=&other=value
1149
+ const request = new Request('http://localhost:3000/api/params?empty=&other=value', {
1150
+ method: 'GET',
1151
+ });
1152
+
1153
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1154
+ const response = await (mockServer as any).fetchHandler(request);
1155
+ const body = await response.json();
1156
+
1157
+ expect(response.status).toBe(200);
1158
+ expect(body.result.empty).toBe('');
1159
+ expect(body.result.other).toBe('value');
1160
+ expect(body.result.emptyIsString).toBe(true);
1161
+ });
1162
+
1163
+ test('should handle missing optional query parameters', async () => {
1164
+ @Controller('/api')
1165
+ class ApiController extends BaseController {
1166
+ @Get('/optional')
1167
+ async optional(
1168
+ @Query('required') required: string,
1169
+ @Query('optional') optional?: string,
1170
+ ) {
1171
+ return { required, optional, hasOptional: optional !== undefined };
1172
+ }
1173
+ }
1174
+
1175
+ @Module({
1176
+ controllers: [ApiController],
1177
+ })
1178
+ class TestModule {}
1179
+
1180
+ const app = createTestApp(TestModule);
1181
+ await app.start();
1182
+
1183
+ // Only required parameter, optional is missing
1184
+ const request = new Request('http://localhost:3000/api/optional?required=value', {
1185
+ method: 'GET',
1186
+ });
1187
+
1188
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1189
+ const response = await (mockServer as any).fetchHandler(request);
1190
+ const body = await response.json();
1191
+
1192
+ expect(response.status).toBe(200);
1193
+ expect(body.result.required).toBe('value');
1194
+ expect(body.result.optional).toBeUndefined();
1195
+ expect(body.result.hasOptional).toBe(false);
1196
+ });
1197
+
1198
+ test('should handle multiple path parameters', async () => {
1199
+ @Controller('/api')
1200
+ class ApiController extends BaseController {
1201
+ @Get('/users/:userId/posts/:postId')
1202
+ async getPost(
1203
+ @Param('userId') userId: string,
1204
+ @Param('postId') postId: string,
1205
+ ) {
1206
+ return { userId: parseInt(userId), postId: parseInt(postId) };
1207
+ }
1208
+ }
1209
+
1210
+ @Module({
1211
+ controllers: [ApiController],
1212
+ })
1213
+ class TestModule {}
1214
+
1215
+ const app = createTestApp(TestModule);
1216
+ await app.start();
1217
+
1218
+ const request = new Request('http://localhost:3000/api/users/42/posts/123', {
1219
+ method: 'GET',
1220
+ });
1221
+
1222
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1223
+ const response = await (mockServer as any).fetchHandler(request);
1224
+ const body = await response.json();
1225
+
1226
+ expect(response.status).toBe(200);
1227
+ expect(body.result.userId).toBe(42);
1228
+ expect(body.result.postId).toBe(123);
1229
+ });
1230
+
1231
+ test('should handle URL-encoded path parameters', async () => {
1232
+ @Controller('/api')
1233
+ class ApiController extends BaseController {
1234
+ @Get('/files/:filename')
1235
+ async getFile(@Param('filename') filename: string) {
1236
+ return { filename };
1237
+ }
1238
+ }
1239
+
1240
+ @Module({
1241
+ controllers: [ApiController],
1242
+ })
1243
+ class TestModule {}
1244
+
1245
+ const app = createTestApp(TestModule);
1246
+ await app.start();
1247
+
1248
+ // URL-encoded filename: "my file.txt"
1249
+ const request = new Request('http://localhost:3000/api/files/my%20file.txt', {
1250
+ method: 'GET',
1251
+ });
1252
+
1253
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1254
+ const response = await (mockServer as any).fetchHandler(request);
1255
+ const body = await response.json();
1256
+
1257
+ expect(response.status).toBe(200);
1258
+ expect(body.result.filename).toBe('my%20file.txt');
1259
+ });
1260
+
1261
+ test('should handle path parameters with query parameters together', async () => {
1262
+ @Controller('/api')
1263
+ class ApiController extends BaseController {
1264
+ @Get('/users/:id/posts')
1265
+ async getUserPosts(
1266
+ @Param('id') userId: string,
1267
+ @Query('page') page?: string,
1268
+ @Query('limit') limit?: string,
1269
+ ) {
1270
+ return {
1271
+ userId: parseInt(userId),
1272
+ page: page ? parseInt(page) : 1,
1273
+ limit: limit ? parseInt(limit) : 10,
1274
+ };
1275
+ }
1276
+ }
1277
+
1278
+ @Module({
1279
+ controllers: [ApiController],
1280
+ })
1281
+ class TestModule {}
1282
+
1283
+ const app = createTestApp(TestModule);
1284
+ await app.start();
1285
+
1286
+ const request = new Request('http://localhost:3000/api/users/5/posts?page=2&limit=20', {
1287
+ method: 'GET',
1288
+ });
1289
+
1290
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1291
+ const response = await (mockServer as any).fetchHandler(request);
1292
+ const body = await response.json();
1293
+
1294
+ expect(response.status).toBe(200);
1295
+ expect(body.result.userId).toBe(5);
1296
+ expect(body.result.page).toBe(2);
1297
+ expect(body.result.limit).toBe(20);
1298
+ });
1299
+
1300
+ test('should handle nested JSON body', async () => {
1301
+ @Controller('/api')
1302
+ class ApiController extends BaseController {
1303
+ @Post('/complex')
1304
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1305
+ async createComplex(@Body() data: any) {
1306
+ return { received: data };
1307
+ }
1308
+ }
1309
+
1310
+ @Module({
1311
+ controllers: [ApiController],
1312
+ })
1313
+ class TestModule {}
1314
+
1315
+ const app = createTestApp(TestModule);
1316
+ await app.start();
1317
+
1318
+ const complexData = {
1319
+ user: {
1320
+ name: 'John',
1321
+ address: {
1322
+ city: 'NYC',
1323
+ zip: '10001',
1324
+ },
1325
+ },
1326
+ items: [{ id: 1, name: 'Item 1' }, { id: 2, name: 'Item 2' }],
1327
+ metadata: {
1328
+ created: '2024-01-01',
1329
+ tags: ['tag1', 'tag2'],
1330
+ },
1331
+ };
1332
+
1333
+ const request = new Request('http://localhost:3000/api/complex', {
1334
+ method: 'POST',
1335
+ headers: {
1336
+ // eslint-disable-next-line @typescript-eslint/naming-convention
1337
+ 'Content-Type': 'application/json',
1338
+ },
1339
+ body: JSON.stringify(complexData),
1340
+ });
1341
+
1342
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1343
+ const response = await (mockServer as any).fetchHandler(request);
1344
+ const body = await response.json();
1345
+
1346
+ expect(response.status).toBe(200);
1347
+ expect(body.result.received).toEqual(complexData);
1348
+ });
1349
+
1350
+ test('should handle empty body gracefully', async () => {
1351
+ @Controller('/api')
1352
+ class ApiController extends BaseController {
1353
+ @Post('/empty-body')
1354
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1355
+ async handleEmpty(@Body() data?: any) {
1356
+ return { hasBody: data !== undefined, data };
1357
+ }
1358
+ }
1359
+
1360
+ @Module({
1361
+ controllers: [ApiController],
1362
+ })
1363
+ class TestModule {}
1364
+
1365
+ const app = createTestApp(TestModule);
1366
+ await app.start();
1367
+
1368
+ const request = new Request('http://localhost:3000/api/empty-body', {
1369
+ method: 'POST',
1370
+ headers: {
1371
+ // eslint-disable-next-line @typescript-eslint/naming-convention
1372
+ 'Content-Type': 'application/json',
1373
+ },
1374
+ });
1375
+
1376
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1377
+ const response = await (mockServer as any).fetchHandler(request);
1378
+ const body = await response.json();
1379
+
1380
+ expect(response.status).toBe(200);
1381
+ expect(body.result.hasBody).toBe(false);
1382
+ expect(body.result.data).toBeUndefined();
1383
+ });
1384
+
1385
+ test('should handle header parameters', async () => {
1386
+ @Controller('/api')
1387
+ class ApiController extends BaseController {
1388
+ @Get('/headers')
1389
+ async getHeaders(
1390
+ @Header('Authorization') auth: string,
1391
+ @Header('X-Custom-Header') custom?: string,
1392
+ ) {
1393
+ return { auth, custom };
1394
+ }
1395
+ }
1396
+
1397
+ @Module({
1398
+ controllers: [ApiController],
1399
+ })
1400
+ class TestModule {}
1401
+
1402
+ const app = createTestApp(TestModule);
1403
+ await app.start();
1404
+
1405
+ const request = new Request('http://localhost:3000/api/headers', {
1406
+ method: 'GET',
1407
+ headers: {
1408
+ // eslint-disable-next-line @typescript-eslint/naming-convention
1409
+ 'Authorization': 'Bearer token123',
1410
+ // eslint-disable-next-line @typescript-eslint/naming-convention
1411
+ 'X-Custom-Header': 'custom-value',
1412
+ },
1413
+ });
1414
+
1415
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1416
+ const response = await (mockServer as any).fetchHandler(request);
1417
+ const body = await response.json();
1418
+
1419
+ expect(response.status).toBe(200);
1420
+ expect(body.result.auth).toBe('Bearer token123');
1421
+ expect(body.result.custom).toBe('custom-value');
1422
+ });
1423
+
1424
+ test('should handle missing optional header', async () => {
1425
+ @Controller('/api')
1426
+ class ApiController extends BaseController {
1427
+ @Get('/optional-header')
1428
+ async getOptionalHeader(
1429
+ @Header('X-Required') required: string,
1430
+ @Header('X-Optional') optional?: string | null,
1431
+ ) {
1432
+ // Note: headers.get() returns null for missing headers, not undefined
1433
+ return { required, optional, hasOptional: optional !== null };
1434
+ }
1435
+ }
1436
+
1437
+ @Module({
1438
+ controllers: [ApiController],
1439
+ })
1440
+ class TestModule {}
1441
+
1442
+ const app = createTestApp(TestModule);
1443
+ await app.start();
1444
+
1445
+ const request = new Request('http://localhost:3000/api/optional-header', {
1446
+ method: 'GET',
1447
+ headers: {
1448
+ // eslint-disable-next-line @typescript-eslint/naming-convention
1449
+ 'X-Required': 'required-value',
1450
+ },
1451
+ });
1452
+
1453
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1454
+ const response = await (mockServer as any).fetchHandler(request);
1455
+ const body = await response.json();
1456
+
1457
+ expect(response.status).toBe(200);
1458
+ expect(body.result.required).toBe('required-value');
1459
+ // headers.get() returns null for missing headers
1460
+ expect(body.result.optional).toBeNull();
1461
+ expect(body.result.hasOptional).toBe(false);
1462
+ });
1463
+
1464
+ test('should return 500 when required query parameter is missing', async () => {
1465
+ @Controller('/api')
1466
+ class ApiController extends BaseController {
1467
+ @Get('/required-query')
1468
+ async requiredQuery(
1469
+ @Query('required', { required: true }) required: string,
1470
+ ) {
1471
+ return { required };
1472
+ }
1473
+ }
1474
+
1475
+ @Module({
1476
+ controllers: [ApiController],
1477
+ })
1478
+ class TestModule {}
1479
+
1480
+ const app = createTestApp(TestModule);
1481
+ await app.start();
1482
+
1483
+ // Missing required query parameter
1484
+ const request = new Request('http://localhost:3000/api/required-query', {
1485
+ method: 'GET',
1486
+ });
1487
+
1488
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1489
+ const response = await (mockServer as any).fetchHandler(request);
1490
+
1491
+ expect(response.status).toBe(500);
1492
+ });
1493
+
1494
+ test('should pass validation with required query parameter present', async () => {
1495
+ @Controller('/api')
1496
+ class ApiController extends BaseController {
1497
+ @Get('/required-query')
1498
+ async requiredQuery(
1499
+ @Query('required', { required: true }) required: string,
1500
+ ) {
1501
+ return { required };
1502
+ }
1503
+ }
1504
+
1505
+ @Module({
1506
+ controllers: [ApiController],
1507
+ })
1508
+ class TestModule {}
1509
+
1510
+ const app = createTestApp(TestModule);
1511
+ await app.start();
1512
+
1513
+ const request = new Request('http://localhost:3000/api/required-query?required=value', {
1514
+ method: 'GET',
1515
+ });
1516
+
1517
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1518
+ const response = await (mockServer as any).fetchHandler(request);
1519
+ const body = await response.json();
1520
+
1521
+ expect(response.status).toBe(200);
1522
+ expect(body.result.required).toBe('value');
1523
+ });
1524
+
1525
+ test('should validate query parameter with arktype schema', async () => {
1526
+ const numberSchema = arktype('string.numeric.parse');
1527
+
1528
+ @Controller('/api')
1529
+ class ApiController extends BaseController {
1530
+ @Get('/validated')
1531
+ async validated(
1532
+ @Query('count', numberSchema) count: number,
1533
+ ) {
1534
+ return { count, typeOf: typeof count };
1535
+ }
1536
+ }
1537
+
1538
+ @Module({
1539
+ controllers: [ApiController],
1540
+ })
1541
+ class TestModule {}
1542
+
1543
+ const app = createTestApp(TestModule);
1544
+ await app.start();
1545
+
1546
+ const request = new Request('http://localhost:3000/api/validated?count=42', {
1547
+ method: 'GET',
1548
+ });
1549
+
1550
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1551
+ const response = await (mockServer as any).fetchHandler(request);
1552
+ const body = await response.json();
1553
+
1554
+ expect(response.status).toBe(200);
1555
+ expect(body.result.count).toBe(42);
1556
+ expect(body.result.typeOf).toBe('number');
1557
+ });
1558
+
1559
+ test('should fail validation with invalid arktype schema value', async () => {
1560
+ const numberSchema = arktype('string.numeric.parse');
1561
+
1562
+ @Controller('/api')
1563
+ class ApiController extends BaseController {
1564
+ @Get('/validated')
1565
+ async validated(
1566
+ @Query('count', numberSchema) count: number,
1567
+ ) {
1568
+ return { count };
1569
+ }
1570
+ }
1571
+
1572
+ @Module({
1573
+ controllers: [ApiController],
1574
+ })
1575
+ class TestModule {}
1576
+
1577
+ const app = createTestApp(TestModule);
1578
+ await app.start();
1579
+
1580
+ // Invalid value: "not-a-number" instead of numeric string
1581
+ const request = new Request('http://localhost:3000/api/validated?count=not-a-number', {
1582
+ method: 'GET',
1583
+ });
1584
+
1585
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1586
+ const response = await (mockServer as any).fetchHandler(request);
1587
+
1588
+ expect(response.status).toBe(500);
1589
+ });
1590
+
1591
+ test('should validate body with arktype schema', async () => {
1592
+ const userSchema = arktype({
1593
+ name: 'string',
1594
+ age: 'number',
1595
+ });
1596
+
1597
+ @Controller('/api')
1598
+ class ApiController extends BaseController {
1599
+ @Post('/user')
1600
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1601
+ async createUser(@Body(userSchema) user: any) {
1602
+ return { user };
1603
+ }
1604
+ }
1605
+
1606
+ @Module({
1607
+ controllers: [ApiController],
1608
+ })
1609
+ class TestModule {}
1610
+
1611
+ const app = createTestApp(TestModule);
1612
+ await app.start();
1613
+
1614
+ const request = new Request('http://localhost:3000/api/user', {
1615
+ method: 'POST',
1616
+ headers: {
1617
+ // eslint-disable-next-line @typescript-eslint/naming-convention
1618
+ 'Content-Type': 'application/json',
1619
+ },
1620
+ body: JSON.stringify({ name: 'John', age: 30 }),
1621
+ });
1622
+
1623
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1624
+ const response = await (mockServer as any).fetchHandler(request);
1625
+ const body = await response.json();
1626
+
1627
+ expect(response.status).toBe(200);
1628
+ expect(body.result.user).toEqual({ name: 'John', age: 30 });
1629
+ });
1630
+
1631
+ test('should fail body validation with invalid data', async () => {
1632
+ const userSchema = arktype({
1633
+ name: 'string',
1634
+ age: 'number',
1635
+ });
1636
+
1637
+ @Controller('/api')
1638
+ class ApiController extends BaseController {
1639
+ @Post('/user')
1640
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1641
+ async createUser(@Body(userSchema) user: any) {
1642
+ return { user };
1643
+ }
1644
+ }
1645
+
1646
+ @Module({
1647
+ controllers: [ApiController],
1648
+ })
1649
+ class TestModule {}
1650
+
1651
+ const app = createTestApp(TestModule);
1652
+ await app.start();
1653
+
1654
+ // Invalid: age is string instead of number
1655
+ const request = new Request('http://localhost:3000/api/user', {
1656
+ method: 'POST',
1657
+ headers: {
1658
+ // eslint-disable-next-line @typescript-eslint/naming-convention
1659
+ 'Content-Type': 'application/json',
1660
+ },
1661
+ body: JSON.stringify({ name: 'John', age: 'thirty' }),
1662
+ });
1663
+
1664
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1665
+ const response = await (mockServer as any).fetchHandler(request);
1666
+
1667
+ expect(response.status).toBe(500);
954
1668
  });
955
1669
 
956
1670
  test('should handle metrics endpoint', async () => {
@@ -635,6 +635,26 @@ export class OneBunApplication {
635
635
  let route = routes.get(exactRouteKey);
636
636
  const paramValues: Record<string, string | string[]> = {};
637
637
 
638
+ // Extract query parameters from URL
639
+ for (const [rawKey, value] of url.searchParams.entries()) {
640
+ // Handle array notation: tag[] -> tag (as array)
641
+ const isArrayNotation = rawKey.endsWith('[]');
642
+ const key = isArrayNotation ? rawKey.replace('[]', '') : rawKey;
643
+
644
+ const existing = paramValues[key];
645
+ if (existing !== undefined) {
646
+ // Handle multiple values with same key (e.g., ?tag=a&tag=b or ?tag[]=a&tag[]=b)
647
+ paramValues[key] = Array.isArray(existing)
648
+ ? [...existing, value]
649
+ : [existing, value];
650
+ } else if (isArrayNotation) {
651
+ // Array notation always creates an array, even with single value
652
+ paramValues[key] = [value];
653
+ } else {
654
+ paramValues[key] = value;
655
+ }
656
+ }
657
+
638
658
  // If no exact match, try pattern matching
639
659
  if (!route) {
640
660
  for (const [_routeKey, routeData] of routes) {
@@ -68,6 +68,7 @@ export class OneBunModule implements ModuleInstance {
68
68
  private controllers: Function[] = [];
69
69
  private controllerInstances: Map<Function, Controller> = new Map();
70
70
  private serviceInstances: Map<Context.Tag<unknown, unknown>, unknown> = new Map();
71
+ private pendingAsyncInits: Array<{ name: string; init: () => Promise<void> }> = [];
71
72
  private logger: SyncLogger;
72
73
  private config: IConfig<OneBunAppConfig>;
73
74
 
@@ -292,6 +293,19 @@ export class OneBunModule implements ModuleInstance {
292
293
  .initializeService(this.logger, this.config);
293
294
  }
294
295
 
296
+ // Track services that need async initialization
297
+ if (
298
+ serviceInstance &&
299
+ typeof serviceInstance === 'object' &&
300
+ 'onAsyncInit' in serviceInstance &&
301
+ typeof (serviceInstance as { onAsyncInit: unknown }).onAsyncInit === 'function'
302
+ ) {
303
+ this.pendingAsyncInits.push({
304
+ name: provider.name,
305
+ init: () => (serviceInstance as { onAsyncInit: () => Promise<void> }).onAsyncInit(),
306
+ });
307
+ }
308
+
295
309
  this.serviceInstances.set(serviceMetadata.tag, serviceInstance);
296
310
  createdServices.add(provider.name);
297
311
  this.logger.debug(
@@ -340,14 +354,12 @@ export class OneBunModule implements ModuleInstance {
340
354
  * Create controller instances and inject services
341
355
  */
342
356
  createControllerInstances(): Effect.Effect<unknown, never, void> {
343
- const self = this;
344
-
345
- return Effect.gen(function* (_) {
357
+ return Effect.sync(() => {
346
358
  // Services are already created in initModule via createServicesWithDI
347
359
  // Just need to set up controllers with DI
348
360
 
349
361
  // Get module metadata to access providers for controller dependency registration
350
- const moduleMetadata = getModuleMetadata(self.moduleClass);
362
+ const moduleMetadata = getModuleMetadata(this.moduleClass);
351
363
  if (moduleMetadata && moduleMetadata.providers) {
352
364
  // Create map of available services for dependency resolution
353
365
  const availableServices = new Map<string, Function>();
@@ -360,7 +372,7 @@ export class OneBunModule implements ModuleInstance {
360
372
  }
361
373
 
362
374
  // Also add services from imported modules
363
- for (const childModule of self.childModules) {
375
+ for (const childModule of this.childModules) {
364
376
  const childMetadata = getModuleMetadata(childModule.moduleClass);
365
377
  if (childMetadata?.exports) {
366
378
  for (const exported of childMetadata.exports) {
@@ -379,13 +391,13 @@ export class OneBunModule implements ModuleInstance {
379
391
  }
380
392
 
381
393
  // Automatically analyze and register dependencies for all controllers
382
- for (const controllerClass of self.controllers) {
394
+ for (const controllerClass of this.controllers) {
383
395
  registerControllerDependencies(controllerClass, availableServices);
384
396
  }
385
397
  }
386
398
 
387
399
  // Now create controller instances with automatic dependency injection
388
- self.createControllersWithDI();
400
+ this.createControllersWithDI();
389
401
  }).pipe(Effect.provide(this.rootLayer));
390
402
  }
391
403
 
@@ -471,7 +483,45 @@ export class OneBunModule implements ModuleInstance {
471
483
  * Setup the module and its dependencies
472
484
  */
473
485
  setup(): Effect.Effect<unknown, never, void> {
474
- return this.createControllerInstances();
486
+ return this.runAsyncServiceInit().pipe(
487
+ // Also run async init for child modules
488
+ Effect.flatMap(() =>
489
+ Effect.forEach(this.childModules, (childModule) => childModule.runAsyncServiceInit(), {
490
+ discard: true,
491
+ }),
492
+ ),
493
+ // Then create controller instances
494
+ Effect.flatMap(() => this.createControllerInstances()),
495
+ );
496
+ }
497
+
498
+ /**
499
+ * Run async initialization for all services that need it
500
+ */
501
+ runAsyncServiceInit(): Effect.Effect<unknown, never, void> {
502
+ if (this.pendingAsyncInits.length === 0) {
503
+ return Effect.void;
504
+ }
505
+
506
+ this.logger.debug(`Running async initialization for ${this.pendingAsyncInits.length} service(s)`);
507
+
508
+ // Run all async inits in parallel
509
+ const initPromises = this.pendingAsyncInits.map(async ({ name, init }) => {
510
+ try {
511
+ await init();
512
+ this.logger.debug(`Service ${name} async initialization completed`);
513
+ } catch (error) {
514
+ this.logger.error(`Service ${name} async initialization failed: ${error}`);
515
+ throw error;
516
+ }
517
+ });
518
+
519
+ return Effect.promise(() => Promise.all(initPromises)).pipe(
520
+ Effect.map(() => {
521
+ // Clear the list after initialization
522
+ this.pendingAsyncInits = [];
523
+ }),
524
+ );
475
525
  }
476
526
 
477
527
  /**
@@ -99,6 +99,16 @@ export class BaseService {
99
99
  this.logger.debug(`Service ${className} initialized`);
100
100
  }
101
101
 
102
+ /**
103
+ * Async initialization hook - called by the framework after initializeService()
104
+ * Override in subclasses that need async initialization (e.g., database connections)
105
+ * The framework will await this method before making the service available
106
+ * @internal
107
+ */
108
+ async onAsyncInit(): Promise<void> {
109
+ // Default: no async init needed
110
+ }
111
+
102
112
  /**
103
113
  * Check if service is initialized
104
114
  * @internal