@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.
- package/package.json +1 -1
- package/src/application/application.test.ts +36 -0
- package/src/application/application.ts +258 -6
- package/src/application/multi-service-application.ts +2 -0
- package/src/application/multi-service.types.ts +1 -1
- package/src/decorators/decorators.test.ts +202 -12
- package/src/decorators/decorators.ts +228 -7
- package/src/docs-examples.test.ts +1339 -254
- package/src/file/index.ts +8 -0
- package/src/file/onebun-file.test.ts +315 -0
- package/src/file/onebun-file.ts +304 -0
- package/src/index.ts +20 -0
- package/src/module/controller.ts +96 -10
- package/src/module/index.ts +2 -1
- package/src/module/lifecycle.ts +13 -0
- package/src/module/middleware.ts +76 -0
- package/src/module/module.test.ts +138 -1
- package/src/module/module.ts +127 -2
- package/src/types.ts +169 -0
|
@@ -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 {
|
|
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
|
-
|
|
679
|
-
|
|
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(
|
|
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(
|
|
710
|
+
expect(route?.middleware).toContain(Middleware1);
|
|
692
711
|
});
|
|
693
712
|
|
|
694
|
-
test('should register multiple middleware
|
|
713
|
+
test('should register multiple middleware classes', () => {
|
|
695
714
|
@Controller()
|
|
696
715
|
class TestController {
|
|
697
716
|
@Get()
|
|
698
|
-
@UseMiddleware(
|
|
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(
|
|
705
|
-
expect(route?.middleware).toContain(
|
|
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(
|
|
713
|
-
@UseMiddleware(
|
|
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
|
-
*
|
|
524
|
-
*
|
|
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
|
-
|
|
528
|
-
|
|
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
|
-
|
|
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
|
/**
|