@onebun/core 0.1.11 → 0.1.13

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.11",
3
+ "version": "0.1.13",
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';
@@ -164,6 +166,98 @@ describe('OneBunApplication', () => {
164
166
  // The actual value access might need the config to be fully initialized
165
167
  // which happens during runtime, not during construction
166
168
  });
169
+
170
+ test('should provide typed access to config values via getConfig()', () => {
171
+ @Module({})
172
+ class TestModule {}
173
+
174
+ // Mock config that simulates typed IConfig<OneBunAppConfig>
175
+ const mockConfigValues = {
176
+ server: { port: 9991, host: 'localhost' },
177
+ };
178
+ const mockConfig = {
179
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
180
+ initialize: mock(async () => {}),
181
+ get: mock((path: string) => {
182
+ const parts = path.split('.');
183
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
184
+ let value: any = mockConfigValues;
185
+ for (const part of parts) {
186
+ value = value?.[part];
187
+ }
188
+
189
+ return value;
190
+ }),
191
+ values: mockConfigValues,
192
+ getSafeConfig: mock(() => mockConfigValues),
193
+ isInitialized: true,
194
+ };
195
+
196
+ const app = createTestApp(TestModule);
197
+ // Inject mock config
198
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
199
+ (app as any).config = mockConfig;
200
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
201
+ (app as any).configService = {
202
+ get: mockConfig.get, values: mockConfig.values, getSafeConfig: mockConfig.getSafeConfig, isInitialized: true,
203
+ };
204
+
205
+ // getConfig() returns IConfig<OneBunAppConfig> which provides typed .get() method
206
+ const config = app.getConfig();
207
+ expect(config).toBeDefined();
208
+
209
+ // Access values through the typed interface
210
+ // TypeScript will infer the correct types based on module augmentation
211
+ const port = config.get('server.port');
212
+ const host = config.get('server.host');
213
+
214
+ expect(port).toBe(9991);
215
+ expect(host).toBe('localhost');
216
+ });
217
+
218
+ test('should provide typed access via getConfigValue() convenience method', () => {
219
+ @Module({})
220
+ class TestModule {}
221
+
222
+ // Mock config that simulates typed IConfig<OneBunAppConfig>
223
+ const mockConfigValues = {
224
+ app: { name: 'test-app', debug: true },
225
+ };
226
+ const mockConfig = {
227
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
228
+ initialize: mock(async () => {}),
229
+ get: mock((path: string) => {
230
+ const parts = path.split('.');
231
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
232
+ let value: any = mockConfigValues;
233
+ for (const part of parts) {
234
+ value = value?.[part];
235
+ }
236
+
237
+ return value;
238
+ }),
239
+ values: mockConfigValues,
240
+ getSafeConfig: mock(() => mockConfigValues),
241
+ isInitialized: true,
242
+ };
243
+
244
+ const app = createTestApp(TestModule);
245
+ // Inject mock config
246
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
247
+ (app as any).config = mockConfig;
248
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
249
+ (app as any).configService = {
250
+ get: mockConfig.get, values: mockConfig.values, getSafeConfig: mockConfig.getSafeConfig, isInitialized: true,
251
+ };
252
+
253
+ // getConfigValue() is a convenience method that delegates to getConfig().get()
254
+ // It also provides typed access based on OneBunAppConfig module augmentation
255
+ const appName = app.getConfigValue('app.name');
256
+ const debug = app.getConfigValue('app.debug');
257
+
258
+ expect(appName).toBe('test-app');
259
+ expect(debug).toBe(true);
260
+ });
167
261
  });
168
262
 
