millas 0.2.11 → 0.2.12-beta

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.
Files changed (47) hide show
  1. package/package.json +17 -3
  2. package/src/auth/AuthController.js +42 -133
  3. package/src/auth/AuthMiddleware.js +12 -23
  4. package/src/auth/RoleMiddleware.js +7 -17
  5. package/src/commands/migrate.js +46 -31
  6. package/src/commands/serve.js +266 -37
  7. package/src/container/Application.js +88 -8
  8. package/src/controller/Controller.js +79 -300
  9. package/src/errors/ErrorRenderer.js +640 -0
  10. package/src/facades/Admin.js +49 -0
  11. package/src/facades/Auth.js +46 -0
  12. package/src/facades/Cache.js +17 -0
  13. package/src/facades/Database.js +43 -0
  14. package/src/facades/Events.js +24 -0
  15. package/src/facades/Http.js +54 -0
  16. package/src/facades/Log.js +56 -0
  17. package/src/facades/Mail.js +40 -0
  18. package/src/facades/Queue.js +23 -0
  19. package/src/facades/Storage.js +17 -0
  20. package/src/facades/Validation.js +69 -0
  21. package/src/http/MillasRequest.js +253 -0
  22. package/src/http/MillasResponse.js +196 -0
  23. package/src/http/RequestContext.js +176 -0
  24. package/src/http/ResponseDispatcher.js +144 -0
  25. package/src/http/helpers.js +164 -0
  26. package/src/http/index.js +13 -0
  27. package/src/index.js +55 -2
  28. package/src/logger/internal.js +76 -0
  29. package/src/logger/patchConsole.js +135 -0
  30. package/src/middleware/CorsMiddleware.js +22 -30
  31. package/src/middleware/LogMiddleware.js +27 -59
  32. package/src/middleware/Middleware.js +24 -15
  33. package/src/middleware/MiddlewarePipeline.js +30 -67
  34. package/src/middleware/MiddlewareRegistry.js +126 -0
  35. package/src/middleware/ThrottleMiddleware.js +22 -26
  36. package/src/orm/fields/index.js +124 -56
  37. package/src/orm/migration/ModelInspector.js +7 -3
  38. package/src/orm/model/Model.js +96 -6
  39. package/src/orm/query/QueryBuilder.js +141 -3
  40. package/src/providers/LogServiceProvider.js +88 -18
  41. package/src/providers/ProviderRegistry.js +14 -1
  42. package/src/providers/ServiceProvider.js +40 -8
  43. package/src/router/Router.js +155 -223
  44. package/src/scaffold/maker.js +24 -59
  45. package/src/scaffold/templates.js +13 -12
  46. package/src/validation/BaseValidator.js +193 -0
  47. package/src/validation/Validator.js +680 -0
@@ -1,363 +1,142 @@
1
1
  'use strict';
2
2
 
3
- const HttpError = require('../errors/HttpError');
3
+ const HttpError = require('../errors/HttpError');
4
+ const { jsonify, redirect, view, text, empty } = require('../http/helpers');
4
5
 
5
6
  /**
6
7
  * Controller
7
8
  *
8
9
  * Base class for all Millas controllers.
9
10
  *
10
- * Provides:
11
- * - Response helpers : this.ok(), this.created(), this.noContent(),
12
- * this.badRequest(), this.unauthorized(),
13
- * this.forbidden(), this.notFound(), this.json()
14
- * - Request helpers : this.input(), this.param(), this.query(), this.all()
15
- * - Validation : this.validate()
16
- * - Pagination helper : this.paginate()
17
- * - Error throwing : this.abort()
11
+ * Controller methods receive a MillasRequest and return a MillasResponse
12
+ * (or a plain value that the kernel auto-wraps). Express is never exposed.
18
13
  *
19
14
  * Usage:
20
15
  * class UserController extends Controller {
21
- * async index(req, res) {
22
- * return this.ok(res, { users: [] });
16
+ * async index(req) {
17
+ * const users = await User.all();
18
+ * return this.ok(users);
19
+ * }
20
+ *
21
+ * async store(req) {
22
+ * const data = await req.validate({
23
+ * name: 'required|string',
24
+ * email: 'required|email',
25
+ * });
26
+ * const user = await User.create(data);
27
+ * return this.created(user);
23
28
  * }
24
29
  * }
25
30
  */
