@onebun/core 0.1.24 → 0.2.1

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.
@@ -49,6 +49,9 @@ import {
49
49
  Module,
50
50
  getModuleMetadata,
51
51
  ApiResponse,
52
+ UploadedFile,
53
+ UploadedFiles,
54
+ FormField,
52
55
  } from './decorators';
53
56
 
54
57
  describe('decorators', () => {
@@ -1077,4 +1080,140 @@ describe('decorators', () => {
1077
1080
  expect(isGlobalModule(NonGlobalModuleC)).toBe(false);
1078
1081
  });
1079
1082
  });
1083
+
1084
+ // ============================================================================
1085
+ // File Upload Decorators
1086
+ // ============================================================================
1087
+
1088
+ describe('File Upload Decorators', () => {
1089
+ test('@UploadedFile should store FILE param metadata', () => {
1090
+ @Controller('/test')
1091
+ class TestController extends BaseController {
1092
+ @Post('/upload')
1093
+ upload(@UploadedFile('avatar') _file: unknown) {}
1094
+ }
1095
+
1096
+ const metadata = getControllerMetadata(TestController);
1097
+ expect(metadata).toBeDefined();
1098
+ const route = metadata!.routes[0];
1099
+ expect(route.params).toBeDefined();
1100
+ expect(route.params!.length).toBe(1);
1101
+ expect(route.params![0].type).toBe(ParamType.FILE);
1102
+ expect(route.params![0].name).toBe('avatar');
1103
+ expect(route.params![0].index).toBe(0);
1104
+ expect(route.params![0].isRequired).toBe(true);
1105
+ });
1106
+
1107
+ test('@UploadedFile should store file options', () => {
1108
+ @Controller('/test')
1109
+ class TestController extends BaseController {
1110
+ @Post('/upload')
1111
+ upload(
1112
+ @UploadedFile('avatar', { maxSize: 1024, mimeTypes: ['image/*'], required: false }) _file: unknown,
1113
+ ) {}
1114
+ }
1115
+
1116
+ const metadata = getControllerMetadata(TestController);
1117
+ const param = metadata!.routes[0].params![0];
1118
+ expect(param.isRequired).toBe(false);
1119
+ expect(param.fileOptions).toBeDefined();
1120
+ expect(param.fileOptions!.maxSize).toBe(1024);
1121
+ expect(param.fileOptions!.mimeTypes).toEqual(['image/*']);
1122
+ });
1123
+
1124
+ test('@UploadedFile without options should be required by default', () => {
1125
+ @Controller('/test')
1126
+ class TestController extends BaseController {
1127
+ @Post('/upload')
1128
+ upload(@UploadedFile('file') _file: unknown) {}
1129
+ }
1130
+
1131
+ const metadata = getControllerMetadata(TestController);
1132
+ const param = metadata!.routes[0].params![0];
1133
+ expect(param.isRequired).toBe(true);
1134
+ expect(param.fileOptions).toBeUndefined();
1135
+ });
1136
+
1137
+ test('@UploadedFiles should store FILES param metadata', () => {
1138
+ @Controller('/test')
1139
+ class TestController extends BaseController {
1140
+ @Post('/upload')
1141
+ upload(@UploadedFiles('docs', { maxCount: 5, maxSize: 2048 }) _files: unknown) {}
1142
+ }
1143
+
1144
+ const metadata = getControllerMetadata(TestController);
1145
+ const param = metadata!.routes[0].params![0];
1146
+ expect(param.type).toBe(ParamType.FILES);
1147
+ expect(param.name).toBe('docs');
1148
+ expect(param.isRequired).toBe(true);
1149
+ expect(param.fileOptions!.maxCount).toBe(5);
1150
+ expect(param.fileOptions!.maxSize).toBe(2048);
1151
+ });
1152
+
1153
+ test('@UploadedFiles without field name should have empty name', () => {
1154
+ @Controller('/test')
1155
+ class TestController extends BaseController {
1156
+ @Post('/upload')
1157
+ upload(@UploadedFiles() _files: unknown) {}
1158
+ }
1159
+
1160
+ const metadata = getControllerMetadata(TestController);
1161
+ const param = metadata!.routes[0].params![0];
1162
+ expect(param.type).toBe(ParamType.FILES);
1163
+ expect(param.name).toBe('');
1164
+ });
1165
+
1166
+ test('@FormField should store FORM_FIELD param metadata', () => {
1167
+ @Controller('/test')
1168
+ class TestController extends BaseController {
1169
+ @Post('/upload')
1170
+ upload(@FormField('name') _name: unknown) {}
1171
+ }
1172
+
1173
+ const metadata = getControllerMetadata(TestController);
1174
+ const param = metadata!.routes[0].params![0];
1175
+ expect(param.type).toBe(ParamType.FORM_FIELD);
1176
+ expect(param.name).toBe('name');
1177
+ expect(param.isRequired).toBe(false);
1178
+ });
1179
+
1180
+ test('@FormField with required option', () => {
1181
+ @Controller('/test')
1182
+ class TestController extends BaseController {
1183
+ @Post('/upload')
1184
+ upload(@FormField('name', { required: true }) _name: unknown) {}
1185
+ }
1186
+
1187
+ const metadata = getControllerMetadata(TestController);
1188
+ const param = metadata!.routes[0].params![0];
1189
+ expect(param.isRequired).toBe(true);
1190
+ });
1191
+
1192
+ test('should support mixing file and form field decorators', () => {
1193
+ @Controller('/test')
1194
+ class TestController extends BaseController {
1195
+ @Post('/profile')
1196
+ createProfile(
1197
+ @UploadedFile('avatar') _avatar: unknown,
1198
+ @FormField('name', { required: true }) _name: unknown,
1199
+ @FormField('email') _email: unknown,
1200
+ ) {}
1201
+ }
1202
+
1203
+ const metadata = getControllerMetadata(TestController);
1204
+ const params = metadata!.routes[0].params!;
1205
+ expect(params.length).toBe(3);
1206
+
1207
+ const fileParam = params.find((p) => p.type === ParamType.FILE);
1208
+ const nameParam = params.find((p) => p.name === 'name');
1209
+ const emailParam = params.find((p) => p.name === 'email');
1210
+
1211
+ expect(fileParam).toBeDefined();
1212
+ expect(nameParam).toBeDefined();
1213
+ expect(nameParam!.type).toBe(ParamType.FORM_FIELD);
1214
+ expect(nameParam!.isRequired).toBe(true);
1215
+ expect(emailParam).toBeDefined();
1216
+ expect(emailParam!.isRequired).toBe(false);
1217
+ });
1218
+ });
1080
1219
  });