169
263
  describe('Layer methods', () => {
@@ -855,10 +949,722 @@ describe('OneBunApplication', () => {
855
949
 
856
950
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
857
951
  const response = await (mockServer as any).fetchHandler(request);
952
+ const body = await response.json();
858
953
 
859
- expect(response).toBeDefined();
860
- // Accept any successful response for now
861
- 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);
862
1668
  });
863
1669
 
864
1670
  test('should handle metrics endpoint', async () => {
@@ -3,7 +3,11 @@ import { Effect, type Layer } from 'effect';
3
3
  import type { Controller } from '../module/controller';
4
4
  import type { WsClientData } from '../websocket/ws.types';
5
5
 
6
- import { TypedEnv } from '@onebun/envs';
6
+ import {
7
+ type DeepPaths,
8
+ type DeepValue,
9
+ TypedEnv,
10
+ } from '@onebun/envs';
7
11
  import {
8
12
  createSyncLogger,
9
13
  type Logger,
@@ -230,9 +234,21 @@ export class OneBunApplication {
230
234
  }
231
235
 
232
236
  /**
233
- * Get configuration service
237
+ * Get configuration service with full type inference.
238
+ * Uses module augmentation of OneBunAppConfig for type-safe access.
239
+ *
240
+ * @example
241
+ * // With module augmentation:
242
+ * declare module '@onebun/core' {
243
+ * interface OneBunAppConfig {
244
+ * server: { port: number; host: string };
245
+ * }
246
+ * }
247
+ *
248
+ * const config = app.getConfig();
249
+ * const port = config.get('server.port'); // number
234
250
  */
235
- getConfig(): ConfigServiceImpl {
251
+ getConfig(): IConfig<OneBunAppConfig> {
236
252
  if (!this.configService) {
237
253
  throw new Error('Configuration not initialized. Provide envSchema in ApplicationOptions.');
238
254
  }
@@ -241,10 +257,25 @@ export class OneBunApplication {
241
257
  }
242
258
 
243
259
  /**
244
- * Get configuration value by path (convenience method)
260
+ * Get configuration value by path (convenience method) with full type inference.
261
+ * Uses module augmentation of OneBunAppConfig for type-safe access.
262
+ *
263
+ * @example
264
+ * // With module augmentation:
265
+ * declare module '@onebun/core' {
266
+ * interface OneBunAppConfig {
267
+ * server: { port: number; host: string };
268
+ * }
269
+ * }
270
+ *
271
+ * const port = app.getConfigValue('server.port'); // number
272
+ * const host = app.getConfigValue('server.host'); // string
245
273
  */
246
- getConfigValue<T = unknown>(path: string): T {
247
- return this.getConfig().get<T>(path);
274
+ getConfigValue<P extends DeepPaths<OneBunAppConfig>>(path: P): DeepValue<OneBunAppConfig, P>;
275
+ /** Fallback for dynamic paths */
276
+ getConfigValue<T = unknown>(path: string): T;
277
+ getConfigValue(path: string): unknown {
278
+ return this.getConfig().get(path);
248
279
  }
249
280
 
250
281
  /**
@@ -604,6 +635,26 @@ export class OneBunApplication {
604
635
  let route = routes.get(exactRouteKey);
605
636
  const paramValues: Record<string, string | string[]> = {};
606
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
+
607
658
  // If no exact match, try pattern matching
608
659
  if (!route) {
609
660
  for (const [_routeKey, routeData] of routes) {
@@ -141,7 +141,8 @@ describe('ConfigService', () => {
141
141
 
142
142
  const result = service.values;
143
143
 
144
- expect(result).toEqual({ test: 'value' });
144
+ // Use unknown cast because tests use mock data that doesn't match OneBunAppConfig augmentation
145
+ expect(result as unknown).toEqual({ test: 'value' });
145
146
  });
146
147
 
147
148
  test('should throw error when config not initialized', () => {
@@ -159,7 +160,8 @@ describe('ConfigService', () => {
159
160
 
160
161
  const result = service.getSafeConfig();
161
162
 
162
- expect(result).toEqual({ test: '***' });
163
+ // Use unknown cast because tests use mock data that doesn't match OneBunAppConfig augmentation
164
+ expect(result as unknown).toEqual({ test: '***' });
163
165
  expect(mockConfig.getSafeConfig).toHaveBeenCalled();
164
166
  });
165
167
 
@@ -344,13 +346,15 @@ describe('ConfigService', () => {
344
346
 
345
347
  expect((service as any).get('database.host')).toBe('localhost');
346
348
  expect((service as any).get('database.port')).toBe(5432);
347
- expect(service.values).toEqual({
349
+ // Use unknown cast because tests use mock data that doesn't match OneBunAppConfig augmentation
350
+ expect(service.values as unknown).toEqual({
348
351
  database: { host: 'localhost', port: 5432 },
349
352
  api: { key: 'secret', timeout: 30000 },
350
353
  });
351
354
 
352
355
  const safeConfig = service.getSafeConfig();
353
- expect(safeConfig).toEqual({
356
+ // Use unknown cast because tests use mock data that doesn't match OneBunAppConfig augmentation
357
+ expect(safeConfig as unknown).toEqual({
354
358
  database: { host: 'localhost', port: 5432 },
355
359
  api: { key: '***', timeout: 30000 },
356
360
  });
@@ -2,6 +2,7 @@ import { Context } from 'effect';
2
2
 
3
3
  import type { IConfig, OneBunAppConfig } from './config.interface';
4
4
 
5
+ import type { DeepPaths, DeepValue } from '@onebun/envs';
5
6
  import type { SyncLogger } from '@onebun/logger';
6
7
 
7
8
  import { BaseService, Service } from './service';
@@ -40,20 +41,35 @@ export class ConfigServiceImpl extends BaseService {
40
41
  }
41
42
 
42
43
  /**
43
- * Get configuration value by path
44
+ * Get configuration value by path with full type inference.
45
+ * Uses module augmentation of OneBunAppConfig for type-safe access.
46
+ *
47
+ * @example
48
+ * // With module augmentation:
49
+ * declare module '@onebun/core' {
50
+ * interface OneBunAppConfig {
51
+ * server: { port: number; host: string };
52
+ * }
53
+ * }
54
+ *
55
+ * const port = configService.get('server.port'); // number
56
+ * const host = configService.get('server.host'); // string
44
57
  */
45
- get<T = unknown>(path: string): T {
58
+ get<P extends DeepPaths<OneBunAppConfig>>(path: P): DeepValue<OneBunAppConfig, P>;
59
+ /** Fallback for dynamic paths */
60
+ get<T = unknown>(path: string): T;
61
+ get(path: string): unknown {
46
62
  if (!this.configInstance) {
47
63
  throw new Error('Configuration not initialized. Provide envSchema in ApplicationOptions.');
48
64
  }
49
65
 
50
- return this.configInstance.get(path) as T;
66
+ return this.configInstance.get(path);
51
67
  }
52
68
 
53
69
  /**
54
70
  * Get all configuration values
55
71
  */
56
- get values(): unknown {
72
+ get values(): OneBunAppConfig {
57
73
  if (!this.configInstance) {
58
74
  throw new Error('Configuration not initialized. Provide envSchema in ApplicationOptions.');
59
75
  }
@@ -64,7 +80,7 @@ export class ConfigServiceImpl extends BaseService {
64
80
  /**
65
81
  * Get safe configuration for logging (sensitive data masked)
66
82
  */
67
- getSafeConfig(): unknown {
83
+ getSafeConfig(): OneBunAppConfig {
68
84
  if (!this.configInstance) {
69
85
  throw new Error('Configuration not initialized. Provide envSchema in ApplicationOptions.');
70
86
  }