millas 0.2.15 → 0.2.17

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.
@@ -0,0 +1,341 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * UploadMiddleware
5
+ *
6
+ * Built-in multipart/form-data handler for Millas.
7
+ * Zero config required — just declare file() fields in your shape or
8
+ * pass options to the constructor and the framework takes care of the rest.
9
+ *
10
+ * Powered by multer under the hood (lazy-required, so it only needs to be
11
+ * installed if you actually handle uploads — multer is listed as an optional
12
+ * peer dependency).
13
+ *
14
+ * ── Usage patterns ───────────────────────────────────────────────────────────
15
+ *
16
+ * 1. No shape, no config — just add 'upload' to middleware:
17
+ *
18
+ * Route.post('/media/upload', ['auth', 'upload'], MediaController, 'upload');
19
+ *
20
+ * async upload({ file, files, user }) {
21
+ * // file — first uploaded file (any field name), UploadedFile instance
22
+ * // files — all files keyed by field name
23
+ * const path = await file.store('media');
24
+ * }
25
+ *
26
+ * 2. Parameterized alias — configure field/count inline:
27
+ *
28
+ * 'upload' → any field, 50 MB limit (default)
29
+ * 'upload:avatar' → single field named 'avatar'
30
+ * 'upload:photos,5' → field 'photos', up to 5 files
31
+ *
32
+ * Route.post('/avatar', ['auth', 'upload:avatar'], UserController, 'updateAvatar');
33
+ *
34
+ * 3. With a shape — Router auto-injects UploadMiddleware, zero manual config:
35
+ *
36
+ * async upload({ body, file, user }) {
37
+ * // file is a multer file object: { buffer, mimetype, size, originalname }
38
+ * // body has the rest of the form fields
39
+ * }
40
+ *
41
+ * ── Manual usage ──────────────────────────────────────────────────────────────
42
+ *
43
+ * const { UploadMiddleware } = require('millas/core/http');
44
+ *
45
+ * // Single named field
46
+ * Route.post('/avatar', ['auth', new UploadMiddleware({ field: 'avatar' })], UserController, 'updateAvatar');
47
+ *
48
+ * // Multiple named fields
49
+ * Route.post('/listing', ['auth', new UploadMiddleware({ fields: [{ name: 'photo', maxCount: 5 }] })], ListingController, 'store');
50
+ *
51
+ * // Any field name (multer .any())
52
+ * Route.post('/upload', [new UploadMiddleware({ any: true })], UploadController, 'store');
53
+ *
54
+ * ── Options ───────────────────────────────────────────────────────────────────
55
+ *
56
+ * {
57
+ * field: string // single field name (default: 'file')
58
+ * fields: [{name, maxCount}] // multiple named fields
59
+ * any: boolean // accept any field name
60
+ * maxSize: number|string // max file size bytes or '10mb' (default: 50mb)
61
+ * storage: 'memory'|'disk' // multer storage (default: 'memory')
62
+ * dest: string // disk storage destination (only when storage: 'disk')
63
+ * filter: Function // custom multer fileFilter(req, file, cb)
64
+ * }
65
+ *
66
+ * ── Handler context ───────────────────────────────────────────────────────────
67
+ *
68
+ * After this middleware runs, RequestContext exposes:
69
+ *
70
+ * ctx.file — single uploaded file (multer file object)
71
+ * ctx.files — multiple uploaded files (object or array)
72
+ * ctx.body — non-file form fields (strings)
73
+ *
74
+ * Multer file object shape:
75
+ * {
76
+ * fieldname: 'file',
77
+ * originalname: 'photo.jpg',
78
+ * mimetype: 'image/jpeg',
79
+ * size: 204800,
80
+ * buffer: <Buffer ...> // memory storage only
81
+ * path: '/tmp/...' // disk storage only
82
+ * }
83
+ */
84
+ class UploadMiddleware {
85
+ /**
86
+ * @param {object} options
87
+ * @param {string} [options.field='file'] single field name
88
+ * @param {Array<{name,maxCount}>} [options.fields] multiple named fields
89
+ * @param {boolean} [options.any=false] accept any field
90
+ * @param {number|string} [options.maxSize] max file size
91
+ * @param {'memory'|'disk'} [options.storage='memory']
92
+ * @param {string} [options.dest] disk dest (storage:'disk')
93
+ * @param {Function} [options.filter] multer fileFilter
94
+ */
95
+ constructor(options = {}) {
96
+ this._options = {
97
+ field: options.field || 'file',
98
+ _explicitField: !!options.field, // true only when caller set field explicitly
99
+ fields: options.fields || null,
100
+ any: options.any || false,
101
+ maxSize: options.maxSize !== undefined ? _parseSize(options.maxSize) : 50 * 1024 * 1024,
102
+ storage: options.storage || 'memory',
103
+ dest: options.dest || null,
104
+ filter: options.filter || null,
105
+ };
106
+ this._multerInstance = null;
107
+ }
108
+
109
+ // ── Millas Middleware protocol ─────────────────────────────────────────────
110
+
111
+ /**
112
+ * Build and cache the multer middleware on first request, then delegate.
113
+ * Runs as a standard Millas middleware — ctx + next.
114
+ *
115
+ * Because multer is Express middleware internally, we reach into ctx.req.raw
116
+ * to get the underlying Express req/res and call multer directly.
117
+ *
118
+ * Skips gracefully when the request is not multipart/form-data so that
119
+ * non-upload requests to the same route (or misconfigured clients) get a
120
+ * clear 400 rather than a multer internal error.
121
+ */
122
+ async handle(ctx, next) {
123
+ const expressReq = ctx.req.raw;
124
+ const contentType = expressReq.headers?.['content-type'] || '';
125
+
126
+ // Skip multer entirely for non-multipart requests.
127
+ // ctx.file / ctx.files stay null/{} — the handler or shape validation
128
+ // will surface a missing-required-field error if needed.
129
+ if (!contentType.includes('multipart/form-data')) {
130
+ return next();
131
+ }
132
+
133
+ const multerMw = this._getMulterMiddleware();
134
+
135
+ await new Promise((resolve, reject) => {
136
+ // multer needs a real Express res object; we synthesize a minimal one
137
+ // that satisfies multer's internal usage (it only calls res.end /
138
+ // statusCode on hard errors, which we convert to thrown exceptions).
139
+ const fakeRes = _buildFakeRes(reject);
140
+ multerMw(expressReq, fakeRes, (err) => {
141
+ if (err) return reject(err);
142
+ resolve();
143
+ });
144
+ });
145
+
146
+ // Wrap raw multer file objects in UploadedFile instances so handlers
147
+ // receive rich, manipulable file objects instead of plain multer blobs.
148
+ const UploadedFile = require('../UploadedFile');
149
+ const { file, files } = UploadedFile.wrapRequest(expressReq);
150
+
151
+ // Sync into RequestContext (which was constructed before multer ran).
152
+ ctx.file = file;
153
+ ctx.files = files;
154
+
155
+ // Also mirror back onto the raw express req so Router._buildShapeMiddleware
156
+ // can pick them up when it merges file inputs for validation.
157
+ expressReq._millaFile = file;
158
+ expressReq._millaFiles = files;
159
+
160
+ // Also update ctx.body with any non-file multipart fields multer parsed.
161
+ // req.body is mutated in-place by multer, but ctx.body was built at
162
+ // RequestContext construction time. Reassign so the handler sees the fields.
163
+ if (expressReq.body && typeof expressReq.body === 'object') {
164
+ // Merge multer-parsed text fields into the existing ctx.body object
165
+ // without breaking the .validate() / .only() / .except() helpers
166
+ // that _buildBody attached as non-enumerable properties.
167
+ Object.assign(ctx.body, expressReq.body);
168
+ }
169
+
170
+ return next();
171
+ }
172
+
173
+ // ── Static factory helpers ──────────────────────────────────────────────────
174
+
175
+ /**
176
+ * Build an UploadMiddleware from parameterized alias string parts.
177
+ * Called by MiddlewareRegistry when the alias includes parameters.
178
+ *
179
+ * 'upload' → any field, 50 MB limit (default)
180
+ * 'upload:avatar' → single field named 'avatar'
181
+ * 'upload:photos,5' → field 'photos', up to 5 files
182
+ * 'upload:*' → any field (explicit)
183
+ *
184
+ * @param {string[]} params Parts after the colon, split by comma
185
+ * @returns {UploadMiddleware}
186
+ */
187
+ static fromParams(params) {
188
+ if (!params || !params.length || params[0] === '*') {
189
+ return new UploadMiddleware({ any: true });
190
+ }
191
+ const field = params[0];
192
+ const maxCount = params[1] ? parseInt(params[1], 10) : 1;
193
+ if (maxCount > 1) {
194
+ return new UploadMiddleware({ fields: [{ name: field, maxCount }] });
195
+ }
196
+ return new UploadMiddleware({ field });
197
+ }
198
+
199
+ /**
200
+ * Build an UploadMiddleware from a shape definition.
201
+ * Called internally by the Router when auto-injecting.
202
+ *
203
+ * @param {import('../Shape').ShapeDefinition} shape
204
+ * @returns {UploadMiddleware}
205
+ */
206
+ static fromShape(shape) {
207
+ const opts = { storage: 'memory' };
208
+
209
+ // Collect file field names from the shape's "in" validators
210
+ const fileFields = _fileFieldsFromShape(shape);
211
+
212
+ if (fileFields.length === 1) {
213
+ opts.field = fileFields[0].name;
214
+ } else if (fileFields.length > 1) {
215
+ opts.fields = fileFields; // [{name, maxCount}]
216
+ }
217
+
218
+ // Honour explicit maxSize from first file validator found
219
+ const firstFileValidator = _firstFileValidator(shape);
220
+ if (firstFileValidator?._maxSizeBytes) {
221
+ opts.maxSize = firstFileValidator._maxSizeBytes;
222
+ }
223
+
224
+ // Build a combined mimeType filter from all file validators
225
+ const allMimes = _allMimeTypes(shape);
226
+ if (allMimes.length) {
227
+ opts.filter = (req, file, cb) => {
228
+ cb(null, allMimes.includes(file.mimetype));
229
+ };
230
+ }
231
+
232
+ return new UploadMiddleware(opts);
233
+ }
234
+
235
+ // ── Internal ───────────────────────────────────────────────────────────────
236
+
237
+ _getMulterMiddleware() {
238
+ if (this._multerInstance) return this._multerInstance;
239
+
240
+ let multer;
241
+ try {
242
+ multer = require('multer');
243
+ } catch {
244
+ throw new Error(
245
+ '[Millas UploadMiddleware] multer is not installed.\n' +
246
+ 'Run: npm install multer\n' +
247
+ 'multer is required for multipart/file upload support.'
248
+ );
249
+ }
250
+
251
+ const { field, fields, any, maxSize, storage, dest, filter } = this._options;
252
+
253
+ // Storage engine
254
+ const storageEngine = storage === 'disk' && dest
255
+ ? multer.diskStorage({ destination: dest, filename: (_req, file, cb) => cb(null, `${Date.now()}_${file.originalname}`) })
256
+ : multer.memoryStorage();
257
+
258
+ const multerConfig = { storage: storageEngine, limits: { fileSize: maxSize } };
259
+ if (filter) multerConfig.fileFilter = filter;
260
+
261
+ const upload = multer(multerConfig);
262
+
263
+ // Pick the right multer handler based on options.
264
+ // When used without a shape (e.g. just 'upload' alias), default to .any()
265
+ // so the handler receives whatever field the client sends, regardless of name.
266
+ if (any || (!fields && this._options.field === 'file' && !this._options._explicitField)) {
267
+ this._multerInstance = upload.any();
268
+ } else if (fields) {
269
+ this._multerInstance = upload.fields(fields);
270
+ } else {
271
+ this._multerInstance = upload.single(field);
272
+ }
273
+
274
+ return this._multerInstance;
275
+ }
276
+ }
277
+
278
+ // ── Helpers ───────────────────────────────────────────────────────────────────
279
+
280
+ /**
281
+ * Parse a human-readable size string to bytes.
282
+ * '5mb' → 5242880, '500kb' → 512000, 1024 → 1024
283
+ */
284
+ function _parseSize(size) {
285
+ if (typeof size === 'number') return size;
286
+ const s = String(size).trim().toLowerCase();
287
+ const n = parseFloat(s);
288
+ if (s.endsWith('gb')) return n * 1024 * 1024 * 1024;
289
+ if (s.endsWith('mb')) return n * 1024 * 1024;
290
+ if (s.endsWith('kb')) return n * 1024;
291
+ return n;
292
+ }
293
+
294
+ /**
295
+ * Scan a shape's "in" schema for FileValidator instances.
296
+ * Returns [{name, maxCount: 1}] for each file field found.
297
+ */
298
+ function _fileFieldsFromShape(shape) {
299
+ if (!shape?.in) return [];
300
+ const fields = [];
301
+ for (const [name, validator] of Object.entries(shape.in)) {
302
+ if (validator?._type === 'file') {
303
+ fields.push({ name, maxCount: 1 });
304
+ }
305
+ }
306
+ return fields;
307
+ }
308
+
309
+ function _firstFileValidator(shape) {
310
+ if (!shape?.in) return null;
311
+ for (const validator of Object.values(shape.in)) {
312
+ if (validator?._type === 'file') return validator;
313
+ }
314
+ return null;
315
+ }
316
+
317
+ function _allMimeTypes(shape) {
318
+ if (!shape?.in) return [];
319
+ const mimes = [];
320
+ for (const validator of Object.values(shape.in)) {
321
+ if (validator?._type === 'file' && Array.isArray(validator._mimeTypes)) {
322
+ mimes.push(...validator._mimeTypes);
323
+ }
324
+ }
325
+ return [...new Set(mimes)];
326
+ }
327
+
328
+ /**
329
+ * Build a minimal fake Express response object for multer.
330
+ * Multer only interacts with res in error cases — we map those to rejections.
331
+ */
332
+ function _buildFakeRes(reject) {
333
+ return {
334
+ statusCode: 200,
335
+ end: (msg) => reject(new Error(msg || 'Upload error')),
336
+ setHeader: () => {},
337
+ getHeader: () => null,
338
+ };
339
+ }
340
+
341
+ module.exports = UploadMiddleware;
@@ -73,6 +73,15 @@ class Router {
73
73
  // validation middleware that runs BEFORE the handler.
74
74
  // On failure → 422 immediately, handler never runs.
75
75
  // On success → ctx.body is replaced with coerced, validated output.
76
+ //
77
+ // When the shape declares encoding:'multipart' or contains any file()
78
+ // field, UploadMiddleware is injected first (before validation) so that
79
+ // multer parses the multipart body and populates req.file / req.files /
80
+ // req.body before the validation step reads them.
81
+ const uploadMiddlewares = route.shape && this._shapeNeedsUpload(route.shape)
82
+ ? this._buildUploadMiddleware(route.shape)
83
+ : [];
84
+
76
85
  const shapeMiddlewares = route.shape
77
86
  ? this._buildShapeMiddleware(route.shape)
78
87
  : [];
@@ -87,29 +96,98 @@ class Router {
87
96
 
88
97
  this._adapter.mountRoute(route.verb, route.path, [
89
98
  ...middlewareHandlers,
99
+ ...uploadMiddlewares,
90
100
  ...shapeMiddlewares,
91
101
  terminalHandler,
92
102
  ]);
93
103
  }
94
104
 
105
+ /**
106
+ * Returns true if the shape requires multipart parsing —
107
+ * either because encoding is explicitly set to 'multipart', or because
108
+ * the "in" schema contains at least one file() validator.
109
+ *
110
+ * @param {import('../http/Shape').ShapeDefinition} shape
111
+ * @returns {boolean}
112
+ */
113
+ _shapeNeedsUpload(shape) {
114
+ if (shape.encoding === 'multipart') return true;
115
+ if (!shape.in) return false;
116
+ return Object.values(shape.in).some(v => v?._type === 'file');
117
+ }
118
+
119
+ /**
120
+ * Build an Express middleware array that runs UploadMiddleware before
121
+ * the shape validation step. The UploadMiddleware is configured from
122
+ * the shape (field names, maxSize, mimeTypes) so developers don't have
123
+ * to configure it separately.
124
+ *
125
+ * @param {import('../http/Shape').ShapeDefinition} shape
126
+ * @returns {Function[]}
127
+ */
128
+ _buildUploadMiddleware(shape) {
129
+ const UploadMiddleware = require('../http/middleware/UploadMiddleware');
130
+ const instance = UploadMiddleware.fromShape(shape);
131
+ // Wrap it through the adapter so it becomes an Express (req,res,next) fn
132
+ return [this._adapter.wrapMiddleware(instance, this._container)];
133
+ }
134
+
95
135
  /**
96
136
  * Build Express middleware functions from a shape definition.
97
137
  * Returns an array of 0, 1, or 2 middleware functions
98
138
  * (one for body/in, one for query) depending on what the shape declares.
99
139
  *
140
+ * For multipart routes, file fields from req.file / req.files are merged
141
+ * into the validation input so file() validators in the schema run correctly.
142
+ *
100
143
  * @param {import('../http/Shape').ShapeDefinition} shape
101
144
  * @returns {Function[]}
102
145
  */
103
146
  _buildShapeMiddleware(shape) {
104
147
  const { Validator } = require('../validation/Validator');
105
148
  const middlewares = [];
149
+ const isMultipart = this._shapeNeedsUpload(shape);
106
150
 
107
151
  // ── Body / in validation ───────────────────────────────────────────────
108
152
  if (shape.in && Object.keys(shape.in).length) {
109
153
  middlewares.push(async (req, res, next) => {
110
154
  try {
111
- const rawBody = req.body || {};
112
- const clean = await Validator.validate(rawBody, shape.in);
155
+ // For multipart requests, merge uploaded files into the validation
156
+ // input so file() validators in the shape's "in" schema can run.
157
+ // req.file → single file (multer .single())
158
+ // req.files → multiple files (multer .fields() or .any())
159
+ let rawBody = req.body || {};
160
+ if (isMultipart) {
161
+ const fileInputs = {};
162
+ // Prefer the already-wrapped UploadedFile instances written by
163
+ // UploadMiddleware so FileValidator receives the same UploadedFile
164
+ // objects that the handler will destructure.
165
+ if (req._millaFile) {
166
+ fileInputs[req._millaFile.fieldName] = req._millaFile;
167
+ }
168
+ if (req._millaFiles) {
169
+ for (const [fieldname, f] of Object.entries(req._millaFiles)) {
170
+ fileInputs[fieldname] = f;
171
+ }
172
+ }
173
+ // Fallback to raw multer objects if UploadMiddleware hasn't run
174
+ // (e.g. manual upload middleware setup without UploadMiddleware)
175
+ if (!Object.keys(fileInputs).length) {
176
+ if (req.file) fileInputs[req.file.fieldname] = req.file;
177
+ if (req.files) {
178
+ if (Array.isArray(req.files)) {
179
+ for (const f of req.files) fileInputs[f.fieldname] = f;
180
+ } else {
181
+ for (const [fieldname, arr] of Object.entries(req.files)) {
182
+ fileInputs[fieldname] = arr.length === 1 ? arr[0] : arr;
183
+ }
184
+ }
185
+ }
186
+ }
187
+ rawBody = { ...rawBody, ...fileInputs };
188
+ }
189
+
190
+ const clean = await Validator.validate(rawBody, shape.in);
113
191
  // Replace req.body with the coerced, validated subset so the
114
192
  // handler's { body } destructure gets clean data automatically.
115
193
  req.body = clean;
@@ -129,6 +129,10 @@ class Storage {
129
129
  const driverName = diskConf.driver || name || 'local';
130
130
 
131
131
  switch (driverName) {
132
+ case 's3': {
133
+ const S3Driver = require('./drivers/S3Driver');
134
+ return new S3Driver(diskConf);
135
+ }
132
136
  case 'local':
133
137
  default: {
134
138
  const LocalDriver = require('./drivers/LocalDriver');