@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.
Files changed (45) hide show
  1. package/lib/activeRecord/README.md +1 -1
  2. package/lib/activeRecord/baseRecord.js +70 -4
  3. package/lib/server/README.md +8 -219
  4. package/lib/server/controllerImporter.js +0 -1
  5. package/lib/server/controllerMixins/callbacks.js +16 -61
  6. package/lib/server/controllerMixins/classScopedRegistry.js +48 -0
  7. package/lib/server/controllerMixins/documentation.js +41 -9
  8. package/lib/server/controllerMixins/renderer.js +140 -99
  9. package/lib/server/controllerMixins/validations.js +57 -0
  10. package/lib/server/doc/callbacks.md +23 -0
  11. package/lib/server/doc/documentation.md +135 -0
  12. package/lib/server/doc/renderer.md +115 -0
  13. package/lib/server/doc/requestDetails.md +52 -0
  14. package/lib/server/doc/validations.md +40 -0
  15. package/lib/server/index.js +1 -1
  16. package/lib/server/openApi/apiDocGenerator.js +426 -0
  17. package/lib/server/openApi/apiDocsGenerator.js +147 -0
  18. package/lib/server/openApi/schemaImporter.js +41 -0
  19. package/lib/server/openApi/schemaRegistry.js +43 -0
  20. package/lib/server/openApi/schemas.js +97 -0
  21. package/lib/server/openApi/tagRegistry.js +27 -0
  22. package/lib/server/server.js +14 -0
  23. package/lib/server/serverController.js +4 -9
  24. package/lib/server/statusTexts.js +38 -0
  25. package/lib/utils/yamlLoader.js +17 -0
  26. package/package.json +4 -2
  27. package/test/activeRecord/baseRecord.test.js +101 -0
  28. package/test/server/apiDocGenerator.test.js +743 -0
  29. package/test/server/controllerMixins/callbacks.test.js +326 -0
  30. package/test/server/controllerMixins/classScopedRegistry.test.js +95 -0
  31. package/test/server/controllerMixins/documentation.test.js +168 -0
  32. package/test/server/controllerMixins/renderer.test.js +734 -0
  33. package/test/server/controllerMixins/requestDetails.test.js +225 -0
  34. package/test/server/controllerMixins/routing.test.js +82 -0
  35. package/test/server/controllerMixins/validations.test.js +509 -0
  36. package/test/server/openApi/apiDocsGenerator.test.js +306 -0
  37. package/test/server/openApi/helpers/apiDocsPackageFixture/package.json +5 -0
  38. package/test/server/openApi/schemaImporter.test.js +54 -0
  39. package/test/server/openApi/schemaRegistry.test.js +93 -0
  40. package/test/server/openApi/schemas.test.js +109 -0
  41. package/test/server/serverController.test.js +8 -867
  42. package/test/server/statusTexts.test.js +30 -0
  43. package/test/utils/yamlLoader.test.js +43 -0
  44. package/lib/server/apiDocsGenerator.js +0 -86
  45. package/test/server/apiDocsGenerator.test.js +0 -38
