@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,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
  }
@@ -1,4 +1,8 @@
1
- const Errors = require('../errors');
1
+ const { create } = require('xmlbuilder2');
2
+
3
+
4
+ const Errors = require('../errors');
5
+ const { STATUS_TEXTS } = require('../statusTexts');
2
6
 
3
7
 
4
8
  class StreamInProgressError extends Error {}
@@ -6,25 +10,26 @@ class NoActiveStreamError extends Error {}
6
10
 
7
11
 
8
12
  const CONTENT_TYPE_BY_EXTENSION = {
9
- html: 'text/html; charset=utf-8',
10
- htm: 'text/html; charset=utf-8',
11
13
  css: 'text/css; charset=utf-8',
14
+ gif: 'image/gif',
15
+ htm: 'text/html; charset=utf-8',
16
+ html: 'text/html; charset=utf-8',
17
+ ico: 'image/x-icon',
18
+ jpeg: 'image/jpeg',
19
+ jpg: 'image/jpeg',
12
20
  js: 'application/javascript; charset=utf-8',
13
- mjs: 'application/javascript; charset=utf-8',
14
21
  json: 'application/json; charset=utf-8',
15
- txt: 'text/plain; charset=utf-8',
22
+ map: 'application/json; charset=utf-8',
23
+ mjs: 'application/javascript; charset=utf-8',
24
+ otf: 'font/otf',
16
25
  png: 'image/png',
17
- jpg: 'image/jpeg',
18
- jpeg: 'image/jpeg',
19
- gif: 'image/gif',
20
26
  svg: 'image/svg+xml',
21
- ico: 'image/x-icon',
27
+ ttf: 'font/ttf',
28
+ txt: 'text/plain; charset=utf-8',
22
29
  webp: 'image/webp',
23
30
  woff: 'font/woff',
24
31
  woff2: 'font/woff2',
25
- ttf: 'font/ttf',
26
- otf: 'font/otf',
27
- map: 'application/json; charset=utf-8',
32
+ xml: 'application/xml; charset=utf-8',
28
33
  };
29
34
 
30
35
 
@@ -51,23 +56,46 @@ const InstanceMethods = {
51
56
  if (this.isStreaming) return;
52
57
 
53
58
  if (typeof body == 'string') {
54
- this._response.send(body);
59
+ this.renderTextResponse(body);
55
60
  } else {
56
- body = await this.processJSONBody(body, options);
61
+ body = await this._serializeResponseBody(body, options);
57
62
 
58
63
  const errors = body.errors || null;
59
64
  delete body.errors;
60
65
 
61
- body = this.formatJSONBody(body, errors, options);
62
- this._response.json(body);
66
+ body = this.formatResponseBody(body, errors, options);
67
+
68
+ if (this.isXMLRequest) {
69
+ this.renderXMLResponse(body);
70
+ } else {
71
+ this.renderJSONResponse(body);
72
+ }
63
73
  }
64
74
  this.markRendered();
65
75
  },
66
76
 
67
77
 
