@vida-global/core 2.0.11 → 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.
- package/lib/activeRecord/README.md +1 -1
- package/lib/activeRecord/baseRecord.js +70 -4
- package/lib/server/README.md +8 -219
- package/lib/server/controllerImporter.js +0 -1
- package/lib/server/controllerMixins/callbacks.js +16 -61
- package/lib/server/controllerMixins/classScopedRegistry.js +48 -0
- package/lib/server/controllerMixins/documentation.js +41 -9
- package/lib/server/controllerMixins/renderer.js +140 -99
- package/lib/server/controllerMixins/validations.js +57 -0
- package/lib/server/doc/callbacks.md +23 -0
- package/lib/server/doc/documentation.md +135 -0
- package/lib/server/doc/renderer.md +115 -0
- package/lib/server/doc/requestDetails.md +52 -0
- package/lib/server/doc/validations.md +40 -0
- package/lib/server/index.js +1 -1
- package/lib/server/openApi/apiDocGenerator.js +426 -0
- package/lib/server/openApi/apiDocsGenerator.js +147 -0
- package/lib/server/openApi/schemaImporter.js +41 -0
- package/lib/server/openApi/schemaRegistry.js +43 -0
- package/lib/server/openApi/schemas.js +97 -0
- package/lib/server/openApi/tagRegistry.js +27 -0
- package/lib/server/server.js +14 -0
- package/lib/server/serverController.js +4 -9
- package/lib/server/statusTexts.js +38 -0
- package/lib/utils/yamlLoader.js +17 -0
- package/package.json +4 -2
- package/test/activeRecord/baseRecord.test.js +101 -0
- package/test/server/apiDocGenerator.test.js +743 -0
- package/test/server/controllerMixins/callbacks.test.js +326 -0
- package/test/server/controllerMixins/classScopedRegistry.test.js +95 -0
- package/test/server/controllerMixins/documentation.test.js +168 -0
- package/test/server/controllerMixins/renderer.test.js +734 -0
- package/test/server/controllerMixins/requestDetails.test.js +225 -0
- package/test/server/controllerMixins/routing.test.js +82 -0
- package/test/server/controllerMixins/validations.test.js +509 -0
- package/test/server/openApi/apiDocsGenerator.test.js +306 -0
- package/test/server/openApi/helpers/apiDocsPackageFixture/package.json +5 -0
- package/test/server/openApi/schemaImporter.test.js +54 -0
- package/test/server/openApi/schemaRegistry.test.js +93 -0
- package/test/server/openApi/schemas.test.js +109 -0
- package/test/server/serverController.test.js +8 -867
- package/test/server/statusTexts.test.js +30 -0
- package/test/utils/yamlLoader.test.js +43 -0
- package/lib/server/apiDocsGenerator.js +0 -86
- package/test/server/apiDocsGenerator.test.js +0 -38
|
@@ -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.
|
package/lib/server/index.js
CHANGED
|
@@ -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 };
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
const { ApiDocGenerator } = require('./apiDocGenerator');
|
|
2
|
+
const { ControllerImporter } = require('../controllerImporter');
|
|
3
|
+
const { SchemaRegistry } = require('./schemaRegistry');
|
|
4
|
+
const { TagRegistry } = require('./tagRegistry');
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ApiDocsGenerator {
|
|
8
|
+
#_controllerClasses;
|
|
9
|
+
#title;
|
|
10
|
+
#version;
|
|
11
|
+
#_paths;
|
|
12
|
+
#_packageInfo;
|
|
13
|
+
#schemaRegistry;
|
|
14
|
+
#tagRegistry;
|
|
15
|
+
#server;
|
|
16
|
+
|
|
17
|
+
constructor(server, { title, version } = {}) {
|
|
18
|
+
this.#server = server;
|
|
19
|
+
this.#title = title || '';
|
|
20
|
+
this.#version = version || '';
|
|
21
|
+
this.#schemaRegistry = new SchemaRegistry(server);
|
|
22
|
+
this.#tagRegistry = new TagRegistry();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
/***********************************************************************************************
|
|
27
|
+
* OPENAPI DOCUMENT
|
|
28
|
+
***********************************************************************************************/
|
|
29
|
+
get specification() {
|
|
30
|
+
const paths = this.paths;
|
|
31
|
+
const tags = this.tags;
|
|
32
|
+
const specification = {
|
|
33
|
+
openapi: '3.0.0',
|
|
34
|
+
info: this.info,
|
|
35
|
+
servers: this.servers,
|
|
36
|
+
};
|
|
37
|
+
if (tags.length) specification.tags = tags;
|
|
38
|
+
specification.paths = paths;
|
|
39
|
+
specification.components = this.components;
|
|
40
|
+
return specification;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
get tags() {
|
|
45
|
+
if (!this.#_paths) this.#collectPaths();
|
|
46
|
+
return this.#tagRegistry.tags;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
get info() {
|
|
51
|
+
return {
|
|
52
|
+
title: this.#title || this.#packageInfo.name || '',
|
|
53
|
+
version: this.#version || this.#packageInfo.version || '',
|
|
54
|
+
description: this.#packageInfo.description || '',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
get servers() {
|
|
60
|
+
return [{ url: this.#server.origin }];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
get paths() {
|
|
65
|
+
if (!this.#_paths) this.#collectPaths();
|
|
66
|
+
return this.#_paths;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
get components() {
|
|
71
|
+
const components = {
|
|
72
|
+
securitySchemes: this.#server.securitySchemes
|
|
73
|
+
};
|
|
74
|
+
const schemas = this.#schemaRegistry.schemas;
|
|
75
|
+
if (Object.keys(schemas).length) components.schemas = schemas;
|
|
76
|
+
return components;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
/***********************************************************************************************
|
|
81
|
+
* DOC GENERATION
|
|
82
|
+
***********************************************************************************************/
|
|
83
|
+
#collectPaths() {
|
|
84
|
+
this.#_paths = {};
|
|
85
|
+
this.#controllerClasses.forEach(controllerClass => {
|
|
86
|
+
controllerClass.actions.forEach(action => {
|
|
87
|
+
this.#generateDocsForAction(action, controllerClass);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
#generateDocsForAction(action, controllerClass) {
|
|
94
|
+
const generator = new ApiDocGenerator(action, controllerClass, this.#schemaRegistry);
|
|
95
|
+
if (!generator.hasDocumentation) return;
|
|
96
|
+
|
|
97
|
+
const endpoint = generator.endpoint;
|
|
98
|
+
const method = generator.method;
|
|
99
|
+
this.#_paths[endpoint] = this.#_paths[endpoint] || {};
|
|
100
|
+
this.#_paths[endpoint][method] = generator.documentation;
|
|
101
|
+
this.#registerTags(generator, controllerClass);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
#registerTags(generator, controllerClass) {
|
|
106
|
+
const usedNames = generator.tags || [];
|
|
107
|
+
if (!usedNames.length) return;
|
|
108
|
+
|
|
109
|
+
const definitions = controllerClass.documentationTagDefinitions();
|
|
110
|
+
const descriptions = new Map(definitions.map(({ name, description }) => [name, description]));
|
|
111
|
+
for (const name of usedNames) {
|
|
112
|
+
this.#tagRegistry.register(name, descriptions.get(name) || '');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
get #controllerClasses() {
|
|
118
|
+
if (!this.#_controllerClasses) {
|
|
119
|
+
const controllerImporter = new ControllerImporter(this.#server.controllerDirectories);
|
|
120
|
+
this.#_controllerClasses = controllerImporter.controllerClasses;
|
|
121
|
+
}
|
|
122
|
+
return [...this.#_controllerClasses];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
/***********************************************************************************************
|
|
127
|
+
* PACKAGE INFO
|
|
128
|
+
***********************************************************************************************/
|
|
129
|
+
get #packageInfo() {
|
|
130
|
+
if (!this.#_packageInfo) this.#_packageInfo = this.#loadPackageInfo();
|
|
131
|
+
return this.#_packageInfo;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
#loadPackageInfo() {
|
|
136
|
+
try {
|
|
137
|
+
return require(`${process.cwd()}/package.json`);
|
|
138
|
+
} catch {
|
|
139
|
+
return {};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
module.exports = {
|
|
146
|
+
ApiDocsGenerator
|
|
147
|
+
}
|