@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.
- package/index.js +2 -0
- package/lib/activeRecord/README.md +1 -1
- package/lib/activeRecord/baseRecord.js +70 -4
- package/lib/cache/index.js +4 -0
- package/lib/cache/memoryCache.js +155 -0
- 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/cache/memoryCache.test.js +233 -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,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.
|
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 };
|