@vida-global/core 2.0.10 → 2.1.0

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/index.js +2 -0
  2. package/lib/activeRecord/README.md +1 -1
  3. package/lib/activeRecord/baseRecord.js +70 -4
  4. package/lib/cache/index.js +4 -0
  5. package/lib/cache/memoryCache.js +155 -0
  6. package/lib/server/README.md +8 -219
  7. package/lib/server/controllerImporter.js +0 -1
  8. package/lib/server/controllerMixins/callbacks.js +16 -61
  9. package/lib/server/controllerMixins/classScopedRegistry.js +48 -0
  10. package/lib/server/controllerMixins/documentation.js +41 -9
  11. package/lib/server/controllerMixins/renderer.js +140 -99
  12. package/lib/server/controllerMixins/validations.js +57 -0
  13. package/lib/server/doc/callbacks.md +23 -0
  14. package/lib/server/doc/documentation.md +135 -0
  15. package/lib/server/doc/renderer.md +115 -0
  16. package/lib/server/doc/requestDetails.md +52 -0
  17. package/lib/server/doc/validations.md +40 -0
  18. package/lib/server/index.js +1 -1
  19. package/lib/server/openApi/apiDocGenerator.js +426 -0
  20. package/lib/server/openApi/apiDocsGenerator.js +147 -0
  21. package/lib/server/openApi/schemaImporter.js +41 -0
  22. package/lib/server/openApi/schemaRegistry.js +43 -0
  23. package/lib/server/openApi/schemas.js +97 -0
  24. package/lib/server/openApi/tagRegistry.js +27 -0
  25. package/lib/server/server.js +14 -0
  26. package/lib/server/serverController.js +4 -9
  27. package/lib/server/statusTexts.js +38 -0
  28. package/lib/utils/yamlLoader.js +17 -0
  29. package/package.json +4 -2
  30. package/test/activeRecord/baseRecord.test.js +101 -0
  31. package/test/cache/memoryCache.test.js +233 -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