26
31
  class Controller {
27
32
 
28
- // ─── Response Helpers ────────────────────────────────────────────────────────
33
+ // ─── Response helpers ────────────────────────────────────────────────────────
29
34
 
30
- /**
31
- * 200 OK
32
- * @param {object} res
33
- * @param {*} data
34
- */
35
- ok(res, data = null) {
36
- return res.status(200).json(this._envelope(200, data));
35
+ /** 200 OK */
36
+ ok(data = null) {
37
+ return jsonify(this._envelope(200, data), { status: 200 });
37
38
  }
38
39
 
39
- /**
40
- * 201 Created
41
- */
42
- created(res, data = null) {
43
- return res.status(201).json(this._envelope(201, data));
40
+ /** 201 Created */
41
+ created(data = null) {
42
+ return jsonify(this._envelope(201, data), { status: 201 });
44
43
  }
45
44
 
46
- /**
47
- * 204 No Content
48
- */
49
- noContent(res) {
50
- return res.status(204).send();
45
+ /** 204 No Content */
46
+ noContent() {
47
+ return empty(204);
51
48
  }
52
49
 
53
- /**
54
- * 200 with a custom JSON payload (no envelope)
55
- */
56
- json(res, data, status = 200) {
57
- return res.status(status).json(data);
50
+ /** 200 with a custom JSON payload (no envelope) */
51
+ json(data, status = 200) {
52
+ return jsonify(data, { status });
58
53
  }
59
54
 
60
- /**
61
- * 400 Bad Request
62
- */
63
- badRequest(res, message = 'Bad Request', errors = null) {
64
- return res.status(400).json({
65
- error: 'Bad Request',
66
- message,
67
- status: 400,
55
+ /** 400 Bad Request */
56
+ badRequest(message = 'Bad Request', errors = null) {
57
+ return jsonify({
58
+ error: 'Bad Request', message, status: 400,
68
59
  ...(errors && { errors }),
69
- });
60
+ }, { status: 400 });
70
61
  }
71
62
 
72
- /**
73
- * 401 Unauthorized
74
- */
75
- unauthorized(res, message = 'Unauthorized') {
76
- return res.status(401).json({ error: 'Unauthorized', message, status: 401 });
63
+ /** 401 Unauthorized */
64
+ unauthorized(message = 'Unauthorized') {
65
+ return jsonify({ error: 'Unauthorized', message, status: 401 }, { status: 401 });
77
66
  }
78
67
 
79
- /**
80
- * 403 Forbidden
81
- */
82
- forbidden(res, message = 'Forbidden') {
83
- return res.status(403).json({ error: 'Forbidden', message, status: 403 });
68
+ /** 403 Forbidden */
69
+ forbidden(message = 'Forbidden') {
70
+ return jsonify({ error: 'Forbidden', message, status: 403 }, { status: 403 });
84
71
  }
85
72
 
86
- /**
87
- * 404 Not Found
88
- */
89
- notFound(res, message = 'Not Found') {
90
- return res.status(404).json({ error: 'Not Found', message, status: 404 });
73
+ /** 404 Not Found */
74
+ notFound(message = 'Not Found') {
75
+ return jsonify({ error: 'Not Found', message, status: 404 }, { status: 404 });
91
76
  }
92
77
 
93
- /**
94
- * 422 Unprocessable Entity — validation failed
95
- */
96
- unprocessable(res, errors) {
97
- return res.status(422).json({
98
- error: 'Unprocessable Entity',
78
+ /** 422 Unprocessable Entity */
79
+ unprocessable(errors) {
80
+ return jsonify({
81
+ error: 'Unprocessable Entity',
99
82
  message: 'Validation failed',
100
- status: 422,
83
+ status: 422,
101
84
  errors,
102
- });
85
+ }, { status: 422 });
103
86
  }
104
87
 
105
- /**
106
- * 500 Internal Server Error
107
- */
108
- serverError(res, message = 'Internal Server Error') {
109
- return res.status(500).json({ error: 'Internal Server Error', message, status: 500 });
88
+ /** 500 Internal Server Error */
89
+ serverError(message = 'Internal Server Error') {
90
+ return jsonify({ error: 'Internal Server Error', message, status: 500 }, { status: 500 });
110
91
  }
111
92
 
112
- // ─── Abort (throw HttpError) ─────────────────────────────────────────────────
113
-
114
- /**
115
- * Throw an HTTP error — caught by the Router error handler.
116
- *
117
- * this.abort(404, 'User not found')
118
- * this.abort(403)
119
- */
120
- abort(status, message) {
121
- throw new HttpError(status, message);
122
- }
123
-
124
- // ─── Request Helpers ─────────────────────────────────────────────────────────
125
-
126
- /**
127
- * Get a value from req.body, req.query, or req.params — in that order.
128
- * Falls back to defaultValue if not found.
129
- *
130
- * this.input(req, 'email')
131
- * this.input(req, 'page', 1)
132
- */
133
- input(req, key, defaultValue = null) {
134
- if (key === undefined) return this.all(req);
135
- const value =
136
- (req.body && req.body[key] !== undefined ? req.body[key] : undefined) ??
137
- (req.query && req.query[key] !== undefined ? req.query[key] : undefined) ??
138
- (req.params && req.params[key] !== undefined ? req.params[key] : undefined);
139
- return value !== undefined ? value : defaultValue;
140
- }
141
-
142
- /**
143
- * Get a URL parameter. this.param(req, 'id')
144
- */
145
- param(req, key, defaultValue = null) {
146
- return req.params?.[key] ?? defaultValue;
147
- }
148
-
149
- /**
150
- * Get a query string value. this.query(req, 'page', 1)
151
- */
152
- query(req, key, defaultValue = null) {
153
- if (key === undefined) return req.query || {};
154
- return req.query?.[key] ?? defaultValue;
93
+ /** Render a view */
94
+ render(template, data = {}, status = 200) {
95
+ return view(template, data, { status });
155
96
  }
156
97
 
157
- /**
158
- * Merge body + query + params into one flat object.
159
- */
160
- all(req) {
161
- return {
162
- ...req.params,
163
- ...req.query,
164
- ...req.body,
165
- };
98
+ /** Redirect */
99
+ redirectTo(url, status = 302) {
100
+ return redirect(url, { status });
166
101
  }
167
102
 
168
103
  /**
169
- * Return only the specified keys from the request.
104
+ * Paginated list response.
170
105
  *
171
- * this.only(req, ['name', 'email'])
106
+ * return this.paginate({ data: users, total: 100, page: 2, perPage: 15 });
172
107
  */
173
- only(req, keys = []) {
174
- const all = this.all(req);
175
- return keys.reduce((acc, k) => {
176
- if (k in all) acc[k] = all[k];
177
- return acc;
178
- }, {});
179
- }
180
-
181
- /**
182
- * Return all request data except the specified keys.
183
- *
184
- * this.except(req, ['password', 'token'])
185
- */
186
- except(req, keys = []) {
187
- const all = this.all(req);
188
- return Object.fromEntries(
189
- Object.entries(all).filter(([k]) => !keys.includes(k))
190
- );
191
- }
192
-
193
- // ─── Validation ──────────────────────────────────────────────────────────────
194
-
195
- /**
196
- * Validate request data against a set of rules.
197
- * Throws a 422 HttpError on failure (caught by the router).
198
- *
199
- * const data = await this.validate(req, {
200
- * name: 'required|string|min:2|max:100',
201
- * email: 'required|email',
202
- * age: 'optional|number|min:0',
203
- * });
204
- *
205
- * On success returns the validated + sanitised data object.
206
- */
207
- async validate(req, rules) {
208
- const data = this.all(req);
209
- const errors = {};
210
-
211
- for (const [field, ruleString] of Object.entries(rules)) {
212
- const fieldRules = ruleString.split('|').map(r => r.trim());
213
- const value = data[field];
214
- const fieldErrors = [];
215
-
216
- for (const rule of fieldRules) {
217
- const [ruleName, ruleArg] = rule.split(':');
218
- const err = this._applyRule(field, value, ruleName, ruleArg);
219
- if (err) fieldErrors.push(err);
220
- }
221
-
222
- if (fieldErrors.length) errors[field] = fieldErrors;
223
- }
224
-
225
- if (Object.keys(errors).length > 0) {
226
- throw new HttpError(422, 'Validation failed', errors);
227
- }
228
-
229
- // Return only the validated fields
230
- const validated = {};
231
- for (const field of Object.keys(rules)) {
232
- if (data[field] !== undefined) validated[field] = data[field];
233
- }
234
- return validated;
235
- }
236
-
237
- _applyRule(field, value, rule, arg) {
238
- const isEmpty = value === undefined || value === null || value === '';
239
-
240
- switch (rule) {
241
- case 'required':
242
- if (isEmpty) return `${field} is required`;
243
- break;
244
-
245
- case 'optional':
246
- if (isEmpty) return null; // skip further checks if optional and empty
247
- break;
248
-
249
- case 'string':
250
- if (!isEmpty && typeof value !== 'string')
251
- return `${field} must be a string`;
252
- break;
253
-
254
- case 'number':
255
- if (!isEmpty && (isNaN(Number(value))))
256
- return `${field} must be a number`;
257
- break;
258
-
259
- case 'boolean':
260
- if (!isEmpty && !['true', 'false', true, false, '1', '0', 1, 0].includes(value))
261
- return `${field} must be a boolean`;
262
- break;
263
-
264
- case 'email': {
265
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
266
- if (!isEmpty && !emailRegex.test(value))
267
- return `${field} must be a valid email address`;
268
- break;
269
- }
270
-
271
- case 'min':
272
- if (!isEmpty) {
273
- if (typeof value === 'string' && value.length < Number(arg))
274
- return `${field} must be at least ${arg} characters`;
275
- if (typeof value === 'number' && value < Number(arg))
276
- return `${field} must be at least ${arg}`;
277
- }
278
- break;
279
-
280
- case 'max':
281
- if (!isEmpty) {
282
- if (typeof value === 'string' && value.length > Number(arg))
283
- return `${field} must not exceed ${arg} characters`;
284
- if (typeof value === 'number' && value > Number(arg))
285
- return `${field} must not exceed ${arg}`;
286
- }
287
- break;
288
-
289
- case 'in': {
290
- const allowed = arg.split(',');
291
- if (!isEmpty && !allowed.includes(String(value)))
292
- return `${field} must be one of: ${allowed.join(', ')}`;
293
- break;
294
- }
295
-
296
- case 'alpha':
297
- if (!isEmpty && !/^[a-zA-Z]+$/.test(value))
298
- return `${field} must contain only letters`;
299
- break;
300
-
301
- case 'alphanumeric':
302
- if (!isEmpty && !/^[a-zA-Z0-9]+$/.test(value))
303
- return `${field} must contain only letters and numbers`;
304
- break;
305
-
306
- case 'url':
307
- try {
308
- if (!isEmpty) new URL(value);
309
- } catch {
310
- return `${field} must be a valid URL`;
311
- }
312
- break;
313
-
314
- case 'confirmed': {
315
- // Expects a matching field named `${field}_confirmation` in the request
316
- // We store the raw req data on the instance during validate()
317
- break;
318
- }
319
-
320
- default:
321
- // Unknown rule — silently ignore (extensible in future)
322
- break;
323
- }
324
-
325
- return null;
326
- }
327
-
328
- // ─── Pagination Helper ───────────────────────────────────────────────────────
329
-
330
- /**
331
- * Build a pagination envelope for list responses.
332
- *
333
- * return this.paginate(res, {
334
- * data: users,
335
- * total: 100,
336
- * page: 2,
337
- * perPage: 15,
338
- * });
339
- */
340
- paginate(res, { data, total, page = 1, perPage = 15 }) {
108
+ paginate({ data, total, page = 1, perPage = 15 }) {
341
109
  const lastPage = Math.ceil(total / perPage);
342
- return res.status(200).json({
110
+ return jsonify({
343
111
  data,
344
112
  meta: {
345
113
  total,
346
- per_page: perPage,
114
+ per_page: perPage,
347
115
  current_page: Number(page),
348
- last_page: lastPage,
349
- from: (page - 1) * perPage + 1,
350
- to: Math.min(page * perPage, total),
116
+ last_page: lastPage,
117
+ from: (page - 1) * perPage + 1,
118
+ to: Math.min(page * perPage, total),
351
119
  },
352
120
  });
353
121
  }
354
122
 
123
+ // ─── Abort ───────────────────────────────────────────────────────────────────
124
+
125
+ /**
126
+ * Throw an HTTP error.
127
+ * this.abort(404, 'User not found')
128
+ * this.abort(403)
129
+ */
130
+ abort(status, message) {
131
+ throw new HttpError(status, message);
132
+ }
133
+
355
134
  // ─── Internal ────────────────────────────────────────────────────────────────
356
135
 
357
136
  _envelope(status, data) {
358
137
  if (data === null) return { status };
359
- // If data already has a recognised top-level shape, return as-is
360
- if (typeof data === 'object' && !Array.isArray(data) && ('data' in data || 'message' in data)) {
138
+ if (typeof data === 'object' && !Array.isArray(data) &&
139
+ ('data' in data || 'message' in data)) {
361
140
  return { status, ...data };
362
141
  }
363
142
  return { status, data };