@onebun/core 0.2.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onebun/core",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Core package for OneBun framework - decorators, DI, modules, controllers",
5
5
  "license": "LGPL-3.0",
6
6
  "author": "RemRyahirev",
@@ -30,6 +30,7 @@ import {
30
30
  getSseMetadata,
31
31
  type SseDecoratorOptions,
32
32
  } from '../decorators/decorators';
33
+ import { OneBunFile, validateFile } from '../file/onebun-file';
33
34
  import {
34
35
  NotInitializedConfig,
35
36
  type IConfig,
@@ -911,6 +912,43 @@ export class OneBunApplication {
911
912
  throw error;
912
913
  }
913
914
 
915
+ /**
916
+ * Extract an OneBunFile from a JSON value.
917
+ * Supports two formats:
918
+ * - String: raw base64 data
919
+ * - Object: { data: string, filename?: string, mimeType?: string }
920
+ */
921
+ function extractFileFromJsonValue(value: unknown): OneBunFile | undefined {
922
+ if (typeof value === 'string' && value.length > 0) {
923
+ return OneBunFile.fromBase64(value);
924
+ }
925
+
926
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
927
+ const obj = value as Record<string, unknown>;
928
+ if (typeof obj.data === 'string' && obj.data.length > 0) {
929
+ return OneBunFile.fromBase64(
930
+ obj.data,
931
+ typeof obj.filename === 'string' ? obj.filename : undefined,
932
+ typeof obj.mimeType === 'string' ? obj.mimeType : undefined,
933
+ );
934
+ }
935
+ }
936
+
937
+ return undefined;
938
+ }
939
+
940
+ /**
941
+ * Extract a file from a JSON body by field name
942
+ */
943
+ function extractFileFromJson(
944
+ jsonBody: Record<string, unknown>,
945
+ fieldName: string,
946
+ ): OneBunFile | undefined {
947
+ const fieldValue = jsonBody[fieldName];
948
+
949
+ return extractFileFromJsonValue(fieldValue);
950
+ }
951
+
914
952
  /**
915
953
  * Execute route handler with parameter injection and validation.
916
954
  * Path parameters come from BunRequest.params (populated by Bun routes API).
@@ -951,6 +989,52 @@ export class OneBunApplication {
951
989
  // Sort params by index to ensure correct order
952
990
  const sortedParams = [...(route.params || [])].sort((a, b) => a.index - b.index);
953
991
 
992
+ // Pre-parse body for file upload params (FormData or JSON, cached for all params)
993
+ const needsFileData = sortedParams.some(
994
+ (p) =>
995
+ p.type === ParamType.FILE ||
996
+ p.type === ParamType.FILES ||
997
+ p.type === ParamType.FORM_FIELD,
998
+ );
999
+
1000
+ // Validate that @Body and file decorators are not used on the same method
1001
+ if (needsFileData) {
1002
+ const hasBody = sortedParams.some((p) => p.type === ParamType.BODY);
1003
+ if (hasBody) {
1004
+ throw new Error(
1005
+ 'Cannot use @Body() together with @UploadedFile/@UploadedFiles/@FormField on the same method. ' +
1006
+ 'Both consume the request body. Use file decorators for multipart/base64 uploads.',
1007
+ );
1008
+ }
1009
+ }
1010
+
1011
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1012
+ let formData: any = null;
1013
+ let jsonBody: Record<string, unknown> | null = null;
1014
+ let isMultipart = false;
1015
+
1016
+ if (needsFileData) {
1017
+ const contentType = req.headers.get('content-type') || '';
1018
+
1019
+ if (contentType.includes('multipart/form-data')) {
1020
+ isMultipart = true;
1021
+ try {
1022
+ formData = await req.formData();
1023
+ } catch {
1024
+ formData = null;
1025
+ }
1026
+ } else if (contentType.includes('application/json')) {
1027
+ try {
1028
+ const parsed = await req.json();
1029
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1030
+ jsonBody = parsed as Record<string, unknown>;
1031
+ }
1032
+ } catch {
1033
+ jsonBody = null;
1034
+ }
1035
+ }
1036
+ }
1037
+
954
1038
  for (const param of sortedParams) {
955
1039
  switch (param.type) {
956
1040
  case ParamType.PATH:
@@ -989,6 +1073,100 @@ export class OneBunApplication {
989
1073
  args[param.index] = undefined;
990
1074
  break;
991
1075
 
1076
+ case ParamType.FILE: {
1077
+ let file: OneBunFile | undefined;
1078
+
1079
+ if (isMultipart && formData && param.name) {
1080
+ const entry = formData.get(param.name);
1081
+ if (entry instanceof File) {
1082
+ file = new OneBunFile(entry);
1083
+ }
1084
+ } else if (jsonBody && param.name) {
1085
+ file = extractFileFromJson(jsonBody, param.name);
1086
+ }
1087
+
1088
+ if (file && param.fileOptions) {
1089
+ validateFile(file, param.fileOptions, param.name);
1090
+ }
1091
+
1092
+ args[param.index] = file;
1093
+ break;
1094
+ }
1095
+
1096
+ case ParamType.FILES: {
1097
+ let files: OneBunFile[] = [];
1098
+
1099
+ if (isMultipart && formData) {
1100
+ if (param.name) {
1101
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1102
+ const entries: any[] = formData.getAll(param.name);
1103
+ files = entries
1104
+ .filter((entry: unknown): entry is File => entry instanceof File)
1105
+ .map((f: File) => new OneBunFile(f));
1106
+ } else {
1107
+ // Get all files from all fields
1108
+ for (const [, value] of formData.entries()) {
1109
+ if (value instanceof File) {
1110
+ files.push(new OneBunFile(value));
1111
+ }
1112
+ }
1113
+ }
1114
+ } else if (jsonBody) {
1115
+ if (param.name) {
1116
+ const fieldValue = jsonBody[param.name];
1117
+ if (Array.isArray(fieldValue)) {
1118
+ files = fieldValue
1119
+ .map((item) => extractFileFromJsonValue(item))
1120
+ .filter((f): f is OneBunFile => f !== undefined);
1121
+ }
1122
+ } else {
1123
+ // Extract all file-like values from JSON
1124
+ for (const [, value] of Object.entries(jsonBody)) {
1125
+ const file = extractFileFromJsonValue(value);
1126
+ if (file) {
1127
+ files.push(file);
1128
+ }
1129
+ }
1130
+ }
1131
+ }
1132
+
1133
+ // Validate maxCount
1134
+ if (param.fileOptions?.maxCount !== undefined && files.length > param.fileOptions.maxCount) {
1135
+ throw new Error(
1136
+ `Too many files for "${param.name || 'upload'}". Got ${files.length}, max is ${param.fileOptions.maxCount}`,
1137
+ );
1138
+ }
1139
+
1140
+ // Validate each file
1141
+ if (param.fileOptions) {
1142
+ for (const file of files) {
1143
+ validateFile(file, param.fileOptions, param.name);
1144
+ }
1145
+ }
1146
+
1147
+ args[param.index] = files;
1148
+ break;
1149
+ }
1150
+
1151
+ case ParamType.FORM_FIELD: {
1152
+ let value: string | undefined;
1153
+
1154
+ if (isMultipart && formData && param.name) {
1155
+ const entry = formData.get(param.name);
1156
+ if (typeof entry === 'string') {
1157
+ value = entry;
1158
+ }
1159
+ } else if (jsonBody && param.name) {
1160
+ const jsonValue = jsonBody[param.name];
1161
+ if (jsonValue !== undefined && jsonValue !== null) {
1162
+ value = String(jsonValue);
1163
+ }
1164
+ }
1165
+
1166
+ args[param.index] = value;
1167
+ break;
1168
+ }
1169
+
992
1170
  default:
993
1171
  args[param.index] = undefined;
994
1172
  }
@@ -998,6 +1176,16 @@ export class OneBunApplication {
998
1176
  throw new Error(`Required parameter ${param.name || param.index} is missing`);
999
1177
  }
1000
1178
 
1179
+ // For FILES type, also check for empty array when required
1180
+ if (
1181
+ param.isRequired &&
1182
+ param.type === ParamType.FILES &&
1183
+ Array.isArray(args[param.index]) &&
1184
+ (args[param.index] as unknown[]).length === 0
1185
+ ) {
1186
+ throw new Error(`Required parameter ${param.name || param.index} is missing`);
1187
+ }
1188
+
1001
1189
  // Apply arktype schema validation if provided
1002
1190
  if (param.schema && args[param.index] !== undefined) {
1003
1191
  try {
@@ -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,
@@ -519,6 +521,119 @@ export const Req = createParamDecorator(ParamType.REQUEST);
519
521
  // eslint-disable-next-line @typescript-eslint/naming-convention
520
522
  export const Res = createParamDecorator(ParamType.RESPONSE);
521
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
+
522
637
  /**
523
638
  * Middleware decorator
524
639
  * @example \@UseMiddleware(authMiddleware)