@@ -0,0 +1,115 @@
1
+ # Rendering responses
2
+
3
+ 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.
4
+
5
+ ```js
6
+ class User {
7
+ toApiResponse(opts) {
8
+ const data = { name: this.name, email: this.email };
9
+ if (opts.includeTitle) data.title = this.title;
10
+ return data;
11
+ }
12
+ }
13
+
14
+ async getFoo() {
15
+ const user1 = new User('Bruce', 'bwayne@wayne-enterprises.inc');
16
+ const user2 = new User('Babs', 'bgordon@wayne-enterprises.inc');
17
+ return { bar: 1, baz: [user1, user2] };
18
+ }
19
+ ```
20
+
21
+ …produces:
22
+
23
+ ```json
24
+ {
25
+ "data": {
26
+ "bar": 1,
27
+ "baz": [
28
+ { "name": "Bruce", "email": "bwayne@wayne-enterprises.inc" },
29
+ { "name": "Babs", "email": "bgordon@wayne-enterprises.inc" }
30
+ ]
31
+ },
32
+ "status": "ok"
33
+ }
34
+ ```
35
+
36
+ Pass options through to `toApiResponse` by calling `render` explicitly:
37
+
38
+ ```js
39
+ await this.render(response, { includeTitle: true });
40
+ ```
41
+
42
+ ## Status code helpers
43
+
44
+ Don't set `this.statusCode` directly — use the helper that pairs the status with `render`:
45
+
46
+ | Status | Helper |
47
+ |---|---|
48
+ | 201 Created | `this.renderCreationSuccessful(body)` |
49
+ | 202 Accepted | `this.renderAccepted(body)` |
50
+ | 204 No Content | `this.renderNoConent(body)` |
51
+ | 301 Moved Permanently | `this.renderMovedPermanently(body)` |
52
+ | 302 Found | `this.renderFound(body)` |
53
+ | 304 Not Modified | `this.renderNotModified(body)` |
54
+ | 307 Temporary Redirect | `this.renderTemporaryRedirect(body)` |
55
+ | 308 Permanent Redirect | `this.renderPermanentRedirect(body)` |
56
+ | 401 Unauthorized | `this.renderUnauthorized(message, body)` |
57
+ | 402 Payment Required | `this.renderPaymentRequired(message, body)` |
58
+ | 403 Forbidden | `this.renderForbidden(message, body)` |
59
+ | 404 Not Found | `this.renderNotFound(message, body)` |
60
+ | 405 Method Not Allowed | `this.renderMethodNotAllowed(message, body)` |
61
+ | 408 Request Timeout | `this.renderRequestTimeout(message, body)` |
62
+ | 409 Conflict | `this.renderConflict(message, body)` |
63
+ | 410 Gone | `this.renderGone(message, body)` |
64
+ | 413 Content Too Large | `this.renderContentTooLarge(message, body)` |
65
+ | 415 Unsupported Media Type | `this.renderUnsupportedMediaType(message, body)` |
66
+ | 422 Unprocessable Content | `this.renderUnprocessableContent(message, body)` |
67
+ | 423 Locked | `this.renderLocked(message, body)` |
68
+ | 429 Too Many Requests | `this.renderTooManyRequests(message, body)` |
69
+
70
+ After a render helper runs, the action does not need to return — the response has already been sent.
71
+
72
+
73
+ ## Error handling
74
+
75
+ ```js
76
+ await this.renderErrors('Something bad happened',
77
+ { password: ['must be > 8 characters', 'must include numbers'] });
78
+ // {data: {errors: {message: "Something bad happened", fields: {password: [...]}}}, status: "bad request"}
79
+
80
+ await this.renderErrors('Something bad happened');
81
+ // {data: {errors: {message: "Something bad happened"}}, status: "bad request"}
82
+
83
+ await this.renderErrors({ password: ['must be > 8 characters', 'must include numbers'] });
84
+ // {data: {errors: {fields: {password: [...]}}}, status: "bad request"}
85
+
86
+ await this.renderUnauthorized('Nope');
87
+ // {data: {message: "Nope"}, status: "unauthorized"}
88
+
89
+ await this.renderForbidden('You shall not pass');
90
+ // {data: {message: "You shall not pass"}, status: "forbidden"}
91
+ ```
92
+
93
+ 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.
94
+
95
+
96
+ ## Server-Sent Events / streaming
97
+
98
+ 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.
99
+
100
+ ```js
101
+ async getEvents() {
102
+ const ctrl = new AbortController();
103
+ await this.streamSseResponse(
104
+ async () => {
105
+ for await (const evt of this.subscribeToEvents(ctrl.signal)) {
106
+ this.writeStreamEvent('update', JSON.stringify(evt));
107
+ }
108
+ },
109
+ async (err) => this.logger.error(err),
110
+ ctrl,
111
+ );
112
+ }
113
+ ```
114
+
115
+ 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.
@@ -0,0 +1,52 @@
1
+ # Request details
2
+
3
+ How a controller reads inbound request state, works with cookies, and coerces parameters.
4
+
5
+
6
+ ## Request Properties
7
+
8
+ Within an action, the controller exposes:
9
+
10
+ | Property | Description |
11
+ |---|---|
12
+ | `this.params` | Merged route, query, and JSON-body parameters. Returns a structured clone — mutate freely. |
13
+ | `this.requestHeaders` | Cloned request headers. |
14
+ | `this.responseHeaders` | Outgoing response headers (mutable). |
15
+ | `this.contentType` | Shortcut for `requestHeaders['content-type']`. |
16
+ | `this.userAgent` | Shortcut for `requestHeaders['user-agent']`. |
17
+ | `this.requestId` | Unique request id (added by `express-request-id`). |
18
+ | `this.requestBody` | Raw request body. |
19
+ | `this.requestMethod` | HTTP method. |
20
+ | `this.requestIp` | Client IP address. |
21
+ | `this.url` | Original URL. |
22
+ | `this.bearerToken` | Parsed `Authorization: Bearer <token>` value, or `null`. |
23
+ | `this.statusCode` | Current outgoing status (settable, but prefer the render helpers below). |
24
+ | `this.logger` | Per-request child logger. |
25
+
26
+
27
+ ## Cookies
28
+
29
+ ```js
30
+ // All cookies (object)
31
+ const all = this.cookies;
32
+
33
+ // A single cookie, or null if missing
34
+ const session = this.getCookie('session');
35
+
36
+ // Set a cookie. Options are passed through to express `res.cookie`.
37
+ this.setCookie('session', token, { httpOnly: true, secure: true, maxAge: 86400_000 });
38
+ ```
39
+
40
+
41
+ ## Parameter coercion
42
+
43
+ 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.
44
+
45
+ ```js
46
+ this.hasParam('agentId'); // true/false
47
+ this.coerceString(this.params.name, { fallback: 'anonymous' }); // trims; empty -> fallback
48
+ this.coerceBoolean(this.params.enabled, { fallback: false }); // accepts true/false, "true"/"false"/"1"/"0"/"yes"/"no"/"on"/"off"
49
+ this.coerceInteger(this.params.limit,
50
+ { min: 1, max: 100, fallback: 25 }); // floors, clamps, nonNegative supported
51
+ this.coerceObject(this.params.filters, { fallback: {} }); // arrays return fallback
52
+ ```
@@ -0,0 +1,40 @@
1
+ # Validations
2
+
3
+ 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.
4
+
5
+ ```js
6
+ class UsersController extends VidaServerController {
7
+ static parametersForGetIndex() {
8
+ return {
9
+ pageSize: { isInteger: { gte: 1, lte: 100 }, optional: true },
10
+ email: { presence: true, isString: { regex: /\S+@\S+\.\S+/ } },
11
+ role: { isEnum: { enums: ['admin', 'member', 'guest'] } },
12
+ title: { function: this.prototype.validateUniqueTitle },
13
+ };
14
+ }
15
+
16
+ async getIndex() {
17
+ // pageSize/email/role/title are already validated when this runs
18
+ }
19
+
20
+ async validateUniqueTitle(title) {
21
+ if (await User.findByTitle(title)) return 'must be unique';
22
+ }
23
+ }
24
+ ```
25
+
26
+ ## Supported validators
27
+
28
+ - `{ optional: true }` — skip validation when the value is `undefined`.
29
+ - `{ presence: true }`
30
+ - `{ isInteger: true }` or `{ isInteger: { gte: 0, lte: 100 } }`
31
+ - `{ isNumber: true }` or `{ isNumber: { gte: 0, lte: 9.99 } }` — accepts integers and decimals.
32
+ - `{ isString: true }` or `{ isString: { length: { gte: 10, lte: 100 }, regex: /foo/ } }`
33
+ - `{ isBoolean: true }` or `{ isBoolean: false }` (asserts the exact value)
34
+ - `{ isDateTime: true }`
35
+ - `{ isEnum: { enums: ['a', 'b', 'c'], error: 'optional message' } }`
36
+ - `{ isArray: true }` or `{ isArray: { of: { isInteger: { gte: 1 } }, length: { gte: 1, lte: 50 } } }` — `of` validates every element against the nested definition; `length` bounds the item count.
37
+ - `{ isObject: true }` or `{ isObject: { properties: { city: { isString: true }, zip: { isString: true, optional: true } } } }` — `properties` validates each declared field against its nested definition.
38
+ - `{ function: someFn }` — return a string to flag an error.
39
+
40
+ `of` and `properties` take the same validator-definition shape as a top-level parameter, so arrays and objects can nest arbitrarily.
@@ -1,7 +1,7 @@
1
1
  const { VidaServer } = require('./server');
