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.
- package/package.json +17 -3
- package/src/auth/AuthController.js +42 -133
- package/src/auth/AuthMiddleware.js +12 -23
- package/src/auth/RoleMiddleware.js +7 -17
- package/src/commands/migrate.js +46 -31
- package/src/commands/serve.js +266 -37
- package/src/container/Application.js +88 -8
- package/src/controller/Controller.js +79 -300
- package/src/errors/ErrorRenderer.js +640 -0
- package/src/facades/Admin.js +49 -0
- package/src/facades/Auth.js +46 -0
- package/src/facades/Cache.js +17 -0
- package/src/facades/Database.js +43 -0
- package/src/facades/Events.js +24 -0
- package/src/facades/Http.js +54 -0
- package/src/facades/Log.js +56 -0
- package/src/facades/Mail.js +40 -0
- package/src/facades/Queue.js +23 -0
- package/src/facades/Storage.js +17 -0
- package/src/facades/Validation.js +69 -0
- package/src/http/MillasRequest.js +253 -0
- package/src/http/MillasResponse.js +196 -0
- package/src/http/RequestContext.js +176 -0
- package/src/http/ResponseDispatcher.js +144 -0
- package/src/http/helpers.js +164 -0
- package/src/http/index.js +13 -0
- package/src/index.js +55 -2
- package/src/logger/internal.js +76 -0
- package/src/logger/patchConsole.js +135 -0
- package/src/middleware/CorsMiddleware.js +22 -30
- package/src/middleware/LogMiddleware.js +27 -59
- package/src/middleware/Middleware.js +24 -15
- package/src/middleware/MiddlewarePipeline.js +30 -67
- package/src/middleware/MiddlewareRegistry.js +126 -0
- package/src/middleware/ThrottleMiddleware.js +22 -26
- package/src/orm/fields/index.js +124 -56
- package/src/orm/migration/ModelInspector.js +7 -3
- package/src/orm/model/Model.js +96 -6
- package/src/orm/query/QueryBuilder.js +141 -3
- package/src/providers/LogServiceProvider.js +88 -18
- package/src/providers/ProviderRegistry.js +14 -1
- package/src/providers/ServiceProvider.js +40 -8
- package/src/router/Router.js +155 -223
- package/src/scaffold/maker.js +24 -59
- package/src/scaffold/templates.js +13 -12
- package/src/validation/BaseValidator.js +193 -0
- package/src/validation/Validator.js +680 -0
|
@@ -1,363 +1,142 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const 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
|
-
*
|
|
11
|
-
*
|
|
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
|
|
22
|
-
*
|
|
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
|
|
33
|
+
// ─── Response helpers ────────────────────────────────────────────────────────
|
|
29
34
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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:
|
|
83
|
+
status: 422,
|
|
101
84
|
errors,
|
|
102
|
-
});
|
|
85
|
+
}, { status: 422 });
|
|
103
86
|
}
|
|
104
87
|
|
|
105
|
-
/**
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
104
|
+
* Paginated list response.
|
|
170
105
|
*
|
|
171
|
-
* this.
|
|
106
|
+
* return this.paginate({ data: users, total: 100, page: 2, perPage: 15 });
|
|
172
107
|
*/
|
|
173
|
-
|
|
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
|
|
110
|
+
return jsonify({
|
|
343
111
|
data,
|
|
344
112
|
meta: {
|
|
345
113
|
total,
|
|
346
|
-
per_page:
|
|
114
|
+
per_page: perPage,
|
|
347
115
|
current_page: Number(page),
|
|
348
|
-
last_page:
|
|
349
|
-
from:
|
|
350
|
-
to:
|
|
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
|
-
|
|
360
|
-
|
|
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 };
|