@onebun/core 0.2.7 → 0.2.9
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 +1 -1
- package/src/application/application.test.ts +52 -6
- package/src/application/application.ts +308 -220
- package/src/decorators/decorators.ts +213 -0
- package/src/docs-examples.test.ts +357 -2
- package/src/exception-filters/exception-filters.test.ts +172 -0
- package/src/exception-filters/exception-filters.ts +129 -0
- package/src/exception-filters/http-exception.ts +22 -0
- package/src/exception-filters/index.ts +2 -0
- package/src/file/onebun-file.ts +8 -2
- package/src/http-guards/http-guards.test.ts +230 -0
- package/src/http-guards/http-guards.ts +173 -0
- package/src/http-guards/index.ts +1 -0
- package/src/index.ts +9 -0
- package/src/module/module.test.ts +78 -0
- package/src/module/module.ts +47 -7
- package/src/queue/docs-examples.test.ts +2 -2
- package/src/security/cors-middleware.ts +212 -0
- package/src/security/index.ts +19 -0
- package/src/security/rate-limit-middleware.ts +276 -0
- package/src/security/security-headers-middleware.ts +188 -0
- package/src/security/security.test.ts +285 -0
- package/src/testing/index.ts +1 -0
- package/src/testing/testing-module.test.ts +199 -0
- package/src/testing/testing-module.ts +252 -0
- package/src/types.ts +98 -0
|
@@ -23,21 +23,24 @@ import {
|
|
|
23
23
|
type SyncLogger,
|
|
24
24
|
} from '@onebun/logger';
|
|
25
25
|
import {
|
|
26
|
-
type ApiResponse,
|
|
27
26
|
createErrorResponse,
|
|
28
27
|
createSuccessResponse,
|
|
29
28
|
HttpStatusCode,
|
|
30
|
-
OneBunBaseError,
|
|
31
29
|
} from '@onebun/requests';
|
|
32
30
|
import { makeTraceService, TraceService } from '@onebun/trace';
|
|
33
31
|
|
|
34
32
|
import {
|
|
33
|
+
getControllerFilters,
|
|
34
|
+
getControllerGuards,
|
|
35
35
|
getControllerMetadata,
|
|
36
36
|
getControllerMiddleware,
|
|
37
37
|
getSseMetadata,
|
|
38
38
|
type SseDecoratorOptions,
|
|
39
39
|
} from '../decorators/decorators';
|
|
40
|
+
import { defaultExceptionFilter, type ExceptionFilter } from '../exception-filters/exception-filters';
|
|
41
|
+
import { HttpException } from '../exception-filters/http-exception';
|
|
40
42
|
import { OneBunFile, validateFile } from '../file/onebun-file';
|
|
43
|
+
import { executeHttpGuards, HttpExecutionContextImpl } from '../http-guards/http-guards';
|
|
41
44
|
import {
|
|
42
45
|
NotInitializedConfig,
|
|
43
46
|
type IConfig,
|
|
@@ -50,7 +53,7 @@ import {
|
|
|
50
53
|
DEFAULT_SSE_HEARTBEAT_MS,
|
|
51
54
|
DEFAULT_SSE_TIMEOUT,
|
|
52
55
|
} from '../module/controller';
|
|
53
|
-
import { OneBunModule } from '../module/module';
|
|
56
|
+
import { OneBunModule, registerGlobalService } from '../module/module';
|
|
54
57
|
import {
|
|
55
58
|
QueueService,
|
|
56
59
|
QueueServiceProxy,
|
|
@@ -62,6 +65,9 @@ import { InMemoryQueueAdapter } from '../queue/adapters/memory.adapter';
|
|
|
62
65
|
import { RedisQueueAdapter } from '../queue/adapters/redis.adapter';
|
|
63
66
|
import { hasQueueDecorators } from '../queue/decorators';
|
|
64
67
|
import { SharedRedisProvider } from '../redis/shared-redis';
|
|
68
|
+
import { CorsMiddleware } from '../security/cors-middleware';
|
|
69
|
+
import { RateLimitMiddleware } from '../security/rate-limit-middleware';
|
|
70
|
+
import { SecurityHeadersMiddleware } from '../security/security-headers-middleware';
|
|
65
71
|
import {
|
|
66
72
|
type ApplicationOptions,
|
|
67
73
|
type HttpMethod,
|
|
@@ -484,18 +490,25 @@ export class OneBunApplication {
|
|
|
484
490
|
this.logger.info('Application configuration initialized');
|
|
485
491
|
}
|
|
486
492
|
|
|
487
|
-
//
|
|
493
|
+
// Register QueueService proxy in global registry BEFORE creating the root module,
|
|
494
|
+
// so all modules (including child modules) pick it up via PHASE 0 of initModule().
|
|
495
|
+
// After initializeQueue(), setDelegate(real) is called when queue is enabled.
|
|
496
|
+
this.queueServiceProxy = new QueueServiceProxy();
|
|
497
|
+
registerGlobalService(
|
|
498
|
+
QueueServiceTag as Context.Tag<unknown, QueueService>,
|
|
499
|
+
this.queueServiceProxy as unknown as QueueService,
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
// Create the root module AFTER config is initialized and QueueService proxy is registered,
|
|
488
503
|
// so services can safely use this.config.get() in their constructors
|
|
504
|
+
// and inject QueueService in any module depth.
|
|
489
505
|
this.rootModule = OneBunModule.create(this.moduleClass, this.loggerLayer, this.config);
|
|
490
506
|
|
|
491
|
-
// Register
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
QueueServiceTag as Context.Tag<unknown, QueueService>,
|
|
497
|
-
this.queueServiceProxy as unknown as QueueService,
|
|
498
|
-
);
|
|
507
|
+
// Register test provider overrides (must happen before setup() so controllers receive mocks)
|
|
508
|
+
if (this.options._testProviders) {
|
|
509
|
+
for (const { tag, value } of this.options._testProviders) {
|
|
510
|
+
this.ensureModule().registerService?.(tag, value);
|
|
511
|
+
}
|
|
499
512
|
}
|
|
500
513
|
|
|
501
514
|
// Start metrics collection if enabled
|
|
@@ -652,41 +665,65 @@ export class OneBunApplication {
|
|
|
652
665
|
try {
|
|
653
666
|
let response: Response;
|
|
654
667
|
|
|
655
|
-
//
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
668
|
+
// Run guards then handler — shared by middleware chain and direct path
|
|
669
|
+
const runGuardedHandler = async (): Promise<Response> => {
|
|
670
|
+
if (routeMeta.guards && routeMeta.guards.length > 0) {
|
|
671
|
+
const guardCtx = new HttpExecutionContextImpl(
|
|
672
|
+
req,
|
|
673
|
+
routeMeta.handler,
|
|
674
|
+
controller.constructor.name,
|
|
675
|
+
);
|
|
676
|
+
const allowed = await executeHttpGuards(routeMeta.guards, guardCtx);
|
|
677
|
+
|
|
678
|
+
if (!allowed) {
|
|
679
|
+
return new Response(
|
|
680
|
+
JSON.stringify(
|
|
681
|
+
createErrorResponse(
|
|
682
|
+
'Forbidden',
|
|
683
|
+
HttpStatusCode.FORBIDDEN,
|
|
684
|
+
'Forbidden',
|
|
685
|
+
),
|
|
686
|
+
),
|
|
660
687
|
{
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
688
|
+
status: HttpStatusCode.OK,
|
|
689
|
+
headers: {
|
|
690
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
691
|
+
'Content-Type': 'application/json',
|
|
692
|
+
},
|
|
666
693
|
},
|
|
667
|
-
req,
|
|
668
|
-
queryParams,
|
|
669
694
|
);
|
|
670
695
|
}
|
|
696
|
+
}
|
|
671
697
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
return await middleware(req, () => next(index + 1));
|
|
675
|
-
};
|
|
676
|
-
|
|
677
|
-
response = await next(0);
|
|
678
|
-
} else {
|
|
679
|
-
response = await executeHandler(
|
|
698
|
+
return await executeHandler(
|
|
680
699
|
{
|
|
681
700
|
handler: boundHandler,
|
|
682
701
|
handlerName: routeMeta.handler,
|
|
683
702
|
controller,
|
|
684
703
|
params: routeMeta.params,
|
|
685
704
|
responseSchemas: routeMeta.responseSchemas,
|
|
705
|
+
filters: routeMeta.filters,
|
|
686
706
|
},
|
|
687
707
|
req,
|
|
688
708
|
queryParams,
|
|
689
709
|
);
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
// Execute middleware chain if any, then guarded handler
|
|
713
|
+
if (routeMeta.middleware && routeMeta.middleware.length > 0) {
|
|
714
|
+
const next = async (index: number): Promise<Response> => {
|
|
715
|
+
if (index >= routeMeta.middleware!.length) {
|
|
716
|
+
return await runGuardedHandler();
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const middleware = routeMeta.middleware![index];
|
|
720
|
+
|
|
721
|
+
return await middleware(req, () => next(index + 1));
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
response = await next(0);
|
|
725
|
+
} else {
|
|
726
|
+
response = await runGuardedHandler();
|
|
690
727
|
}
|
|
691
728
|
|
|
692
729
|
const duration = Date.now() - startTime;
|
|
@@ -782,10 +819,31 @@ export class OneBunApplication {
|
|
|
782
819
|
};
|
|
783
820
|
}
|
|
784
821
|
|
|
822
|
+
// Build auto-configured security middleware from shorthand options.
|
|
823
|
+
// These are prepended/appended in a fixed order: CORS → RateLimit → [user] → Security.
|
|
824
|
+
const autoPrefix: Function[] = [];
|
|
825
|
+
const autoSuffix: Function[] = [];
|
|
826
|
+
|
|
827
|
+
if (this.options.cors) {
|
|
828
|
+
const corsOpts = this.options.cors === true ? {} : this.options.cors;
|
|
829
|
+
autoPrefix.push(CorsMiddleware.configure(corsOpts));
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
if (this.options.rateLimit) {
|
|
833
|
+
const rlOpts = this.options.rateLimit === true ? {} : this.options.rateLimit;
|
|
834
|
+
autoPrefix.push(RateLimitMiddleware.configure(rlOpts));
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (this.options.security) {
|
|
838
|
+
const secOpts = this.options.security === true ? {} : this.options.security;
|
|
839
|
+
autoSuffix.push(SecurityHeadersMiddleware.configure(secOpts));
|
|
840
|
+
}
|
|
841
|
+
|
|
785
842
|
// Application-wide middleware — resolve class constructors via root module DI
|
|
786
|
-
const
|
|
787
|
-
const
|
|
788
|
-
|
|
843
|
+
const userMiddlewareClasses = (this.options.middleware as Function[] | undefined) ?? [];
|
|
844
|
+
const allGlobalClasses = [...autoPrefix, ...userMiddlewareClasses, ...autoSuffix];
|
|
845
|
+
const globalMiddleware: Function[] = allGlobalClasses.length > 0
|
|
846
|
+
? (this.ensureModule().resolveMiddleware?.(allGlobalClasses) ?? [])
|
|
789
847
|
: [];
|
|
790
848
|
|
|
791
849
|
// Add routes from controllers
|
|
@@ -847,9 +905,23 @@ export class OneBunApplication {
|
|
|
847
905
|
...ctrlMiddleware,
|
|
848
906
|
...routeMiddleware,
|
|
849
907
|
];
|
|
908
|
+
|
|
909
|
+
// Merge guards: controller-level first, then route-level
|
|
910
|
+
const ctrlGuards = getControllerGuards(controllerClass);
|
|
911
|
+
const routeGuards = route.guards ?? [];
|
|
912
|
+
const mergedGuards = [...ctrlGuards, ...routeGuards];
|
|
913
|
+
|
|
914
|
+
// Merge exception filters: global → controller → route (route has highest priority)
|
|
915
|
+
const globalFilters = (this.options.filters as ExceptionFilter[] | undefined) ?? [];
|
|
916
|
+
const ctrlFilters = getControllerFilters(controllerClass);
|
|
917
|
+
const routeFilters = route.filters ?? [];
|
|
918
|
+
const mergedFilters = [...globalFilters, ...ctrlFilters, ...routeFilters];
|
|
919
|
+
|
|
850
920
|
const routeWithMergedMiddleware: RouteMetadata = {
|
|
851
921
|
...route,
|
|
852
922
|
middleware: mergedMiddleware.length > 0 ? mergedMiddleware : undefined,
|
|
923
|
+
guards: mergedGuards.length > 0 ? mergedGuards : undefined,
|
|
924
|
+
filters: mergedFilters.length > 0 ? mergedFilters : undefined,
|
|
853
925
|
};
|
|
854
926
|
|
|
855
927
|
// Create wrapped handler with full OneBun lifecycle (tracing, metrics, middleware)
|
|
@@ -1201,6 +1273,7 @@ export class OneBunApplication {
|
|
|
1201
1273
|
controller: Controller;
|
|
1202
1274
|
params?: ParamMetadata[];
|
|
1203
1275
|
responseSchemas?: RouteMetadata['responseSchemas'];
|
|
1276
|
+
filters?: ExceptionFilter[];
|
|
1204
1277
|
},
|
|
1205
1278
|
req: OneBunRequest,
|
|
1206
1279
|
queryParams: Record<string, string | string[]>,
|
|
@@ -1223,224 +1296,226 @@ export class OneBunApplication {
|
|
|
1223
1296
|
return result;
|
|
1224
1297
|
}
|
|
1225
1298
|
|
|
1299
|
+
try {
|
|
1226
1300
|
// Prepare arguments array based on parameter metadata
|
|
1227
|
-
|
|
1301
|
+
const args: unknown[] = [];
|
|
1228
1302
|
|
|
1229
|
-
|
|
1230
|
-
|
|
1303
|
+
// Sort params by index to ensure correct order
|
|
1304
|
+
const sortedParams = [...(route.params || [])].sort((a, b) => a.index - b.index);
|
|
1231
1305
|
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1306
|
+
// Pre-parse body for file upload params (FormData or JSON, cached for all params)
|
|
1307
|
+
const needsFileData = sortedParams.some(
|
|
1308
|
+
(p) =>
|
|
1309
|
+
p.type === ParamType.FILE ||
|
|
1236
1310
|
p.type === ParamType.FILES ||
|
|
1237
1311
|
p.type === ParamType.FORM_FIELD,
|
|
1238
|
-
|
|
1312
|
+
);
|
|
1239
1313
|
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1314
|
+
// Validate that @Body and file decorators are not used on the same method
|
|
1315
|
+
if (needsFileData) {
|
|
1316
|
+
const hasBody = sortedParams.some((p) => p.type === ParamType.BODY);
|
|
1317
|
+
if (hasBody) {
|
|
1318
|
+
throw new HttpException(
|
|
1319
|
+
HttpStatusCode.BAD_REQUEST,
|
|
1320
|
+
'Cannot use @Body() together with @UploadedFile/@UploadedFiles/@FormField on the same method. ' +
|
|
1246
1321
|
'Both consume the request body. Use file decorators for multipart/base64 uploads.',
|
|
1247
|
-
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1248
1324
|
}
|
|
1249
|
-
}
|
|
1250
1325
|
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1326
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1327
|
+
let formData: any = null;
|
|
1328
|
+
let jsonBody: Record<string, unknown> | null = null;
|
|
1329
|
+
let isMultipart = false;
|
|
1255
1330
|
|
|
1256
|
-
|
|
1257
|
-
|
|
1331
|
+
if (needsFileData) {
|
|
1332
|
+
const contentType = req.headers.get('content-type') || '';
|
|
1258
1333
|
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1334
|
+
if (contentType.includes('multipart/form-data')) {
|
|
1335
|
+
isMultipart = true;
|
|
1336
|
+
try {
|
|
1337
|
+
formData = await req.formData();
|
|
1338
|
+
} catch {
|
|
1339
|
+
formData = null;
|
|
1340
|
+
}
|
|
1341
|
+
} else if (contentType.includes('application/json')) {
|
|
1342
|
+
try {
|
|
1343
|
+
const parsed = await req.json();
|
|
1344
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
1345
|
+
jsonBody = parsed as Record<string, unknown>;
|
|
1346
|
+
}
|
|
1347
|
+
} catch {
|
|
1348
|
+
jsonBody = null;
|
|
1271
1349
|
}
|
|
1272
|
-
} catch {
|
|
1273
|
-
jsonBody = null;
|
|
1274
1350
|
}
|
|
1275
1351
|
}
|
|
1276
|
-
}
|
|
1277
1352
|
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1353
|
+
for (const param of sortedParams) {
|
|
1354
|
+
switch (param.type) {
|
|
1355
|
+
case ParamType.PATH:
|
|
1281
1356
|
// Use req.params from BunRequest (natively populated by Bun routes API)
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1357
|
+
args[param.index] = param.name
|
|
1358
|
+
? (req.params as Record<string, string>)[param.name]
|
|
1359
|
+
: undefined;
|
|
1360
|
+
break;
|
|
1286
1361
|
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1362
|
+
case ParamType.QUERY:
|
|
1363
|
+
args[param.index] = param.name ? queryParams[param.name] : undefined;
|
|
1364
|
+
break;
|
|
1290
1365
|
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1366
|
+
case ParamType.BODY:
|
|
1367
|
+
try {
|
|
1368
|
+
args[param.index] = await req.json();
|
|
1369
|
+
} catch {
|
|
1370
|
+
args[param.index] = undefined;
|
|
1371
|
+
}
|
|
1372
|
+
break;
|
|
1298
1373
|
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1374
|
+
case ParamType.HEADER:
|
|
1375
|
+
args[param.index] = param.name ? req.headers.get(param.name) : undefined;
|
|
1376
|
+
break;
|
|
1302
1377
|
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1378
|
+
case ParamType.COOKIE:
|
|
1379
|
+
args[param.index] = param.name ? req.cookies.get(param.name) ?? undefined : undefined;
|
|
1380
|
+
break;
|
|
1306
1381
|
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1382
|
+
case ParamType.REQUEST:
|
|
1383
|
+
args[param.index] = req;
|
|
1384
|
+
break;
|
|
1310
1385
|
|
|
1311
|
-
|
|
1386
|
+
case ParamType.RESPONSE:
|
|
1312
1387
|
// For now, we don't support direct response manipulation
|
|
1313
|
-
|
|
1314
|
-
|
|
1388
|
+
args[param.index] = undefined;
|
|
1389
|
+
break;
|
|
1315
1390
|
|
|
1316
|
-
|
|
1317
|
-
|
|
1391
|
+
case ParamType.FILE: {
|
|
1392
|
+
let file: OneBunFile | undefined;
|
|
1318
1393
|
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1394
|
+
if (isMultipart && formData && param.name) {
|
|
1395
|
+
const entry = formData.get(param.name);
|
|
1396
|
+
if (entry instanceof File) {
|
|
1397
|
+
file = new OneBunFile(entry);
|
|
1398
|
+
}
|
|
1399
|
+
} else if (jsonBody && param.name) {
|
|
1400
|
+
file = extractFileFromJson(jsonBody, param.name);
|
|
1323
1401
|
}
|
|
1324
|
-
} else if (jsonBody && param.name) {
|
|
1325
|
-
file = extractFileFromJson(jsonBody, param.name);
|
|
1326
|
-
}
|
|
1327
1402
|
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1403
|
+
if (file && param.fileOptions) {
|
|
1404
|
+
validateFile(file, param.fileOptions, param.name);
|
|
1405
|
+
}
|
|
1331
1406
|
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1407
|
+
args[param.index] = file;
|
|
1408
|
+
break;
|
|
1409
|
+
}
|
|
1335
1410
|
|
|
1336
|
-
|
|
1337
|
-
|
|
1411
|
+
case ParamType.FILES: {
|
|
1412
|
+
let files: OneBunFile[] = [];
|
|
1338
1413
|
|
|
1339
|
-
|
|
1340
|
-
|
|
1414
|
+
if (isMultipart && formData) {
|
|
1415
|
+
if (param.name) {
|
|
1341
1416
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1417
|
+
const entries: any[] = formData.getAll(param.name);
|
|
1418
|
+
files = entries
|
|
1419
|
+
.filter((entry: unknown): entry is File => entry instanceof File)
|
|
1420
|
+
.map((f: File) => new OneBunFile(f));
|
|
1421
|
+
} else {
|
|
1347
1422
|
// Get all files from all fields
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1423
|
+
for (const [, value] of formData.entries()) {
|
|
1424
|
+
if (value instanceof File) {
|
|
1425
|
+
files.push(new OneBunFile(value));
|
|
1426
|
+
}
|
|
1351
1427
|
}
|
|
1352
1428
|
}
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
}
|
|
1362
|
-
} else {
|
|
1429
|
+
} else if (jsonBody) {
|
|
1430
|
+
if (param.name) {
|
|
1431
|
+
const fieldValue = jsonBody[param.name];
|
|
1432
|
+
if (Array.isArray(fieldValue)) {
|
|
1433
|
+
files = fieldValue
|
|
1434
|
+
.map((item) => extractFileFromJsonValue(item))
|
|
1435
|
+
.filter((f): f is OneBunFile => f !== undefined);
|
|
1436
|
+
}
|
|
1437
|
+
} else {
|
|
1363
1438
|
// Extract all file-like values from JSON
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1439
|
+
for (const [, value] of Object.entries(jsonBody)) {
|
|
1440
|
+
const file = extractFileFromJsonValue(value);
|
|
1441
|
+
if (file) {
|
|
1442
|
+
files.push(file);
|
|
1443
|
+
}
|
|
1368
1444
|
}
|
|
1369
1445
|
}
|
|
1370
1446
|
}
|
|
1371
|
-
}
|
|
1372
1447
|
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1448
|
+
// Validate maxCount
|
|
1449
|
+
if (param.fileOptions?.maxCount !== undefined && files.length > param.fileOptions.maxCount) {
|
|
1450
|
+
throw new HttpException(
|
|
1451
|
+
HttpStatusCode.BAD_REQUEST,
|
|
1452
|
+
`Too many files for "${param.name || 'upload'}". Got ${files.length}, max is ${param.fileOptions.maxCount}`,
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1379
1455
|
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1456
|
+
// Validate each file
|
|
1457
|
+
if (param.fileOptions) {
|
|
1458
|
+
for (const file of files) {
|
|
1459
|
+
validateFile(file, param.fileOptions, param.name);
|
|
1460
|
+
}
|
|
1384
1461
|
}
|
|
1385
|
-
}
|
|
1386
1462
|
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1463
|
+
args[param.index] = files;
|
|
1464
|
+
break;
|
|
1465
|
+
}
|
|
1390
1466
|
|
|
1391
|
-
|
|
1392
|
-
|
|
1467
|
+
case ParamType.FORM_FIELD: {
|
|
1468
|
+
let value: string | undefined;
|
|
1393
1469
|
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1470
|
+
if (isMultipart && formData && param.name) {
|
|
1471
|
+
const entry = formData.get(param.name);
|
|
1472
|
+
if (typeof entry === 'string') {
|
|
1473
|
+
value = entry;
|
|
1474
|
+
}
|
|
1475
|
+
} else if (jsonBody && param.name) {
|
|
1476
|
+
const jsonValue = jsonBody[param.name];
|
|
1477
|
+
if (jsonValue !== undefined && jsonValue !== null) {
|
|
1478
|
+
value = String(jsonValue);
|
|
1479
|
+
}
|
|
1403
1480
|
}
|
|
1481
|
+
|
|
1482
|
+
args[param.index] = value;
|
|
1483
|
+
break;
|
|
1404
1484
|
}
|
|
1405
1485
|
|
|
1406
|
-
|
|
1407
|
-
|
|
1486
|
+
default:
|
|
1487
|
+
args[param.index] = undefined;
|
|
1408
1488
|
}
|
|
1409
1489
|
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
// Validate parameter if required
|
|
1415
|
-
if (param.isRequired && (args[param.index] === undefined || args[param.index] === null)) {
|
|
1416
|
-
throw new Error(`Required parameter ${param.name || param.index} is missing`);
|
|
1417
|
-
}
|
|
1490
|
+
// Validate parameter if required
|
|
1491
|
+
if (param.isRequired && (args[param.index] === undefined || args[param.index] === null)) {
|
|
1492
|
+
throw new HttpException(HttpStatusCode.BAD_REQUEST, `Required parameter ${param.name || param.index} is missing`);
|
|
1493
|
+
}
|
|
1418
1494
|
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1495
|
+
// For FILES type, also check for empty array when required
|
|
1496
|
+
if (
|
|
1497
|
+
param.isRequired &&
|
|
1422
1498
|
param.type === ParamType.FILES &&
|
|
1423
1499
|
Array.isArray(args[param.index]) &&
|
|
1424
1500
|
(args[param.index] as unknown[]).length === 0
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1501
|
+
) {
|
|
1502
|
+
throw new HttpException(HttpStatusCode.BAD_REQUEST, `Required parameter ${param.name || param.index} is missing`);
|
|
1503
|
+
}
|
|
1428
1504
|
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1505
|
+
// Apply arktype schema validation if provided
|
|
1506
|
+
if (param.schema && args[param.index] !== undefined) {
|
|
1507
|
+
try {
|
|
1508
|
+
args[param.index] = validateOrThrow(param.schema, args[param.index]);
|
|
1509
|
+
} catch (error) {
|
|
1510
|
+
const errorMessage =
|
|
1511
|
+
error instanceof Error ? error.message : String(error);
|
|
1512
|
+
throw new HttpException(
|
|
1513
|
+
HttpStatusCode.BAD_REQUEST,
|
|
1514
|
+
`Parameter ${param.name || param.index} validation failed: ${errorMessage}`,
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1439
1517
|
}
|
|
1440
1518
|
}
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
try {
|
|
1444
1519
|
// Call handler with injected parameters
|
|
1445
1520
|
const result = await route.handler(...args);
|
|
1446
1521
|
|
|
@@ -1555,31 +1630,25 @@ export class OneBunApplication {
|
|
|
1555
1630
|
},
|
|
1556
1631
|
});
|
|
1557
1632
|
} catch (error) {
|
|
1558
|
-
//
|
|
1559
|
-
|
|
1633
|
+
// Run through exception filters (route → controller → global), then default
|
|
1634
|
+
const filters = route.filters ?? [];
|
|
1635
|
+
|
|
1636
|
+
if (filters.length > 0) {
|
|
1637
|
+
const guardCtx = new HttpExecutionContextImpl(
|
|
1638
|
+
req,
|
|
1639
|
+
route.handlerName ?? '',
|
|
1640
|
+
route.controller.constructor.name,
|
|
1641
|
+
);
|
|
1560
1642
|
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
} else {
|
|
1564
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1565
|
-
const code =
|
|
1566
|
-
error instanceof Error && 'code' in error
|
|
1567
|
-
? Number((error as { code: unknown }).code)
|
|
1568
|
-
: HttpStatusCode.INTERNAL_SERVER_ERROR;
|
|
1569
|
-
errorResponse = createErrorResponse(message, code, message, undefined, {
|
|
1570
|
-
originalErrorName: error instanceof Error ? error.name : 'UnknownError',
|
|
1571
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
1572
|
-
});
|
|
1643
|
+
// Last filter wins (route-level filters were appended last and take highest priority)
|
|
1644
|
+
return await filters[filters.length - 1].catch(error, guardCtx);
|
|
1573
1645
|
}
|
|
1574
1646
|
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
'Content-Type': 'application/json',
|
|
1581
|
-
},
|
|
1582
|
-
});
|
|
1647
|
+
return await defaultExceptionFilter.catch(error, new HttpExecutionContextImpl(
|
|
1648
|
+
req,
|
|
1649
|
+
route.handlerName ?? '',
|
|
1650
|
+
route.controller.constructor.name,
|
|
1651
|
+
));
|
|
1583
1652
|
}
|
|
1584
1653
|
}
|
|
1585
1654
|
|
|
@@ -1918,12 +1987,31 @@ export class OneBunApplication {
|
|
|
1918
1987
|
return this.logger;
|
|
1919
1988
|
}
|
|
1920
1989
|
|
|
1990
|
+
/**
|
|
1991
|
+
* Get the actual port the server is listening on.
|
|
1992
|
+
* When `port: 0` is passed, the OS assigns a free port — use this method
|
|
1993
|
+
* to obtain the real port after `start()`.
|
|
1994
|
+
* @returns Actual listening port, or the configured port if not yet started
|
|
1995
|
+
*/
|
|
1996
|
+
getPort(): number {
|
|
1997
|
+
return this.server?.port ?? this.options.port ?? 3000;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
/**
|
|
2001
|
+
* Get the underlying Bun server instance.
|
|
2002
|
+
* Use this to dispatch requests directly (bypassing the global `fetch`) e.g. in tests.
|
|
2003
|
+
* @internal
|
|
2004
|
+
*/
|
|
2005
|
+
getServer(): ReturnType<typeof Bun.serve> | null {
|
|
2006
|
+
return this.server;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
1921
2009
|
/**
|
|
1922
2010
|
* Get the HTTP URL where the application is listening
|
|
1923
2011
|
* @returns The HTTP URL
|
|
1924
2012
|
*/
|
|
1925
2013
|
getHttpUrl(): string {
|
|
1926
|
-
return `http://${this.options.host}:${this.
|
|
2014
|
+
return `http://${this.options.host ?? '0.0.0.0'}:${this.getPort()}`;
|
|
1927
2015
|
}
|
|
1928
2016
|
|
|
1929
2017
|
/**
|