@@ -0,0 +1,306 @@
1
+ jest.mock('../../../lib/server/controllerImporter');
2
+ jest.mock('../../../lib/server/openApi/schemaImporter');
3
+ jest.mock('../../../lib/utils/yamlLoader');
4
+
5
+ const path = require('path');
6
+ const { ControllerImporter } = require('../../../lib/server/controllerImporter');
7
+ const { SchemaImporter } = require('../../../lib/server/openApi/schemaImporter');
8
+ const { YamlLoader } = require('../../../lib/utils/yamlLoader');
9
+ const { ApiDocsGenerator } = require('../../../lib/server/openApi/apiDocsGenerator');
10
+ const { StaticMethods } = require('../../../lib/server/controllerMixins/renderer');
11
+
12
+
13
+ const PACKAGE_FIXTURE_DIR = path.join(__dirname, 'helpers/apiDocsPackageFixture');
14
+
15
+
16
+ const buildServer = (overrides = {}) => ({
17
+ controllerDirectories: ['/controllers'],
18
+ schemaDirectories: [],
19
+ origin: 'https://localhost',
20
+ securitySchemes: { apiKeyAuth: { type: 'apiKey', in: 'header', name: 'Authorization' } },
21
+ ...overrides,
22
+ });
23
+
24
+
25
+ const buildControllerClass = (overrides = {}) => ({
26
+ name: 'ThingsController',
27
+ actions: [{ action: 'getIndex', method: 'GET', path: '/things' }],
28
+ parametersForAction: () => ({ q: { isString: true } }),
29
+ _authenticationDocumentation: () => undefined,
30
+ _tagsDocumentation: () => [],
31
+ documentationTagDefinitions: () => [],
32
+ formatResponseBody: StaticMethods.formatResponseBody,
33
+ formatErrors: StaticMethods.formatErrors,
34
+ ...overrides,
35
+ });
36
+
37
+
38
+ const mockControllerClasses = (classes) => {
39
+ ControllerImporter.mockImplementation(() => ({ controllerClasses: classes }));
40
+ };
41
+
42
+
43
+ beforeEach(() => {
44
+ jest.spyOn(process, 'cwd').mockReturnValue(PACKAGE_FIXTURE_DIR);
45
+ SchemaImporter.mockImplementation(() => ({ schemaClasses: new Map() }));
46
+ YamlLoader.fileExists.mockReturnValue(true);
47
+ YamlLoader.loadFile.mockReturnValue({ description: 'Lists things', summary: 'List things' });
48
+ });
49
+ afterEach(() => jest.resetAllMocks());
50
+
51
+
52
+ describe('ApiDocsGenerator', () => {
53
+ describe('#specification', () => {
54
+ it ('returns a full OpenAPI 3.0 document with info, servers, paths and components', () => {
55
+ mockControllerClasses([buildControllerClass()]);
56
+ const generator = new ApiDocsGenerator(buildServer(), { title: 'Vida API', version: '2.1.0' });
57
+
58
+ expect(generator.specification).toEqual({
59
+ openapi: '3.0.0',
60
+ info: { title: 'Vida API', version: '2.1.0', description: 'Fixture API description' },
61
+ servers: [{ url: 'https://localhost' }],
62
+ paths: {
63
+ '/things': {
64
+ get: {
65
+ operationId: 'thingsGetIndex',
66
+ summary: 'List things',
67
+ description: 'Lists things',
68
+ parameters: [{ name: 'q', in: 'query', required: true, description: '', schema: { type: 'string' } }],
69
+ responses: { '200': { description: 'Successful response' } },
70
+ },
71
+ },
72
+ },
73
+ components: {
74
+ securitySchemes: {
75
+ apiKeyAuth: { type: 'apiKey', in: 'header', name: 'Authorization' },
76
+ },
77
+ },
78
+ });
79
+ });
80
+
81
+ it ('uses the server origin for the servers url', () => {
82
+ mockControllerClasses([buildControllerClass()]);
83
+ const generator = new ApiDocsGenerator(buildServer({ origin: 'https://api.vida.inc' }));
84
+
85
+ expect(generator.specification.servers).toEqual([{ url: 'https://api.vida.inc' }]);
86
+ });
87
+
88
+ it ('falls back to package.json name, version and description when not provided', () => {
89
+ mockControllerClasses([buildControllerClass()]);
90
+ const generator = new ApiDocsGenerator(buildServer());
91
+
92
+ expect(generator.specification.info).toEqual({
93
+ title: 'fixture-api',
94
+ version: '9.9.9',
95
+ description: 'Fixture API description',
96
+ });
97
+ });
98
+
99
+ it ('prefers explicit title and version over package.json', () => {
100
+ mockControllerClasses([buildControllerClass()]);
101
+ const generator = new ApiDocsGenerator(buildServer(), { title: 'Explicit', version: '0.0.1' });
102
+
103
+ expect(generator.specification.info).toMatchObject({ title: 'Explicit', version: '0.0.1' });
104
+ });
105
+
106
+ it ('defaults info fields to empty strings when no package.json is found', () => {
107
+ process.cwd.mockReturnValue(__dirname);
108
+ mockControllerClasses([buildControllerClass()]);
109
+ const generator = new ApiDocsGenerator(buildServer());
110
+
111
+ expect(generator.specification.info).toEqual({ title: '', version: '', description: '' });
112
+ });
113
+ });
114
+
115
+
116
+ describe('#paths', () => {
117
+ it ('nests each action documentation under its endpoint and lowercase method', () => {
118
+ const controllerClass = buildControllerClass({
119
+ actions: [
120
+ { action: 'getIndex', method: 'GET', path: '/things' },
121
+ { action: 'postIndex', method: 'POST', path: '/things' },
122
+ ],
123
+ parametersForAction: () => ({}),
124
+ });
125
+ YamlLoader.loadFile.mockReturnValue({ description: 'Documented' });
126
+ mockControllerClasses([controllerClass]);
127
+ const generator = new ApiDocsGenerator(buildServer());
128
+
129
+ expect(Object.keys(generator.paths)).toEqual(['/things']);
130
+ expect(Object.keys(generator.paths['/things'])).toEqual(['get', 'post']);
131
+ });
132
+
133
+ it ('skips actions that have no documentation', () => {
134
+ const controllerClass = buildControllerClass({
135
+ actions: [{ action: 'getIndex', method: 'GET', path: '/things' }],
136
+ });
137
+ YamlLoader.loadFile.mockReturnValue({});
138
+ mockControllerClasses([controllerClass]);
139
+ const generator = new ApiDocsGenerator(buildServer());
140
+
141
+ expect(generator.paths).toEqual({});
142
+ });
143
+
144
+ it ('aggregates paths across multiple controllers', () => {
145
+ const things = buildControllerClass({ parametersForAction: () => ({}) });
146
+ const users = buildControllerClass({
147
+ actions: [{ action: 'getIndex', method: 'GET', path: '/users' }],
148
+ parametersForAction: () => ({}),
149
+ });
150
+ mockControllerClasses([things, users]);
151
+ const generator = new ApiDocsGenerator(buildServer());
152
+
153
+ expect(Object.keys(generator.paths).sort()).toEqual(['/things', '/users']);
154
+ });
155
+ });
156
+
157
+
158
+ describe('#components', () => {
159
+ it ('omits schemas when no controller references one', () => {
160
+ mockControllerClasses([buildControllerClass()]);
161
+ const generator = new ApiDocsGenerator(buildServer());
162
+
163
+ expect(generator.specification.components.schemas).toBeUndefined();
164
+ });
165
+
166
+ it ('collects referenced schemas into components.schemas and emits a $ref', () => {
167
+ class Widget {
168
+ static documentationSchema() {
169
+ return { id: { type: 'integer' } };
170
+ }
171
+ }
172
+ const controllerClass = buildControllerClass({
173
+ parametersForAction: () => ({ widget: { ref: Widget, queryParameter: true } }),
174
+ });
175
+ mockControllerClasses([controllerClass]);
176
+ const generator = new ApiDocsGenerator(buildServer());
177
+ const spec = generator.specification;
178
+
179
+ expect(spec.components.schemas).toEqual({
180
+ Widget: { type: 'object', properties: { id: { type: 'integer' } } },
181
+ });
182
+ expect(spec.paths['/things'].get.parameters[0].schema).toEqual({ $ref: '#/components/schemas/Widget' });
183
+ });
184
+
185
+ it ('resolves a string ref via the schemaDirectories registry into components.schemas', () => {
186
+ class Widget {
187
+ static documentationSchema() { return { id: { type: 'integer' } }; }
188
+ }
189
+ SchemaImporter.mockImplementation(() => ({ schemaClasses: new Map([['Widget', Widget]]) }));
190
+ YamlLoader.loadFile.mockReturnValue({
191
+ description: 'Lists things',
192
+ responses: { '200': { description: 'A widget', body: { ref: 'Widget' } } },
193
+ });
194
+ const controllerClass = buildControllerClass({
195
+ parametersForAction: () => ({}),
196
+ });
197
+ mockControllerClasses([controllerClass]);
198
+ const generator = new ApiDocsGenerator(buildServer());
199
+ const spec = generator.specification;
200
+
201
+ expect(spec.components.schemas).toEqual({
202
+ Widget: { type: 'object', properties: { id: { type: 'integer' } } },
203
+ });
204
+ expect(spec.paths['/things'].get.responses['200'].content['application/json'].schema.properties.data)
205
+ .toEqual({ $ref: '#/components/schemas/Widget' });
206
+ });
207
+ });
208
+
209
+
210
+ describe('#tags', () => {
211
+ it ('aggregates a top-level tags list from a controller documentationTagDefinitions', () => {
212
+ const controllerClass = buildControllerClass({
213
+ _tagsDocumentation: () => ['Things'],
214
+ documentationTagDefinitions: () => [{ name: 'Things', description: 'Thing endpoints' }],
215
+ });
216
+ mockControllerClasses([controllerClass]);
217
+ const generator = new ApiDocsGenerator(buildServer());
218
+
219
+ expect(generator.specification.tags).toEqual([{ name: 'Things', description: 'Thing endpoints' }]);
220
+ });
221
+
222
+ it ('omits the description key when the tag has no description', () => {
223
+ const controllerClass = buildControllerClass({
224
+ _tagsDocumentation: () => ['Things'],
225
+ documentationTagDefinitions: () => [{ name: 'Things', description: '' }],
226
+ });
227
+ mockControllerClasses([controllerClass]);
228
+ const generator = new ApiDocsGenerator(buildServer());
229
+
230
+ expect(generator.specification.tags).toEqual([{ name: 'Things' }]);
231
+ });
232
+
233
+ it ('de-duplicates a tag used across multiple actions', () => {
234
+ const controllerClass = buildControllerClass({
235
+ actions: [
236
+ { action: 'getIndex', method: 'GET', path: '/things' },
237
+ { action: 'postIndex', method: 'POST', path: '/things' },
238
+ ],
239
+ parametersForAction: () => ({}),
240
+ _tagsDocumentation: () => ['Things'],
241
+ documentationTagDefinitions: () => [{ name: 'Things', description: 'Thing endpoints' }],
242
+ });
243
+ mockControllerClasses([controllerClass]);
244
+ const generator = new ApiDocsGenerator(buildServer());
245
+
246
+ expect(generator.specification.tags).toEqual([{ name: 'Things', description: 'Thing endpoints' }]);
247
+ });
248
+
249
+ it ('de-duplicates a tag shared across multiple controllers', () => {
250
+ const things = buildControllerClass({
251
+ parametersForAction: () => ({}),
252
+ _tagsDocumentation: () => ['Shared'],
253
+ documentationTagDefinitions: () => [{ name: 'Shared', description: '' }],
254
+ });
255
+ const users = buildControllerClass({
256
+ actions: [{ action: 'getIndex', method: 'GET', path: '/users' }],
257
+ parametersForAction: () => ({}),
258
+ _tagsDocumentation: () => ['Shared'],
259
+ documentationTagDefinitions: () => [{ name: 'Shared', description: '' }],
260
+ });
261
+ mockControllerClasses([things, users]);
262
+ const generator = new ApiDocsGenerator(buildServer());
263
+
264
+ expect(generator.specification.tags).toEqual([{ name: 'Shared' }]);
265
+ });
266
+
267
+ it.each([
268
+ ['empty first', '', 'Has description'],
269
+ ['empty second', 'Has description', ''],
270
+ ])('keeps the first non-empty description when the same tag is declared twice (%s)', (_label, descA, descB) => {
271
+ const a = buildControllerClass({
272
+ parametersForAction: () => ({}),
273
+ _tagsDocumentation: () => ['Shared'],
274
+ documentationTagDefinitions: () => [{ name: 'Shared', description: descA }],
275
+ });
276
+ const b = buildControllerClass({
277
+ actions: [{ action: 'getIndex', method: 'GET', path: '/users' }],
278
+ parametersForAction: () => ({}),
279
+ _tagsDocumentation: () => ['Shared'],
280
+ documentationTagDefinitions: () => [{ name: 'Shared', description: descB }],
281
+ });
282
+ mockControllerClasses([a, b]);
283
+ const generator = new ApiDocsGenerator(buildServer());
284
+
285
+ expect(generator.specification.tags).toEqual([{ name: 'Shared', description: 'Has description' }]);
286
+ });
287
+
288
+ it ('omits the top-level tags key when no controller declares a tag', () => {
289
+ mockControllerClasses([buildControllerClass()]);
290
+ const generator = new ApiDocsGenerator(buildServer());
291
+
292
+ expect(generator.specification).not.toHaveProperty('tags');
293
+ });
294
+
295
+ it ('places the tags key before paths in the document', () => {
296
+ const controllerClass = buildControllerClass({
297
+ _tagsDocumentation: () => ['Things'],
298
+ documentationTagDefinitions: () => [{ name: 'Things', description: 'Thing endpoints' }],
299
+ });
300
+ mockControllerClasses([controllerClass]);
301
+ const generator = new ApiDocsGenerator(buildServer());
302
+
303
+ expect(Object.keys(generator.specification)).toEqual(['openapi', 'info', 'servers', 'tags', 'paths', 'components']);
304
+ });
305
+ });
306
+ });
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "fixture-api",
3
+ "version": "9.9.9",
4
+ "description": "Fixture API description"
5
+ }
@@ -0,0 +1,54 @@
1
+ const { SchemaImporter } = require('../../../lib/server/openApi/schemaImporter');
2
+ const { AbstractAutoImporter } = require('../../../lib/utils/abstractAutoImporter');
3
+
4
+
5
+ afterEach(() => jest.restoreAllMocks());
6
+
7
+
8
+ const withImports = (imports) => {
9
+ jest.spyOn(AbstractAutoImporter.prototype, 'imports', 'get').mockReturnValue(imports);
10
+ return new SchemaImporter([]);
11
+ };
12
+
13
+
14
+ describe('SchemaImporter', () => {
15
+ describe('#shouldImport', () => {
16
+ it ('returns true for a class exposing a static documentationSchema', () => {
17
+ class Widget { static documentationSchema() { return {}; } }
18
+ expect(new SchemaImporter([]).shouldImport(Widget)).toBe(true);
19
+ });
20
+
21
+
22
+ it ('returns false for a class without documentationSchema', () => {
23
+ class Bare {}
24
+ expect(new SchemaImporter([]).shouldImport(Bare)).toBe(false);
25
+ });
26
+ });
27
+
28
+
29
+ describe('#schemaClasses', () => {
30
+ it ('keys imported classes by their name', () => {
31
+ class Widget { static documentationSchema() {} }
32
+ const importer = withImports([Widget]);
33
+ expect(importer.schemaClasses.get('Widget')).toBe(Widget);
34
+ });
35
+
36
+
37
+ it ('keys classes by documentationSchemaName when provided', () => {
38
+ class Widget {
39
+ static documentationSchemaName = 'CustomWidget';
40
+ static documentationSchema() {}
41
+ }
42
+ const importer = withImports([Widget]);
43
+ expect(importer.schemaClasses.get('CustomWidget')).toBe(Widget);
44
+ });
45
+
46
+
47
+ it ('throws when two classes resolve to the same name', () => {
48
+ class A { static documentationSchemaName = 'Dup'; static documentationSchema() {} }
49
+ class B { static documentationSchemaName = 'Dup'; static documentationSchema() {} }
50
+ const importer = withImports([A, B]);
51
+ expect(() => importer.schemaClasses).toThrow();
52
+ });
53
+ });
54
+ });
@@ -0,0 +1,93 @@
1
+ jest.mock('../../../lib/server/openApi/schemaImporter');
2
+
3
+ const { SchemaImporter } = require('../../../lib/server/openApi/schemaImporter');
4
+ const { SchemaRegistry } = require('../../../lib/server/openApi/schemaRegistry');
5
+ const { Faker } = require('@vida-global/test-helpers');
6
+
7
+
8
+ const registryWith = (classes = new Map()) => {
9
+ SchemaImporter.mockImplementation(() => ({ schemaClasses: classes }));
10
+ return new SchemaRegistry({ schemaDirectories: [] });
11
+ };
12
+
13
+
14
+ afterEach(() => jest.resetAllMocks());
15
+
16
+
17
+ describe('SchemaRegistry', () => {
18
+ describe('#has', () => {
19
+ it ('returns false for an unknown name', () => {
20
+ const registry = registryWith();
21
+ expect(registry.has(Faker.Text.randomString())).toBe(false);
22
+ });
23
+
24
+
25
+ it ('returns true after the name has been reserved', () => {
26
+ const registry = registryWith();
27
+ const name = Faker.Text.randomString();
28
+ registry.reserve(name);
29
+ expect(registry.has(name)).toBe(true);
30
+ });
31
+
32
+
33
+ it ('returns true after the name has been set', () => {
34
+ const registry = registryWith();
35
+ const name = Faker.Text.randomString();
36
+ registry.set(name, { type: 'object' });
37
+ expect(registry.has(name)).toBe(true);
38
+ });
39
+ });
40
+
41
+
42
+ describe('#reserve', () => {
43
+ it ('stores an empty placeholder so cycles can short-circuit', () => {
44
+ const registry = registryWith();
45
+ const name = Faker.Text.randomString();
46
+ registry.reserve(name);
47
+ expect(registry.schemas[name]).toEqual({});
48
+ });
49
+ });
50
+
51
+
52
+ describe('#set', () => {
53
+ it ('replaces a reserved placeholder with the resolved schema', () => {
54
+ const registry = registryWith();
55
+ const name = Faker.Text.randomString();
56
+ const schema = { type: 'object', properties: { id: { type: 'integer' } } };
57
+ registry.reserve(name);
58
+ registry.set(name, schema);
59
+ expect(registry.schemas[name]).toEqual(schema);
60
+ });
61
+ });
62
+
63
+
64
+ describe('#resolve', () => {
65
+ it ('returns the class mapped to the name', () => {
66
+ class Widget {}
67
+ const name = Faker.Text.randomString();
68
+ const registry = registryWith(new Map([[name, Widget]]));
69
+ expect(registry.resolve(name)).toBe(Widget);
70
+ });
71
+
72
+
73
+ it ('throws for an unknown name', () => {
74
+ const registry = registryWith();
75
+ expect(() => registry.resolve(Faker.Text.randomString())).toThrow();
76
+ });
77
+ });
78
+
79
+
80
+ describe('#schemas', () => {
81
+ it ('returns all stored schemas keyed by name', () => {
82
+ const registry = registryWith();
83
+ const nameA = Faker.Text.randomString();
84
+ const nameB = Faker.Text.randomString();
85
+ registry.set(nameA, { type: 'string' });
86
+ registry.set(nameB, { type: 'number' });
87
+ expect(registry.schemas).toEqual({
88
+ [nameA]: { type: 'string' },
89
+ [nameB]: { type: 'number' },
90
+ });
91
+ });
92
+ });
93
+ });
@@ -0,0 +1,109 @@
1
+ const { Schemas } = require('../../../lib/server/openApi/schemas');
2
+
3
+
4
+ describe('Schemas', () => {
5
+ describe('.for', () => {
6
+ it.each([
7
+ ['isInteger', { isInteger: { gte: 0, lte: 9 } }, { type: 'integer', minimum: 0, maximum: 9 }],
8
+ ['isNumber', { isNumber: { gte: 1.5 } }, { type: 'number', minimum: 1.5 }],
9
+ ['isString', { isString: { length: { gte: 2, lte: 8 }, regex: /^\d+$/ } }, { type: 'string', minLength: 2, maxLength: 8, pattern: '^\\d+$' }],
10
+ ['isBoolean', { isBoolean: true }, { type: 'boolean' }],
11
+ ['isDateTime', { isDateTime: true }, { type: 'string', format: 'date-time' }],
12
+ ['isEnum', { isEnum: { enums: ['a', 'b'] } }, { type: 'string', enum: ['a', 'b'] }],
13
+ ])('converts %s to the expected OpenAPI schema', (_label, details, expected) => {
14
+ expect(Schemas.for(details)).toEqual(expected);
15
+ });
16
+
17
+ it ('returns an empty schema for unrecognized details', () => {
18
+ expect(Schemas.for({ function: () => {} })).toEqual({});
19
+ });
20
+ });
21
+
22
+
23
+ describe('.schemaFor', () => {
24
+ it ('recurses into nested array items', () => {
25
+ const details = { isArray: { of: { isInteger: true } } };
26
+
27
+ expect(Schemas.schemaFor(details)).toEqual({ type: 'array', items: { type: 'integer' } });
28
+ });
29
+
30
+ it ('recurses into nested object properties and collects required names', () => {
31
+ const details = {
32
+ isObject: {
33
+ properties: {
34
+ city: { isString: true },
35
+ zip: { isString: true, optional: true },
36
+ },
37
+ },
38
+ };
39
+
40
+ expect(Schemas.schemaFor(details)).toEqual({
41
+ type: 'object',
42
+ properties: { city: { type: 'string' }, zip: { type: 'string' } },
43
+ required: ['city'],
44
+ });
45
+ });
46
+
47
+ it ('delegates ref nodes at any depth to the resolver', () => {
48
+ const details = {
49
+ isObject: {
50
+ properties: {
51
+ items: { isArray: { of: { ref: 'Widget' } } },
52
+ },
53
+ },
54
+ };
55
+ const resolveRef = jest.fn(name => ({ $ref: `#/components/schemas/${name}` }));
56
+
57
+ expect(Schemas.schemaFor(details, resolveRef)).toEqual({
58
+ type: 'object',
59
+ properties: { items: { type: 'array', items: { $ref: '#/components/schemas/Widget' } } },
60
+ required: ['items'],
61
+ });
62
+ expect(resolveRef).toHaveBeenCalledWith('Widget');
63
+ });
64
+
65
+ it ('ignores ref nodes when no resolver is provided', () => {
66
+ expect(Schemas.schemaFor({ isArray: { of: { ref: 'Widget' } } })).toEqual({
67
+ type: 'array',
68
+ items: {},
69
+ });
70
+ });
71
+ });
72
+
73
+
74
+ describe('.isRequired', () => {
75
+ it ('treats details without optional as required', () => {
76
+ expect(Schemas.isRequired({ isString: true })).toBe(true);
77
+ });
78
+
79
+ it ('treats optional:true as not required', () => {
80
+ expect(Schemas.isRequired({ isString: true, optional: true })).toBe(false);
81
+ });
82
+ });
83
+
84
+
85
+ describe('.objectSchema', () => {
86
+ it ('omits the required array when every property is optional', () => {
87
+ const schema = Schemas.objectSchema({
88
+ properties: { a: { isString: true, optional: true }, b: { isInteger: true, optional: true } },
89
+ });
90
+
91
+ expect(schema).toEqual({ type: 'object', properties: { a: { type: 'string' }, b: { type: 'integer' } } });
92
+ expect(schema).not.toHaveProperty('required');
93
+ });
94
+ });
95
+
96
+
97
+ describe('.booleanSchema', () => {
98
+ it ('returns the OpenAPI boolean schema', () => {
99
+ expect(Schemas.booleanSchema()).toEqual({ type: 'boolean' });
100
+ });
101
+ });
102
+
103
+
104
+ describe('.dateTimeSchema', () => {
105
+ it ('returns the OpenAPI date-time string schema', () => {
106
+ expect(Schemas.dateTimeSchema()).toEqual({ type: 'string', format: 'date-time' });
107
+ });
108
+ });
109
+ });