@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
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
59
|
+
this.renderTextResponse(body);
|
|
55
60
|
} else {
|
|
56
|
-
body = await this.
|
|
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.
|
|
62
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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.
|
|
129
|
+
return await this._serializeResponseBody(formattedBody, options);
|
|
106
130
|
|
|
107
131
|
} else if (Array.isArray(body)) {
|
|
108
|
-
|
|
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
|
|
135
|
+
return await this._serializeDateForResponseBody(body, options);
|
|
116
136
|
|
|
117
137
|
} else {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
```
|
|
@@ -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.
|