@onebun/core 0.2.0 → 0.2.2

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.
@@ -20,8 +20,14 @@ import {
20
20
  BaseService,
21
21
  Service,
22
22
  } from '../module';
23
+ import { BaseMiddleware } from '../module/middleware';
23
24
  import { makeMockLoggerLayer } from '../testing';
24
- import { HttpMethod, ParamType } from '../types';
25
+ import {
26
+ HttpMethod,
27
+ ParamType,
28
+ type OneBunRequest,
29
+ type OneBunResponse,
30
+ } from '../types';
25
31
 
26
32
  import {
27
33
  injectable,
@@ -49,6 +55,10 @@ import {
49
55
  Module,
50
56
  getModuleMetadata,
51
57
  ApiResponse,
58
+ UploadedFile,
59
+ UploadedFiles,
60
+ FormField,
61
+ getControllerMiddleware,
52
62
  } from './decorators';
53
63
 
54
64
  describe('decorators', () => {
@@ -675,42 +685,51 @@ describe('decorators', () => {
675
685
  });
676
686
 
677
687
  describe('UseMiddleware decorator', () => {
678
- const middleware1 = () => {};
679
- const middleware2 = () => {};
688
+ class Middleware1 extends BaseMiddleware {
689
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
690
+ return await next();
691
+ }
692
+ }
693
+
694
+ class Middleware2 extends BaseMiddleware {
695
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
696
+ return await next();
697
+ }
698
+ }
680
699
 
681
- test('should register middleware for method', () => {
700
+ test('should register middleware class for method', () => {
682
701
  @Controller()
683
702
  class TestController {
684
703
  @Get()
685
- @UseMiddleware(middleware1)
704
+ @UseMiddleware(Middleware1)
686
705
  test() {}
687
706
  }
688
707
 
689
708
  const metadata = getControllerMetadata(TestController);
690
709
  const route = metadata?.routes[0];
691
- expect(route?.middleware).toContain(middleware1);
710
+ expect(route?.middleware).toContain(Middleware1);
692
711
  });
693
712
 
694
- test('should register multiple middleware functions', () => {
713
+ test('should register multiple middleware classes', () => {
695
714
  @Controller()
696
715
  class TestController {
697
716
  @Get()
698
- @UseMiddleware(middleware1, middleware2)
717
+ @UseMiddleware(Middleware1, Middleware2)
699
718
  test() {}
700
719
  }
701
720
 
702
721
  const metadata = getControllerMetadata(TestController);
703
722
  const route = metadata?.routes[0];
704
- expect(route?.middleware).toContain(middleware1);
705
- expect(route?.middleware).toContain(middleware2);
723
+ expect(route?.middleware).toContain(Middleware1);
724
+ expect(route?.middleware).toContain(Middleware2);
706
725
  });
707
726
 
708
727
  test('should append to existing middleware', () => {
709
728
  @Controller()
710
729
  class TestController {
711
730
  @Get()
712
- @UseMiddleware(middleware1)
713
- @UseMiddleware(middleware2)
731
+ @UseMiddleware(Middleware1)
732
+ @UseMiddleware(Middleware2)
714
733
  test() {}
715
734
  }
716
735
 
@@ -718,6 +737,41 @@ describe('decorators', () => {
718
737
  const route = metadata?.routes[0];
719
738
  expect(route?.middleware).toHaveLength(2);
720
739
  });
740
+
741
+ test('should register middleware as class decorator', () => {
742
+ @Controller()
743
+ @UseMiddleware(Middleware1, Middleware2)
744
+ class TestController {
745
+ @Get()
746
+ test() {}
747
+ }
748
+
749
+ const controllerMw = getControllerMiddleware(TestController);
750
+ expect(controllerMw).toHaveLength(2);
751
+ expect(controllerMw).toContain(Middleware1);
752
+ expect(controllerMw).toContain(Middleware2);
753
+ });
754
+
755
+ test('should keep class and method middleware separate', () => {
756
+ @Controller()
757
+ @UseMiddleware(Middleware1)
758
+ class TestController {
759
+ @Get()
760
+ @UseMiddleware(Middleware2)
761
+ test() {}
762
+ }
763
+
764
+ // Class-level
765
+ const controllerMw = getControllerMiddleware(TestController);
766
+ expect(controllerMw).toHaveLength(1);
767
+ expect(controllerMw[0]).toBe(Middleware1);
768
+
769
+ // Route-level
770
+ const metadata = getControllerMetadata(TestController);
771
+ const route = metadata?.routes[0];
772
+ expect(route?.middleware).toHaveLength(1);
773
+ expect(route?.middleware?.[0]).toBe(Middleware2);
774
+ });
721
775
  });
722
776
 
723
777
  describe('Module decorator', () => {
@@ -1077,4 +1131,140 @@ describe('decorators', () => {
1077
1131
  expect(isGlobalModule(NonGlobalModuleC)).toBe(false);
1078
1132
  });
1079
1133
  });
1134
+
1135
+ // ============================================================================
1136
+ // File Upload Decorators
1137
+ // ============================================================================
1138
+
1139
+ describe('File Upload Decorators', () => {
1140
+ test('@UploadedFile should store FILE param metadata', () => {
1141
+ @Controller('/test')
1142
+ class TestController extends BaseController {
1143
+ @Post('/upload')
1144
+ upload(@UploadedFile('avatar') _file: unknown) {}
1145
+ }
1146
+
1147
+ const metadata = getControllerMetadata(TestController);
1148
+ expect(metadata).toBeDefined();
1149
+ const route = metadata!.routes[0];
1150
+ expect(route.params).toBeDefined();
1151
+ expect(route.params!.length).toBe(1);
1152
+ expect(route.params![0].type).toBe(ParamType.FILE);
1153
+ expect(route.params![0].name).toBe('avatar');
1154
+ expect(route.params![0].index).toBe(0);
1155
+ expect(route.params![0].isRequired).toBe(true);
1156
+ });
1157
+
1158
+ test('@UploadedFile should store file options', () => {
1159
+ @Controller('/test')
1160
+ class TestController extends BaseController {
1161
+ @Post('/upload')
1162
+ upload(
1163
+ @UploadedFile('avatar', { maxSize: 1024, mimeTypes: ['image/*'], required: false }) _file: unknown,
1164
+ ) {}
1165
+ }
1166
+
1167
+ const metadata = getControllerMetadata(TestController);
1168
+ const param = metadata!.routes[0].params![0];
1169
+ expect(param.isRequired).toBe(false);
1170
+ expect(param.fileOptions).toBeDefined();
1171
+ expect(param.fileOptions!.maxSize).toBe(1024);
1172
+ expect(param.fileOptions!.mimeTypes).toEqual(['image/*']);
1173
+ });
1174
+
1175
+ test('@UploadedFile without options should be required by default', () => {
1176
+ @Controller('/test')
1177
+ class TestController extends BaseController {
1178
+ @Post('/upload')
1179
+ upload(@UploadedFile('file') _file: unknown) {}
1180
+ }
1181
+
1182
+ const metadata = getControllerMetadata(TestController);
1183
+ const param = metadata!.routes[0].params![0];
1184
+ expect(param.isRequired).toBe(true);
1185
+ expect(param.fileOptions).toBeUndefined();
1186
+ });
1187
+
1188
+ test('@UploadedFiles should store FILES param metadata', () => {
1189
+ @Controller('/test')
1190
+ class TestController extends BaseController {
1191
+ @Post('/upload')
1192
+ upload(@UploadedFiles('docs', { maxCount: 5, maxSize: 2048 }) _files: unknown) {}
1193
+ }
1194
+
1195
+ const metadata = getControllerMetadata(TestController);
1196
+ const param = metadata!.routes[0].params![0];
1197
+ expect(param.type).toBe(ParamType.FILES);
1198
+ expect(param.name).toBe('docs');
1199
+ expect(param.isRequired).toBe(true);
1200
+ expect(param.fileOptions!.maxCount).toBe(5);
1201
+ expect(param.fileOptions!.maxSize).toBe(2048);
1202
+ });
1203
+
1204
+ test('@UploadedFiles without field name should have empty name', () => {
1205
+ @Controller('/test')
1206
+ class TestController extends BaseController {
1207
+ @Post('/upload')
1208
+ upload(@UploadedFiles() _files: unknown) {}
1209
+ }
1210
+
1211
+ const metadata = getControllerMetadata(TestController);
1212
+ const param = metadata!.routes[0].params![0];
1213
+ expect(param.type).toBe(ParamType.FILES);
1214
+ expect(param.name).toBe('');
1215
+ });
1216
+
1217
+ test('@FormField should store FORM_FIELD param metadata', () => {
1218
+ @Controller('/test')
1219
+ class TestController extends BaseController {
1220
+ @Post('/upload')
1221
+ upload(@FormField('name') _name: unknown) {}
1222
+ }
1223
+
1224
+ const metadata = getControllerMetadata(TestController);
1225
+ const param = metadata!.routes[0].params![0];
1226
+ expect(param.type).toBe(ParamType.FORM_FIELD);
1227
+ expect(param.name).toBe('name');
1228
+ expect(param.isRequired).toBe(false);
1229
+ });
1230
+
1231
+ test('@FormField with required option', () => {
1232
+ @Controller('/test')
1233
+ class TestController extends BaseController {
1234
+ @Post('/upload')
1235
+ upload(@FormField('name', { required: true }) _name: unknown) {}
1236
+ }
1237
+
1238
+ const metadata = getControllerMetadata(TestController);
1239
+ const param = metadata!.routes[0].params![0];
1240
+ expect(param.isRequired).toBe(true);
1241
+ });
1242
+
1243
+ test('should support mixing file and form field decorators', () => {
1244
+ @Controller('/test')
1245
+ class TestController extends BaseController {
1246
+ @Post('/profile')
1247
+ createProfile(
1248
+ @UploadedFile('avatar') _avatar: unknown,
1249
+ @FormField('name', { required: true }) _name: unknown,
1250
+ @FormField('email') _email: unknown,
1251
+ ) {}
1252
+ }
1253
+
1254
+ const metadata = getControllerMetadata(TestController);
1255
+ const params = metadata!.routes[0].params!;
1256
+ expect(params.length).toBe(3);
1257
+
1258
+ const fileParam = params.find((p) => p.type === ParamType.FILE);
1259
+ const nameParam = params.find((p) => p.name === 'name');
1260
+ const emailParam = params.find((p) => p.name === 'email');
1261
+
1262
+ expect(fileParam).toBeDefined();
1263
+ expect(nameParam).toBeDefined();
1264
+ expect(nameParam!.type).toBe(ParamType.FORM_FIELD);
1265
+ expect(nameParam!.isRequired).toBe(true);
1266
+ expect(emailParam).toBeDefined();
1267
+ expect(emailParam!.isRequired).toBe(false);
1268
+ });
1269
+ });
1080
1270
  });
@@ -3,10 +3,13 @@ import { type, type Type } from 'arktype';
3
3
 
4
4
  import {
5
5
  type ControllerMetadata,
6
+ type FileUploadOptions,
7
+ type FilesUploadOptions,
6
8
  HttpMethod,
7
9
  type ParamDecoratorOptions,
8
10
  type ParamMetadata,
9
11
  ParamType,
12
+ type RouteOptions,
10
13
  } from '../types';
11
14
 
12
15
  import { getConstructorParamTypes as getDesignParamTypes, Reflect } from './metadata';
@@ -198,6 +201,18 @@ export function controllerDecorator(basePath: string = '') {
198
201
  META_CONSTRUCTOR_PARAMS.set(WrappedController, existingDeps);
199
202
  }
200
203
 
204
+ // Copy controller-level middleware from original class to wrapped class
205
+ // This ensures @UseMiddleware works regardless of decorator order
206
+ const existingControllerMiddleware: Function[] | undefined =
207
+ Reflect.getMetadata(CONTROLLER_MIDDLEWARE_METADATA, target);
208
+ if (existingControllerMiddleware) {
209
+ Reflect.defineMetadata(
210
+ CONTROLLER_MIDDLEWARE_METADATA,
211
+ existingControllerMiddleware,
212
+ WrappedController,
213
+ );
214
+ }
215
+
201
216
  return WrappedController as T;
202
217
  };
203
218
  }
@@ -264,7 +279,7 @@ const RESPONSE_SCHEMAS_METADATA = 'onebun:responseSchemas';
264
279
  * Base route decorator factory
265
280
  */
266
281
  function createRouteDecorator(method: HttpMethod) {
267
- return (path: string = '') =>
282
+ return (path: string = '', options?: RouteOptions) =>
268
283
  (target: object, propertyKey: string, descriptor: PropertyDescriptor) => {
269
284
  const controllerClass = target.constructor as Function;
270
285
 
@@ -307,6 +322,7 @@ function createRouteDecorator(method: HttpMethod) {
307
322
  schema: rs.schema,
308
323
  description: rs.description,
309
324
  })),
325
+ ...(options?.timeout !== undefined ? { timeout: options.timeout } : {}),
310
326
  });
311
327
 
312
328
  META_CONTROLLERS.set(controllerClass, metadata);
@@ -519,13 +535,186 @@ export const Req = createParamDecorator(ParamType.REQUEST);
519
535
  // eslint-disable-next-line @typescript-eslint/naming-convention
520
536
  export const Res = createParamDecorator(ParamType.RESPONSE);
521
537
 
538
+ // =============================================================================
539
+ // File Upload Decorators
540
+ // =============================================================================
541
+
522
542
  /**
523
- * Middleware decorator
524
- * @example \@UseMiddleware(authMiddleware)
543
+ * Single file upload decorator.
544
+ * Extracts a single file from the request (multipart/form-data or JSON base64).
545
+ * Required by default.
546
+ *
547
+ * @param fieldName - Form field name to extract file from
548
+ * @param options - File upload options (maxSize, mimeTypes, required)
549
+ *
550
+ * @example \@UploadedFile('avatar')
551
+ * @example \@UploadedFile('avatar', { maxSize: 5 * 1024 * 1024 })
552
+ * @example \@UploadedFile('avatar', { mimeTypes: [MimeType.PNG, MimeType.JPEG] })
553
+ * @example \@UploadedFile('avatar', { maxSize: 5 * 1024 * 1024, mimeTypes: [MimeType.ANY_IMAGE] })
525
554
  */
555
+ // eslint-disable-next-line @typescript-eslint/naming-convention
556
+ export function UploadedFile(fieldName?: string, options?: FileUploadOptions): ParameterDecorator {
557
+ return (target: object, propertyKey: string | symbol | undefined, parameterIndex: number) => {
558
+ const params: ParamMetadata[] =
559
+ Reflect.getMetadata(PARAMS_METADATA, target, propertyKey) || [];
560
+
561
+ const isRequired = options?.required ?? true;
562
+
563
+ const metadata: ParamMetadata = {
564
+ type: ParamType.FILE,
565
+ name: fieldName || '',
566
+ index: parameterIndex,
567
+ isRequired,
568
+ fileOptions: options ? {
569
+ maxSize: options.maxSize,
570
+ mimeTypes: options.mimeTypes,
571
+ required: options.required,
572
+ } : undefined,
573
+ };
526
574
 
527
- export function UseMiddleware(...middleware: Function[]): MethodDecorator {
528
- return (target: object, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
575
+ params.push(metadata);
576
+ Reflect.defineMetadata(PARAMS_METADATA, params, target, propertyKey as string);
577
+ };
578
+ }
579
+
580
+ /**
581
+ * Multiple file upload decorator.
582
+ * Extracts multiple files from the request (multipart/form-data or JSON base64).
583
+ * Required by default (at least one file expected).
584
+ *
585
+ * @param fieldName - Form field name to extract files from. If omitted, extracts all files.
586
+ * @param options - File upload options (maxSize, mimeTypes, maxCount, required)
587
+ *
588
+ * @example \@UploadedFiles('documents')
589
+ * @example \@UploadedFiles('documents', { maxCount: 5 })
590
+ * @example \@UploadedFiles(undefined, { maxCount: 10 }) - all files
591
+ * @example \@UploadedFiles('images', { mimeTypes: [MimeType.ANY_IMAGE], maxSize: 10 * 1024 * 1024 })
592
+ */
593
+ // eslint-disable-next-line @typescript-eslint/naming-convention
594
+ export function UploadedFiles(fieldName?: string, options?: FilesUploadOptions): ParameterDecorator {
595
+ return (target: object, propertyKey: string | symbol | undefined, parameterIndex: number) => {
596
+ const params: ParamMetadata[] =
597
+ Reflect.getMetadata(PARAMS_METADATA, target, propertyKey) || [];
598
+
599
+ const isRequired = options?.required ?? true;
600
+
601
+ const metadata: ParamMetadata = {
602
+ type: ParamType.FILES,
603
+ name: fieldName || '',
604
+ index: parameterIndex,
605
+ isRequired,
606
+ fileOptions: options ? {
607
+ maxSize: options.maxSize,
608
+ mimeTypes: options.mimeTypes,
609
+ required: options.required,
610
+ maxCount: options.maxCount,
611
+ } : undefined,
612
+ };
613
+
614
+ params.push(metadata);
615
+ Reflect.defineMetadata(PARAMS_METADATA, params, target, propertyKey as string);
616
+ };
617
+ }
618
+
619
+ /**
620
+ * Form field decorator.
621
+ * Extracts a non-file field from the request (multipart/form-data or JSON body).
622
+ * Optional by default.
623
+ *
624
+ * @param fieldName - Form field name to extract
625
+ * @param options - Options (required)
626
+ *
627
+ * @example \@FormField('name')
628
+ * @example \@FormField('name', { required: true })
629
+ * @example \@FormField('email')
630
+ */
631
+ // eslint-disable-next-line @typescript-eslint/naming-convention
632
+ export function FormField(fieldName: string, options?: ParamDecoratorOptions): ParameterDecorator {
633
+ return (target: object, propertyKey: string | symbol | undefined, parameterIndex: number) => {
634
+ const params: ParamMetadata[] =
635
+ Reflect.getMetadata(PARAMS_METADATA, target, propertyKey) || [];
636
+
637
+ const isRequired = options?.required ?? false;
638
+
639
+ const metadata: ParamMetadata = {
640
+ type: ParamType.FORM_FIELD,
641
+ name: fieldName,
642
+ index: parameterIndex,
643
+ isRequired,
644
+ };
645
+
646
+ params.push(metadata);
647
+ Reflect.defineMetadata(PARAMS_METADATA, params, target, propertyKey as string);
648
+ };
649
+ }
650
+
651
+ /**
652
+ * Metadata key for controller-level middleware
653
+ */
654
+ const CONTROLLER_MIDDLEWARE_METADATA = 'onebun:controller_middleware';
655
+
656
+ /**
657
+ * Middleware decorator — can be applied to both controllers (class) and individual routes (method).
658
+ *
659
+ * Pass middleware **class constructors** (extending `BaseMiddleware`), not instances.
660
+ * The framework instantiates them once at startup with full DI support.
661
+ *
662
+ * When applied to a class, the middleware is added to **every** route in that controller
663
+ * and runs after global and module-level middleware but before route-level middleware.
664
+ *
665
+ * When applied to a method, the middleware runs after controller-level middleware.
666
+ *
667
+ * Execution order: global → controller → route → handler
668
+ *
669
+ * @example Class-level (all routes)
670
+ * ```typescript
671
+ * \@Controller('/admin')
672
+ * \@UseMiddleware(AuthMiddleware)
673
+ * class AdminController extends BaseController { ... }
674
+ * ```
675
+ *
676
+ * @example Method-level (single route)
677
+ * ```typescript
678
+ * \@Post('/action')
679
+ * \@UseMiddleware(LogMiddleware)
680
+ * action() { ... }
681
+ * ```
682
+ *
683
+ * @example Combined
684
+ * ```typescript
685
+ * \@Controller('/admin')
686
+ * \@UseMiddleware(AuthMiddleware) // runs on every route
687
+ * class AdminController extends BaseController {
688
+ * \@Get('/dashboard')
689
+ * \@UseMiddleware(CacheMiddleware) // runs only on this route, after auth
690
+ * getDashboard() { ... }
691
+ * }
692
+ * ```
693
+ */
694
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
695
+ export function UseMiddleware(...middleware: Function[]): any {
696
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
697
+ return function useMiddlewareDecorator(...args: any[]): any {
698
+ // ---- Class decorator: target is a constructor function ----
699
+ if (args.length === 1 && typeof args[0] === 'function') {
700
+ const target = args[0] as Function;
701
+ const existing: Function[] =
702
+ Reflect.getMetadata(CONTROLLER_MIDDLEWARE_METADATA, target) || [];
703
+ Reflect.defineMetadata(
704
+ CONTROLLER_MIDDLEWARE_METADATA,
705
+ [...existing, ...middleware],
706
+ target,
707
+ );
708
+
709
+ return target;
710
+ }
711
+
712
+ // ---- Method decorator: (target, propertyKey, descriptor) ----
713
+ const [target, propertyKey, descriptor] = args as [
714
+ object,
715
+ string | symbol,
716
+ PropertyDescriptor,
717
+ ];
529
718
  const existingMiddleware: Function[] =
530
719
  Reflect.getMetadata(MIDDLEWARE_METADATA, target, propertyKey) || [];
531
720
  Reflect.defineMetadata(
@@ -539,6 +728,17 @@ export function UseMiddleware(...middleware: Function[]): MethodDecorator {
539
728
  };
540
729
  }
541
730
 
731
+ /**
732
+ * Get controller-level middleware class constructors for a controller class.
733
+ * Returns middleware registered via @UseMiddleware() applied to the class.
734
+ *
735
+ * @param target - Controller class (constructor)
736
+ * @returns Array of middleware class constructors
737
+ */
738
+ export function getControllerMiddleware(target: Function): Function[] {
739
+ return Reflect.getMetadata(CONTROLLER_MIDDLEWARE_METADATA, target) || [];
740
+ }
741
+
542
742
  /**
543
743
  * HTTP GET decorator
544
744
  */
@@ -604,8 +804,17 @@ export interface SseDecoratorOptions {
604
804
  * Heartbeat interval in milliseconds.
605
805
  * When set, the server will send a comment (": heartbeat\n\n")
606
806
  * at this interval to keep the connection alive.
807
+ * @defaultValue 30000 (30 seconds) when using @Sse() decorator
607
808
  */
608
809
  heartbeat?: number;
810
+
811
+ /**
812
+ * Per-request idle timeout in seconds for this SSE connection.
813
+ * Overrides the global `idleTimeout` from `ApplicationOptions`.
814
+ * Set to 0 to disable the timeout entirely.
815
+ * @defaultValue 600 (10 minutes) for SSE endpoints
816
+ */
817
+ timeout?: number;
609
818
  }
610
819
 
611
820
  /**
@@ -651,7 +860,10 @@ export function Sse(options?: SseDecoratorOptions): MethodDecorator {
651
860
  }
652
861
 
653
862
  /**
654
- * Check if a method is marked as SSE endpoint
863
+ * Check if a method is marked as SSE endpoint.
864
+ * Traverses the prototype chain so that metadata stored on the original class
865
+ * prototype is found even when `@Controller` wraps the class.
866
+ *
655
867
  * @param target - Controller instance or prototype
656
868
  * @param methodName - Method name
657
869
  * @returns SSE options if method is SSE endpoint, undefined otherwise
@@ -660,7 +872,16 @@ export function getSseMetadata(
660
872
  target: object,
661
873
  methodName: string,
662
874
  ): SseDecoratorOptions | undefined {
663
- return Reflect.getMetadata(SSE_METADATA, target, methodName);
875
+ let proto: object | null = target;
876
+ while (proto) {
877
+ const metadata = Reflect.getMetadata(SSE_METADATA, proto, methodName);
878
+ if (metadata !== undefined) {
879
+ return metadata;
880
+ }
881
+ proto = Object.getPrototypeOf(proto);
882
+ }
883
+
884
+ return undefined;
664
885
  }
665
886
 
666
887
  /**