68
- async renderErrors(message, fields) {
69
- const errors = {message: null, fields: {}};
78
+ renderJSONResponse(body) {
79
+ this.setHeader('content-type', CONTENT_TYPE_BY_EXTENSION.json);
80
+ this._response.json(body);
81
+ },
70
82
 
83
+
84
+ renderXMLResponse(body) {
85
+ this.setHeader('content-type', CONTENT_TYPE_BY_EXTENSION.xml);
86
+ const doc = create({ response: body });
87
+ const xml = doc.end();
88
+ this._response.send(xml);
89
+ },
90
+
91
+
92
+ renderTextResponse(body) {
93
+ this.setHeader('content-type', CONTENT_TYPE_BY_EXTENSION.txt);
94
+ this._response.send(body);
95
+ },
96
+
97
+
98
+ async renderErrors(message, fields) {
71
99
  if (message && !fields && typeof message == 'object') {
72
100
  fields = message;
73
101
  message = null;
@@ -77,50 +105,84 @@ const InstanceMethods = {
77
105
  for (const [field, values] of Object.entries(fields)) {
78
106
  if (!Array.isArray(values)) fields[field] = [values];
79
107
  }
80
- errors.fields = fields;
81
108
  }
82
109
 
110
+ const errors = this.constructor.formatErrors(message, fields);
83
111
  await this.renderBadRequest(message, { errors });
84
112
  },
85
113
 
86
114
 
87
- formatJSONBody(body, errors, options) {
88
- if (options.standardize === false) return body;
89
-
90
- const response = {data: body, status: this.statusText};
91
- if (errors) response.errors = errors;
92
-
93
- return response;
115
+ formatResponseBody(body, errors, options={}) {
116
+ return this.constructor.formatResponseBody(body, errors, {
117
+ statusCode: this.statusCode,
118
+ statusText: this.statusText,
119
+ });
94
120
  },
95
121
 
96
122
 
97
- async processJSONBody(body, options) {
123
+ async _serializeResponseBody(body, options) {
98
124
  if (body === undefined) return null;
99
125
  if (!body || typeof body != 'object') return body;
100
126
 
101
- let processedBody;
102
-
103
127
  if (body.toApiResponse) {
104
128
  const formattedBody = await body.toApiResponse(options);
105
- return await this.processJSONBody(formattedBody, options);
129
+ return await this._serializeResponseBody(formattedBody, options);
106
130
 
107
131
  } else if (Array.isArray(body)) {
108
- const processed = [];
109
- for (const idx in body) {
110
- processed.push(await this.processJSONBody(body[idx], options));
111
- }
112
- return processed;
132
+ return await this._serializeArrayForResponseBody(body, options);
113
133
 
114
134
  } else if (body.constructor == Date) {
115
- return body.getTime();
135
+ return await this._serializeDateForResponseBody(body, options);
116
136
 
117
137
  } else {
118
- const processed = {};
119
- for (let [key, val] of Object.entries(body)) {
120
- processed[key] = await this.processJSONBody(val, options);
138
+ return await this._serializeDefaultResponseBody(body, options);
139
+ }
140
+ },
141
+
142
+
143
+ async _serializeArrayForResponseBody(arr, options) {
144
+ const children = [];
145
+ const response = this.isXMLRequest ? {'#': children} : children;
146
+
147
+ for (const child of arr) {
148
+ let serialized = await this._serializeResponseBody(child, options);
149
+ if (this.isXMLRequest) {
150
+ serialized = this._wrapXMLArrayItem(child, serialized);
121
151
  }
122
- return processed;
152
+ children.push(serialized);
123
153
  }
154
+
155
+ return response;
156
+ },
157
+
158
+
159
+ _wrapXMLArrayItem(child, serialized) {
160
+ const tagName = child?.apiXMLTagName || 'item';
161
+ return {[tagName]: serialized};
162
+ },
163
+
164
+
165
+ _serializeDateForResponseBody(date, options) {
166
+ return date.getTime();
167
+ },
168
+
169
+
170
+ async _serializeDefaultResponseBody(body, options) {
171
+ const processed = {};
172
+ for (let [key, val] of Object.entries(body)) {
173
+ const outputKey = this.isXMLRequest ? this._toXMLElementName(key) : key;
174
+ processed[outputKey] = await this._serializeResponseBody(val, options);
175
+ }
176
+ return processed;
177
+ },
178
+
179
+
180
+ // XML element names can't start with a digit or contain spaces/other invalid characters,
181
+ // so sanitize keys that would otherwise make xmlbuilder2 throw on dynamic response data.
182
+ _toXMLElementName(key) {
183
+ let name = `${key}`.replace(/[^a-zA-Z0-9_.-]/g, '_');
184
+ if (!/^[a-zA-Z_]/.test(name)) name = `_${name}`;
185
+ return name;
124
186
  },
125
187
 
126
188
 
@@ -243,67 +305,45 @@ const InstanceMethods = {
243
305
  }
244
306
 
245
307
 
308
+ const StaticMethods = {
309
+ formatResponseBody(body, errors, { statusCode, statusText }) {
310
+ const response = {data: body, status: statusText};
311
+ if (errors) response.errors = errors;
312
+
313
+ return response;
314
+ },
315
+
316
+
317
+ formatErrors(message, fields) {
318
+ return { message: message ?? null, fields: fields ?? {} };
319
+ },
320
+ }
321
+
322
+
246
323
  const Accessors = {
324
+ requestedFormat: {
325
+ get() {
326
+ const override = `${this.params._format || ''}`.toLowerCase();
327
+ if (override === 'xml' || override === 'json') return override;
328
+ if (/\bxml\b/i.test(this.requestHeaders.accept || '')) return 'xml';
329
+ return 'json';
330
+ }
331
+ },
332
+
333
+
334
+ isJSONRequest: {
335
+ get() { return this.requestedFormat === 'json' }
336
+ },
337
+
338
+
339
+ isXMLRequest: {
340
+ get() { return this.requestedFormat === 'xml' }
341
+ },
342
+
343
+
247
344
  statusText: {
248
345
  get() {
249
- switch(this.statusCode) {
250
- case 200:
251
- return 'ok';
252
- case 201:
253
- return 'created';
254
- case 202:
255
- return 'accepted';
256
- case 204:
257
- return 'no content';
258
- case 301:
259
- return 'moved permanently';
260
- case 302:
261
- return 'found';
262
- case 304:
263
- return 'not modified';
264
- case 307:
265
- return 'temporary redirect';
266
- case 308:
267
- return 'permanent redirect';
268
- case 400:
269
- return 'bad request'
270
- case 401:
271
- return 'unauthorized';
272
- case 402:
273
- return 'payment required';
274
- case 403:
275
- return 'forbidden';
276
- case 404:
277
- return 'not found';
278
- case 405:
279
- return 'method not allowed';
280
- case 408:
281
- return 'request timeout';
282
- case 409:
283
- return 'conflict';
284
- case 410:
285
- return 'gone';
286
- case 413:
287
- return 'content too large';
288
- case 415:
289
- return 'unsupported media type';
290
- case 422:
291
- return 'unprocessable content';
292
- case 423:
293
- return 'locked';
294
- case 429:
295
- return 'too many requests';
296
- case 500:
297
- return 'server error';
298
- case 501:
299
- return 'not implemented';
300
- case 502:
301
- return 'bad gateway';
302
- case 503:
303
- return 'service unavailable';
304
- case 504:
305
- return 'gateway timeout';
306
- }
346
+ return STATUS_TEXTS[this.statusCode];
307
347
  },
308
348
  }
309
349
  }
@@ -312,6 +352,7 @@ const Accessors = {
312
352
  module.exports = {
313
353
  Accessors,
314
354
  InstanceMethods,
355
+ StaticMethods,
315
356
  NoActiveStreamError,
316
- StreamInProgressError
357
+ StreamInProgressError
317
358
  }
@@ -101,6 +101,63 @@ const InstanceMethods = {
101
101
  },
102
102
 
103
103
 
104
+ validateIsNumber(value, options) {
105
+ if (typeof value == 'string' && !value.match(/^[\s\d,.]+$/)) return 'must be a number';
106
+ const num = parseFloat(value);
107
+ if (isNaN(num)) return 'must be a number';
108
+
109
+ if (options?.gte !== undefined) {
110
+ if (num < options.gte) return `must be greater than or equal to ${options.gte}`;
111
+ }
112
+ if (options?.lte !== undefined) {
113
+ if (num > options.lte) return `must be less than or equal to ${options.lte}`;
114
+ }
115
+ },
116
+
117
+
118
+ async validateIsArray(value, options) {
119
+ if (!Array.isArray(value)) return 'must be an array';
120
+
121
+ const lengthError = this.validateArrayLength(value, options?.length);
122
+ if (lengthError) return lengthError;
123
+
124
+ if (options?.of) return await this.validateArrayElements(value, options.of);
125
+ },
126
+
127
+
128
+ validateArrayLength(value, length) {
129
+ if (length?.gte !== undefined) {
130
+ if (value.length < length.gte) return `must have at least ${length.gte} items`;
131
+ }
132
+ if (length?.lte !== undefined) {
133
+ if (value.length > length.lte) return `must have at most ${length.lte} items`;
134
+ }
135
+ },
136
+
137
+
138
+ async validateArrayElements(value, elementValidations) {
139
+ for (const element of value) {
140
+ const errors = await this.validateParameter(element, elementValidations);
141
+ if (errors.length) return `elements ${errors.join(', ')}`;
142
+ }
143
+ },
144
+
145
+
146
+ async validateIsObject(value, options) {
147
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return 'must be an object';
148
+
149
+ if (options?.properties) return await this.validateObjectProperties(value, options.properties);
150
+ },
151
+
152
+
153
+ async validateObjectProperties(value, properties) {
154
+ for (const [propertyName, propertyValidations] of Object.entries(properties)) {
155
+ const errors = await this.validateParameter(value[propertyName], propertyValidations);
156
+ if (errors.length) return `${propertyName} ${errors.join(', ')}`;
157
+ }
158
+ },
159
+
160
+
104
161
  async validateFunction(value, fnc) {
105
162
  const error = await fnc.call(this, value);
106
163
  if (error) return error;
@@ -0,0 +1,23 @@
1
+ # Callbacks
2
+
3
+ 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.
4
+
5
+ ```js
6
+ setupCallbacks() {
7
+ // Runs before every action except getIndex; halts in production
8
+ this.beforeCallback(() => process.env.NODE_ENV != 'production', { except: 'getIndex' });
9
+
10
+ // Runs the `setUpSomeStuff` instance method before every action
11
+ this.beforeCallback('setUpSomeStuff');
12
+
13
+ // Runs `cleanupRequest` after getFoo and getBar
14
+ this.afterCallback('cleanupRequest', { only: ['getFoo', 'getBar'] });
15
+ }
16
+ ```
17
+
18
+ A subclass can suppress a callback inherited from a parent without removing it for everyone:
19
+
20
+ ```js
21
+ this.skipBeforeCallback('authenticateRequest', { only: 'getHealthCheck' });
22
+ this.skipAfterCallback('logActivity', { except: ['getRecord'] });
23
+ ```
@@ -0,0 +1,135 @@
1
+ # API Documentation (OpenAPI)
2
+
3
+ The server can generate an OpenAPI 3.0 specification for every controller action. Documentation is **opt-in per action**: an action only appears in the spec once it has a description. The spec is assembled by `ApiDocsGenerator` (`lib/server/openApi/apiDocsGenerator.js`), which walks every registered controller action and merges the pieces described below.
4
+
5
+
6
+ ## Describing an action (YAML)
7
+
8
+ The human-written parts of an operation — its summary, description, and response shapes — live in a YAML file, not in the controller. For each action, `ApiDocGenerator` looks for:
9
+
10
+ ```
11
+ docs/controllers<routePrefix>Controller/<actionName>.yaml
12
+ ```
13
+
14
+ For example, `UsersController` (routed at `/api/v2/users`) documents its `getIndex` action in `docs/controllers/api/v2/usersController/getIndex.yaml`:
15
+
16
+ ```yaml
17
+ summary: List users
18
+ description: Returns a paginated list of users.
19
+ responses:
20
+ '200':
21
+ description: A page of users
22
+ body:
23
+ users:
24
+ - ref: User
25
+ total: 0
26
+ ```
27
+
28
+ An action is only included in the spec when its YAML defines a `description`. Actions with no YAML file (or no `description`) are skipped.
29
+
30
+ Inside a `responses.<status>.body`, plain values describe the shape directly; an object with a `ref` key emits a `$ref` to a registered component schema (see [Schema references](#schema-references)). If no `responses` are declared, the action defaults to `200` with `Successful response`.
31
+
32
+ The `body` you write describes only the **`data`** payload, and its shape passes through to the documented schema as-authored (with any `ref` resolved to a `$ref`). The generator then wraps it in the controller's standard response envelope — it feeds your body schema through the controller's own static `formatResponseBody`/`formatErrors` (the same code that shapes runtime responses), so the documented schema always matches what the server returns even when a controller overrides the envelope. For the default envelope, the `getIndex` example above produces:
33
+
34
+ ```yaml
35
+ responses:
36
+ '200':
37
+ description: A page of users
38
+ content:
39
+ application/json:
40
+ schema:
41
+ type: object
42
+ properties:
43
+ data:
44
+ users:
45
+ - $ref: '#/components/schemas/User'
46
+ total: 0
47
+ status:
48
+ type: string
49
+ example: ok
50
+ ```
51
+
52
+ Responses with a status `>= 400` additionally document an `errors` object (`{ message, fields }`) inside the envelope. A controller whose envelope spreads the body (rather than nesting it under `data`) instead flattens the body's properties into the envelope's `properties`.
53
+
54
+
55
+ ## Parameters and request body
56
+
57
+ Parameters and request bodies are **derived automatically** from the action's `parametersFor<ActionName>` validator definitions (see [Validations](./validations.md)) — there is no need to repeat them in YAML. Each declared parameter is placed by location:
58
+
59
+ - **Path parameters** — any parameter whose name appears in the route (e.g. `:id`). Always required.
60
+ - **Query parameters** — for `GET`/`DELETE` actions, or any parameter marked `{ queryParameter: true }`.
61
+ - **Request body** — for `POST`/`PUT` actions, declared params become a `requestBody` object schema; non-`optional` params are listed as `required`. The body defaults to `application/json`, but switches to `multipart/form-data` automatically when any body param declares `{ format: 'binary' }` (rendered as `{ type: 'string', format: 'binary' }` — the standard way to document a file upload).
62
+
63
+ The validator definition itself becomes the parameter's JSON schema, and `example`, `default`, `format`, and `nullable` pass through when present. Set `{ nullable: true }` to document a value that may be `null`.
64
+
65
+ For a path or query parameter, an `example` is emitted at the **parameter-object** level (a sibling of `schema`), so Swagger UI's "Try it out" pre-fills it. For a request-body property, the `example` stays inside the property's schema.
66
+
67
+ ```js
68
+ static parametersForPostImport() {
69
+ return {
70
+ file: { isString: true, format: 'binary' }, // → multipart/form-data
71
+ label: { isString: true, optional: true, example: 'Q3 report', nullable: true },
72
+ };
73
+ }
74
+ ```
75
+
76
+
77
+ ## Tags
78
+
79
+ Group operations under OpenAPI tags by declaring them in `setupDocumentation()` on the controller. Like callbacks, a tag applies to **every action by default** but honors `only` / `except`:
80
+
81
+ ```js
82
+ setupDocumentation() {
83
+ // Tag every action in this controller
84
+ this.documentationTag('Users', { description: 'User management endpoints' });
85
+
86
+ // Tag only specific actions
87
+ this.documentationTag('Admin', { only: ['deleteRecord'] });
88
+ }
89
+ ```
90
+
91
+ Each operation emits a `tags: [...]` list, and the full spec emits a de-duplicated top-level `tags: [{ name, description }]` (the first non-empty description for a name wins; an empty description is omitted). Tags are entirely opt-in — declare none and no `tags` key is produced.
92
+
93
+
94
+ ## Schema references
95
+
96
+ To reuse a schema across operations, point a `ref` at a class that exposes a static `documentationSchema()` method. The method returns a bare properties map; the generator wraps it in `{ type: 'object', properties: ... }`, emits a `$ref`, and registers the schema under `components.schemas`:
97
+
98
+ ```js
99
+ class User {
100
+ static documentationSchema() {
101
+ return { id: { type: 'integer' }, email: { type: 'string' } };
102
+ }
103
+ }
104
+ ```
105
+
106
+ ```yaml
107
+ responses:
108
+ '200':
109
+ description: A single user
110
+ body:
111
+ ref: User
112
+ ```
113
+
114
+ The component is named after the class (`User`); set a static `documentationSchemaName` to override it. Referencing two different classes that resolve to the same name throws a duplicate-name error.
115
+
116
+
117
+ ## Authentication
118
+
119
+ Actions guarded by authentication (via the `authenticateRequest` before-callback — see [Callbacks](./callbacks.md)) automatically emit `security: [{ apiKeyAuth: [] }]`. The spec declares the matching `apiKeyAuth` security scheme (an `Authorization` header) under `components.securitySchemes`.
120
+
121
+
122
+ ## The full specification
123
+
124
+ `ApiDocsGenerator` produces a single OpenAPI 3.0.0 document with this top-level shape:
125
+
126
+ ```
127
+ openapi — "3.0.0"
128
+ info — title / version / description (from constructor args, falling back to package.json)
129
+ servers — [{ url: <server origin> }]
130
+ tags — de-duplicated tag definitions (omitted when none are declared)
131
+ paths — every documented action, keyed by endpoint and lowercase method
132
+ components
133
+ securitySchemes.apiKeyAuth
134
+ schemas — every referenced documentationSchema (omitted when none are used)
135
+ ```