@vida-global/core 2.0.11 → 2.1.1

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 (49) hide show
  1. package/lib/activeRecord/README.md +1 -1
  2. package/lib/activeRecord/baseRecord.js +70 -4
  3. package/lib/http/README.md +4 -4
  4. package/lib/http/client.js +33 -2
  5. package/lib/server/README.md +8 -219
  6. package/lib/server/controllerImporter.js +0 -1
  7. package/lib/server/controllerMixins/callbacks.js +16 -61
  8. package/lib/server/controllerMixins/classScopedRegistry.js +48 -0
  9. package/lib/server/controllerMixins/documentation.js +41 -9
  10. package/lib/server/controllerMixins/renderer.js +140 -99
  11. package/lib/server/controllerMixins/validations.js +57 -0
  12. package/lib/server/doc/callbacks.md +23 -0
  13. package/lib/server/doc/documentation.md +135 -0
  14. package/lib/server/doc/renderer.md +115 -0
  15. package/lib/server/doc/requestDetails.md +52 -0
  16. package/lib/server/doc/validations.md +40 -0
  17. package/lib/server/index.js +1 -1
  18. package/lib/server/openApi/apiDocGenerator.js +426 -0
  19. package/lib/server/openApi/apiDocsGenerator.js +147 -0
  20. package/lib/server/openApi/schemaImporter.js +41 -0
  21. package/lib/server/openApi/schemaRegistry.js +43 -0
  22. package/lib/server/openApi/schemas.js +97 -0
  23. package/lib/server/openApi/tagRegistry.js +27 -0
  24. package/lib/server/server.js +14 -0
  25. package/lib/server/serverController.js +4 -9
  26. package/lib/server/statusTexts.js +38 -0
  27. package/lib/utils/yamlLoader.js +17 -0
  28. package/package.json +4 -2
  29. package/test/activeRecord/baseRecord.test.js +101 -0
  30. package/test/http/client.test.js +34 -0
  31. package/test/http/helpers/client.js +31 -0
  32. package/test/server/apiDocGenerator.test.js +743 -0
  33. package/test/server/controllerMixins/callbacks.test.js +326 -0
  34. package/test/server/controllerMixins/classScopedRegistry.test.js +95 -0
  35. package/test/server/controllerMixins/documentation.test.js +168 -0
  36. package/test/server/controllerMixins/renderer.test.js +734 -0
  37. package/test/server/controllerMixins/requestDetails.test.js +225 -0
  38. package/test/server/controllerMixins/routing.test.js +82 -0
  39. package/test/server/controllerMixins/validations.test.js +509 -0
  40. package/test/server/openApi/apiDocsGenerator.test.js +306 -0
  41. package/test/server/openApi/helpers/apiDocsPackageFixture/package.json +5 -0
  42. package/test/server/openApi/schemaImporter.test.js +54 -0
  43. package/test/server/openApi/schemaRegistry.test.js +93 -0
  44. package/test/server/openApi/schemas.test.js +109 -0
  45. package/test/server/serverController.test.js +8 -867
  46. package/test/server/statusTexts.test.js +30 -0
  47. package/test/utils/yamlLoader.test.js +43 -0
  48. package/lib/server/apiDocsGenerator.js +0 -86
  49. package/test/server/apiDocsGenerator.test.js +0 -38