@@ -3,6 +3,8 @@ 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,
@@ -493,6 +495,18 @@ export const Body = createParamDecorator(ParamType.BODY);
493
495
  // eslint-disable-next-line @typescript-eslint/naming-convention
494
496
  export const Header = createParamDecorator(ParamType.HEADER);
495
497
 
498
+ /**
499
+ * Cookie parameter decorator.
500
+ * Extracts a cookie value by name using BunRequest.cookies (CookieMap).
501
+ * Optional by default, use { required: true } for required cookies.
502
+ * @example \@Cookie('session_id') - optional
503
+ * @example \@Cookie('session_id', { required: true }) - required
504
+ * @example \@Cookie('session_id', schema) - optional with validation
505
+ * @example \@Cookie('session_id', schema, { required: true }) - required with validation
506
+ */
507
+ // eslint-disable-next-line @typescript-eslint/naming-convention
508
+ export const Cookie = createParamDecorator(ParamType.COOKIE);
509
+
496
510
  /**
497
511
  * Request object decorator
498
512
  * @example \@Req()
@@ -507,6 +521,119 @@ export const Req = createParamDecorator(ParamType.REQUEST);
507
521
  // eslint-disable-next-line @typescript-eslint/naming-convention
508
522
  export const Res = createParamDecorator(ParamType.RESPONSE);
509
523
 
524
+ // =============================================================================
525
+ // File Upload Decorators
526
+ // =============================================================================
527
+
528
+ /**
529
+ * Single file upload decorator.
530
+ * Extracts a single file from the request (multipart/form-data or JSON base64).
531
+ * Required by default.
532
+ *
533
+ * @param fieldName - Form field name to extract file from
534
+ * @param options - File upload options (maxSize, mimeTypes, required)
535
+ *
536
+ * @example \@UploadedFile('avatar')
537
+ * @example \@UploadedFile('avatar', { maxSize: 5 * 1024 * 1024 })
538
+ * @example \@UploadedFile('avatar', { mimeTypes: [MimeType.PNG, MimeType.JPEG] })
539
+ * @example \@UploadedFile('avatar', { maxSize: 5 * 1024 * 1024, mimeTypes: [MimeType.ANY_IMAGE] })
540
+ */
541
+ // eslint-disable-next-line @typescript-eslint/naming-convention
542
+ export function UploadedFile(fieldName?: string, options?: FileUploadOptions): ParameterDecorator {
543
+ return (target: object, propertyKey: string | symbol | undefined, parameterIndex: number) => {
544
+ const params: ParamMetadata[] =
545
+ Reflect.getMetadata(PARAMS_METADATA, target, propertyKey) || [];
546
+
547
+ const isRequired = options?.required ?? true;
548
+
549
+ const metadata: ParamMetadata = {
550
+ type: ParamType.FILE,
551
+ name: fieldName || '',
552
+ index: parameterIndex,
553
+ isRequired,
554
+ fileOptions: options ? {
555
+ maxSize: options.maxSize,
556
+ mimeTypes: options.mimeTypes,
557
+ required: options.required,
558
+ } : undefined,
559
+ };
560
+
561
+ params.push(metadata);
562
+ Reflect.defineMetadata(PARAMS_METADATA, params, target, propertyKey as string);
563
+ };
564
+ }
565
+
566
+ /**
567
+ * Multiple file upload decorator.
568
+ * Extracts multiple files from the request (multipart/form-data or JSON base64).
569
+ * Required by default (at least one file expected).
570
+ *
571
+ * @param fieldName - Form field name to extract files from. If omitted, extracts all files.
572
+ * @param options - File upload options (maxSize, mimeTypes, maxCount, required)
573
+ *
574
+ * @example \@UploadedFiles('documents')
575
+ * @example \@UploadedFiles('documents', { maxCount: 5 })
576
+ * @example \@UploadedFiles(undefined, { maxCount: 10 }) - all files
577
+ * @example \@UploadedFiles('images', { mimeTypes: [MimeType.ANY_IMAGE], maxSize: 10 * 1024 * 1024 })
578
+ */
579
+ // eslint-disable-next-line @typescript-eslint/naming-convention
580
+ export function UploadedFiles(fieldName?: string, options?: FilesUploadOptions): ParameterDecorator {
581
+ return (target: object, propertyKey: string | symbol | undefined, parameterIndex: number) => {
582
+ const params: ParamMetadata[] =
583
+ Reflect.getMetadata(PARAMS_METADATA, target, propertyKey) || [];
584
+
585
+ const isRequired = options?.required ?? true;
586
+
587
+ const metadata: ParamMetadata = {
588
+ type: ParamType.FILES,
589
+ name: fieldName || '',
590
+ index: parameterIndex,
591
+ isRequired,
592
+ fileOptions: options ? {
593
+ maxSize: options.maxSize,
594
+ mimeTypes: options.mimeTypes,
595
+ required: options.required,
596
+ maxCount: options.maxCount,
597
+ } : undefined,
598
+ };
599
+
600
+ params.push(metadata);
601
+ Reflect.defineMetadata(PARAMS_METADATA, params, target, propertyKey as string);
602
+ };
603
+ }
604
+
605
+ /**
606
+ * Form field decorator.
607
+ * Extracts a non-file field from the request (multipart/form-data or JSON body).
608
+ * Optional by default.
609
+ *
610
+ * @param fieldName - Form field name to extract
611
+ * @param options - Options (required)
612
+ *
613
+ * @example \@FormField('name')
614
+ * @example \@FormField('name', { required: true })
615
+ * @example \@FormField('email')
616
+ */
617
+ // eslint-disable-next-line @typescript-eslint/naming-convention
618
+ export function FormField(fieldName: string, options?: ParamDecoratorOptions): ParameterDecorator {
619
+ return (target: object, propertyKey: string | symbol | undefined, parameterIndex: number) => {
620
+ const params: ParamMetadata[] =
621
+ Reflect.getMetadata(PARAMS_METADATA, target, propertyKey) || [];
622
+
623
+ const isRequired = options?.required ?? false;
624
+
625
+ const metadata: ParamMetadata = {
626
+ type: ParamType.FORM_FIELD,
627
+ name: fieldName,
628
+ index: parameterIndex,
629
+ isRequired,
630
+ };
631
+
632
+ params.push(metadata);
633
+ Reflect.defineMetadata(PARAMS_METADATA, params, target, propertyKey as string);
634
+ };
635
+ }
636
+
510
637
  /**
511
638
  * Middleware decorator
512
639
  * @example \@UseMiddleware(authMiddleware)