2
2
  const { VidaServerController } = require('./serverController');
3
3
  const Errors = require('./errors');
4
- const { ApiDocsGenerator } = require('./apiDocsGenerator');
4
+ const { ApiDocsGenerator } = require('./openApi/apiDocsGenerator');
5
5
 
6
6
 
7
7
  module.exports = {
@@ -0,0 +1,426 @@
1
+ const { Schemas } = require('./schemas');
2
+ const { SchemaRegistry } = require('./schemaRegistry');
3
+ const { YamlLoader } = require('../../utils/yamlLoader');
4
+ const { statusTextFor } = require('../statusTexts');
5
+
6
+
7
+ const ERROR_MESSAGE_SCHEMA = { type: 'string', nullable: true };
8
+ const ERROR_FIELDS_SCHEMA = { type: 'object', additionalProperties: { type: 'array', items: { type: 'string' } } };
9
+
10
+ const BODY_MARKER_KEY = '__vidaApiDocBodyMarker__';
11
+ const BODY_MARKER = { [BODY_MARKER_KEY]: true };
12
+ const MESSAGE_MARKER = { __vidaApiDocMessageMarker__: true };
13
+ const FIELDS_MARKER = { __vidaApiDocFieldsMarker__: true };
14
+
15
+
16
+ class ApiDocGenerator {
17
+ #action;
18
+ #_allRequestInputs;
19
+ #controllerClass;
20
+ #details;
21
+ #documentation;
22
+ #_requestInputsByName;
23
+ #_pathParamNames;
24
+ #schemaRegistry;
25
+
26
+
27
+ constructor(action, controllerClass, schemaRegistry = new SchemaRegistry()) {
28
+ this.#action = action;
29
+ this.#controllerClass = controllerClass;
30
+ this.#schemaRegistry = schemaRegistry;
31
+ this.#details = this.#loadActionDocumentation();
32
+ }
33
+
34
+
35
+ #loadActionDocumentation() {
36
+ if (!YamlLoader.fileExists(this.#actionDocumentationPath)) return {};
37
+ return YamlLoader.loadFile(this.#actionDocumentationPath);
38
+ }
39
+
40
+
41
+ get #actionDocumentationPath() {
42
+ return `${this.#controllerClass.documentationDirectory}/${this.#action.action}.yaml`;
43
+ }
44
+
45
+
46
+ get schemaRegistry() {
47
+ return this.#schemaRegistry;
48
+ }
49
+
50
+
51
+ get hasDocumentation() {
52
+ if (this.skip) return false;
53
+ return Boolean(this.#details.description);
54
+ }
55
+
56
+
57
+ get skip() {
58
+ return this.#details.skip === true;
59
+ }
60
+
61
+
62
+ get documentation() {
63
+ if (!this.#documentation) {
64
+ this.#documentation = this.#generateDocumentation();
65
+ }
66
+ return structuredClone(this.#documentation);
67
+ }
68
+
69
+
70
+ #generateDocumentation() {
71
+ const documentation = {
72
+ operationId: this.operationId,
73
+ summary: this.summary,
74
+ description: this.description,
75
+ parameters: this.parameters,
76
+ responses: this.responses,
77
+ };
78
+
79
+ const requestBody = this.requestBody;
80
+ if (requestBody) documentation.requestBody = requestBody;
81
+
82
+ const security = this.security;
83
+ if (security) documentation.security = security;
84
+
85
+ const tags = this.tags;
86
+ if (tags && tags.length) documentation.tags = tags;
87
+
88
+ return documentation;
89
+ }
90
+
91
+
92
+ get security() {
93
+ return this.#controllerClass._authenticationDocumentation(this.#action.action);
94
+ }
95
+
96
+
97
+ get tags() {
98
+ return this.#controllerClass._tagsDocumentation(this.#action.action);
99
+ }
100
+
101
+
102
+ get responses() {
103
+ const responses = this.#details.responses;
104
+ if (!responses) return { '200': { description: 'Successful response' } };
105
+ return this.#formatResponses(responses);
106
+ }
107
+
108
+
109
+ #formatResponses(responses) {
110
+ const formatted = {};
111
+ for (const [status, def] of Object.entries(responses)) {
112
+ formatted[status] = this.#formatResponse(status, def);
113
+ }
114
+ return formatted;
115
+ }
116
+
117
+
118
+ #formatResponse(status, def) {
119
+ const response = { description: def.description || '' };
120
+ if (def.body === undefined) return response;
121
+
122
+ const dataSchema = this.#responseSchema(def.body);
123
+ const envelope = this.#envelopeSchema(status);
124
+ const schema = this.#schematizeNode(envelope, dataSchema);
125
+ response.content = { 'application/json': { schema } };
126
+ return response;
127
+ }
128
+
129
+
130
+ #envelopeSchema(status) {
131
+ const code = Number(status);
132
+ const errors = code >= 400 ? this.#controllerClass.formatErrors(MESSAGE_MARKER, FIELDS_MARKER) : null;
133
+ return this.#controllerClass.formatResponseBody(BODY_MARKER, errors, {
134
+ statusCode: code,
135
+ statusText: statusTextFor(code),
136
+ });
137
+ }
138
+
139
+
140
+ #schematizeNode(node, dataSchema) {
141
+ if (node === BODY_MARKER) return dataSchema;
142
+ if (node === MESSAGE_MARKER) return structuredClone(ERROR_MESSAGE_SCHEMA);
143
+ if (node === FIELDS_MARKER) return structuredClone(ERROR_FIELDS_SCHEMA);
144
+ if (Array.isArray(node)) return node.map(item => this.#schematizeNode(item, dataSchema));
145
+ if (node !== null && typeof node === 'object') return this.#schematizeObject(node, dataSchema);
146
+ return this.#schematizeScalar(node);
147
+ }
148
+
149
+
150
+ #schematizeObject(node, dataSchema) {
151
+ if (this.#isBodySpread(node)) return this.#schematizeSpread(node, dataSchema);
152
+ const properties = {};
153
+ for (const [key, value] of Object.entries(node)) {
154
+ properties[key] = this.#schematizeNode(value, dataSchema);
155
+ }
156
+ return { type: 'object', properties };
157
+ }
158
+
159
+
160
+ #schematizeSpread(node, dataSchema) {
161
+ const properties = {};
162
+ for (const [key, value] of Object.entries(node)) {
163
+ if (key === BODY_MARKER_KEY) continue;
164
+ properties[key] = this.#schematizeNode(value, dataSchema);
165
+ }
166
+ return this.#mergeBodyProperties(properties, dataSchema);
167
+ }
168
+
169
+
170
+ #mergeBodyProperties(properties, dataSchema) {
171
+ if (!this.#isObjectSchema(dataSchema)) {
172
+ return { type: 'object', properties, allOf: [dataSchema] };
173
+ }
174
+ Object.assign(properties, dataSchema.properties);
175
+ const schema = { type: 'object', properties };
176
+ if (dataSchema.required) schema.required = dataSchema.required;
177
+ return schema;
178
+ }
179
+
180
+
181
+ #schematizeScalar(value) {
182
+ return { type: typeof value, example: value };
183
+ }
184
+
185
+
186
+ #isBodySpread(node) {
187
+ return Object.prototype.hasOwnProperty.call(node, BODY_MARKER_KEY);
188
+ }
189
+
190
+
191
+ #isObjectSchema(schema) {
192
+ return Boolean(schema && schema.type === 'object' && schema.properties);
193
+ }
194
+
195
+
196
+ #responseSchema(node) {
197
+ if (Array.isArray(node)) return this.#responseSchemaArray(node);
198
+ if (node !== null && typeof node === 'object') return this.#responseSchemaObject(node);
199
+ return node;
200
+ }
201
+
202
+
203
+ #responseSchemaArray(nodes) {
204
+ return nodes.map(node => this.#responseSchema(node));
205
+ }
206
+
207
+
208
+ #responseSchemaObject(node) {
209
+ if (node.ref !== undefined) return this.#refSchema(node.ref);
210
+ const schema = {};
211
+ for (const [key, value] of Object.entries(node)) {
212
+ schema[key] = this.#responseSchema(value);
213
+ }
214
+ return schema;
215
+ }
216
+
217
+
218
+ get endpoint() {
219
+ return this.#action.path.replace(/:([^/]+)/g, '{$1}');
220
+ }
221
+
222
+
223
+ get method() {
224
+ return this.#action.method.toLowerCase();
225
+ }
226
+
227
+
228
+ get operationId() {
229
+ const action = this.#action.action;
230
+ const prefix = this.#controllerPrefix;
231
+ if (!prefix) return action;
232
+ return `${prefix}${action.charAt(0).toUpperCase()}${action.slice(1)}`;
233
+ }
234
+
235
+
236
+ get #controllerPrefix() {
237
+ const name = this.#controllerClass.name;
238
+ if (!name) return '';
239
+ const base = name.replace(/Controller$/, '');
240
+ return base.charAt(0).toLowerCase() + base.slice(1);
241
+ }
242
+
243
+
244
+ get description() {
245
+ return this.#details.description || null;
246
+ }
247
+
248
+
249
+ get summary() {
250
+ return this.#details.summary || null;
251
+ }
252
+
253
+
254
+ /***********************************************************************************************
255
+ * INPUTS
256
+ ***********************************************************************************************/
257
+ get parameters() {
258
+ return [
259
+ ...Object.values(this.pathParams),
260
+ ...Object.values(this.queryParams),
261
+ ];
262
+ }
263
+
264
+
265
+ get requestBody() {
266
+ const properties = this.bodyProperties;
267
+ if (!Object.keys(properties).length) return undefined;
268
+
269
+ const required = Object.keys(properties).filter(name => this.#isRequired(this.#requestInputsByName[name]));
270
+ const schema = { type: 'object', properties };
271
+ if (required.length) schema.required = required;
272
+
273
+ return {
274
+ required: required.length > 0,
275
+ content: { [this.#requestMediaType]: { schema } },
276
+ };
277
+ }
278
+
279
+
280
+ get #requestMediaType() {
281
+ if (this.#hasBinaryBodyProperty) return 'multipart/form-data';
282
+ return 'application/json';
283
+ }
284
+
285
+
286
+ get #hasBinaryBodyProperty() {
287
+ return Object.values(this.bodyProperties).some(property => property.format === 'binary');
288
+ }
289
+
290
+
291
+ get pathParams() {
292
+ const params = this.#allRequestInputs.filter(([name, _]) => this.#pathParamNames.includes(name));
293
+ return this.#formatInputs(Object.fromEntries(params), 'path');
294
+ }
295
+
296
+
297
+ get queryParams() {
298
+ const params = this.#allRequestInputs.filter(([name, details]) => {
299
+ if (this.#pathParamNames.includes(name)) return false;
300
+ if (details?.queryParameter === true) return true;
301
+ return this.method == 'get' || this.method == 'delete';
302
+ });
303
+ return this.#formatInputs(Object.fromEntries(params), 'query');
304
+ }
305
+
306
+
307
+ get bodyProperties() {
308
+ const inputs = this.#allRequestInputs.filter(([name, _]) => {
309
+ if (this.#pathParamNames.includes(name)) return false;
310
+ return this.method == 'post' || this.method == 'put';
311
+ });
312
+ return this.#formatInputs(Object.fromEntries(inputs), 'body');
313
+ }
314
+
315
+
316
+ #formatInputs(inputs, type) {
317
+ const formatted = {};
318
+ for (const [name, details] of Object.entries(inputs || {})) {
319
+ formatted[name] = this.#formatInput(name, details, type);
320
+ }
321
+ return formatted;
322
+ }
323
+
324
+
325
+ #formatInput(name, details, type) {
326
+ if (type == 'body') {
327
+ return this.#formatBodyProperty(name, details);
328
+ } else {
329
+ return this.#formatParam(name, details, type);
330
+ }
331
+ }
332
+
333
+
334
+ #formatParam(name, details, type) {
335
+ const schema = this.#schemaFor(details);
336
+ const param = {
337
+ name,
338
+ in: type,
339
+ required: type == 'path' || this.#isRequired(details),
340
+ description: details.description || '',
341
+ schema,
342
+ };
343
+ if (details.example !== undefined) {
344
+ param.example = details.example;
345
+ delete schema.example;
346
+ }
347
+ return param;
348
+ }
349
+
350
+
351
+ #formatBodyProperty(name, details) {
352
+ return {
353
+ ...this.#schemaFor(details),
354
+ description: details.description || '',
355
+ };
356
+ }
357
+
358
+
359
+ get #pathParamNames() {
360
+ if (!this.#_pathParamNames) {
361
+ const names = this.endpoint.match(/{[^}]+}/g) || [];
362
+ this.#_pathParamNames = names.map(p => {
363
+ return p.replace(/[{}]/g, '')
364
+ });
365
+ }
366
+ return this.#_pathParamNames;
367
+ }
368
+
369
+
370
+ get #allRequestInputs() {
371
+ if (!this.#_allRequestInputs) {
372
+ const inputs = this.#controllerClass.parametersForAction(this.#action.action) || {};
373
+ this.#_allRequestInputs = Object.entries(inputs);
374
+ }
375
+ return this.#_allRequestInputs;
376
+ }
377
+
378
+
379
+ get #requestInputsByName() {
380
+ if (!this.#_requestInputsByName) {
381
+ this.#_requestInputsByName = Object.fromEntries(this.#allRequestInputs);
382
+ }
383
+ return this.#_requestInputsByName;
384
+ }
385
+
386
+
387
+ #isRequired(details) {
388
+ return details.optional !== true;
389
+ }
390
+
391
+
392
+ /***********************************************************************************************
393
+ * SCHEMA
394
+ ***********************************************************************************************/
395
+ #schemaFor(details) {
396
+ if (details.ref !== undefined) return this.#refSchema(details.ref);
397
+ const schema = Schemas.for(details, ref => this.#refSchema(ref));
398
+ if (details.example !== undefined) schema.example = details.example;
399
+ if (details.default !== undefined) schema.default = details.default;
400
+ if (details.format !== undefined) schema.format = details.format;
401
+ if (details.nullable !== undefined) schema.nullable = details.nullable;
402
+ return schema;
403
+ }
404
+
405
+
406
+ #refSchema(ref) {
407
+ const cls = (typeof ref === 'string') ? this.#schemaRegistry.resolve(ref) : ref;
408
+ if (!cls || typeof cls.documentationSchema !== 'function') {
409
+ throw new Error('A ref target must expose a static documentationSchema() method');
410
+ }
411
+ const name = this.#schemaName(cls);
412
+ if (!this.#schemaRegistry.has(name)) {
413
+ this.#schemaRegistry.reserve(name);
414
+ this.#schemaRegistry.set(name, { type: 'object', properties: cls.documentationSchema() });
415
+ }
416
+ return { $ref: `#/components/schemas/${name}` };
417
+ }
418
+
419
+
420
+ #schemaName(cls) {
421
+ return cls.documentationSchemaName || cls.name;
422
+ }
423
+ }
424
+
425
+
426
+ module.exports = { ApiDocGenerator };