@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,743 @@
1
+ jest.mock('../../lib/server/openApi/schemaImporter');
2
+ jest.mock('../../lib/utils/yamlLoader');
3
+
4
+ const { ApiDocGenerator } = require('../../lib/server/openApi/apiDocGenerator');
5
+ const { SchemaRegistry } = require('../../lib/server/openApi/schemaRegistry');
6
+ const { SchemaImporter } = require('../../lib/server/openApi/schemaImporter');
7
+ const { YamlLoader } = require('../../lib/utils/yamlLoader');
8
+ const { StaticMethods } = require('../../lib/server/controllerMixins/renderer');
9
+ const { Faker } = require('@vida-global/test-helpers');
10
+
11
+
12
+ const ControllerClass = {
13
+ name: 'ThingsController',
14
+ parametersForAction: jest.fn(),
15
+ _authenticationDocumentation: jest.fn(),
16
+ _tagsDocumentation: jest.fn(),
17
+ formatResponseBody: StaticMethods.formatResponseBody,
18
+ formatErrors: StaticMethods.formatErrors,
19
+ };
20
+ const V2ControllerClass = {
21
+ name: 'ThingsController',
22
+ parametersForAction: jest.fn(),
23
+ _authenticationDocumentation: jest.fn(),
24
+ _tagsDocumentation: jest.fn(),
25
+ formatErrors: StaticMethods.formatErrors,
26
+ formatResponseBody(body, errors, { statusCode, statusText }) {
27
+ const response = { success: statusCode < 400 };
28
+ if (!response.success) {
29
+ response.message = errors?.message || statusText;
30
+ if (errors && errors.fields) response.errors = errors.fields;
31
+ }
32
+ if (body && typeof body === 'object') Object.assign(response, body);
33
+ return response;
34
+ },
35
+ };
36
+ const shuffle = (arr) => arr.sort(() => Math.random() - 0.5);
37
+
38
+
39
+ beforeEach(() => {
40
+ SchemaImporter.mockImplementation(() => ({ schemaClasses: new Map() }));
41
+ YamlLoader.fileExists.mockReturnValue(true);
42
+ YamlLoader.loadFile.mockReturnValue({});
43
+ });
44
+ afterEach(() => jest.resetAllMocks());
45
+
46
+
47
+ describe('ApiDocGenerator', () => {
48
+ const toExpressUrl = (pcs, params) => '/' + pcs.map(pc => params.includes(pc) ? `:${pc}` : pc).join('/');
49
+ const toFormattedUrl = (pcs, params) => '/' + pcs.map(pc => params.includes(pc) ? `{${pc}}` : pc).join('/');
50
+ describe('#endpoint', () => {
51
+ it ('replaces a single :param with {param}', () => {
52
+ const pcs = [Faker.Text.randomString()];
53
+ const params = [Faker.Text.randomString()];
54
+ const pathElements = shuffle([...pcs, ...params])
55
+ const path = toExpressUrl(pathElements, params);
56
+ const formattedUrl = toFormattedUrl(pathElements, params);
57
+ const action = { path };
58
+ const generator = new ApiDocGenerator(action, ControllerClass);
59
+ expect(generator.endpoint).toEqual(formattedUrl);
60
+ });
61
+
62
+ it ('replaces multiple :params with {params}', () => {
63
+ const pcs = [Faker.Text.randomString(), Faker.Text.randomString()];
64
+ const params = [Faker.Text.randomString(), Faker.Text.randomString()];
65
+ const pathElements = shuffle([...pcs, ...params])
66
+ const path = toExpressUrl(pathElements, params);
67
+ const formattedUrl = toFormattedUrl(pathElements, params);
68
+ const action = { path };
69
+ const generator = new ApiDocGenerator(action, ControllerClass);
70
+ expect(generator.endpoint).toEqual(formattedUrl);
71
+ });
72
+
73
+ it ('returns the path unchanged when there are no params', () => {
74
+ const path = `/${Faker.Text.randomString()}/${Faker.Text.randomString()}`;
75
+ const action = { path };
76
+ const generator = new ApiDocGenerator(action, ControllerClass);
77
+ expect(generator.endpoint).toEqual(path);
78
+ });
79
+
80
+ it ('handles an empty path', () => {
81
+ const path = '';
82
+ const action = { path };
83
+ const generator = new ApiDocGenerator(action, ControllerClass);
84
+ expect(generator.endpoint).toEqual('');
85
+ });
86
+ });
87
+
88
+
89
+ describe('#hasDocumentation', () => {
90
+ const action = { action: 'getIndex', method: 'GET', path: '/things' };
91
+
92
+ it ('returns true when the documentation has a description', () => {
93
+ YamlLoader.loadFile.mockReturnValue({ description: Faker.Text.randomString() });
94
+ const generator = new ApiDocGenerator(action, ControllerClass);
95
+ expect(generator.hasDocumentation).toBe(true);
96
+ });
97
+
98
+ it ('returns false when the documentation sets skip to true', () => {
99
+ YamlLoader.loadFile.mockReturnValue({ description: Faker.Text.randomString(), skip: true });
100
+ const generator = new ApiDocGenerator(action, ControllerClass);
101
+ expect(generator.hasDocumentation).toBe(false);
102
+ });
103
+
104
+ it ('ignores a non-true skip value', () => {
105
+ YamlLoader.loadFile.mockReturnValue({ description: Faker.Text.randomString(), skip: 'yes' });
106
+ const generator = new ApiDocGenerator(action, ControllerClass);
107
+ expect(generator.hasDocumentation).toBe(true);
108
+ });
109
+ });
110
+
111
+
112
+ describe('#pathParams', () => {
113
+ it ('builds an OpenAPI path parameter object from the path param definition', () => {
114
+ const action = { action: 'getRecord', method: 'GET', path: '/user/:id' };
115
+ ControllerClass.parametersForAction.mockReturnValue({ id: { isInteger: { gte: 1 } } });
116
+ const generator = new ApiDocGenerator(action, ControllerClass);
117
+
118
+ expect(generator.pathParams).toEqual({
119
+ id: {
120
+ name: 'id',
121
+ in: 'path',
122
+ required: true,
123
+ description: '',
124
+ schema: { type: 'integer', minimum: 1 },
125
+ },
126
+ });
127
+ });
128
+
129
+ it ('marks path params required even when the definition is optional', () => {
130
+ const action = { action: 'getRecord', method: 'GET', path: '/user/:id' };
131
+ ControllerClass.parametersForAction.mockReturnValue({ id: { isString: true, optional: true } });
132
+ const generator = new ApiDocGenerator(action, ControllerClass);
133
+
134
+ expect(generator.pathParams.id.required).toBe(true);
135
+ });
136
+
137
+ it ('excludes parameters that are not in the path', () => {
138
+ const action = { action: 'getRecord', method: 'GET', path: '/user/:id' };
139
+ ControllerClass.parametersForAction.mockReturnValue({
140
+ id: { isInteger: true },
141
+ other: { isString: true },
142
+ });
143
+ const generator = new ApiDocGenerator(action, ControllerClass);
144
+
145
+ expect(Object.keys(generator.pathParams)).toEqual(['id']);
146
+ });
147
+ });
148
+
149
+
150
+ describe('#queryParams', () => {
151
+ it ('builds OpenAPI query parameter objects for a GET action', () => {
152
+ const action = { action: 'getIndex', method: 'GET', path: '/things' };
153
+ ControllerClass.parametersForAction.mockReturnValue({
154
+ q: { isString: true },
155
+ page: { isInteger: { gte: 1 }, optional: true },
156
+ });
157
+ const generator = new ApiDocGenerator(action, ControllerClass);
158
+
159
+ expect(generator.queryParams).toEqual({
160
+ q: {
161
+ name: 'q', in: 'query', required: true, description: '',
162
+ schema: { type: 'string' },
163
+ },
164
+ page: {
165
+ name: 'page', in: 'query', required: false, description: '',
166
+ schema: { type: 'integer', minimum: 1 },
167
+ },
168
+ });
169
+ });
170
+
171
+ it ('includes params flagged queryParameter on a POST and excludes the rest', () => {
172
+ const action = { action: 'postIndex', method: 'POST', path: '/things' };
173
+ ControllerClass.parametersForAction.mockReturnValue({
174
+ baz: { isInteger: { gte: 0 }, queryParameter: true },
175
+ body: { isString: true },
176
+ });
177
+ const generator = new ApiDocGenerator(action, ControllerClass);
178
+
179
+ expect(Object.keys(generator.queryParams)).toEqual(['baz']);
180
+ expect(generator.queryParams.baz.in).toEqual('query');
181
+ });
182
+
183
+ it ('excludes path params from the query', () => {
184
+ const action = { action: 'getRecordStatus', method: 'GET', path: '/user/:id/status' };
185
+ ControllerClass.parametersForAction.mockReturnValue({
186
+ id: { isInteger: true },
187
+ detail: { isString: true },
188
+ });
189
+ const generator = new ApiDocGenerator(action, ControllerClass);
190
+
191
+ expect(Object.keys(generator.queryParams)).toEqual(['detail']);
192
+ });
193
+ });
194
+
195
+
196
+ describe('#bodyProperties', () => {
197
+ it ('builds JSON Schema properties for a POST body and passes through example', () => {
198
+ const action = { action: 'postIndex', method: 'POST', path: '/things' };
199
+ ControllerClass.parametersForAction.mockReturnValue({
200
+ foo: { isString: { length: { gte: 4, lte: 50 } }, example: 'Mark Grayson' },
201
+ bar: { isInteger: { gte: 0 } },
202
+ opt: { isString: true, optional: true },
203
+ });
204
+ const generator = new ApiDocGenerator(action, ControllerClass);
205
+
206
+ expect(generator.bodyProperties).toEqual({
207
+ foo: { type: 'string', minLength: 4, maxLength: 50, example: 'Mark Grayson', description: '' },
208
+ bar: { type: 'integer', minimum: 0, description: '' },
209
+ opt: { type: 'string', description: '' },
210
+ });
211
+ });
212
+
213
+ it ('excludes path params and is empty for a GET action', () => {
214
+ const action = { action: 'getIndex', method: 'GET', path: '/things' };
215
+ ControllerClass.parametersForAction.mockReturnValue({ q: { isString: true } });
216
+ const generator = new ApiDocGenerator(action, ControllerClass);
217
+
218
+ expect(generator.bodyProperties).toEqual({});
219
+ });
220
+ });
221
+
222
+
223
+ describe('schema mapping', () => {
224
+ const cases = [
225
+ ['isInteger with bounds', { isInteger: { gte: 0, lte: 9 } }, { type: 'integer', minimum: 0, maximum: 9 }],
226
+ ['isString with length', { isString: { length: { gte: 2, lte: 8 } } }, { type: 'string', minLength: 2, maxLength: 8 }],
227
+ ['isString with regex', { isString: { regex: /^\d+$/ } }, { type: 'string', pattern: '^\\d+$' }],
228
+ ['isBoolean', { isBoolean: true }, { type: 'boolean' }],
229
+ ['isDateTime', { isDateTime: true }, { type: 'string', format: 'date-time' }],
230
+ ['isEnum', { isEnum: { enums: ['a', 'b'] } }, { type: 'string', enum: ['a', 'b'] }],
231
+ ['isNumber with bounds', { isNumber: { gte: 0, lte: 9.99 } }, { type: 'number', minimum: 0, maximum: 9.99 }],
232
+ ['numeric isEnum', { isEnum: { enums: [1, 2] } }, { type: 'integer', enum: [1, 2] }],
233
+ ['float isEnum', { isEnum: { enums: [1.5, 2] } }, { type: 'number', enum: [1.5, 2] }],
234
+ ['isArray with items', { isArray: { of: { isInteger: { gte: 1 } }, length: { gte: 1, lte: 5 } } }, { type: 'array', items: { type: 'integer', minimum: 1 }, minItems: 1, maxItems: 5 }],
235
+ ['isObject with props', { isObject: { properties: { city: { isString: true }, zip: { isString: true, optional: true } } } }, { type: 'object', properties: { city: { type: 'string' }, zip: { type: 'string' } }, required: ['city'] }],
236
+ ['function only', { function: () => {} }, {}],
237
+ ];
238
+
239
+ it.each(cases)('maps %s to the expected schema', (_label, definition, expectedSchema) => {
240
+ const action = { action: 'getIndex', method: 'GET', path: '/things' };
241
+ ControllerClass.parametersForAction.mockReturnValue({ field: definition });
242
+ const generator = new ApiDocGenerator(action, ControllerClass);
243
+
244
+ expect(generator.queryParams.field.schema).toEqual(expectedSchema);
245
+ });
246
+ });
247
+
248
+
249
+ describe('ref schemas', () => {
250
+ const buildRefGenerator = (refClass, registry) => {
251
+ const action = { action: 'getIndex', method: 'GET', path: '/things' };
252
+ ControllerClass.parametersForAction.mockReturnValue({ field: { ref: refClass } });
253
+ return new ApiDocGenerator(action, ControllerClass, registry);
254
+ };
255
+
256
+ it ('emits a $ref pointing at the component name', () => {
257
+ class Widget { static documentationSchema() { return { name: { type: 'string' } }; } }
258
+ const generator = buildRefGenerator(Widget, new SchemaRegistry());
259
+
260
+ expect(generator.queryParams.field.schema).toEqual({ $ref: '#/components/schemas/Widget' });
261
+ });
262
+
263
+ it ('registers the resolved schema once under the class name', () => {
264
+ class Widget {
265
+ static documentationSchema() {
266
+ return { id: { type: 'integer' } };
267
+ }
268
+ }
269
+ const registry = new SchemaRegistry();
270
+ const generator = buildRefGenerator(Widget, registry);
271
+
272
+ generator.queryParams;
273
+ expect(registry.schemas).toEqual({
274
+ Widget: { type: 'object', properties: { id: { type: 'integer' } } },
275
+ });
276
+ });
277
+
278
+ it ('uses documentationSchemaName as the component key when provided', () => {
279
+ class Widget {
280
+ static documentationSchemaName = 'CustomWidget';
281
+ static documentationSchema() { return { name: { type: 'string' } }; }
282
+ }
283
+ const registry = new SchemaRegistry();
284
+ const generator = buildRefGenerator(Widget, registry);
285
+
286
+ expect(generator.queryParams.field.schema).toEqual({ $ref: '#/components/schemas/CustomWidget' });
287
+ expect(registry.schemas.CustomWidget).toEqual({ type: 'object', properties: { name: { type: 'string' } } });
288
+ });
289
+
290
+ it ('registers a schema containing a literal self $ref as provided', () => {
291
+ class Node {
292
+ static documentationSchema() {
293
+ return { next: { $ref: '#/components/schemas/Node' } };
294
+ }
295
+ }
296
+ const registry = new SchemaRegistry();
297
+ const generator = buildRefGenerator(Node, registry);
298
+
299
+ expect(generator.queryParams.field.schema).toEqual({ $ref: '#/components/schemas/Node' });
300
+ expect(registry.schemas).toEqual({
301
+ Node: { type: 'object', properties: { next: { $ref: '#/components/schemas/Node' } } },
302
+ });
303
+ });
304
+
305
+ it ('throws when the ref target has no documentationSchema method', () => {
306
+ class Bare {}
307
+ const generator = buildRefGenerator(Bare, new SchemaRegistry());
308
+
309
+ expect(() => generator.queryParams).toThrow();
310
+ });
311
+
312
+ it ('throws a descriptive error when a live-class ref is null', () => {
313
+ const generator = buildRefGenerator(null, new SchemaRegistry());
314
+
315
+ expect(() => generator.queryParams).toThrow('A ref target must expose a static documentationSchema() method');
316
+ });
317
+
318
+ it ('resolves a string ref against the registry class map', () => {
319
+ class Widget { static documentationSchema() { return { name: { type: 'string' } }; } }
320
+ SchemaImporter.mockImplementation(() => ({ schemaClasses: new Map([['Widget', Widget]]) }));
321
+ const registry = new SchemaRegistry({ schemaDirectories: [] });
322
+ const generator = buildRefGenerator('Widget', registry);
323
+
324
+ expect(generator.queryParams.field.schema).toEqual({ $ref: '#/components/schemas/Widget' });
325
+ expect(registry.schemas.Widget).toEqual({ type: 'object', properties: { name: { type: 'string' } } });
326
+ });
327
+
328
+ it ('throws when a string ref is not in the registry', () => {
329
+ const generator = buildRefGenerator('Missing', new SchemaRegistry());
330
+
331
+ expect(() => generator.queryParams).toThrow();
332
+ });
333
+
334
+ it ('resolves a ref nested inside array items', () => {
335
+ class Widget { static documentationSchema() { return { name: { type: 'string' } }; } }
336
+ const action = { action: 'getIndex', method: 'GET', path: '/things' };
337
+ ControllerClass.parametersForAction.mockReturnValue({ field: { isArray: { of: { ref: Widget } } } });
338
+ const registry = new SchemaRegistry();
339
+ const generator = new ApiDocGenerator(action, ControllerClass, registry);
340
+
341
+ expect(generator.queryParams.field.schema).toEqual({
342
+ type: 'array',
343
+ items: { $ref: '#/components/schemas/Widget' },
344
+ });
345
+ expect(registry.schemas.Widget).toEqual({ type: 'object', properties: { name: { type: 'string' } } });
346
+ });
347
+
348
+ it ('resolves a ref nested inside object properties', () => {
349
+ class Widget { static documentationSchema() { return { name: { type: 'string' } }; } }
350
+ const action = { action: 'getIndex', method: 'GET', path: '/things' };
351
+ ControllerClass.parametersForAction.mockReturnValue({
352
+ field: { isObject: { properties: { widget: { ref: Widget }, count: { isInteger: true } } } },
353
+ });
354
+ const registry = new SchemaRegistry();
355
+ const generator = new ApiDocGenerator(action, ControllerClass, registry);
356
+
357
+ expect(generator.queryParams.field.schema).toEqual({
358
+ type: 'object',
359
+ properties: { widget: { $ref: '#/components/schemas/Widget' }, count: { type: 'integer' } },
360
+ required: ['widget', 'count'],
361
+ });
362
+ expect(registry.schemas.Widget).toEqual({ type: 'object', properties: { name: { type: 'string' } } });
363
+ });
364
+ });
365
+
366
+
367
+ describe('#documentation', () => {
368
+ const documented = (overrides = {}) => {
369
+ const action = { action: 'postIndex', method: 'POST', path: '/things', ...overrides.action };
370
+ YamlLoader.loadFile.mockReturnValue({
371
+ description: 'Creates a thing',
372
+ summary: 'Create thing',
373
+ responses: overrides.responses,
374
+ ...overrides.details,
375
+ });
376
+ ControllerClass.parametersForAction.mockReturnValue(overrides.params || {});
377
+ ControllerClass._authenticationDocumentation.mockReturnValue(overrides.security);
378
+ return new ApiDocGenerator(action, ControllerClass);
379
+ };
380
+
381
+ it ('assembles the operation object with summary, description, parameters and default responses', () => {
382
+ const generator = documented({
383
+ action: { action: 'getIndex', method: 'GET', path: '/things' },
384
+ params: { q: { isString: true } },
385
+ });
386
+
387
+ expect(generator.documentation).toEqual({
388
+ operationId: 'thingsGetIndex',
389
+ summary: 'Create thing',
390
+ description: 'Creates a thing',
391
+ parameters: [{ name: 'q', in: 'query', required: true, description: '', schema: { type: 'string' } }],
392
+ responses: { '200': { description: 'Successful response' } },
393
+ });
394
+ });
395
+
396
+ it ('defaults responses to a 200 stub when the resolver returns null', () => {
397
+ const generator = documented({ params: {} });
398
+
399
+ expect(generator.documentation.responses).toEqual({ '200': { description: 'Successful response' } });
400
+ });
401
+
402
+ it ('treats a missing documentation file as undocumented', () => {
403
+ YamlLoader.fileExists.mockReturnValue(false);
404
+ const action = { action: 'getIndex', method: 'GET', path: '/things' };
405
+ const generator = new ApiDocGenerator(action, ControllerClass);
406
+
407
+ expect(generator.hasDocumentation).toBe(false);
408
+ expect(YamlLoader.loadFile).not.toHaveBeenCalled();
409
+ });
410
+
411
+ it ('formats description-only responses without a content block', () => {
412
+ const generator = documented({
413
+ params: {},
414
+ responses: {
415
+ '200': { description: 'Created' },
416
+ '400': { description: 'Bad request' },
417
+ },
418
+ });
419
+
420
+ expect(generator.documentation.responses).toEqual({
421
+ '200': { description: 'Created' },
422
+ '400': { description: 'Bad request' },
423
+ });
424
+ });
425
+
426
+ it ('wraps a raw OpenAPI response body in the standard envelope schema', () => {
427
+ const generator = documented({
428
+ params: {},
429
+ responses: {
430
+ '200': {
431
+ description: 'A thing',
432
+ body: { type: 'object', properties: { id: { type: 'integer' } }, required: ['id'] },
433
+ },
434
+ },
435
+ });
436
+
437
+ expect(generator.documentation.responses).toEqual({
438
+ '200': {
439
+ description: 'A thing',
440
+ content: {
441
+ 'application/json': {
442
+ schema: {
443
+ type: 'object',
444
+ properties: {
445
+ data: {
446
+ type: 'object',
447
+ properties: { id: { type: 'integer' } },
448
+ required: ['id'],
449
+ },
450
+ status: { type: 'string', example: 'ok' },
451
+ },
452
+ },
453
+ },
454
+ },
455
+ },
456
+ });
457
+ });
458
+
459
+ it ('places the documented body under the envelope data key', () => {
460
+ const body = {
461
+ type: 'object',
462
+ properties: { id: { type: 'integer', example: 5 }, tags: { type: 'array', items: { type: 'string' } } },
463
+ required: ['id'],
464
+ };
465
+ const generator = documented({ params: {}, responses: { '200': { description: 'Thing', body } } });
466
+ const schema = generator.documentation.responses['200'].content['application/json'].schema;
467
+
468
+ expect(schema.type).toEqual('object');
469
+ expect(schema.properties.data).toEqual(body);
470
+ expect(schema.properties.status).toEqual({ type: 'string', example: 'ok' });
471
+ });
472
+
473
+ it ('resolves a ref in a response body to a $ref under data and registers the component', () => {
474
+ class Widget { static documentationSchema() { return { name: { type: 'string' } }; } }
475
+ const generator = documented({
476
+ params: {},
477
+ responses: { '200': { description: 'A widget', body: { ref: Widget } } },
478
+ });
479
+
480
+ expect(generator.documentation.responses['200'].content['application/json'].schema.properties.data)
481
+ .toEqual({ $ref: '#/components/schemas/Widget' });
482
+ expect(generator.schemaRegistry.schemas.Widget).toEqual({ type: 'object', properties: { name: { type: 'string' } } });
483
+ });
484
+
485
+ it ('resolves a ref nested inside a response body schema', () => {
486
+ class Widget { static documentationSchema() { return { name: { type: 'string' } }; } }
487
+ const generator = documented({
488
+ params: {},
489
+ responses: {
490
+ '200': {
491
+ description: 'Widgets',
492
+ body: { type: 'object', properties: { items: { type: 'array', items: { ref: Widget } } } },
493
+ },
494
+ },
495
+ });
496
+
497
+ expect(generator.documentation.responses['200'].content['application/json'].schema.properties.data).toEqual({
498
+ type: 'object',
499
+ properties: { items: { type: 'array', items: { $ref: '#/components/schemas/Widget' } } },
500
+ });
501
+ expect(generator.schemaRegistry.schemas.Widget).toEqual({ type: 'object', properties: { name: { type: 'string' } } });
502
+ });
503
+
504
+ it ('adds an errors object to the envelope for responses >= 400', () => {
505
+ const generator = documented({
506
+ params: {},
507
+ responses: {
508
+ '400': {
509
+ description: 'Bad request',
510
+ body: { type: 'object', properties: { id: { type: 'integer' } } },
511
+ },
512
+ },
513
+ });
514
+
515
+ expect(generator.documentation.responses['400'].content['application/json'].schema).toEqual({
516
+ type: 'object',
517
+ properties: {
518
+ data: { type: 'object', properties: { id: { type: 'integer' } } },
519
+ status: { type: 'string', example: 'bad request' },
520
+ errors: {
521
+ type: 'object',
522
+ properties: {
523
+ message: { type: 'string', nullable: true },
524
+ fields: { type: 'object', additionalProperties: { type: 'array', items: { type: 'string' } } },
525
+ },
526
+ },
527
+ },
528
+ });
529
+ });
530
+
531
+ it ('flattens the body into the envelope for a spread (V2) formatResponseBody', () => {
532
+ YamlLoader.loadFile.mockReturnValue({
533
+ description: 'Creates a thing',
534
+ responses: {
535
+ '200': {
536
+ description: 'A thing',
537
+ body: { type: 'object', properties: { id: { type: 'integer' } }, required: ['id'] },
538
+ },
539
+ },
540
+ });
541
+ V2ControllerClass.parametersForAction.mockReturnValue({});
542
+ const action = { action: 'postIndex', method: 'POST', path: '/things' };
543
+ const generator = new ApiDocGenerator(action, V2ControllerClass);
544
+
545
+ expect(generator.documentation.responses['200'].content['application/json'].schema).toEqual({
546
+ type: 'object',
547
+ properties: {
548
+ success: { type: 'boolean', example: true },
549
+ id: { type: 'integer' },
550
+ },
551
+ required: ['id'],
552
+ });
553
+ });
554
+
555
+ it ('wraps a $ref body in allOf for a spread (V2) formatResponseBody', () => {
556
+ class Widget { static documentationSchema() { return { name: { type: 'string' } }; } }
557
+ YamlLoader.loadFile.mockReturnValue({
558
+ description: 'Creates a widget',
559
+ responses: { '201': { description: 'Created', body: { ref: Widget } } },
560
+ });
561
+ V2ControllerClass.parametersForAction.mockReturnValue({});
562
+ const action = { action: 'postIndex', method: 'POST', path: '/things' };
563
+ const generator = new ApiDocGenerator(action, V2ControllerClass);
564
+
565
+ expect(generator.documentation.responses['201'].content['application/json'].schema).toEqual({
566
+ type: 'object',
567
+ properties: { success: { type: 'boolean', example: true } },
568
+ allOf: [{ $ref: '#/components/schemas/Widget' }],
569
+ });
570
+ expect(generator.schemaRegistry.schemas.Widget).toEqual({ type: 'object', properties: { name: { type: 'string' } } });
571
+ });
572
+
573
+ it ('adds a requestBody with a required list for POST body params', () => {
574
+ const generator = documented({
575
+ params: {
576
+ foo: { isString: true },
577
+ opt: { isInteger: { gte: 0 }, optional: true },
578
+ },
579
+ });
580
+
581
+ expect(generator.documentation.requestBody).toEqual({
582
+ required: true,
583
+ content: {
584
+ 'application/json': {
585
+ schema: {
586
+ type: 'object',
587
+ properties: {
588
+ foo: { type: 'string', description: '' },
589
+ opt: { type: 'integer', minimum: 0, description: '' },
590
+ },
591
+ required: ['foo'],
592
+ },
593
+ },
594
+ },
595
+ });
596
+ });
597
+
598
+ it ('omits requestBody when there are no body params', () => {
599
+ const generator = documented({
600
+ action: { action: 'getIndex', method: 'GET', path: '/things' },
601
+ params: {},
602
+ });
603
+
604
+ expect(generator.documentation).not.toHaveProperty('requestBody');
605
+ });
606
+
607
+ it ('infers a multipart/form-data body when a body param is a binary file', () => {
608
+ const generator = documented({
609
+ params: { file: { isString: true, format: 'binary' } },
610
+ });
611
+
612
+ expect(generator.documentation.requestBody.content).toEqual({
613
+ 'multipart/form-data': {
614
+ schema: {
615
+ type: 'object',
616
+ properties: { file: { type: 'string', format: 'binary', description: '' } },
617
+ required: ['file'],
618
+ },
619
+ },
620
+ });
621
+ });
622
+
623
+ it ('includes security when the action is authenticated', () => {
624
+ const generator = documented({ params: {}, security: [{ apiKeyAuth: [] }] });
625
+
626
+ expect(generator.documentation.security).toEqual([{ apiKeyAuth: [] }]);
627
+ });
628
+
629
+ it ('omits security when the action is not authenticated', () => {
630
+ const generator = documented({ params: {}, security: undefined });
631
+
632
+ expect(generator.documentation).not.toHaveProperty('security');
633
+ });
634
+ });
635
+
636
+
637
+ describe('description / default / format pass-through', () => {
638
+ it ('puts description on the parameter object and default in its schema', () => {
639
+ const action = { action: 'getIndex', method: 'GET', path: '/things' };
640
+ ControllerClass.parametersForAction.mockReturnValue({
641
+ q: { isString: true, description: 'Search term', default: 'all' },
642
+ });
643
+ const generator = new ApiDocGenerator(action, ControllerClass);
644
+
645
+ expect(generator.queryParams.q).toEqual({
646
+ name: 'q', in: 'query', required: true, description: 'Search term',
647
+ schema: { type: 'string', default: 'all' },
648
+ });
649
+ });
650
+
651
+ it ('puts description and default on a body property schema', () => {
652
+ const action = { action: 'postIndex', method: 'POST', path: '/things' };
653
+ ControllerClass.parametersForAction.mockReturnValue({
654
+ foo: { isString: true, description: 'A thing', default: 'x' },
655
+ });
656
+ const generator = new ApiDocGenerator(action, ControllerClass);
657
+
658
+ expect(generator.bodyProperties.foo).toEqual({ type: 'string', default: 'x', description: 'A thing' });
659
+ });
660
+
661
+ it ('passes a format hint through to the schema', () => {
662
+ const action = { action: 'getIndex', method: 'GET', path: '/things' };
663
+ ControllerClass.parametersForAction.mockReturnValue({ email: { isString: true, format: 'email' } });
664
+ const generator = new ApiDocGenerator(action, ControllerClass);
665
+
666
+ expect(generator.queryParams.email.schema).toEqual({ type: 'string', format: 'email' });
667
+ });
668
+
669
+ it ('passes nullable through to the schema', () => {
670
+ const action = { action: 'getIndex', method: 'GET', path: '/things' };
671
+ ControllerClass.parametersForAction.mockReturnValue({ q: { isString: true, nullable: true } });
672
+ const generator = new ApiDocGenerator(action, ControllerClass);
673
+
674
+ expect(generator.queryParams.q.schema).toEqual({ type: 'string', nullable: true });
675
+ });
676
+
677
+ it ('places a parameter example at the parameter level, not inside the schema', () => {
678
+ const action = { action: 'getIndex', method: 'GET', path: '/things' };
679
+ ControllerClass.parametersForAction.mockReturnValue({ q: { isString: true, example: 'blue widgets' } });
680
+ const generator = new ApiDocGenerator(action, ControllerClass);
681
+
682
+ expect(generator.queryParams.q).toEqual({
683
+ name: 'q', in: 'query', required: true, description: '',
684
+ schema: { type: 'string' },
685
+ example: 'blue widgets',
686
+ });
687
+ });
688
+ });
689
+
690
+
691
+ describe('#operationId', () => {
692
+ it ('combines the controller name and action', () => {
693
+ const action = { action: 'getIndex', method: 'GET', path: '/things' };
694
+ const generator = new ApiDocGenerator(action, ControllerClass);
695
+
696
+ expect(generator.operationId).toEqual('thingsGetIndex');
697
+ });
698
+
699
+ it ('falls back to the action alone when the controller has no name', () => {
700
+ const action = { action: 'getIndex', method: 'GET', path: '/things' };
701
+ const namelessClass = { parametersForAction: jest.fn() };
702
+ const generator = new ApiDocGenerator(action, namelessClass);
703
+
704
+ expect(generator.operationId).toEqual('getIndex');
705
+ });
706
+ });
707
+
708
+
709
+ describe('#tags', () => {
710
+ const tagged = (tagsReturn) => {
711
+ const action = { action: 'getIndex', method: 'GET', path: '/things' };
712
+ YamlLoader.loadFile.mockReturnValue({ description: 'Lists things' });
713
+ ControllerClass.parametersForAction.mockReturnValue({});
714
+ ControllerClass._tagsDocumentation.mockReturnValue(tagsReturn);
715
+ return new ApiDocGenerator(action, ControllerClass);
716
+ };
717
+
718
+ it ('delegates to the controller _tagsDocumentation with the action name', () => {
719
+ const generator = tagged(['Things']);
720
+
721
+ expect(generator.tags).toEqual(['Things']);
722
+ expect(ControllerClass._tagsDocumentation).toHaveBeenCalledWith('getIndex');
723
+ });
724
+
725
+ it ('emits documentation.tags when the controller returns a non-empty list', () => {
726
+ const generator = tagged(['Things', 'Admin']);
727
+
728
+ expect(generator.documentation.tags).toEqual(['Things', 'Admin']);
729
+ });
730
+
731
+ it ('omits tags when the controller returns an empty list', () => {
732
+ const generator = tagged([]);
733
+
734
+ expect(generator.documentation).not.toHaveProperty('tags');
735
+ });
736
+
737
+ it ('omits tags when the controller returns undefined', () => {
738
+ const generator = tagged(undefined);
739
+
740
+ expect(generator.documentation).not.toHaveProperty('tags');
741
+ });
742
+ });
743
+ });