@@ -173,7 +173,7 @@ const rows = await User.findAll({
173
173
 
174
174
  ### API serialization
175
175
 
176
- `record.toApiResponse()` returns a plain object containing every column with overridden getters applied. The server's response renderer calls this automatically when a record is included in an action's return value.
176
+ `record.toApiResponse()` returns a plain object containing the columns exposed by the model's static `documentationSchema()` (which excludes `created_at`, `updated_at`, and `deleted_at`), with overridden getters applied. The server's response renderer calls this automatically when a record is included in an action's return value.
177
177
 
178
178
  ### Closing connections
179
179
 
@@ -1,5 +1,6 @@
1
1
  const { Connection, DEFAULT_DATABASE_ID } = require('./db/connection');
2
2
  const { getActiveRecordSchema } = require('./db/schema');
3
+ const { Schemas } = require('../server/openApi/schemas');
3
4
  const { logger } = require('../logger');
4
5
  const { Model, Op, Sequelize } = require('sequelize');
5
6
  const nodeUtil = require('util');
@@ -373,11 +374,76 @@ class BaseRecord extends Model {
373
374
 
374
375
 
375
376
  toApiResponse() {
376
- const obj = this.dataValues;
377
- for (const attr of Object.keys(obj)) {
378
- obj[attr] = this[attr]; // Use overridden getters when available
377
+ const response = {};
378
+ for (const column of this.apiResponseColumns) {
379
+ response[column] = this[column]; // Use overridden getters when available
379
380
  }
380
- return obj
381
+ return response;
382
+ }
383
+
384
+
385
+ get apiResponseColumns() {
386
+ return Object.keys(this.constructor.documentationSchema());
387
+ }
388
+
389
+
390
+ /***********************************************************************************************
391
+ * DOCUMENTATION
392
+ ***********************************************************************************************/
393
+ static documentationSchema() {
394
+ this.initialize();
395
+ const schemas = {};
396
+ for (const [name, attribute] of Object.entries(this.getAttributes())) {
397
+ if (this.documentationExcludedColumns.includes(name)) continue;
398
+ schemas[name] = this.documentationColumnSchema(attribute);
399
+ }
400
+ return schemas;
401
+ }
402
+
403
+
404
+ static get documentationExcludedColumns() {
405
+ return ['created_at', 'updated_at', 'deleted_at'];
406
+ }
407
+
408
+
409
+ static documentationColumnSchema(attribute) {
410
+ const builder = this.documentationTypeBuilders[attribute.type?.key];
411
+ if (!builder) return Schemas.stringSchema();
412
+ return builder(attribute);
413
+ }
414
+
415
+
416
+ static get documentationTypeBuilders() {
417
+ return {
418
+ INTEGER: () => Schemas.integerSchema(),
419
+ BIGINT: () => Schemas.integerSchema(),
420
+ SMALLINT: () => Schemas.integerSchema(),
421
+ TINYINT: () => Schemas.integerSchema(),
422
+ MEDIUMINT: () => Schemas.integerSchema(),
423
+ FLOAT: () => Schemas.numberSchema(),
424
+ DOUBLE: () => Schemas.numberSchema(),
425
+ REAL: () => Schemas.numberSchema(),
426
+ DECIMAL: () => Schemas.numberSchema(),
427
+ NUMBER: () => Schemas.numberSchema(),
428
+ BOOLEAN: () => Schemas.booleanSchema(),
429
+ DATE: () => Schemas.dateTimeSchema(),
430
+ DATEONLY: () => Schemas.dateTimeSchema(),
431
+ TIME: () => Schemas.dateTimeSchema(),
432
+ ENUM: (attribute) => Schemas.enumSchema({ enums: attribute.type.values }),
433
+ JSON: () => Schemas.objectSchema(),
434
+ JSONB: () => Schemas.objectSchema(),
435
+ ARRAY: () => Schemas.arraySchema(),
436
+ STRING: () => Schemas.stringSchema(),
437
+ CHAR: () => Schemas.stringSchema(),
438
+ TEXT: () => Schemas.stringSchema(),
439
+ CITEXT: () => Schemas.stringSchema(),
440
+ UUID: () => Schemas.stringSchema(),
441
+ };
442
+ }
443
+
444
+
445
+ get apiXMLTagName() {
446
+ return this.constructor.name;
381
447
  }
382
448
  }
383
449
 
@@ -25,10 +25,8 @@ class MyApiClient extends HttpClient {
25
25
  return this.#dev ? 'https://staging.foo.com' : 'https://foo.com';
26
26
  }
27
27
 
28
- get defaultHeaders() {
29
- const headers = super.defaultHeaders;
30
- headers.Authorization = `Bearer ${this.#token}`;
31
- return headers;
28
+ get bearerToken() {
29
+ return this.#token;
32
30
  }
33
31
 
34
32
  async getTickets({ page, pageSize }) {
@@ -104,4 +102,6 @@ Subclasses customize behavior through getters on the class:
104
102
 
105
103
  - `urlRoot` — base URL prepended to every endpoint.
106
104
  - `defaultHeaders` — merged into every request (per-call `headers` override).
105
+ - `bearerToken` — when it returns a value, a `Authorization: Bearer <token>` header is added to `defaultHeaders`.
106
+ - `basicAuthUsername` / `basicAuthPassword` — when both return values, a `Authorization: Basic <base64>` header is added to `defaultHeaders`. `bearerToken` takes precedence if both are set.
107
107
  - `logger` — scoped logger used for the `API CALL: ...` debug line emitted on each request (defaults to `logger.http`).
@@ -178,10 +178,41 @@ class HttpClient {
178
178
 
179
179
 
180
180
  get defaultHeaders() {
181
- return {
181
+ const headers = {
182
182
  Accept: "application/json",
183
183
  "Content-Type": "application/json",
184
- }
184
+ };
185
+ if (this.authorizationHeader) headers.Authorization = this.authorizationHeader;
186
+ return headers;
187
+ }
188
+
189
+
190
+ get authorizationHeader() {
191
+ if (this.bearerToken) return `Bearer ${this.bearerToken}`;
192
+ if (this.basicAuthCredentials) return `Basic ${this.basicAuthCredentials}`;
193
+ return null;
194
+ }
195
+
196
+
197
+ get bearerToken() {
198
+ return null;
199
+ }
200
+
201
+
202
+ get basicAuthCredentials() {
203
+ if (!this.basicAuthUsername || !this.basicAuthPassword) return null;
204
+ const credentials = `${this.basicAuthUsername}:${this.basicAuthPassword}`;
205
+ return Buffer.from(credentials).toString('base64');
206
+ }
207
+
208
+
209
+ get basicAuthUsername() {
210
+ return null;
211
+ }
212
+
213
+
214
+ get basicAuthPassword() {
215
+ return null;
185
216
  }
186
217
 
187
218
 
@@ -113,240 +113,29 @@ module.exports = { InstanceMethods, Accessors };
113
113
  ```
114
114
 
115
115
 
116
- ## Request Properties
116
+ ## Request details
117
117
 
118
- Within an action, the controller exposes:
119
-
120
- | Property | Description |
121
- |---|---|
122
- | `this.params` | Merged route, query, and JSON-body parameters. Returns a structured clone — mutate freely. |
123
- | `this.requestHeaders` | Cloned request headers. |
124
- | `this.responseHeaders` | Outgoing response headers (mutable). |
125
- | `this.contentType` | Shortcut for `requestHeaders['content-type']`. |
126
- | `this.userAgent` | Shortcut for `requestHeaders['user-agent']`. |
127
- | `this.requestId` | Unique request id (added by `express-request-id`). |
128
- | `this.requestBody` | Raw request body. |
129
- | `this.requestMethod` | HTTP method. |
130
- | `this.requestIp` | Client IP address. |
131
- | `this.url` | Original URL. |
132
- | `this.bearerToken` | Parsed `Authorization: Bearer <token>` value, or `null`. |
133
- | `this.statusCode` | Current outgoing status (settable, but prefer the render helpers below). |
134
- | `this.logger` | Per-request child logger. |
135
-
136
-
137
- ## Cookies
138
-
139
- ```js
140
- // All cookies (object)
141
- const all = this.cookies;
142
-
143
- // A single cookie, or null if missing
144
- const session = this.getCookie('session');
145
-
146
- // Set a cookie. Options are passed through to express `res.cookie`.
147
- this.setCookie('session', token, { httpOnly: true, secure: true, maxAge: 86400_000 });
148
- ```
149
-
150
-
151
- ## Parameter coercion
152
-
153
- Use these on the controller instance to normalize inbound params without throwing. Each accepts a `{ fallback }` option (and other shape-specific options) and returns the fallback for unusable input.
154
-
155
- ```js
156
- this.hasParam('agentId'); // true/false
157
- this.coerceString(this.params.name, { fallback: 'anonymous' }); // trims; empty -> fallback
158
- this.coerceBoolean(this.params.enabled, { fallback: false }); // accepts true/false, "true"/"false"/"1"/"0"/"yes"/"no"/"on"/"off"
159
- this.coerceInteger(this.params.limit,
160
- { min: 1, max: 100, fallback: 25 }); // floors, clamps, nonNegative supported
161
- this.coerceObject(this.params.filters, { fallback: {} }); // arrays return fallback
162
- ```
118
+ See [Request details](./doc/requestDetails.md) — request properties, cookies, and parameter coercion.
163
119
 
164
120
 
165
121
  ## Validations
166
122
 
167
- Prefer the static `parametersFor<ActionName>` method on the controller class — the framework runs it before the action and renders a 400 response with structured field errors if anything fails.
168
-
169
- ```js
170
- class UsersController extends VidaServerController {
171
- static parametersForGetIndex() {
172
- return {
173
- pageSize: { isInteger: { gte: 1, lte: 100 }, optional: true },
174
- email: { presence: true, isString: { regex: /\S+@\S+\.\S+/ } },
175
- role: { isEnum: { enums: ['admin', 'member', 'guest'] } },
176
- title: { function: this.prototype.validateUniqueTitle },
177
- };
178
- }
179
-
180
- async getIndex() {
181
- // pageSize/email/role/title are already validated when this runs
182
- }
183
-
184
- async validateUniqueTitle(title) {
185
- if (await User.findByTitle(title)) return 'must be unique';
186
- }
187
- }
188
- ```
189
-
190
- If you need to validate values that are constructed mid-action, call `this.validateParameters(...)` directly with the same shape.
191
-
192
- ### Supported validators
193
-
194
- - `{ optional: true }` — skip validation when the value is `undefined`.
195
- - `{ presence: true }`
196
- - `{ isInteger: true }` or `{ isInteger: { gte: 0, lte: 100 } }`
197
- - `{ isString: true }` or `{ isString: { length: { gte: 10, lte: 100 }, regex: /foo/ } }`
198
- - `{ isBoolean: true }` or `{ isBoolean: false }` (asserts the exact value)
199
- - `{ isDateTime: true }`
200
- - `{ isEnum: { enums: ['a', 'b', 'c'], error: 'optional message' } }`
201
- - `{ function: someFn }` — return a string to flag an error.
202
-
203
-
204
- ## Rendering responses
205
-
206
- By default the controller renders the value returned from the action. Render walks the result recursively and calls `toApiResponse(opts)` on anything that defines it, then wraps the result in a standard envelope.
207
-
208
- ```js
209
- class User {
210
- toApiResponse(opts) {
211
- const data = { name: this.name, email: this.email };
212
- if (opts.includeTitle) data.title = this.title;
213
- return data;
214
- }
215
- }
216
-
217
- async getFoo() {
218
- const user1 = new User('Bruce', 'bwayne@wayne-enterprises.inc');
219
- const user2 = new User('Babs', 'bgordon@wayne-enterprises.inc');
220
- return { bar: 1, baz: [user1, user2] };
221
- }
222
- ```
223
-
224
- …produces:
225
-
226
- ```json
227
- {
228
- "data": {
229
- "bar": 1,
230
- "baz": [
231
- { "name": "Bruce", "email": "bwayne@wayne-enterprises.inc" },
232
- { "name": "Babs", "email": "bgordon@wayne-enterprises.inc" }
233
- ]
234
- },
235
- "status": "ok"
236
- }
237
- ```
238
-
239
- Pass options through to `toApiResponse` by calling `render` explicitly:
240
-
241
- ```js
242
- await this.render(response, { includeTitle: true });
243
- ```
244
-
245
- To skip the standard envelope and emit the body verbatim (legacy clients, file-style payloads):
246
-
247
- ```js
248
- return await this.render(response, { standardize: false });
249
- ```
250
-
251
- ### Status code helpers
252
-
253
- Don't set `this.statusCode` directly — use the helper that pairs the status with `render`:
254
-
255
- | Status | Helper |
256
- |---|---|
257
- | 201 Created | `this.renderCreationSuccessful(body)` |
258
- | 202 Accepted | `this.renderAccepted(body)` |
259
- | 204 No Content | `this.renderNoConent(body)` |
260
- | 301 Moved Permanently | `this.renderMovedPermanently(body)` |
261
- | 302 Found | `this.renderFound(body)` |
262
- | 304 Not Modified | `this.renderNotModified(body)` |
263
- | 307 Temporary Redirect | `this.renderTemporaryRedirect(body)` |
264
- | 308 Permanent Redirect | `this.renderPermanentRedirect(body)` |
265
- | 401 Unauthorized | `this.renderUnauthorized(message, body)` |
266
- | 402 Payment Required | `this.renderPaymentRequired(message, body)` |
267
- | 403 Forbidden | `this.renderForbidden(message, body)` |
268
- | 404 Not Found | `this.renderNotFound(message, body)` |
269
- | 405 Method Not Allowed | `this.renderMethodNotAllowed(message, body)` |
270
- | 408 Request Timeout | `this.renderRequestTimeout(message, body)` |
271
- | 409 Conflict | `this.renderConflict(message, body)` |
272
- | 410 Gone | `this.renderGone(message, body)` |
273
- | 413 Content Too Large | `this.renderContentTooLarge(message, body)` |
274
- | 415 Unsupported Media Type | `this.renderUnsupportedMediaType(message, body)` |
275
- | 422 Unprocessable Content | `this.renderUnprocessableContent(message, body)` |
276
- | 423 Locked | `this.renderLocked(message, body)` |
277
- | 429 Too Many Requests | `this.renderTooManyRequests(message, body)` |
278
-
279
- After a render helper runs, the action does not need to return — the response has already been sent.
280
-
281
-
282
- ## Error handling
283
-
284
- ```js
285
- await this.renderErrors('Something bad happened',
286
- { password: ['must be > 8 characters', 'must include numbers'] });
287
- // {data: {errors: {message: "Something bad happened", fields: {password: [...]}}}, status: "bad request"}
288
-
289
- await this.renderErrors('Something bad happened');
290
- // {data: {errors: {message: "Something bad happened"}}, status: "bad request"}
291
-
292
- await this.renderErrors({ password: ['must be > 8 characters', 'must include numbers'] });
293
- // {data: {errors: {fields: {password: [...]}}}, status: "bad request"}
294
-
295
- await this.renderUnauthorized('Nope');
296
- // {data: {message: "Nope"}, status: "unauthorized"}
297
-
298
- await this.renderForbidden('You shall not pass');
299
- // {data: {message: "You shall not pass"}, status: "forbidden"}
300
- ```
301
-
302
- Throwing any of `this.Errors.Authorization(msg)`, `this.Errors.Forbidden(msg)`, `this.Errors.NotFound(msg)`, or `this.Errors.Validation(msg, fields)` from anywhere in the call chain triggers the matching render helper. Any other thrown error becomes a 500.
123
+ See [Validations](./doc/validations.md) — `parametersFor<ActionName>` and the supported validators.
303
124
 
304
125
 
305
126
  ## Callbacks
306
127
 
307
- Callbacks are methods that run before or after an action. They are used for authentication, action setup, cleanup, etc. If a callback returns `false`, execution stops and no further callbacks or the action are run. Define callbacks in `setupCallbacks()` on the controller.
128
+ See [Callbacks](./doc/callbacks.md) — before/after action callbacks and how subclasses skip them.
308
129
 
309
- ```js
310
- setupCallbacks() {
311
- // Runs before every action except getIndex; halts in production
312
- this.beforeCallback(() => process.env.NODE_ENV != 'production', { except: 'getIndex' });
313
-
314
- // Runs the `setUpSomeStuff` instance method before every action
315
- this.beforeCallback('setUpSomeStuff');
316
-
317
- // Runs `cleanupRequest` after getFoo and getBar
318
- this.afterCallback('cleanupRequest', { only: ['getFoo', 'getBar'] });
319
- }
320
- ```
321
-
322
- A subclass can suppress a callback inherited from a parent without removing it for everyone:
323
-
324
- ```js
325
- this.skipBeforeCallback('authenticateRequest', { only: 'getHealthCheck' });
326
- this.skipAfterCallback('logActivity', { except: ['getRecord'] });
327
- ```
328
130
 
131
+ ## Rendering responses
329
132
 
330
- ## Server-Sent Events / streaming
133
+ See [Rendering responses](./doc/renderer.md) — the render envelope, status-code helpers, error handling, and Server-Sent Events / streaming.
331
134
 
332
- Use `streamSseResponse` to write a long-lived stream of events instead of a one-shot JSON response. The handler is run with `this` bound to the controller, and `writeStreamEvent(name, data)` emits SSE frames.
333
135
 
334
- ```js
335
- async getEvents() {
336
- const ctrl = new AbortController();
337
- await this.streamSseResponse(
338
- async () => {
339
- for await (const evt of this.subscribeToEvents(ctrl.signal)) {
340
- this.writeStreamEvent('update', JSON.stringify(evt));
341
- }
342
- },
343
- async (err) => this.logger.error(err),
344
- ctrl,
345
- );
346
- }
347
- ```
136
+ ## Documentation
348
137
 
349
- A trailing `data: [DONE]\n\n` frame is emitted automatically when the handler returns. If the client disconnects, the abort controller (when provided) is aborted.
138
+ See [API Documentation (OpenAPI)](./doc/documentation.md) — generating an OpenAPI spec: per-action YAML, tags, schema refs, and auth.
350
139
 
351
140
 
352
141
  ## Advanced
@@ -16,7 +16,6 @@ class ControllerImporter extends AbstractAutoImporter {
16
16
  processImport(obj, directoryPrefix) {
17
17
  obj.directoryPrefix = directoryPrefix;
18
18
  obj.autoLoadMixins();
19
- obj.autoLoadDocumentation();
20
19
  }
21
20
  }
22
21
 
@@ -1,55 +1,27 @@
1
1
  const APM = require('../../apm');
2
+ const { ClassScopedRegistry, appliesToAction } = require('./classScopedRegistry');
2
3
 
3
4
 
4
5
  const AUTH_CALLBACK_NAME = 'authenticateRequest';
5
6
 
6
7
 
7
- const afterCallbacks = new Map();
8
- const afterCallbacksToSkip = new Map();
9
- const beforeCallbacks = new Map();
10
- const beforeCallbacksToSkip = new Map();
11
- const callbacksSetup = new Map();
8
+ const registry = new ClassScopedRegistry();
12
9
 
13
10
 
14
- function callbacksListForController(list, controller) {
15
- const cls = controller.constructor;
16
- if (!list.has(cls)) {
17
- list.set(cls, []);
18
- }
19
- return list.get(cls);
20
- }
21
-
22
-
23
- function addCallback(controller, callback, options, callbacksList) {
24
- const list = callbacksListForController(callbacksList, controller)
25
- list.push({ callback, options });
26
- }
27
-
28
-
29
- function shouldRunCallback(actionName, options) {
30
- if (options.only) {
31
- if (Array.isArray(options.only)) return options.only.includes(actionName);
32
- return options.only == actionName;
33
- }
34
-
35
- if (options.except) {
36
- if (Array.isArray(options.except)) return !options.except.includes(actionName);
37
- return options.except != actionName;
38
- }
39
-
40
- return true;
11
+ function addCallback(controller, callback, options, listName) {
12
+ registry.list(listName, controller).push({ callback, options });
41
13
  }
42
14
 
43
15
 
44
16
  async function runCallbacks(callbacksList, callbacksToSkipList, actionName, controller) {
45
17
  let toSkip = callbacksToSkipList.filter(({ callback, options }) => {
46
- return shouldRunCallback(actionName, options);
18
+ return appliesToAction(actionName, options);
47
19
  });
48
20
  toSkip = toSkip.map(({ callback }) => callback);
49
21
 
50
22
  for (const { callback, options } of callbacksList) {
51
- if (!shouldRunCallback(actionName, options)) continue;
52
- if (toSkip.includes(callback)) continue;
23
+ if (!appliesToAction(actionName, options)) continue;
24
+ if (toSkip.includes(callback)) continue;
53
25
  const result = await runCallback(callback, controller)
54
26
  if (result === false) return false;
55
27
  }
@@ -69,50 +41,33 @@ const InstanceMethods = {
69
41
 
70
42
 
71
43
  _applyCallbacks() {
72
- const cls = this.constructor;
73
- if (!callbacksSetup.has(cls)) {
74
- this.setupCallbacks();
75
- callbacksSetup.set(cls, true)
76
- }
77
-
78
- // set up the callbacks when the instance is constructed and prevent it from being called again
79
- this.setupCallbacks = undefined;
44
+ registry.applySetupOnce(this, 'setupCallbacks');
80
45
  },
81
46
 
82
47
 
83
48
  beforeCallback(callback, options={}) {
84
- addCallback(this, callback, options, beforeCallbacks);
49
+ addCallback(this, callback, options, 'before');
85
50
  },
86
51
 
87
52
 
88
53
  afterCallback(callback, options={}) {
89
- addCallback(this, callback, options, afterCallbacks);
54
+ addCallback(this, callback, options, 'after');
90
55
  },
91
56
 
92
57
 
93
58
  skipBeforeCallback(callback, options={}) {
94
- addCallback(this, callback, options, beforeCallbacksToSkip);
59
+ addCallback(this, callback, options, 'beforeSkip');
95
60
  },
96
61
 
97
62
 
98
63
  skipAfterCallback(callback, options={}) {
99
- addCallback(this, callback, options, afterCallbacksToSkip);
64
+ addCallback(this, callback, options, 'afterSkip');
100
65
  },
101
66
 
102
67
 
103
68
  async _runCallbacks(type, actionName) {
104
- let callbacksList, callbacksToSkipList;
105
-
106
- switch(type) {
107
- case 'after':
108
- callbacksList = callbacksListForController(afterCallbacks, this);
109
- callbacksToSkipList = callbacksListForController(afterCallbacksToSkip, this);
110
- break;
111
- case 'before':
112
- callbacksList = callbacksListForController(beforeCallbacks, this);
113
- callbacksToSkipList = callbacksListForController(beforeCallbacksToSkip, this);
114
- break;
115
- }
69
+ const callbacksList = registry.list(type, this);
70
+ const callbacksToSkipList = registry.list(`${type}Skip`, this);
116
71
 
117
72
  return await APM.startSegment(`${type}Callbacks`, true, () => {
118
73
  return runCallbacks(callbacksList, callbacksToSkipList, actionName, this);
@@ -121,10 +76,10 @@ const InstanceMethods = {
121
76
 
122
77
 
123
78
  isAuthenticatedAction(actionName) {
124
- const list = callbacksListForController(beforeCallbacks, this);
79
+ const list = registry.list('before', this);
125
80
  const entry = list.find(({ callback }) => callback === AUTH_CALLBACK_NAME);
126
81
  if (!entry) return false;
127
- return shouldRunCallback(actionName, entry.options);
82
+ return appliesToAction(actionName, entry.options);
128
83
  }
129
84
  }
130
85
 
@@ -0,0 +1,48 @@
1
+ function appliesToAction(actionName, options) {
2
+ if (options.only) {
3
+ if (Array.isArray(options.only)) return options.only.includes(actionName);
4
+ return options.only == actionName;
5
+ }
6
+
7
+ if (options.except) {
8
+ if (Array.isArray(options.except)) return !options.except.includes(actionName);
9
+ return options.except != actionName;
10
+ }
11
+
12
+ return true;
13
+ }
14
+
15
+
16
+ class ClassScopedRegistry {
17
+ #lists = new Map();
18
+ #setup = new Map();
19
+
20
+
21
+ list(name, controller) {
22
+ const byClass = this.#listsFor(name);
23
+ const cls = controller.constructor;
24
+ if (!byClass.has(cls)) byClass.set(cls, []);
25
+ return byClass.get(cls);
26
+ }
27
+
28
+
29
+ #listsFor(name) {
30
+ if (!this.#lists.has(name)) this.#lists.set(name, new Map());
31
+ return this.#lists.get(name);
32
+ }
33
+
34
+
35
+ applySetupOnce(controller, hookName) {
36
+ const cls = controller.constructor;
37
+ if (!this.#setup.has(cls)) {
38
+ controller[hookName]();
39
+ this.#setup.set(cls, true);
40
+ }
41
+
42
+ // run the hook once when the instance is constructed and prevent it from being called again
43
+ controller[hookName] = undefined;
44
+ }
45
+ }
46
+
47
+
48
+ module.exports = { ClassScopedRegistry, appliesToAction };
@@ -1,36 +1,68 @@
1
- const { camelize } = require('inflection');
1
+ const { ClassScopedRegistry, appliesToAction } = require('./classScopedRegistry');
2
2
 
3
3
 
4
- const StaticMethods = {
5
- documentationForAction(actionName) {
6
- const methodName = this.documentationMethodForAction(actionName);
7
- if (this[methodName]) return this[methodName]();
8
- return null;
4
+ const registry = new ClassScopedRegistry();
5
+
6
+
7
+ const InstanceMethods = {
8
+ setupDocumentation() {},
9
+
10
+
11
+ _applyDocumentation() {
12
+ registry.applySetupOnce(this, 'setupDocumentation');
9
13
  },
10
14
 
11
15
 
12
- documentationMethodForAction(actionName) {
13
- return `document${camelize(actionName)}`;
16
+ documentationTag(name, options = {}) {
17
+ registry.list('tags', this).push({ name, options });
14
18
  },
15
19
 
16
20
 
21
+ tagsForAction(actionName) {
22
+ return registry.list('tags', this)
23
+ .filter(({ options }) => appliesToAction(actionName, options))
24
+ .map(({ name }) => name);
25
+ }
26
+ }
27
+
28
+
29
+ const StaticMethods = {
17
30
  isAuthenticatedAction(actionName) {
18
- const controller = new this();
31
+ const controller = new this({}, {});
19
32
  return controller.isAuthenticatedAction(actionName);
20
33
  },
21
34
 
22
35
 
23
36
  _authenticationDocumentation(actionName) {
24
37
  if (!this.isAuthenticatedAction(actionName)) return undefined;
38
+ return [this.authenticationDocumentation(actionName)];
25
39
  },
26
40
 
27
41
 
28
42
  authenticationDocumentation(actionName) {
29
43
  return {apiKeyAuth: []};
44
+ },
45
+
46
+
47
+ _tagsDocumentation(actionName) {
48
+ const controller = new this({}, {});
49
+ return controller.tagsForAction(actionName);
50
+ },
51
+
52
+
53
+ documentationTagDefinitions() {
54
+ const controller = new this({}, {});
55
+ const list = registry.list('tags', controller);
56
+ const definitions = new Map();
57
+ for (const { name, options } of list) {
58
+ if (!definitions.has(name)) definitions.set(name, options.description || '');
59
+ }
60
+ return [...definitions].map(([name, description]) => ({ name, description }));
30
61
  }
31
62
  }
32
63
 
33
64
 
34
65
  module.exports = {
66
+ InstanceMethods,
35
67
  StaticMethods
36
68
  }