@onebun/core 0.2.5 → 0.2.6
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 +521 -0
- package/src/application/application.ts +8 -4
- package/src/decorators/decorators.test.ts +28 -0
- package/src/decorators/decorators.ts +26 -0
- package/src/module/module.test.ts +60 -7
- package/src/module/module.ts +18 -0
- package/src/types.ts +7 -0
package/package.json
CHANGED
|
@@ -11,6 +11,12 @@ import {
|
|
|
11
11
|
import { register } from 'prom-client';
|
|
12
12
|
|
|
13
13
|
import type { ApplicationOptions } from '../types';
|
|
14
|
+
import type {
|
|
15
|
+
MiddlewareClass,
|
|
16
|
+
OneBunRequest,
|
|
17
|
+
OneBunResponse,
|
|
18
|
+
} from '../types';
|
|
19
|
+
import type { OnModuleConfigure } from '../types';
|
|
14
20
|
|
|
15
21
|
import {
|
|
16
22
|
Module,
|
|
@@ -23,8 +29,12 @@ import {
|
|
|
23
29
|
Header,
|
|
24
30
|
Cookie,
|
|
25
31
|
Req,
|
|
32
|
+
UseMiddleware,
|
|
33
|
+
Middleware,
|
|
26
34
|
} from '../decorators/decorators';
|
|
27
35
|
import { Controller as BaseController } from '../module/controller';
|
|
36
|
+
import { BaseMiddleware } from '../module/middleware';
|
|
37
|
+
import { Service } from '../module/service';
|
|
28
38
|
import { makeMockLoggerLayer } from '../testing/test-utils';
|
|
29
39
|
|
|
30
40
|
import { OneBunApplication } from './application';
|
|
@@ -2562,6 +2572,517 @@ describe('OneBunApplication', () => {
|
|
|
2562
2572
|
// Verify they are the same (no duplication due to trailing slash)
|
|
2563
2573
|
expect(recordedMetrics[0].route).toBe(recordedMetrics[1].route);
|
|
2564
2574
|
});
|
|
2575
|
+
|
|
2576
|
+
describe('Middleware composition (docs: controllers.md#middleware-execution-order)', () => {
|
|
2577
|
+
test('should run route-level middlewares left-to-right then handler', async () => {
|
|
2578
|
+
const order: string[] = [];
|
|
2579
|
+
|
|
2580
|
+
class MwA extends BaseMiddleware {
|
|
2581
|
+
async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2582
|
+
order.push('MwA');
|
|
2583
|
+
const res = await next();
|
|
2584
|
+
order.push('MwA-after');
|
|
2585
|
+
|
|
2586
|
+
return res;
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
class MwB extends BaseMiddleware {
|
|
2591
|
+
async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2592
|
+
order.push('MwB');
|
|
2593
|
+
const res = await next();
|
|
2594
|
+
order.push('MwB-after');
|
|
2595
|
+
|
|
2596
|
+
return res;
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
@Controller('/api')
|
|
2601
|
+
class ApiController extends BaseController {
|
|
2602
|
+
@Get('/chain')
|
|
2603
|
+
@UseMiddleware(MwA, MwB)
|
|
2604
|
+
getChain() {
|
|
2605
|
+
order.push('handler');
|
|
2606
|
+
|
|
2607
|
+
return this.success({ ok: true });
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
@Module({ controllers: [ApiController] })
|
|
2612
|
+
class TestModule {}
|
|
2613
|
+
|
|
2614
|
+
const app = createTestApp(TestModule);
|
|
2615
|
+
await app.start();
|
|
2616
|
+
|
|
2617
|
+
const request = new Request('http://localhost:3000/api/chain', { method: 'GET' });
|
|
2618
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2619
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
2620
|
+
const body = await response.json();
|
|
2621
|
+
|
|
2622
|
+
expect(response.status).toBe(200);
|
|
2623
|
+
expect(body.result).toEqual({ ok: true });
|
|
2624
|
+
expect(order).toEqual(['MwA', 'MwB', 'handler', 'MwB-after', 'MwA-after']);
|
|
2625
|
+
});
|
|
2626
|
+
|
|
2627
|
+
test('should run controller-level then route-level middleware', async () => {
|
|
2628
|
+
const order: string[] = [];
|
|
2629
|
+
|
|
2630
|
+
class CtrlMw extends BaseMiddleware {
|
|
2631
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2632
|
+
order.push('CtrlMw');
|
|
2633
|
+
|
|
2634
|
+
return await next();
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
class RouteMw extends BaseMiddleware {
|
|
2639
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2640
|
+
order.push('RouteMw');
|
|
2641
|
+
|
|
2642
|
+
return await next();
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
@Controller('/api')
|
|
2647
|
+
@UseMiddleware(CtrlMw)
|
|
2648
|
+
class ApiController extends BaseController {
|
|
2649
|
+
@Get('/dashboard')
|
|
2650
|
+
getDashboard() {
|
|
2651
|
+
order.push('handler');
|
|
2652
|
+
|
|
2653
|
+
return this.success({ stats: {} });
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
@Get('/settings')
|
|
2657
|
+
@UseMiddleware(RouteMw)
|
|
2658
|
+
getSettings() {
|
|
2659
|
+
order.push('handler-settings');
|
|
2660
|
+
|
|
2661
|
+
return this.success({ updated: false });
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
@Module({ controllers: [ApiController] })
|
|
2666
|
+
class TestModule {}
|
|
2667
|
+
|
|
2668
|
+
const app = createTestApp(TestModule);
|
|
2669
|
+
await app.start();
|
|
2670
|
+
|
|
2671
|
+
order.length = 0;
|
|
2672
|
+
const req1 = new Request('http://localhost:3000/api/dashboard', { method: 'GET' });
|
|
2673
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2674
|
+
const res1 = await (mockServer as any).fetchHandler(req1);
|
|
2675
|
+
expect(res1.status).toBe(200);
|
|
2676
|
+
expect(order).toEqual(['CtrlMw', 'handler']);
|
|
2677
|
+
|
|
2678
|
+
order.length = 0;
|
|
2679
|
+
const req2 = new Request('http://localhost:3000/api/settings', { method: 'GET' });
|
|
2680
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2681
|
+
const res2 = await (mockServer as any).fetchHandler(req2);
|
|
2682
|
+
expect(res2.status).toBe(200);
|
|
2683
|
+
expect(order).toEqual(['CtrlMw', 'RouteMw', 'handler-settings']);
|
|
2684
|
+
});
|
|
2685
|
+
|
|
2686
|
+
test('should run module-level then controller-level then route-level middleware', async () => {
|
|
2687
|
+
const order: string[] = [];
|
|
2688
|
+
|
|
2689
|
+
class ModuleMw extends BaseMiddleware {
|
|
2690
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2691
|
+
order.push('ModuleMw');
|
|
2692
|
+
|
|
2693
|
+
return await next();
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
class CtrlMw extends BaseMiddleware {
|
|
2698
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2699
|
+
order.push('CtrlMw');
|
|
2700
|
+
|
|
2701
|
+
return await next();
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
class RouteMw extends BaseMiddleware {
|
|
2706
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2707
|
+
order.push('RouteMw');
|
|
2708
|
+
|
|
2709
|
+
return await next();
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
@Controller('/admin')
|
|
2714
|
+
@UseMiddleware(CtrlMw)
|
|
2715
|
+
class AdminController extends BaseController {
|
|
2716
|
+
@Get('/users')
|
|
2717
|
+
@UseMiddleware(RouteMw)
|
|
2718
|
+
getUsers() {
|
|
2719
|
+
order.push('handler');
|
|
2720
|
+
|
|
2721
|
+
return this.success({ users: [] });
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
@Module({ controllers: [AdminController] })
|
|
2726
|
+
class TestModule implements OnModuleConfigure {
|
|
2727
|
+
configureMiddleware(): MiddlewareClass[] {
|
|
2728
|
+
return [ModuleMw];
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
const app = createTestApp(TestModule);
|
|
2733
|
+
await app.start();
|
|
2734
|
+
|
|
2735
|
+
const request = new Request('http://localhost:3000/admin/users', { method: 'GET' });
|
|
2736
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2737
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
2738
|
+
const body = await response.json();
|
|
2739
|
+
|
|
2740
|
+
expect(response.status).toBe(200);
|
|
2741
|
+
expect(body.result).toEqual({ users: [] });
|
|
2742
|
+
expect(order).toEqual(['ModuleMw', 'CtrlMw', 'RouteMw', 'handler']);
|
|
2743
|
+
});
|
|
2744
|
+
|
|
2745
|
+
test('should run application-wide then module then controller then route middleware', async () => {
|
|
2746
|
+
const order: string[] = [];
|
|
2747
|
+
|
|
2748
|
+
class GlobalMw extends BaseMiddleware {
|
|
2749
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2750
|
+
order.push('GlobalMw');
|
|
2751
|
+
|
|
2752
|
+
return await next();
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
class ModuleMw extends BaseMiddleware {
|
|
2757
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2758
|
+
order.push('ModuleMw');
|
|
2759
|
+
|
|
2760
|
+
return await next();
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
class CtrlMw extends BaseMiddleware {
|
|
2765
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2766
|
+
order.push('CtrlMw');
|
|
2767
|
+
|
|
2768
|
+
return await next();
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
class RouteMw extends BaseMiddleware {
|
|
2773
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2774
|
+
order.push('RouteMw');
|
|
2775
|
+
|
|
2776
|
+
return await next();
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
@Controller('/admin')
|
|
2781
|
+
@UseMiddleware(CtrlMw)
|
|
2782
|
+
class AdminController extends BaseController {
|
|
2783
|
+
@Get('/stats')
|
|
2784
|
+
@UseMiddleware(RouteMw)
|
|
2785
|
+
getStats() {
|
|
2786
|
+
order.push('handler');
|
|
2787
|
+
|
|
2788
|
+
return this.success({ stats: {} });
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
@Module({ controllers: [AdminController] })
|
|
2793
|
+
class TestModule implements OnModuleConfigure {
|
|
2794
|
+
configureMiddleware(): MiddlewareClass[] {
|
|
2795
|
+
return [ModuleMw];
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
const app = createTestApp(TestModule, { middleware: [GlobalMw] });
|
|
2800
|
+
await app.start();
|
|
2801
|
+
|
|
2802
|
+
const request = new Request('http://localhost:3000/admin/stats', { method: 'GET' });
|
|
2803
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2804
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
2805
|
+
const body = await response.json();
|
|
2806
|
+
|
|
2807
|
+
expect(response.status).toBe(200);
|
|
2808
|
+
expect(body.result).toEqual({ stats: {} });
|
|
2809
|
+
expect(order).toEqual(['GlobalMw', 'ModuleMw', 'CtrlMw', 'RouteMw', 'handler']);
|
|
2810
|
+
});
|
|
2811
|
+
|
|
2812
|
+
test('should short-circuit when middleware returns without calling next()', async () => {
|
|
2813
|
+
const order: string[] = [];
|
|
2814
|
+
|
|
2815
|
+
class AuthMw extends BaseMiddleware {
|
|
2816
|
+
async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2817
|
+
order.push('AuthMw');
|
|
2818
|
+
const token = req.headers.get('X-Test-Token');
|
|
2819
|
+
if (token !== 'secret') {
|
|
2820
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
2821
|
+
status: 403,
|
|
2822
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
2823
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2824
|
+
});
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
return await next();
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
@Controller('/api')
|
|
2832
|
+
class ApiController extends BaseController {
|
|
2833
|
+
@Get('/protected')
|
|
2834
|
+
@UseMiddleware(AuthMw)
|
|
2835
|
+
getProtected() {
|
|
2836
|
+
order.push('handler');
|
|
2837
|
+
|
|
2838
|
+
return { data: 'secret' };
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
@Module({ controllers: [ApiController] })
|
|
2843
|
+
class TestModule {}
|
|
2844
|
+
|
|
2845
|
+
const app = createTestApp(TestModule);
|
|
2846
|
+
await app.start();
|
|
2847
|
+
|
|
2848
|
+
const request = new Request('http://localhost:3000/api/protected', {
|
|
2849
|
+
method: 'GET',
|
|
2850
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
2851
|
+
headers: { 'X-Test-Token': 'wrong' },
|
|
2852
|
+
});
|
|
2853
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2854
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
2855
|
+
const body = await response.json();
|
|
2856
|
+
|
|
2857
|
+
expect(response.status).toBe(403);
|
|
2858
|
+
expect(body.error).toBe('Unauthorized');
|
|
2859
|
+
expect(order).toEqual(['AuthMw']);
|
|
2860
|
+
});
|
|
2861
|
+
|
|
2862
|
+
test('should allow middleware to modify response after next() (onion model)', async () => {
|
|
2863
|
+
class AddHeaderAfterNextMw extends BaseMiddleware {
|
|
2864
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2865
|
+
const res = await next();
|
|
2866
|
+
res.headers.set('X-After-Next', 'true');
|
|
2867
|
+
|
|
2868
|
+
return res;
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
@Controller('/api')
|
|
2873
|
+
class ApiController extends BaseController {
|
|
2874
|
+
@Get('/onion')
|
|
2875
|
+
@UseMiddleware(AddHeaderAfterNextMw)
|
|
2876
|
+
getOnion() {
|
|
2877
|
+
return this.success({ value: 1 });
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
@Module({ controllers: [ApiController] })
|
|
2882
|
+
class TestModule {}
|
|
2883
|
+
|
|
2884
|
+
const app = createTestApp(TestModule);
|
|
2885
|
+
await app.start();
|
|
2886
|
+
|
|
2887
|
+
const request = new Request('http://localhost:3000/api/onion', { method: 'GET' });
|
|
2888
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2889
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
2890
|
+
const body = await response.json();
|
|
2891
|
+
|
|
2892
|
+
expect(response.status).toBe(200);
|
|
2893
|
+
expect(body.result).toEqual({ value: 1 });
|
|
2894
|
+
expect(response.headers.get('X-After-Next')).toBe('true');
|
|
2895
|
+
});
|
|
2896
|
+
});
|
|
2897
|
+
|
|
2898
|
+
describe('Middleware DI (service injection)', () => {
|
|
2899
|
+
test('should inject service into application-wide middleware', async () => {
|
|
2900
|
+
@Service()
|
|
2901
|
+
class RootService {
|
|
2902
|
+
getValue(): string {
|
|
2903
|
+
return 'global';
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
@Middleware()
|
|
2908
|
+
class GlobalMw extends BaseMiddleware {
|
|
2909
|
+
constructor(private readonly svc: RootService) {
|
|
2910
|
+
super();
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2914
|
+
const res = await next();
|
|
2915
|
+
res.headers.set('X-Injected-Value', this.svc.getValue());
|
|
2916
|
+
|
|
2917
|
+
return res;
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
@Controller('/api')
|
|
2922
|
+
class ApiController extends BaseController {
|
|
2923
|
+
@Get('/ping')
|
|
2924
|
+
ping() {
|
|
2925
|
+
return this.success({ pong: true });
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
@Module({ controllers: [ApiController], providers: [RootService] })
|
|
2930
|
+
class TestModule {}
|
|
2931
|
+
|
|
2932
|
+
const app = createTestApp(TestModule, { middleware: [GlobalMw] });
|
|
2933
|
+
await app.start();
|
|
2934
|
+
|
|
2935
|
+
const request = new Request('http://localhost:3000/api/ping', { method: 'GET' });
|
|
2936
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2937
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
2938
|
+
expect(response.status).toBe(200);
|
|
2939
|
+
expect(response.headers.get('X-Injected-Value')).toBe('global');
|
|
2940
|
+
});
|
|
2941
|
+
|
|
2942
|
+
test('should inject service into module-level middleware', async () => {
|
|
2943
|
+
@Service()
|
|
2944
|
+
class FeatureService {
|
|
2945
|
+
getValue(): string {
|
|
2946
|
+
return 'module';
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
@Middleware()
|
|
2951
|
+
class FeatureModuleMw extends BaseMiddleware {
|
|
2952
|
+
constructor(private readonly svc: FeatureService) {
|
|
2953
|
+
super();
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
2957
|
+
const res = await next();
|
|
2958
|
+
res.headers.set('X-Injected-Value', this.svc.getValue());
|
|
2959
|
+
|
|
2960
|
+
return res;
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
@Controller('/feature')
|
|
2965
|
+
class FeatureController extends BaseController {
|
|
2966
|
+
@Get('/data')
|
|
2967
|
+
getData() {
|
|
2968
|
+
return this.success({ data: true });
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
@Module({ controllers: [FeatureController], providers: [FeatureService] })
|
|
2973
|
+
class FeatureModule implements OnModuleConfigure {
|
|
2974
|
+
configureMiddleware(): MiddlewareClass[] {
|
|
2975
|
+
return [FeatureModuleMw];
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
@Module({ imports: [FeatureModule], controllers: [] })
|
|
2980
|
+
class RootModule {}
|
|
2981
|
+
|
|
2982
|
+
const app = createTestApp(RootModule);
|
|
2983
|
+
await app.start();
|
|
2984
|
+
|
|
2985
|
+
const request = new Request('http://localhost:3000/feature/data', { method: 'GET' });
|
|
2986
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2987
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
2988
|
+
expect(response.status).toBe(200);
|
|
2989
|
+
expect(response.headers.get('X-Injected-Value')).toBe('module');
|
|
2990
|
+
});
|
|
2991
|
+
|
|
2992
|
+
test('should inject service into controller-level middleware from owner module', async () => {
|
|
2993
|
+
@Service()
|
|
2994
|
+
class FeatureService {
|
|
2995
|
+
getValue(): string {
|
|
2996
|
+
return 'controller';
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
@Middleware()
|
|
3001
|
+
class FeatureCtrlMw extends BaseMiddleware {
|
|
3002
|
+
constructor(private readonly svc: FeatureService) {
|
|
3003
|
+
super();
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
3007
|
+
const res = await next();
|
|
3008
|
+
res.headers.set('X-Injected-Value', this.svc.getValue());
|
|
3009
|
+
|
|
3010
|
+
return res;
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
@Controller('/feature')
|
|
3015
|
+
@UseMiddleware(FeatureCtrlMw)
|
|
3016
|
+
class FeatureController extends BaseController {
|
|
3017
|
+
@Get('/ctrl')
|
|
3018
|
+
getCtrl() {
|
|
3019
|
+
return this.success({ ok: true });
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
@Module({ controllers: [FeatureController], providers: [FeatureService] })
|
|
3024
|
+
class FeatureModule {}
|
|
3025
|
+
|
|
3026
|
+
@Module({ imports: [FeatureModule], controllers: [] })
|
|
3027
|
+
class RootModule {}
|
|
3028
|
+
|
|
3029
|
+
const app = createTestApp(RootModule);
|
|
3030
|
+
await app.start();
|
|
3031
|
+
|
|
3032
|
+
const request = new Request('http://localhost:3000/feature/ctrl', { method: 'GET' });
|
|
3033
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3034
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
3035
|
+
expect(response.status).toBe(200);
|
|
3036
|
+
expect(response.headers.get('X-Injected-Value')).toBe('controller');
|
|
3037
|
+
});
|
|
3038
|
+
|
|
3039
|
+
test('should inject service into route-level middleware from owner module', async () => {
|
|
3040
|
+
@Service()
|
|
3041
|
+
class FeatureService {
|
|
3042
|
+
getValue(): string {
|
|
3043
|
+
return 'route';
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
@Middleware()
|
|
3048
|
+
class FeatureRouteMw extends BaseMiddleware {
|
|
3049
|
+
constructor(private readonly svc: FeatureService) {
|
|
3050
|
+
super();
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
3054
|
+
const res = await next();
|
|
3055
|
+
res.headers.set('X-Injected-Value', this.svc.getValue());
|
|
3056
|
+
|
|
3057
|
+
return res;
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
@Controller('/feature')
|
|
3062
|
+
class FeatureController extends BaseController {
|
|
3063
|
+
@Get('/route')
|
|
3064
|
+
@UseMiddleware(FeatureRouteMw)
|
|
3065
|
+
getRoute() {
|
|
3066
|
+
return this.success({ ok: true });
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
@Module({ controllers: [FeatureController], providers: [FeatureService] })
|
|
3071
|
+
class FeatureModule {}
|
|
3072
|
+
|
|
3073
|
+
@Module({ imports: [FeatureModule], controllers: [] })
|
|
3074
|
+
class RootModule {}
|
|
3075
|
+
|
|
3076
|
+
const app = createTestApp(RootModule);
|
|
3077
|
+
await app.start();
|
|
3078
|
+
|
|
3079
|
+
const request = new Request('http://localhost:3000/feature/route', { method: 'GET' });
|
|
3080
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3081
|
+
const response = await (mockServer as any).fetchHandler(request);
|
|
3082
|
+
expect(response.status).toBe(200);
|
|
3083
|
+
expect(response.headers.get('X-Injected-Value')).toBe('route');
|
|
3084
|
+
});
|
|
3085
|
+
});
|
|
2565
3086
|
});
|
|
2566
3087
|
|
|
2567
3088
|
describe('Tracing integration', () => {
|
|
@@ -758,10 +758,14 @@ export class OneBunApplication {
|
|
|
758
758
|
// Module-level middleware (already resolved bound functions)
|
|
759
759
|
const moduleMiddleware = this.ensureModule().getModuleMiddleware?.(controllerClass) ?? [];
|
|
760
760
|
|
|
761
|
-
//
|
|
761
|
+
// Resolve controller-level and route-level middleware with the owner module's DI
|
|
762
|
+
const ownerModule =
|
|
763
|
+
this.ensureModule().getOwnerModuleForController?.(controllerClass) ?? this.ensureModule();
|
|
764
|
+
|
|
765
|
+
// Controller-level middleware — resolve via owner module DI
|
|
762
766
|
const ctrlMiddlewareClasses = getControllerMiddleware(controllerClass);
|
|
763
767
|
const ctrlMiddleware: Function[] = ctrlMiddlewareClasses.length > 0
|
|
764
|
-
? (
|
|
768
|
+
? (ownerModule.resolveMiddleware?.(ctrlMiddlewareClasses) ?? [])
|
|
765
769
|
: [];
|
|
766
770
|
|
|
767
771
|
for (const route of controllerMetadata.routes) {
|
|
@@ -773,10 +777,10 @@ export class OneBunApplication {
|
|
|
773
777
|
controller,
|
|
774
778
|
);
|
|
775
779
|
|
|
776
|
-
// Route-level middleware — resolve
|
|
780
|
+
// Route-level middleware — resolve via owner module DI
|
|
777
781
|
const routeMiddlewareClasses = route.middleware ?? [];
|
|
778
782
|
const routeMiddleware: Function[] = routeMiddlewareClasses.length > 0
|
|
779
|
-
? (
|
|
783
|
+
? (ownerModule.resolveMiddleware?.(routeMiddlewareClasses) ?? [])
|
|
780
784
|
: [];
|
|
781
785
|
|
|
782
786
|
// Merge middleware: global → module → controller → route
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
Req,
|
|
53
53
|
Res,
|
|
54
54
|
UseMiddleware,
|
|
55
|
+
Middleware,
|
|
55
56
|
Module,
|
|
56
57
|
getModuleMetadata,
|
|
57
58
|
ApiResponse,
|
|
@@ -301,6 +302,33 @@ describe('decorators', () => {
|
|
|
301
302
|
});
|
|
302
303
|
});
|
|
303
304
|
|
|
305
|
+
describe('Middleware decorator', () => {
|
|
306
|
+
test('should emit design:paramtypes for automatic DI', () => {
|
|
307
|
+
@Service()
|
|
308
|
+
class HelperService {
|
|
309
|
+
getValue() {
|
|
310
|
+
return 'ok';
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
@Middleware()
|
|
315
|
+
class TestMiddleware extends BaseMiddleware {
|
|
316
|
+
constructor(private readonly helper: HelperService) {
|
|
317
|
+
super();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
321
|
+
return await next();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const deps = getConstructorParamTypes(TestMiddleware);
|
|
326
|
+
expect(deps).toBeDefined();
|
|
327
|
+
expect(deps).toHaveLength(1);
|
|
328
|
+
expect(deps?.[0]).toBe(HelperService);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
304
332
|
describe('HTTP method decorators', () => {
|
|
305
333
|
test('should register GET route', () => {
|
|
306
334
|
@Controller('test')
|
|
@@ -240,6 +240,32 @@ export function Inject<T>(serviceType: new (...args: any[]) => T) {
|
|
|
240
240
|
};
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Class decorator for middleware. Apply to classes that extend BaseMiddleware so that
|
|
245
|
+
* TypeScript emits design:paramtypes and constructor dependencies are resolved automatically
|
|
246
|
+
* by the framework (no need for @Inject on each parameter). You can still use @Inject when needed.
|
|
247
|
+
*
|
|
248
|
+
* @example
|
|
249
|
+
* ```ts
|
|
250
|
+
* @Middleware()
|
|
251
|
+
* class AuthMiddleware extends BaseMiddleware {
|
|
252
|
+
* constructor(private authService: AuthService) {
|
|
253
|
+
* super();
|
|
254
|
+
* }
|
|
255
|
+
* async use(req, next) { ... }
|
|
256
|
+
* }
|
|
257
|
+
* ```
|
|
258
|
+
*/
|
|
259
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
260
|
+
export function Middleware(): ClassDecorator {
|
|
261
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
262
|
+
return (target: any): any => {
|
|
263
|
+
injectable()(target);
|
|
264
|
+
|
|
265
|
+
return target;
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
243
269
|
/**
|
|
244
270
|
* Register dependencies manually (fallback method)
|
|
245
271
|
*/
|
|
@@ -24,7 +24,11 @@ import type {
|
|
|
24
24
|
} from '../types';
|
|
25
25
|
|
|
26
26
|
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
Controller as CtrlDeco,
|
|
29
|
+
Middleware,
|
|
30
|
+
Module,
|
|
31
|
+
} from '../decorators/decorators';
|
|
28
32
|
import { makeMockLoggerLayer } from '../testing/test-utils';
|
|
29
33
|
import { BaseWebSocketGateway } from '../websocket/ws-base-gateway';
|
|
30
34
|
import { WebSocketGateway } from '../websocket/ws-decorators';
|
|
@@ -685,7 +689,7 @@ describe('OneBunModule', () => {
|
|
|
685
689
|
|
|
686
690
|
describe('Controller DI with @Inject decorator', () => {
|
|
687
691
|
const {
|
|
688
|
-
Inject, getConstructorParamTypes, Controller: ControllerDecorator, clearGlobalModules,
|
|
692
|
+
Inject: InjectDecorator, getConstructorParamTypes, Controller: ControllerDecorator, clearGlobalModules,
|
|
689
693
|
} = require('../decorators/decorators');
|
|
690
694
|
const { Controller: BaseController } = require('./controller');
|
|
691
695
|
const { clearGlobalServicesRegistry: clearRegistry } = require('./module');
|
|
@@ -716,7 +720,7 @@ describe('OneBunModule', () => {
|
|
|
716
720
|
|
|
717
721
|
// Define controller with @Inject BEFORE @Controller
|
|
718
722
|
class OriginalController extends BaseController {
|
|
719
|
-
constructor(@
|
|
723
|
+
constructor(@InjectDecorator(SimpleService) private svc: SimpleService) {
|
|
720
724
|
super();
|
|
721
725
|
}
|
|
722
726
|
}
|
|
@@ -1189,6 +1193,55 @@ describe('OneBunModule', () => {
|
|
|
1189
1193
|
});
|
|
1190
1194
|
});
|
|
1191
1195
|
|
|
1196
|
+
describe('Middleware with service injection', () => {
|
|
1197
|
+
test('should resolve middleware with injected service and use it in use()', async () => {
|
|
1198
|
+
@Service()
|
|
1199
|
+
class HelperService {
|
|
1200
|
+
getValue(): string {
|
|
1201
|
+
return 'injected';
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
@Middleware()
|
|
1206
|
+
class MiddlewareWithService extends BaseMiddleware {
|
|
1207
|
+
constructor(private readonly helper: HelperService) {
|
|
1208
|
+
super();
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>): Promise<OneBunResponse> {
|
|
1212
|
+
const res = await next();
|
|
1213
|
+
res.headers.set('X-Injected-Value', this.helper.getValue());
|
|
1214
|
+
|
|
1215
|
+
return res;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
@CtrlDeco('/test')
|
|
1220
|
+
class TestCtrl extends CtrlBase {}
|
|
1221
|
+
|
|
1222
|
+
@Module({
|
|
1223
|
+
controllers: [TestCtrl],
|
|
1224
|
+
providers: [HelperService],
|
|
1225
|
+
})
|
|
1226
|
+
class TestModule {}
|
|
1227
|
+
|
|
1228
|
+
const module = new OneBunModule(TestModule, mockLoggerLayer);
|
|
1229
|
+
await Effect.runPromise(module.setup() as Effect.Effect<unknown, never, never>);
|
|
1230
|
+
|
|
1231
|
+
const resolved = module.resolveMiddleware([MiddlewareWithService]);
|
|
1232
|
+
expect(resolved).toHaveLength(1);
|
|
1233
|
+
|
|
1234
|
+
const mockReq = Object.assign(new Request('http://localhost/'), {
|
|
1235
|
+
params: {},
|
|
1236
|
+
cookies: new Map(),
|
|
1237
|
+
}) as unknown as OneBunRequest;
|
|
1238
|
+
const next = async (): Promise<OneBunResponse> => new Response('ok');
|
|
1239
|
+
const response = await resolved[0](mockReq, next);
|
|
1240
|
+
|
|
1241
|
+
expect(response.headers.get('X-Injected-Value')).toBe('injected');
|
|
1242
|
+
});
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1192
1245
|
describe('Lifecycle hooks', () => {
|
|
1193
1246
|
const { clearGlobalModules } = require('../decorators/decorators');
|
|
1194
1247
|
const { clearGlobalServicesRegistry: clearRegistry, OneBunModule: ModuleClass } = require('./module');
|
|
@@ -1375,7 +1428,7 @@ describe('OneBunModule', () => {
|
|
|
1375
1428
|
const {
|
|
1376
1429
|
Controller: ControllerDecorator,
|
|
1377
1430
|
Get,
|
|
1378
|
-
Inject,
|
|
1431
|
+
Inject: InjectDecorator,
|
|
1379
1432
|
clearGlobalModules,
|
|
1380
1433
|
} = require('../decorators/decorators');
|
|
1381
1434
|
const { Controller: BaseController } = require('./controller');
|
|
@@ -1404,7 +1457,7 @@ describe('OneBunModule', () => {
|
|
|
1404
1457
|
}
|
|
1405
1458
|
|
|
1406
1459
|
class CounterController extends BaseController {
|
|
1407
|
-
constructor(@
|
|
1460
|
+
constructor(@InjectDecorator(CounterService) private readonly counterService: CounterService) {
|
|
1408
1461
|
super();
|
|
1409
1462
|
}
|
|
1410
1463
|
getCount() {
|
|
@@ -1439,7 +1492,7 @@ describe('OneBunModule', () => {
|
|
|
1439
1492
|
}
|
|
1440
1493
|
|
|
1441
1494
|
class ChildController extends BaseController {
|
|
1442
|
-
constructor(@
|
|
1495
|
+
constructor(@InjectDecorator(ChildService) private readonly childService: ChildService) {
|
|
1443
1496
|
super();
|
|
1444
1497
|
}
|
|
1445
1498
|
getValue() {
|
|
@@ -1486,7 +1539,7 @@ describe('OneBunModule', () => {
|
|
|
1486
1539
|
class SharedModule {}
|
|
1487
1540
|
|
|
1488
1541
|
class AppController extends BaseController {
|
|
1489
|
-
constructor(@
|
|
1542
|
+
constructor(@InjectDecorator(SharedService) private readonly sharedService: SharedService) {
|
|
1490
1543
|
super();
|
|
1491
1544
|
}
|
|
1492
1545
|
getLabel() {
|
package/src/module/module.ts
CHANGED
|
@@ -974,6 +974,24 @@ export class OneBunModule implements ModuleInstance {
|
|
|
974
974
|
return [];
|
|
975
975
|
}
|
|
976
976
|
|
|
977
|
+
/**
|
|
978
|
+
* Get the module instance that owns the given controller.
|
|
979
|
+
* Used to resolve controller-level and route-level middleware with the owner module's DI.
|
|
980
|
+
*/
|
|
981
|
+
getOwnerModuleForController(controllerClass: Function): ModuleInstance | undefined {
|
|
982
|
+
if (this.controllers.includes(controllerClass)) {
|
|
983
|
+
return this;
|
|
984
|
+
}
|
|
985
|
+
for (const childModule of this.childModules) {
|
|
986
|
+
const owner = childModule.getOwnerModuleForController(controllerClass);
|
|
987
|
+
if (owner) {
|
|
988
|
+
return owner;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
return undefined;
|
|
993
|
+
}
|
|
994
|
+
|
|
977
995
|
/**
|
|
978
996
|
* Get all controller instances from this module and child modules (recursive).
|
|
979
997
|
*/
|
package/src/types.ts
CHANGED
|
@@ -163,6 +163,13 @@ export interface ModuleInstance {
|
|
|
163
163
|
*/
|
|
164
164
|
getModuleMiddleware?(controllerClass: Function): Function[];
|
|
165
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Get the module instance that owns the given controller (the module in whose
|
|
168
|
+
* `controllers` array the controller is declared). Returns this module or a
|
|
169
|
+
* child module, or undefined if the controller is not in this module tree.
|
|
170
|
+
*/
|
|
171
|
+
getOwnerModuleForController?(controllerClass: Function): ModuleInstance | undefined;
|
|
172
|
+
|
|
166
173
|
/**
|
|
167
174
|
* Resolve middleware class constructors into bound `use()` functions
|
|
168
175
|
* using this module's DI scope (services + logger + config).
|