millas 0.2.13 → 0.2.15
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 +6 -3
- package/src/admin/Admin.js +107 -1027
- package/src/admin/AdminAuth.js +1 -1
- package/src/admin/ViewContext.js +1 -1
- package/src/admin/handlers/ActionHandler.js +103 -0
- package/src/admin/handlers/ApiHandler.js +113 -0
- package/src/admin/handlers/AuthHandler.js +76 -0
- package/src/admin/handlers/ExportHandler.js +70 -0
- package/src/admin/handlers/InlineHandler.js +71 -0
- package/src/admin/handlers/PageHandler.js +351 -0
- package/src/admin/resources/AdminResource.js +22 -1
- package/src/admin/static/SelectFilter2.js +34 -0
- package/src/admin/static/actions.js +201 -0
- package/src/admin/static/admin.css +7 -0
- package/src/admin/static/change_form.js +585 -0
- package/src/admin/static/core.js +128 -0
- package/src/admin/static/login.js +76 -0
- package/src/admin/static/vendor/bi/bootstrap-icons.min.css +5 -0
- package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff +0 -0
- package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff2 +0 -0
- package/src/admin/static/vendor/jquery.min.js +2 -0
- package/src/admin/views/layouts/base.njk +30 -113
- package/src/admin/views/pages/detail.njk +10 -9
- package/src/admin/views/pages/form.njk +4 -4
- package/src/admin/views/pages/list.njk +11 -193
- package/src/admin/views/pages/login.njk +19 -64
- package/src/admin/views/partials/form-field.njk +1 -1
- package/src/admin/views/partials/form-scripts.njk +4 -478
- package/src/admin/views/partials/form-widget.njk +10 -10
- package/src/ai/AITokenBudget.js +1 -1
- package/src/auth/Auth.js +112 -3
- package/src/auth/AuthMiddleware.js +18 -15
- package/src/auth/Hasher.js +15 -43
- package/src/cli.js +3 -0
- package/src/commands/call.js +190 -0
- package/src/commands/createsuperuser.js +3 -4
- package/src/commands/key.js +97 -0
- package/src/commands/make.js +16 -2
- package/src/commands/new.js +16 -1
- package/src/commands/serve.js +5 -5
- package/src/console/Command.js +337 -0
- package/src/console/CommandLoader.js +165 -0
- package/src/console/index.js +6 -0
- package/src/container/AppInitializer.js +48 -1
- package/src/container/Application.js +3 -1
- package/src/container/HttpServer.js +0 -1
- package/src/container/MillasConfig.js +48 -0
- package/src/controller/Controller.js +13 -11
- package/src/core/docs.js +6 -0
- package/src/core/foundation.js +8 -0
- package/src/core/http.js +20 -10
- package/src/core/validation.js +58 -27
- package/src/docs/Docs.js +268 -0
- package/src/docs/DocsServiceProvider.js +80 -0
- package/src/docs/SchemaInferrer.js +131 -0
- package/src/docs/handlers/ApiHandler.js +305 -0
- package/src/docs/handlers/PageHandler.js +47 -0
- package/src/docs/index.js +13 -0
- package/src/docs/resources/ApiResource.js +402 -0
- package/src/docs/static/docs.css +723 -0
- package/src/docs/static/docs.js +1181 -0
- package/src/encryption/Encrypter.js +381 -0
- package/src/facades/Auth.js +5 -2
- package/src/facades/Crypt.js +166 -0
- package/src/facades/Docs.js +43 -0
- package/src/facades/Mail.js +1 -1
- package/src/http/MillasRequest.js +7 -31
- package/src/http/RequestContext.js +11 -7
- package/src/http/SecurityBootstrap.js +24 -2
- package/src/http/Shape.js +168 -0
- package/src/http/adapters/ExpressAdapter.js +9 -5
- package/src/middleware/CorsMiddleware.js +3 -0
- package/src/middleware/ThrottleMiddleware.js +10 -7
- package/src/orm/model/Model.js +20 -2
- package/src/providers/EncryptionServiceProvider.js +66 -0
- package/src/router/MiddlewareRegistry.js +79 -54
- package/src/router/Route.js +9 -4
- package/src/router/RouteEntry.js +91 -0
- package/src/router/Router.js +71 -1
- package/src/scaffold/maker.js +138 -1
- package/src/scaffold/templates.js +12 -0
- package/src/serializer/Serializer.js +239 -0
- package/src/support/Str.js +1080 -0
- package/src/validation/BaseValidator.js +45 -5
- package/src/validation/Validator.js +67 -61
- package/src/validation/types.js +490 -0
- package/src/middleware/AuthMiddleware.js +0 -46
- package/src/middleware/MiddlewareRegistry.js +0 -106
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { isShape } = require('../http/Shape');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* RouteEntry
|
|
7
|
+
*
|
|
8
|
+
* A thin wrapper returned by Route.get/post/put/patch/delete/resource.
|
|
9
|
+
* Exposes .shape() and .fromShape() for attaching a shape definition,
|
|
10
|
+
* then writes it back to the registry entry.
|
|
11
|
+
*
|
|
12
|
+
* The Route instance itself is passed in so group-level chaining still
|
|
13
|
+
* works — .shape() returns the RouteEntry, but the Route is unaffected.
|
|
14
|
+
*
|
|
15
|
+
* ── Usage ─────────────────────────────────────────────────────────────────────
|
|
16
|
+
*
|
|
17
|
+
* Route.post('/properties', PropertyController, 'store')
|
|
18
|
+
* .shape({ label: 'Create property', in: { name: string().required() }, out: {} });
|
|
19
|
+
*
|
|
20
|
+
* Route.post('/properties', PropertyController, 'store')
|
|
21
|
+
* .fromShape(CreatePropertyShape);
|
|
22
|
+
*/
|
|
23
|
+
class RouteEntry {
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} entry — the raw entry object stored in RouteRegistry
|
|
26
|
+
* @param {Route} router — the Route instance (for method chaining back)
|
|
27
|
+
*/
|
|
28
|
+
constructor(entry, router) {
|
|
29
|
+
this._entry = entry;
|
|
30
|
+
this._router = router;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Attach an inline shape definition to this route.
|
|
35
|
+
*
|
|
36
|
+
* Route.post('/users', UserController, 'store')
|
|
37
|
+
* .shape({
|
|
38
|
+
* label: 'Create user',
|
|
39
|
+
* group: 'Users',
|
|
40
|
+
* in: { email: email().required() },
|
|
41
|
+
* out: { 201: { id: 1 } },
|
|
42
|
+
* });
|
|
43
|
+
*
|
|
44
|
+
* The "in" schema runs as validation middleware before the handler.
|
|
45
|
+
* If validation fails, 422 is returned immediately.
|
|
46
|
+
* The handler receives clean, coerced data via { body }.
|
|
47
|
+
*
|
|
48
|
+
* @param {object} def — plain shape definition OR a shape() result
|
|
49
|
+
* @returns {RouteEntry}
|
|
50
|
+
*/
|
|
51
|
+
shape(def) {
|
|
52
|
+
// Accept both raw objects and shape() factory results
|
|
53
|
+
// If raw object, wrap through shape() for validation + freezing
|
|
54
|
+
if (!isShape(def)) {
|
|
55
|
+
const { shape: makeShape } = require('../http/Shape');
|
|
56
|
+
def = makeShape(def);
|
|
57
|
+
}
|
|
58
|
+
this._entry.shape = def;
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Attach a pre-built shape from a shapes file.
|
|
64
|
+
* Identical to .shape() — exists for readability and convention.
|
|
65
|
+
*
|
|
66
|
+
* const { CreatePropertyShape } = require('../app/shapes/PropertyShape');
|
|
67
|
+
* Route.post('/properties', PropertyController, 'store')
|
|
68
|
+
* .fromShape(CreatePropertyShape);
|
|
69
|
+
*
|
|
70
|
+
* @param {object} shapeDefinition — result of shape() factory
|
|
71
|
+
* @returns {RouteEntry}
|
|
72
|
+
*/
|
|
73
|
+
fromShape(shapeDefinition) {
|
|
74
|
+
return this.shape(shapeDefinition);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Add extra middleware to this specific route after registration.
|
|
79
|
+
* Middleware aliases are appended to the existing list.
|
|
80
|
+
*
|
|
81
|
+
* @param {string|string[]} middleware
|
|
82
|
+
* @returns {RouteEntry}
|
|
83
|
+
*/
|
|
84
|
+
middleware(mw) {
|
|
85
|
+
const list = Array.isArray(mw) ? mw : [mw];
|
|
86
|
+
this._entry.middleware = [...(this._entry.middleware || []), ...list];
|
|
87
|
+
return this;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = RouteEntry;
|
package/src/router/Router.js
CHANGED
|
@@ -67,7 +67,17 @@ class Router {
|
|
|
67
67
|
|
|
68
68
|
_bindRoute(route) {
|
|
69
69
|
const middlewareHandlers = this._resolveMiddleware(route.middleware || []);
|
|
70
|
-
|
|
70
|
+
|
|
71
|
+
// ── Shape validation middleware ────────────────────────────────────────
|
|
72
|
+
// If the route has a .shape() / .fromShape() declaration, inject a
|
|
73
|
+
// validation middleware that runs BEFORE the handler.
|
|
74
|
+
// On failure → 422 immediately, handler never runs.
|
|
75
|
+
// On success → ctx.body is replaced with coerced, validated output.
|
|
76
|
+
const shapeMiddlewares = route.shape
|
|
77
|
+
? this._buildShapeMiddleware(route.shape)
|
|
78
|
+
: [];
|
|
79
|
+
|
|
80
|
+
const terminalHandler = this._resolveTerminalHandler(
|
|
71
81
|
route.handler,
|
|
72
82
|
route.method,
|
|
73
83
|
route.verb,
|
|
@@ -77,10 +87,70 @@ class Router {
|
|
|
77
87
|
|
|
78
88
|
this._adapter.mountRoute(route.verb, route.path, [
|
|
79
89
|
...middlewareHandlers,
|
|
90
|
+
...shapeMiddlewares,
|
|
80
91
|
terminalHandler,
|
|
81
92
|
]);
|
|
82
93
|
}
|
|
83
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Build Express middleware functions from a shape definition.
|
|
97
|
+
* Returns an array of 0, 1, or 2 middleware functions
|
|
98
|
+
* (one for body/in, one for query) depending on what the shape declares.
|
|
99
|
+
*
|
|
100
|
+
* @param {import('../http/Shape').ShapeDefinition} shape
|
|
101
|
+
* @returns {Function[]}
|
|
102
|
+
*/
|
|
103
|
+
_buildShapeMiddleware(shape) {
|
|
104
|
+
const { Validator } = require('../validation/Validator');
|
|
105
|
+
const middlewares = [];
|
|
106
|
+
|
|
107
|
+
// ── Body / in validation ───────────────────────────────────────────────
|
|
108
|
+
if (shape.in && Object.keys(shape.in).length) {
|
|
109
|
+
middlewares.push(async (req, res, next) => {
|
|
110
|
+
try {
|
|
111
|
+
const rawBody = req.body || {};
|
|
112
|
+
const clean = await Validator.validate(rawBody, shape.in);
|
|
113
|
+
// Replace req.body with the coerced, validated subset so the
|
|
114
|
+
// handler's { body } destructure gets clean data automatically.
|
|
115
|
+
req.body = clean;
|
|
116
|
+
next();
|
|
117
|
+
} catch (err) {
|
|
118
|
+
// ValidationError → 422, any other error → pass to error handler
|
|
119
|
+
if (err.code === 'EVALIDATION' || err.name === 'ValidationError') {
|
|
120
|
+
return res.status(422).json({
|
|
121
|
+
status: 422,
|
|
122
|
+
message: 'Validation failed',
|
|
123
|
+
errors: err.errors || {},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
next(err);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Query validation ───────────────────────────────────────────────────
|
|
132
|
+
if (shape.query && Object.keys(shape.query).length) {
|
|
133
|
+
middlewares.push(async (req, res, next) => {
|
|
134
|
+
try {
|
|
135
|
+
const clean = await Validator.validate(req.query || {}, shape.query);
|
|
136
|
+
req.query = clean;
|
|
137
|
+
next();
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (err.code === 'EVALIDATION' || err.name === 'ValidationError') {
|
|
140
|
+
return res.status(422).json({
|
|
141
|
+
status: 422,
|
|
142
|
+
message: 'Validation failed',
|
|
143
|
+
errors: err.errors || {},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
next(err);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return middlewares;
|
|
152
|
+
}
|
|
153
|
+
|
|
84
154
|
_resolveMiddleware(list) {
|
|
85
155
|
return list.map(alias => {
|
|
86
156
|
try {
|
package/src/scaffold/maker.js
CHANGED
|
@@ -262,6 +262,141 @@ module.exports = {
|
|
|
262
262
|
return write(filePath, content);
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
|
|
266
|
+
async function makeShape(name) {
|
|
267
|
+
const baseName = name.endsWith('Shape') ? name : (name + 'Shape');
|
|
268
|
+
const className = pascalCase(baseName);
|
|
269
|
+
const filePath = resolveAppPath('app/shapes', className + '.js');
|
|
270
|
+
const groupBase = className.replace(/Shape$/, '');
|
|
271
|
+
const group = groupBase + 's';
|
|
272
|
+
|
|
273
|
+
const lines = [
|
|
274
|
+
"'use strict';",
|
|
275
|
+
'',
|
|
276
|
+
"const { shape } = require('millas/core/http');",
|
|
277
|
+
"const { string, number, boolean, array, email, date } = require('millas/core/validation');",
|
|
278
|
+
'',
|
|
279
|
+
'/**',
|
|
280
|
+
' * ' + className,
|
|
281
|
+
' *',
|
|
282
|
+
' * Route input/output contracts for ' + groupBase + ' endpoints.',
|
|
283
|
+
' *',
|
|
284
|
+
' * Usage:',
|
|
285
|
+
' * Route.post(\'/path\', ' + groupBase + 'Controller, \'store\').fromShape(Create' + groupBase + 'Shape);',
|
|
286
|
+
' * Route.put(\'/path/:id\', ' + groupBase + 'Controller, \'update\').fromShape(Update' + groupBase + 'Shape);',
|
|
287
|
+
' *',
|
|
288
|
+
' * The "in" schema validates the request body before the handler runs.',
|
|
289
|
+
' * Failures return 422 automatically. Use { body } in your handler — it is clean.',
|
|
290
|
+
' */',
|
|
291
|
+
'',
|
|
292
|
+
'const Create' + groupBase + 'Shape = shape({',
|
|
293
|
+
" label: 'Create " + groupBase.toLowerCase() + "',",
|
|
294
|
+
" group: '" + group + "',",
|
|
295
|
+
' description: null,',
|
|
296
|
+
' in: {',
|
|
297
|
+
' // name: string().required().max(200).example(\'My ' + groupBase + '\'),',
|
|
298
|
+
' // email: email().required().example(\'user@example.com\'),',
|
|
299
|
+
' // count: number().required().min(1).example(1),',
|
|
300
|
+
' // active: boolean().optional().example(true),',
|
|
301
|
+
" // tags: array().of(string()).optional().example(['tag1', 'tag2']),",
|
|
302
|
+
" // type: string().required().oneOf(['option_a', 'option_b']),",
|
|
303
|
+
' },',
|
|
304
|
+
' out: {',
|
|
305
|
+
' 201: { id: 1 },',
|
|
306
|
+
" 422: { message: 'Validation failed', errors: {} },",
|
|
307
|
+
' },',
|
|
308
|
+
'});',
|
|
309
|
+
'',
|
|
310
|
+
'const Update' + groupBase + 'Shape = shape({',
|
|
311
|
+
" label: 'Update " + groupBase.toLowerCase() + "',",
|
|
312
|
+
" group: '" + group + "',",
|
|
313
|
+
' in: {',
|
|
314
|
+
' // name: string().optional().max(200),',
|
|
315
|
+
' },',
|
|
316
|
+
' out: {',
|
|
317
|
+
' 200: { id: 1 },',
|
|
318
|
+
" 422: { message: 'Validation failed', errors: {} },",
|
|
319
|
+
' },',
|
|
320
|
+
'});',
|
|
321
|
+
'',
|
|
322
|
+
'module.exports = { Create' + groupBase + 'Shape, Update' + groupBase + 'Shape };',
|
|
323
|
+
];
|
|
324
|
+
|
|
325
|
+
return write(filePath, lines.join('\n'));
|
|
326
|
+
}
|
|
327
|
+
async function makeCommand(name) {
|
|
328
|
+
// If name contains ':' it's a namespaced signature like 'email:SendDigest'.
|
|
329
|
+
// Split off the namespace prefix and use only the last segment for the
|
|
330
|
+
// class name / filename, but keep the full input as the signature.
|
|
331
|
+
const parts = name.split(':');
|
|
332
|
+
const basePart = parts[parts.length - 1]; // e.g. 'SendDigest'
|
|
333
|
+
const cleanBase = basePart.replace(/Command$/i, ''); // strip trailing Command
|
|
334
|
+
const className = pascalCase(cleanBase) + 'Command'; // e.g. 'SendDigestCommand'
|
|
335
|
+
|
|
336
|
+
// Build signature from the full name:
|
|
337
|
+
// 'email:SendDigest' → 'email:digest'
|
|
338
|
+
// 'SendDigest' → 'send-digest'
|
|
339
|
+
// 'send-digest' → 'send-digest'
|
|
340
|
+
const signatureParts = name
|
|
341
|
+
.replace(/Command$/i, '')
|
|
342
|
+
.split(':')
|
|
343
|
+
.map((seg, i, arr) => {
|
|
344
|
+
// Last segment: strip camelCase — 'SendDigest' → 'send-digest'
|
|
345
|
+
if (i === arr.length - 1) {
|
|
346
|
+
return seg
|
|
347
|
+
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
|
348
|
+
.toLowerCase();
|
|
349
|
+
}
|
|
350
|
+
// Namespace segments: lowercase as-is
|
|
351
|
+
return seg.toLowerCase();
|
|
352
|
+
});
|
|
353
|
+
const signature = signatureParts.join(':');
|
|
354
|
+
|
|
355
|
+
const filePath = resolveAppPath('app/commands', `${className}.js`);
|
|
356
|
+
|
|
357
|
+
const content = `'use strict';
|
|
358
|
+
|
|
359
|
+
const { Command } = require('millas/console');
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* ${className}
|
|
363
|
+
*
|
|
364
|
+
* Run with: millas call ${signature}
|
|
365
|
+
*/
|
|
366
|
+
class ${className} extends Command {
|
|
367
|
+
static signature = '${signature}';
|
|
368
|
+
static description = 'Description of ${signature}';
|
|
369
|
+
|
|
370
|
+
// Optional: positional arguments
|
|
371
|
+
// static args = [
|
|
372
|
+
// { name: 'target', description: 'The target to act on', default: 'all' },
|
|
373
|
+
// ];
|
|
374
|
+
|
|
375
|
+
// Optional: named options / flags
|
|
376
|
+
// static options = [
|
|
377
|
+
// { flag: '--dry-run', description: 'Preview without making changes' },
|
|
378
|
+
// { flag: '--limit <n>', description: 'Max items to process', default: '50' },
|
|
379
|
+
// ];
|
|
380
|
+
|
|
381
|
+
async handle() {
|
|
382
|
+
// const target = this.argument('target');
|
|
383
|
+
// const limit = this.option('limit');
|
|
384
|
+
// const dry = this.option('dryRun');
|
|
385
|
+
|
|
386
|
+
this.info('Running ${signature}...');
|
|
387
|
+
|
|
388
|
+
// Your command logic here
|
|
389
|
+
|
|
390
|
+
this.success('Done.');
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
module.exports = ${className};
|
|
395
|
+
`;
|
|
396
|
+
|
|
397
|
+
return write(filePath, content);
|
|
398
|
+
}
|
|
399
|
+
|
|
265
400
|
module.exports = {
|
|
266
401
|
makeController,
|
|
267
402
|
makeModel,
|
|
@@ -269,4 +404,6 @@ module.exports = {
|
|
|
269
404
|
makeService,
|
|
270
405
|
makeJob,
|
|
271
406
|
makeMigration,
|
|
272
|
-
|
|
407
|
+
makeShape,
|
|
408
|
+
makeCommand,
|
|
409
|
+
};
|
|
@@ -161,6 +161,18 @@ module.exports = {
|
|
|
161
161
|
// Set use_i18n: true to enable the translation system.
|
|
162
162
|
// Then run: millas lang:publish <locale>
|
|
163
163
|
use_i18n: false,
|
|
164
|
+
|
|
165
|
+
// ── CORS ──────────────────────────────────────────────────────────────────
|
|
166
|
+
// Uncomment and call .withCors() in bootstrap/app.js to enable.
|
|
167
|
+
// All values shown are the defaults — only include what you need to change.
|
|
168
|
+
//
|
|
169
|
+
// cors: {
|
|
170
|
+
// origins: ['*'], // or ['https://app.example.com']
|
|
171
|
+
// methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
172
|
+
// headers: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
|
173
|
+
// credentials: false, // true requires explicit origins, not '*'
|
|
174
|
+
// maxAge: 86400, // preflight cache in seconds
|
|
175
|
+
// },
|
|
164
176
|
};
|
|
165
177
|
`,
|
|
166
178
|
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Serializer
|
|
5
|
+
*
|
|
6
|
+
* Controls the exact shape of API output — what fields go out, what's
|
|
7
|
+
* nested, and what's hidden. Sits between your model and your JSON response.
|
|
8
|
+
*
|
|
9
|
+
* ── Why use a Serializer instead of jsonify(model) ───────────────────────────
|
|
10
|
+
*
|
|
11
|
+
* jsonify(user) — dumps every column, relies on model.hidden
|
|
12
|
+
* UserSerializer.one(user) — precise whitelist, nested relations, custom fields
|
|
13
|
+
*
|
|
14
|
+
* ── Defining a Serializer ────────────────────────────────────────────────────
|
|
15
|
+
*
|
|
16
|
+
* const { Serializer } = require('millas/src/serializer/Serializer');
|
|
17
|
+
*
|
|
18
|
+
* class UserSerializer extends Serializer {
|
|
19
|
+
* // Whitelist of fields to include. If omitted, all fields are included
|
|
20
|
+
* // (minus model.hidden and any fields listed in static hidden below).
|
|
21
|
+
* static fields = ['id', 'name', 'email', 'role', 'created_at'];
|
|
22
|
+
*
|
|
23
|
+
* // Extra fields to exclude on top of model.hidden.
|
|
24
|
+
* // Only needed when static fields is not set.
|
|
25
|
+
* static hidden = ['internal_notes', 'stripe_customer_id'];
|
|
26
|
+
*
|
|
27
|
+
* // Nested serializers — keyed by the relation name on the model.
|
|
28
|
+
* // The relation must be eager-loaded (.with('roles')) before serializing.
|
|
29
|
+
* // If the relation is not loaded, the key is silently omitted.
|
|
30
|
+
* static nested = {
|
|
31
|
+
* roles: RoleSerializer,
|
|
32
|
+
* profile: ProfileSerializer,
|
|
33
|
+
* };
|
|
34
|
+
*
|
|
35
|
+
* // Computed fields — functions that receive the model instance and
|
|
36
|
+
* // return an additional value not stored on the model.
|
|
37
|
+
* static computed = {
|
|
38
|
+
* full_name: (user) => `${user.first_name} ${user.last_name}`,
|
|
39
|
+
* is_admin: (user) => user.role === 'admin',
|
|
40
|
+
* };
|
|
41
|
+
* }
|
|
42
|
+
*
|
|
43
|
+
* ── Usage ─────────────────────────────────────────────────────────────────────
|
|
44
|
+
*
|
|
45
|
+
* // Single record
|
|
46
|
+
* const user = await User.with('roles', 'profile').find(id);
|
|
47
|
+
* return jsonify(UserSerializer.one(user));
|
|
48
|
+
*
|
|
49
|
+
* // Collection
|
|
50
|
+
* const users = await User.with('roles').all();
|
|
51
|
+
* return jsonify(UserSerializer.many(users));
|
|
52
|
+
*
|
|
53
|
+
* // Paginated
|
|
54
|
+
* const result = await User.with('roles').paginate(page, perPage);
|
|
55
|
+
* return jsonify(UserSerializer.paginate(result));
|
|
56
|
+
*
|
|
57
|
+
* // With request context (for conditional fields)
|
|
58
|
+
* return jsonify(UserSerializer.one(user, { req }));
|
|
59
|
+
*
|
|
60
|
+
* ── Nested relations ──────────────────────────────────────────────────────────
|
|
61
|
+
*
|
|
62
|
+
* The relation must be eager-loaded first. If not loaded, it is skipped.
|
|
63
|
+
*
|
|
64
|
+
* // WRONG — roles not loaded, will be silently omitted
|
|
65
|
+
* const user = await User.find(id);
|
|
66
|
+
* UserSerializer.one(user);
|
|
67
|
+
*
|
|
68
|
+
* // CORRECT — roles eager-loaded, will be serialized through RoleSerializer
|
|
69
|
+
* const user = await User.with('roles').find(id);
|
|
70
|
+
* UserSerializer.one(user);
|
|
71
|
+
*
|
|
72
|
+
* ── Conditional fields ────────────────────────────────────────────────────────
|
|
73
|
+
*
|
|
74
|
+
* Override the instance method serialize(instance, ctx) for per-request logic:
|
|
75
|
+
*
|
|
76
|
+
* class UserSerializer extends Serializer {
|
|
77
|
+
* static fields = ['id', 'name', 'email'];
|
|
78
|
+
*
|
|
79
|
+
* serialize(instance, ctx = {}) {
|
|
80
|
+
* const data = super.serialize(instance, ctx);
|
|
81
|
+
* if (ctx.req?.user?.is_admin) {
|
|
82
|
+
* data.internal_notes = instance.internal_notes;
|
|
83
|
+
* }
|
|
84
|
+
* return data;
|
|
85
|
+
* }
|
|
86
|
+
* }
|
|
87
|
+
*/
|
|
88
|
+
class Serializer {
|
|
89
|
+
/**
|
|
90
|
+
* Whitelist of field names to include.
|
|
91
|
+
* When set, ONLY these fields appear in output (plus computed + nested).
|
|
92
|
+
* When null/undefined, all fields are included minus hidden ones.
|
|
93
|
+
*
|
|
94
|
+
* @type {string[]|null}
|
|
95
|
+
*/
|
|
96
|
+
static fields = null;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Extra fields to exclude, on top of the model's static hidden list.
|
|
100
|
+
* Only relevant when static fields is not set.
|
|
101
|
+
*
|
|
102
|
+
* @type {string[]}
|
|
103
|
+
*/
|
|
104
|
+
static hidden = [];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Nested serializers, keyed by relation name.
|
|
108
|
+
* The relation must be eager-loaded before calling serialize().
|
|
109
|
+
* If the value on the instance is a function (lazy-loaded), it is skipped.
|
|
110
|
+
*
|
|
111
|
+
* @type {Object.<string, typeof Serializer>}
|
|
112
|
+
*/
|
|
113
|
+
static nested = {};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Computed fields — functions that receive the model instance and
|
|
117
|
+
* optional context, returning a value to include in output.
|
|
118
|
+
*
|
|
119
|
+
* @type {Object.<string, function(instance, ctx): *>}
|
|
120
|
+
*/
|
|
121
|
+
static computed = {};
|
|
122
|
+
|
|
123
|
+
// ── Core serialization ─────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Serialize a single model instance.
|
|
127
|
+
* Override this instance method for per-request conditional logic.
|
|
128
|
+
*
|
|
129
|
+
* @param {object} instance — model instance or plain object
|
|
130
|
+
* @param {object} [ctx] — optional context (e.g. { req })
|
|
131
|
+
* @returns {object}
|
|
132
|
+
*/
|
|
133
|
+
serialize(instance, ctx = {}) {
|
|
134
|
+
if (!instance) return null;
|
|
135
|
+
|
|
136
|
+
const ctor = this.constructor;
|
|
137
|
+
const raw = typeof instance.toJSON === 'function' ? instance.toJSON() : { ...instance };
|
|
138
|
+
const result = {};
|
|
139
|
+
|
|
140
|
+
// ── Field whitelist or full set ─────────────────────────────────────────
|
|
141
|
+
if (ctor.fields && ctor.fields.length) {
|
|
142
|
+
// Whitelist mode — only declared fields
|
|
143
|
+
for (const key of ctor.fields) {
|
|
144
|
+
if (key in raw) {
|
|
145
|
+
result[key] = raw[key];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
// All-fields mode — exclude serializer.hidden on top of model.hidden
|
|
150
|
+
// (model.hidden is already applied by toJSON())
|
|
151
|
+
const hiddenSet = new Set(ctor.hidden || []);
|
|
152
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
153
|
+
if (!hiddenSet.has(key)) {
|
|
154
|
+
result[key] = value;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Computed fields ─────────────────────────────────────────────────────
|
|
160
|
+
for (const [key, fn] of Object.entries(ctor.computed || {})) {
|
|
161
|
+
result[key] = fn(instance, ctx);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Nested serializers ──────────────────────────────────────────────────
|
|
165
|
+
for (const [key, NestedSerializer] of Object.entries(ctor.nested || {})) {
|
|
166
|
+
const value = instance[key];
|
|
167
|
+
|
|
168
|
+
// Skip if not loaded (still a function = lazy-load accessor)
|
|
169
|
+
if (typeof value === 'function') continue;
|
|
170
|
+
// Skip if not present
|
|
171
|
+
if (value === undefined) continue;
|
|
172
|
+
|
|
173
|
+
const nested = new NestedSerializer();
|
|
174
|
+
|
|
175
|
+
if (value === null) {
|
|
176
|
+
result[key] = null;
|
|
177
|
+
} else if (Array.isArray(value)) {
|
|
178
|
+
result[key] = value.map(item => nested.serialize(item, ctx));
|
|
179
|
+
} else {
|
|
180
|
+
result[key] = nested.serialize(value, ctx);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Static convenience methods ─────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Serialize a single model instance.
|
|
191
|
+
*
|
|
192
|
+
* UserSerializer.one(user)
|
|
193
|
+
* UserSerializer.one(user, { req })
|
|
194
|
+
*
|
|
195
|
+
* @param {object} instance
|
|
196
|
+
* @param {object} [ctx]
|
|
197
|
+
* @returns {object|null}
|
|
198
|
+
*/
|
|
199
|
+
static one(instance, ctx = {}) {
|
|
200
|
+
if (!instance) return null;
|
|
201
|
+
return new this().serialize(instance, ctx);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Serialize an array of model instances.
|
|
206
|
+
*
|
|
207
|
+
* UserSerializer.many(users)
|
|
208
|
+
* UserSerializer.many(users, { req })
|
|
209
|
+
*
|
|
210
|
+
* @param {object[]} instances
|
|
211
|
+
* @param {object} [ctx]
|
|
212
|
+
* @returns {object[]}
|
|
213
|
+
*/
|
|
214
|
+
static many(instances, ctx = {}) {
|
|
215
|
+
if (!instances || !instances.length) return [];
|
|
216
|
+
const s = new this();
|
|
217
|
+
return instances.map(i => s.serialize(i, ctx));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Serialize a paginated result from Model.paginate() or QueryBuilder.paginate().
|
|
222
|
+
* Preserves the pagination meta and wraps data through the serializer.
|
|
223
|
+
*
|
|
224
|
+
* const result = await User.with('roles').paginate(page, perPage);
|
|
225
|
+
* return jsonify(UserSerializer.paginate(result));
|
|
226
|
+
*
|
|
227
|
+
* @param {{ data: object[], meta: object }} result
|
|
228
|
+
* @param {object} [ctx]
|
|
229
|
+
* @returns {{ data: object[], meta: object }}
|
|
230
|
+
*/
|
|
231
|
+
static paginate(result, ctx = {}) {
|
|
232
|
+
return {
|
|
233
|
+
data: this.many(result.data || [], ctx),
|
|
234
|
+
meta: result.meta || {},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = { Serializer };
|