json-rest-api 0.1.1 → 1.0.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/.claude/settings.local.json +16 -0
- package/LICENSE +674 -20
- package/README.md +728 -120
- package/RELATIONSHIPS.md +1013 -0
- package/TODO_rest-api-plugin-extras.js +142 -0
- package/connectors_ideas/express-plugin.js +120 -0
- package/connectors_ideas/fastify.js +219 -0
- package/connectors_ideas/http-plugin.js +138 -0
- package/connectors_ideas/koa-plugin.js +113 -0
- package/docs/fileUploads.md +722 -0
- package/docs/mini-guides/file-uploads-complete.md +159 -0
- package/examples/.gitkeep +0 -0
- package/examples/express-example.js +262 -0
- package/examples/express-plugin.js +366 -0
- package/examples/file-upload-example.js +205 -0
- package/examples/filename-strategies.js +140 -0
- package/examples/knex-basic.js +66 -0
- package/index.js +28 -202
- package/lib/payload-validators.js +685 -0
- package/lib/rest-api-errors.js +58 -0
- package/package.json +37 -20
- package/payloadValdators.js +686 -0
- package/plugins/core/connectors/express-plugin.js +449 -0
- package/plugins/core/connectors/http-plugin.js +450 -0
- package/plugins/core/file-handling-plugin.js +338 -0
- package/plugins/core/lib/busboy-detector.js +167 -0
- package/plugins/core/lib/formidable-detector.js +130 -0
- package/plugins/core/rest-api-knex-plugin.js +200 -0
- package/plugins/core/rest-api-plugin.js +1419 -0
- package/plugins/storage/local-storage.js +218 -0
- package/plugins/storage/s3-storage.js +125 -0
- package/test-examples/package-lock.json +906 -0
- package/test-examples/package.json +15 -0
- package/test-examples/test-basic-api.js +11 -0
- package/test-examples/test-books-resource.js +116 -0
- package/test-examples/test-complete-file-upload.js +140 -0
- package/test-examples/test-debug.js +28 -0
- package/test-examples/test-express-plugin.js +177 -0
- package/test-examples/test-express-simple.js +28 -0
- package/test-examples/test-express-with-import.js +71 -0
- package/test-examples/test-file-upload.js +132 -0
- package/test-examples/test-http-plugin.js +159 -0
- package/test-examples/test-programmatic-api.js +188 -0
- package/test-examples/test-rest-api-plugin.js +13 -0
- package/.jshintrc +0 -48
- package/.npmignore +0 -15
- package/examples/client.js +0 -18
- package/examples/client2.js +0 -27
- package/examples/server.js +0 -23
- package/test.js +0 -101
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Handling Plugin for JSON REST API
|
|
3
|
+
*
|
|
4
|
+
* This plugin provides automatic file upload handling based on schema definitions.
|
|
5
|
+
* It works with any protocol connector (HTTP, Express, WebSocket, etc.) by using
|
|
6
|
+
* a detector registry pattern.
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Schema-driven: Detects file fields from type: 'file' in schemas
|
|
10
|
+
* - Protocol-agnostic: Works with any connector that registers a detector
|
|
11
|
+
* - Storage pluggable: Different fields can use different storage backends
|
|
12
|
+
* - Zero configuration: Just define file fields in your schema
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* ```javascript
|
|
16
|
+
* // 1. Define schema with file fields
|
|
17
|
+
* const imageSchema = {
|
|
18
|
+
* title: { type: 'string' },
|
|
19
|
+
* uploadedImage: {
|
|
20
|
+
* type: 'file',
|
|
21
|
+
* storage: S3Storage,
|
|
22
|
+
* accepts: ['image/jpeg', 'image/png']
|
|
23
|
+
* }
|
|
24
|
+
* };
|
|
25
|
+
*
|
|
26
|
+
* // 2. Use plugins (order matters - file-handling depends on rest-api)
|
|
27
|
+
* api.use(RestApiPlugin);
|
|
28
|
+
* api.use(FileHandlingPlugin);
|
|
29
|
+
* api.use(ExpressPlugin); // Or any other connector
|
|
30
|
+
*
|
|
31
|
+
* // 3. Files are automatically handled!
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { RestApiValidationError } from '../../lib/rest-api-errors.js';
|
|
36
|
+
|
|
37
|
+
export const FileHandlingPlugin = {
|
|
38
|
+
name: 'file-handling',
|
|
39
|
+
dependencies: ['rest-api'],
|
|
40
|
+
|
|
41
|
+
install({ addHook, helpers, scopes, log, vars, on, api }) {
|
|
42
|
+
// Track which scopes have file fields
|
|
43
|
+
const fileScopes = new Map(); // scopeName -> fileField[]
|
|
44
|
+
|
|
45
|
+
// Registry of file detectors from various protocols
|
|
46
|
+
const detectorRegistry = [];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Register a file detector from a protocol plugin
|
|
50
|
+
*
|
|
51
|
+
* @param {Object} detector - The detector object
|
|
52
|
+
* @param {string} detector.name - Name of the detector (e.g., 'http-multipart')
|
|
53
|
+
* @param {Function} detector.detect - Function to check if this detector applies
|
|
54
|
+
* @param {Function} detector.parse - Function to parse files from the request
|
|
55
|
+
*/
|
|
56
|
+
api.rest.registerFileDetector = (detector) => {
|
|
57
|
+
if (!detector || !detector.name || !detector.detect || !detector.parse) {
|
|
58
|
+
throw new Error('File detector must have name, detect(), and parse() properties');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
detectorRegistry.push(detector);
|
|
62
|
+
log.debug(`Registered file detector: ${detector.name}`);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Store detectors array for inspection
|
|
66
|
+
api.rest.fileDetectors = detectorRegistry;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Analyze a scope's schema to find file fields
|
|
70
|
+
*/
|
|
71
|
+
const analyzeScopeSchema = (scopeName, scopeOptions) => {
|
|
72
|
+
const schema = scopeOptions?.schema;
|
|
73
|
+
if (!schema) return;
|
|
74
|
+
|
|
75
|
+
const fileFields = [];
|
|
76
|
+
|
|
77
|
+
// Look for fields with type: 'file'
|
|
78
|
+
for (const [fieldName, fieldConfig] of Object.entries(schema)) {
|
|
79
|
+
if (fieldConfig && fieldConfig.type === 'file') {
|
|
80
|
+
fileFields.push({
|
|
81
|
+
field: fieldName,
|
|
82
|
+
storage: fieldConfig.storage,
|
|
83
|
+
accepts: fieldConfig.accepts || ['*'],
|
|
84
|
+
maxSize: fieldConfig.maxSize,
|
|
85
|
+
required: fieldConfig.required || false
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (fileFields.length > 0) {
|
|
91
|
+
fileScopes.set(scopeName, fileFields);
|
|
92
|
+
log.info(`Scope '${scopeName}' has ${fileFields.length} file field(s): ${fileFields.map(f => f.field).join(', ')}`);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Analyze existing scopes
|
|
97
|
+
for (const [scopeName, scope] of Object.entries(scopes)) {
|
|
98
|
+
if (scope._scopeOptions) {
|
|
99
|
+
analyzeScopeSchema(scopeName, scope._scopeOptions);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Listen for new scopes being added
|
|
104
|
+
on('scope:added', 'analyzeFileFields', ({ eventData }) => {
|
|
105
|
+
const { scopeName, scopeOptions } = eventData;
|
|
106
|
+
analyzeScopeSchema(scopeName, scopeOptions);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Process files for a scope if it has file fields
|
|
111
|
+
*/
|
|
112
|
+
const processFiles = async (scopeName, params) => {
|
|
113
|
+
const fileFields = fileScopes.get(scopeName);
|
|
114
|
+
if (!fileFields || fileFields.length === 0) {
|
|
115
|
+
return; // This scope doesn't have file fields
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Try each detector to see if we have files
|
|
119
|
+
let parsed = null;
|
|
120
|
+
let detectorUsed = null;
|
|
121
|
+
|
|
122
|
+
for (const detector of detectorRegistry) {
|
|
123
|
+
try {
|
|
124
|
+
if (await detector.detect(params)) {
|
|
125
|
+
log.debug(`Detector '${detector.name}' matched for scope '${scopeName}'`);
|
|
126
|
+
parsed = await detector.parse(params);
|
|
127
|
+
detectorUsed = detector.name;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
log.warn(`Detector '${detector.name}' failed:`, error);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!parsed) {
|
|
136
|
+
// No files detected - check if any were required
|
|
137
|
+
for (const fieldConfig of fileFields) {
|
|
138
|
+
if (fieldConfig.required && !params.inputRecord?.data?.attributes?.[fieldConfig.field]) {
|
|
139
|
+
throw new RestApiValidationError(
|
|
140
|
+
`Required file field '${fieldConfig.field}' is missing`,
|
|
141
|
+
{
|
|
142
|
+
fields: [fieldConfig.field],
|
|
143
|
+
violations: [{
|
|
144
|
+
field: fieldConfig.field,
|
|
145
|
+
message: 'This field is required'
|
|
146
|
+
}]
|
|
147
|
+
}
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
log.debug(`Processing files with detector '${detectorUsed}'`);
|
|
155
|
+
const { fields, files } = parsed;
|
|
156
|
+
|
|
157
|
+
// Process each file field defined in schema
|
|
158
|
+
for (const fieldConfig of fileFields) {
|
|
159
|
+
const file = files[fieldConfig.field];
|
|
160
|
+
|
|
161
|
+
if (!file) {
|
|
162
|
+
if (fieldConfig.required) {
|
|
163
|
+
throw new RestApiValidationError(
|
|
164
|
+
`Required file field '${fieldConfig.field}' is missing`,
|
|
165
|
+
{
|
|
166
|
+
fields: [fieldConfig.field],
|
|
167
|
+
violations: [{
|
|
168
|
+
field: fieldConfig.field,
|
|
169
|
+
message: 'This field is required'
|
|
170
|
+
}]
|
|
171
|
+
}
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Validate mime type
|
|
178
|
+
if (fieldConfig.accepts[0] !== '*') {
|
|
179
|
+
const acceptable = fieldConfig.accepts.some(pattern => {
|
|
180
|
+
if (pattern.endsWith('/*')) {
|
|
181
|
+
// e.g., 'image/*'
|
|
182
|
+
const prefix = pattern.slice(0, -2);
|
|
183
|
+
return file.mimetype.startsWith(prefix + '/');
|
|
184
|
+
}
|
|
185
|
+
return file.mimetype === pattern;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (!acceptable) {
|
|
189
|
+
throw new RestApiValidationError(
|
|
190
|
+
`Invalid file type for field '${fieldConfig.field}'`,
|
|
191
|
+
{
|
|
192
|
+
fields: [fieldConfig.field],
|
|
193
|
+
violations: [{
|
|
194
|
+
field: fieldConfig.field,
|
|
195
|
+
message: `Expected ${fieldConfig.accepts.join(' or ')}, got ${file.mimetype}`
|
|
196
|
+
}]
|
|
197
|
+
}
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Validate file size
|
|
203
|
+
if (fieldConfig.maxSize) {
|
|
204
|
+
const maxBytes = parseSize(fieldConfig.maxSize);
|
|
205
|
+
if (file.size > maxBytes) {
|
|
206
|
+
throw new RestApiValidationError(
|
|
207
|
+
`File too large for field '${fieldConfig.field}'`,
|
|
208
|
+
{
|
|
209
|
+
fields: [fieldConfig.field],
|
|
210
|
+
violations: [{
|
|
211
|
+
field: fieldConfig.field,
|
|
212
|
+
message: `Maximum size is ${fieldConfig.maxSize}, got ${formatSize(file.size)}`
|
|
213
|
+
}]
|
|
214
|
+
}
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Upload to storage
|
|
220
|
+
if (!fieldConfig.storage) {
|
|
221
|
+
throw new Error(`No storage configured for file field '${fieldConfig.field}'`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const storedUrl = await fieldConfig.storage.upload(file);
|
|
226
|
+
fields[fieldConfig.field] = storedUrl;
|
|
227
|
+
log.debug(`Uploaded file for field '${fieldConfig.field}' to: ${storedUrl}`);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
// Cleanup if file has cleanup function
|
|
230
|
+
if (file.cleanup) {
|
|
231
|
+
try {
|
|
232
|
+
await file.cleanup();
|
|
233
|
+
} catch (cleanupError) {
|
|
234
|
+
log.warn(`Failed to cleanup file after upload error:`, cleanupError);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
throw new RestApiValidationError(
|
|
239
|
+
`Failed to upload file for field '${fieldConfig.field}': ${error.message}`,
|
|
240
|
+
{
|
|
241
|
+
fields: [fieldConfig.field],
|
|
242
|
+
violations: [{
|
|
243
|
+
field: fieldConfig.field,
|
|
244
|
+
message: error.message
|
|
245
|
+
}]
|
|
246
|
+
}
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Replace inputRecord with processed data
|
|
252
|
+
if (!params.inputRecord) {
|
|
253
|
+
params.inputRecord = { data: { attributes: {} } };
|
|
254
|
+
}
|
|
255
|
+
if (!params.inputRecord.data) {
|
|
256
|
+
params.inputRecord.data = { attributes: {} };
|
|
257
|
+
}
|
|
258
|
+
if (!params.inputRecord.data.attributes) {
|
|
259
|
+
params.inputRecord.data.attributes = {};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Merge fields into attributes
|
|
263
|
+
Object.assign(params.inputRecord.data.attributes, fields);
|
|
264
|
+
|
|
265
|
+
// Cleanup any remaining temp files
|
|
266
|
+
for (const file of Object.values(files)) {
|
|
267
|
+
if (file.cleanup) {
|
|
268
|
+
try {
|
|
269
|
+
await file.cleanup();
|
|
270
|
+
} catch (error) {
|
|
271
|
+
log.warn(`Failed to cleanup temp file:`, error);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Hook into REST API methods to process files
|
|
279
|
+
*/
|
|
280
|
+
addHook('beforeProcessing', 'processFiles', {}, async (context) => {
|
|
281
|
+
// Only process for mutation methods
|
|
282
|
+
if (!['post', 'put', 'patch'].includes(context.method)) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Process files if this scope has file fields
|
|
287
|
+
await processFiles(context.scopeName, context.params);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
log.info('File handling plugin initialized successfully');
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Parse size string to bytes
|
|
296
|
+
* @param {string} size - Size string like '10mb', '1.5GB'
|
|
297
|
+
* @returns {number} Size in bytes
|
|
298
|
+
*/
|
|
299
|
+
function parseSize(size) {
|
|
300
|
+
const units = {
|
|
301
|
+
b: 1,
|
|
302
|
+
kb: 1024,
|
|
303
|
+
mb: 1024 * 1024,
|
|
304
|
+
gb: 1024 * 1024 * 1024
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const match = size.toLowerCase().match(/^(\d+(?:\.\d+)?)\s*([a-z]+)$/);
|
|
308
|
+
if (!match) {
|
|
309
|
+
throw new Error(`Invalid size format: ${size}`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const [, num, unit] = match;
|
|
313
|
+
const multiplier = units[unit];
|
|
314
|
+
|
|
315
|
+
if (!multiplier) {
|
|
316
|
+
throw new Error(`Unknown size unit: ${unit}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return parseFloat(num) * multiplier;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Format bytes to human readable size
|
|
324
|
+
* @param {number} bytes - Size in bytes
|
|
325
|
+
* @returns {string} Human readable size
|
|
326
|
+
*/
|
|
327
|
+
function formatSize(bytes) {
|
|
328
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
329
|
+
let size = bytes;
|
|
330
|
+
let unitIndex = 0;
|
|
331
|
+
|
|
332
|
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
333
|
+
size /= 1024;
|
|
334
|
+
unitIndex++;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return `${size.toFixed(1)}${units[unitIndex]}`;
|
|
338
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Busboy File Detector for HTTP Multipart Uploads
|
|
3
|
+
*
|
|
4
|
+
* This detector handles multipart/form-data uploads using the busboy library.
|
|
5
|
+
* It's suitable for both HTTP and Express connectors.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Streaming parser (memory efficient)
|
|
9
|
+
* - Configurable file size limits
|
|
10
|
+
* - Automatic field parsing
|
|
11
|
+
* - No temporary files by default (keeps in memory)
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* ```javascript
|
|
15
|
+
* import { createBusboyDetector } from 'jsonrestapi/plugins/core/lib/busboy-detector.js';
|
|
16
|
+
*
|
|
17
|
+
* api.use(HttpPlugin, {
|
|
18
|
+
* fileParser: 'busboy',
|
|
19
|
+
* fileParserOptions: {
|
|
20
|
+
* limits: { fileSize: 10 * 1024 * 1024 } // 10MB
|
|
21
|
+
* }
|
|
22
|
+
* });
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import Busboy from 'busboy';
|
|
27
|
+
import { Readable } from 'stream';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Creates a busboy-based file detector
|
|
31
|
+
*
|
|
32
|
+
* @param {Object} options - Busboy configuration options
|
|
33
|
+
* @param {Object} options.limits - Size limits
|
|
34
|
+
* @param {number} options.limits.fileSize - Max file size in bytes
|
|
35
|
+
* @param {number} options.limits.files - Max number of files
|
|
36
|
+
* @param {number} options.limits.fields - Max number of fields
|
|
37
|
+
* @returns {Object} Detector object with detect() and parse() methods
|
|
38
|
+
*/
|
|
39
|
+
export function createBusboyDetector(options = {}) {
|
|
40
|
+
return {
|
|
41
|
+
name: 'busboy-multipart',
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if this detector can handle the request
|
|
45
|
+
* @param {Object} params - Request parameters
|
|
46
|
+
* @returns {boolean} True if this is a multipart request
|
|
47
|
+
*/
|
|
48
|
+
detect: (params) => {
|
|
49
|
+
const req = params._httpReq || params._expressReq;
|
|
50
|
+
if (!req || !req.headers) return false;
|
|
51
|
+
|
|
52
|
+
const contentType = req.headers['content-type'] || '';
|
|
53
|
+
return contentType.includes('multipart/form-data');
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse multipart data from the request
|
|
58
|
+
* @param {Object} params - Request parameters
|
|
59
|
+
* @returns {Promise<{fields: Object, files: Object}>} Parsed data
|
|
60
|
+
*/
|
|
61
|
+
parse: async (params) => {
|
|
62
|
+
const req = params._httpReq || params._expressReq;
|
|
63
|
+
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const busboy = new Busboy({
|
|
66
|
+
headers: req.headers,
|
|
67
|
+
...options
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const fields = {};
|
|
71
|
+
const files = {};
|
|
72
|
+
const filePromises = [];
|
|
73
|
+
|
|
74
|
+
// Handle fields
|
|
75
|
+
busboy.on('field', (fieldname, val) => {
|
|
76
|
+
// Handle array notation (field[] or field[0])
|
|
77
|
+
const arrayMatch = fieldname.match(/^(.+)\[\d*\]$/);
|
|
78
|
+
if (arrayMatch) {
|
|
79
|
+
const baseName = arrayMatch[1];
|
|
80
|
+
if (!fields[baseName]) {
|
|
81
|
+
fields[baseName] = [];
|
|
82
|
+
}
|
|
83
|
+
if (Array.isArray(fields[baseName])) {
|
|
84
|
+
fields[baseName].push(val);
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
fields[fieldname] = val;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Handle files
|
|
92
|
+
busboy.on('file', (fieldname, file, filename, encoding, mimetype) => {
|
|
93
|
+
const chunks = [];
|
|
94
|
+
let size = 0;
|
|
95
|
+
|
|
96
|
+
const filePromise = new Promise((fileResolve, fileReject) => {
|
|
97
|
+
file.on('data', (chunk) => {
|
|
98
|
+
chunks.push(chunk);
|
|
99
|
+
size += chunk.length;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
file.on('limit', () => {
|
|
103
|
+
fileReject(new Error(`File size limit exceeded for field '${fieldname}'`));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
file.on('end', () => {
|
|
107
|
+
files[fieldname] = {
|
|
108
|
+
filename: filename,
|
|
109
|
+
mimetype: mimetype,
|
|
110
|
+
encoding: encoding,
|
|
111
|
+
size: size,
|
|
112
|
+
data: Buffer.concat(chunks)
|
|
113
|
+
};
|
|
114
|
+
fileResolve();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
file.on('error', fileReject);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
filePromises.push(filePromise);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Handle completion
|
|
124
|
+
busboy.on('finish', async () => {
|
|
125
|
+
try {
|
|
126
|
+
// Wait for all files to be fully read
|
|
127
|
+
await Promise.all(filePromises);
|
|
128
|
+
resolve({ fields, files });
|
|
129
|
+
} catch (error) {
|
|
130
|
+
reject(error);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Handle errors
|
|
135
|
+
busboy.on('error', (error) => {
|
|
136
|
+
reject(error);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Handle limit errors
|
|
140
|
+
busboy.on('partsLimit', () => {
|
|
141
|
+
reject(new Error('Parts limit exceeded'));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
busboy.on('filesLimit', () => {
|
|
145
|
+
reject(new Error('Files limit exceeded'));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
busboy.on('fieldsLimit', () => {
|
|
149
|
+
reject(new Error('Fields limit exceeded'));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Pipe request to busboy
|
|
153
|
+
if (req.pipe) {
|
|
154
|
+
req.pipe(busboy);
|
|
155
|
+
} else if (req.on) {
|
|
156
|
+
// For already buffered requests
|
|
157
|
+
const stream = new Readable();
|
|
158
|
+
stream.push(req.body || req);
|
|
159
|
+
stream.push(null);
|
|
160
|
+
stream.pipe(busboy);
|
|
161
|
+
} else {
|
|
162
|
+
reject(new Error('Request object is not a stream'));
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formidable File Detector for HTTP Multipart Uploads
|
|
3
|
+
*
|
|
4
|
+
* This detector handles multipart/form-data uploads using the formidable library.
|
|
5
|
+
* Unlike busboy, formidable saves files to disk temporarily, which is better
|
|
6
|
+
* for large files.
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Disk-based storage (better for large files)
|
|
10
|
+
* - Automatic temp file cleanup
|
|
11
|
+
* - Progress tracking support
|
|
12
|
+
* - Built-in file type detection
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* ```javascript
|
|
16
|
+
* import { createFormidableDetector } from 'jsonrestapi/plugins/core/lib/formidable-detector.js';
|
|
17
|
+
*
|
|
18
|
+
* api.use(ExpressPlugin, {
|
|
19
|
+
* fileParser: 'formidable',
|
|
20
|
+
* fileParserOptions: {
|
|
21
|
+
* uploadDir: './uploads/temp',
|
|
22
|
+
* keepExtensions: true,
|
|
23
|
+
* maxFileSize: 200 * 1024 * 1024 // 200MB
|
|
24
|
+
* }
|
|
25
|
+
* });
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import formidable from 'formidable';
|
|
30
|
+
import { promises as fs } from 'fs';
|
|
31
|
+
import path from 'path';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Creates a formidable-based file detector
|
|
35
|
+
*
|
|
36
|
+
* @param {Object} options - Formidable configuration options
|
|
37
|
+
* @param {string} options.uploadDir - Directory for temporary files
|
|
38
|
+
* @param {boolean} options.keepExtensions - Keep file extensions
|
|
39
|
+
* @param {number} options.maxFileSize - Max file size in bytes
|
|
40
|
+
* @param {boolean} options.multiples - Allow multiple files per field
|
|
41
|
+
* @returns {Object} Detector object with detect() and parse() methods
|
|
42
|
+
*/
|
|
43
|
+
export function createFormidableDetector(options = {}) {
|
|
44
|
+
// Set default upload directory
|
|
45
|
+
const uploadDir = options.uploadDir || path.join(process.cwd(), 'uploads', 'temp');
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
name: 'formidable-multipart',
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check if this detector can handle the request
|
|
52
|
+
* @param {Object} params - Request parameters
|
|
53
|
+
* @returns {boolean} True if this is a multipart request
|
|
54
|
+
*/
|
|
55
|
+
detect: (params) => {
|
|
56
|
+
const req = params._httpReq || params._expressReq;
|
|
57
|
+
if (!req || !req.headers) return false;
|
|
58
|
+
|
|
59
|
+
const contentType = req.headers['content-type'] || '';
|
|
60
|
+
return contentType.includes('multipart/form-data');
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse multipart data from the request
|
|
65
|
+
* @param {Object} params - Request parameters
|
|
66
|
+
* @returns {Promise<{fields: Object, files: Object}>} Parsed data
|
|
67
|
+
*/
|
|
68
|
+
parse: async (params) => {
|
|
69
|
+
const req = params._httpReq || params._expressReq;
|
|
70
|
+
|
|
71
|
+
// Ensure upload directory exists
|
|
72
|
+
await fs.mkdir(uploadDir, { recursive: true });
|
|
73
|
+
|
|
74
|
+
// Create form parser
|
|
75
|
+
const form = formidable({
|
|
76
|
+
uploadDir,
|
|
77
|
+
keepExtensions: true,
|
|
78
|
+
...options
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Parse the request
|
|
82
|
+
const [fields, fileUploads] = await form.parse(req);
|
|
83
|
+
|
|
84
|
+
// Normalize formidable's file format to our standard format
|
|
85
|
+
const files = {};
|
|
86
|
+
|
|
87
|
+
for (const [fieldname, uploadedFiles] of Object.entries(fileUploads)) {
|
|
88
|
+
// Formidable returns arrays for multiple files
|
|
89
|
+
const fileArray = Array.isArray(uploadedFiles) ? uploadedFiles : [uploadedFiles];
|
|
90
|
+
|
|
91
|
+
// For now, just take the first file (TODO: handle multiple files per field)
|
|
92
|
+
const file = fileArray[0];
|
|
93
|
+
|
|
94
|
+
if (file) {
|
|
95
|
+
// Read file data into memory (for small files)
|
|
96
|
+
// For large files, you might want to keep them on disk
|
|
97
|
+
const data = await fs.readFile(file.filepath);
|
|
98
|
+
|
|
99
|
+
files[fieldname] = {
|
|
100
|
+
filename: file.originalFilename || 'unknown',
|
|
101
|
+
mimetype: file.mimetype || 'application/octet-stream',
|
|
102
|
+
size: file.size,
|
|
103
|
+
data: data,
|
|
104
|
+
filepath: file.filepath, // Keep for reference
|
|
105
|
+
|
|
106
|
+
// Cleanup function to remove temp file
|
|
107
|
+
cleanup: async () => {
|
|
108
|
+
try {
|
|
109
|
+
await fs.unlink(file.filepath);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
// File might already be deleted, ignore
|
|
112
|
+
if (error.code !== 'ENOENT') {
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Normalize fields (formidable returns arrays for repeated fields)
|
|
122
|
+
const normalizedFields = {};
|
|
123
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
124
|
+
normalizedFields[key] = Array.isArray(value) && value.length === 1 ? value[0] : value;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { fields: normalizedFields, files };
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|