millas 0.2.15 → 0.2.16

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,287 @@
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
+ * ── Auto-injection (recommended) ─────────────────────────────────────────────
15
+ *
16
+ * When a route shape has encoding: 'multipart' or any file() field in its
17
+ * "in" schema, the Router injects UploadMiddleware automatically.
18
+ * You never touch it.
19
+ *
20
+ * Route.post('/media/upload', MediaController, 'upload')
21
+ * .shape({
22
+ * label: 'Upload media',
23
+ * encoding: 'multipart',
24
+ * in: {
25
+ * file: file().required().maxSize('50mb').mimeType(['image/jpeg','image/png','video/mp4']),
26
+ * },
27
+ * });
28
+ *
29
+ * async upload({ body, file, user }) {
30
+ * // file is a multer file object: { buffer, mimetype, size, originalname }
31
+ * // body has the rest of the form fields
32
+ * }
33
+ *
34
+ * ── Manual usage ──────────────────────────────────────────────────────────────
35
+ *
36
+ * const { UploadMiddleware } = require('millas/core/http');
37
+ *
38
+ * // Single named field
39
+ * Route.post('/avatar', ['auth', new UploadMiddleware({ field: 'avatar' })], UserController, 'updateAvatar');
40
+ *
41
+ * // Multiple named fields
42
+ * Route.post('/listing', ['auth', new UploadMiddleware({ fields: [{ name: 'photo', maxCount: 5 }] })], ListingController, 'store');
43
+ *
44
+ * // Any field name (multer .any())
45
+ * Route.post('/upload', [new UploadMiddleware({ any: true })], UploadController, 'store');
46
+ *
47
+ * ── Options ───────────────────────────────────────────────────────────────────
48
+ *
49
+ * {
50
+ * field: string // single field name (default: 'file')
51
+ * fields: [{name, maxCount}] // multiple named fields
52
+ * any: boolean // accept any field name
53
+ * maxSize: number|string // max file size bytes or '10mb' (default: 50mb)
54
+ * storage: 'memory'|'disk' // multer storage (default: 'memory')
55
+ * dest: string // disk storage destination (only when storage: 'disk')
56
+ * filter: Function // custom multer fileFilter(req, file, cb)
57
+ * }
58
+ *
59
+ * ── Handler context ───────────────────────────────────────────────────────────
60
+ *
61
+ * After this middleware runs, RequestContext exposes:
62
+ *
63
+ * ctx.file — single uploaded file (multer file object)
64
+ * ctx.files — multiple uploaded files (object or array)
65
+ * ctx.body — non-file form fields (strings)
66
+ *
67
+ * Multer file object shape:
68
+ * {
69
+ * fieldname: 'file',
70
+ * originalname: 'photo.jpg',
71
+ * mimetype: 'image/jpeg',
72
+ * size: 204800,
73
+ * buffer: <Buffer ...> // memory storage only
74
+ * path: '/tmp/...' // disk storage only
75
+ * }
76
+ */
77
+ class UploadMiddleware {
78
+ /**
79
+ * @param {object} options
80
+ * @param {string} [options.field='file'] single field name
81
+ * @param {Array<{name,maxCount}>} [options.fields] multiple named fields
82
+ * @param {boolean} [options.any=false] accept any field
83
+ * @param {number|string} [options.maxSize] max file size
84
+ * @param {'memory'|'disk'} [options.storage='memory']
85
+ * @param {string} [options.dest] disk dest (storage:'disk')
86
+ * @param {Function} [options.filter] multer fileFilter
87
+ */
88
+ constructor(options = {}) {
89
+ this._options = {
90
+ field: options.field || 'file',
91
+ fields: options.fields || null,
92
+ any: options.any || false,
93
+ maxSize: options.maxSize !== undefined ? _parseSize(options.maxSize) : 50 * 1024 * 1024,
94
+ storage: options.storage || 'memory',
95
+ dest: options.dest || null,
96
+ filter: options.filter || null,
97
+ };
98
+ this._multerInstance = null;
99
+ }
100
+
101
+ // ── Millas Middleware protocol ─────────────────────────────────────────────
102
+
103
+ /**
104
+ * Build and cache the multer middleware on first request, then delegate.
105
+ * Runs as a standard Millas middleware — ctx + next.
106
+ *
107
+ * Because multer is Express middleware internally, we reach into ctx.req.raw
108
+ * to get the underlying Express req/res and call multer directly.
109
+ */
110
+ async handle(ctx, next) {
111
+ const multerMw = this._getMulterMiddleware();
112
+ const expressReq = ctx.req.raw;
113
+
114
+ await new Promise((resolve, reject) => {
115
+ // multer needs a real Express res object; we synthesize a minimal one
116
+ // that satisfies multer's internal usage (it only calls res.end / statusCode
117
+ // on hard errors, which we convert to thrown exceptions via reject).
118
+ const fakeRes = _buildFakeRes(reject);
119
+ multerMw(expressReq, fakeRes, (err) => {
120
+ if (err) return reject(err);
121
+ resolve();
122
+ });
123
+ });
124
+
125
+ // Wrap raw multer file objects in UploadedFile instances so handlers
126
+ // receive rich, manipulable file objects instead of plain multer blobs.
127
+ const UploadedFile = require('../UploadedFile');
128
+ const { file, files } = UploadedFile.wrapRequest(expressReq);
129
+
130
+ // Sync into RequestContext (which was constructed before multer ran).
131
+ ctx.file = file;
132
+ ctx.files = files;
133
+
134
+ // Also mirror back onto the raw express req so Router._buildShapeMiddleware
135
+ // can pick them up when it merges file inputs for validation.
136
+ expressReq._millaFile = file;
137
+ expressReq._millaFiles = files;
138
+
139
+ // req.body is already a live reference — multer mutated it in-place,
140
+ // so non-file form fields are automatically visible via ctx.body.
141
+
142
+ return next();
143
+ }
144
+
145
+ // ── Static factory helpers ──────────────────────────────────────────────────
146
+
147
+ /**
148
+ * Build an UploadMiddleware from a shape definition.
149
+ * Called internally by the Router when auto-injecting.
150
+ *
151
+ * @param {import('../Shape').ShapeDefinition} shape
152
+ * @returns {UploadMiddleware}
153
+ */
154
+ static fromShape(shape) {
155
+ const opts = { storage: 'memory' };
156
+
157
+ // Collect file field names from the shape's "in" validators
158
+ const fileFields = _fileFieldsFromShape(shape);
159
+
160
+ if (fileFields.length === 1) {
161
+ opts.field = fileFields[0].name;
162
+ } else if (fileFields.length > 1) {
163
+ opts.fields = fileFields; // [{name, maxCount}]
164
+ }
165
+
166
+ // Honour explicit maxSize from first file validator found
167
+ const firstFileValidator = _firstFileValidator(shape);
168
+ if (firstFileValidator?._maxSizeBytes) {
169
+ opts.maxSize = firstFileValidator._maxSizeBytes;
170
+ }
171
+
172
+ // Build a combined mimeType filter from all file validators
173
+ const allMimes = _allMimeTypes(shape);
174
+ if (allMimes.length) {
175
+ opts.filter = (req, file, cb) => {
176
+ cb(null, allMimes.includes(file.mimetype));
177
+ };
178
+ }
179
+
180
+ return new UploadMiddleware(opts);
181
+ }
182
+
183
+ // ── Internal ───────────────────────────────────────────────────────────────
184
+
185
+ _getMulterMiddleware() {
186
+ if (this._multerInstance) return this._multerInstance;
187
+
188
+ let multer;
189
+ try {
190
+ multer = require('multer');
191
+ } catch {
192
+ throw new Error(
193
+ '[Millas UploadMiddleware] multer is not installed.\n' +
194
+ 'Run: npm install multer\n' +
195
+ 'multer is required for multipart/file upload support.'
196
+ );
197
+ }
198
+
199
+ const { field, fields, any, maxSize, storage, dest, filter } = this._options;
200
+
201
+ // Storage engine
202
+ const storageEngine = storage === 'disk' && dest
203
+ ? multer.diskStorage({ destination: dest, filename: (_req, file, cb) => cb(null, `${Date.now()}_${file.originalname}`) })
204
+ : multer.memoryStorage();
205
+
206
+ const multerConfig = { storage: storageEngine, limits: { fileSize: maxSize } };
207
+ if (filter) multerConfig.fileFilter = filter;
208
+
209
+ const upload = multer(multerConfig);
210
+
211
+ // Pick the right multer handler based on options
212
+ if (any) {
213
+ this._multerInstance = upload.any();
214
+ } else if (fields) {
215
+ this._multerInstance = upload.fields(fields);
216
+ } else {
217
+ this._multerInstance = upload.single(field);
218
+ }
219
+
220
+ return this._multerInstance;
221
+ }
222
+ }
223
+
224
+ // ── Helpers ───────────────────────────────────────────────────────────────────
225
+
226
+ /**
227
+ * Parse a human-readable size string to bytes.
228
+ * '5mb' → 5242880, '500kb' → 512000, 1024 → 1024
229
+ */
230
+ function _parseSize(size) {
231
+ if (typeof size === 'number') return size;
232
+ const s = String(size).trim().toLowerCase();
233
+ const n = parseFloat(s);
234
+ if (s.endsWith('gb')) return n * 1024 * 1024 * 1024;
235
+ if (s.endsWith('mb')) return n * 1024 * 1024;
236
+ if (s.endsWith('kb')) return n * 1024;
237
+ return n;
238
+ }
239
+
240
+ /**
241
+ * Scan a shape's "in" schema for FileValidator instances.
242
+ * Returns [{name, maxCount: 1}] for each file field found.
243
+ */
244
+ function _fileFieldsFromShape(shape) {
245
+ if (!shape?.in) return [];
246
+ const fields = [];
247
+ for (const [name, validator] of Object.entries(shape.in)) {
248
+ if (validator?._type === 'file') {
249
+ fields.push({ name, maxCount: 1 });
250
+ }
251
+ }
252
+ return fields;
253
+ }
254
+
255
+ function _firstFileValidator(shape) {
256
+ if (!shape?.in) return null;
257
+ for (const validator of Object.values(shape.in)) {
258
+ if (validator?._type === 'file') return validator;
259
+ }
260
+ return null;
261
+ }
262
+
263
+ function _allMimeTypes(shape) {
264
+ if (!shape?.in) return [];
265
+ const mimes = [];
266
+ for (const validator of Object.values(shape.in)) {
267
+ if (validator?._type === 'file' && Array.isArray(validator._mimeTypes)) {
268
+ mimes.push(...validator._mimeTypes);
269
+ }
270
+ }
271
+ return [...new Set(mimes)];
272
+ }
273
+
274
+ /**
275
+ * Build a minimal fake Express response object for multer.
276
+ * Multer only interacts with res in error cases — we map those to rejections.
277
+ */
278
+ function _buildFakeRes(reject) {
279
+ return {
280
+ statusCode: 200,
281
+ end: (msg) => reject(new Error(msg || 'Upload error')),
282
+ setHeader: () => {},
283
+ getHeader: () => null,
284
+ };
285
+ }
286
+
287
+ 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');