@ebarahona/loopback-openapi-v3 1.0.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/LICENSE +21 -0
- package/README.md +132 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +6 -0
- package/dist/keys.js +10 -0
- package/dist/keys.js.map +1 -0
- package/dist/openapi-version.component.d.ts +4 -0
- package/dist/openapi-version.component.js +18 -0
- package/dist/openapi-version.component.js.map +1 -0
- package/dist/openapi-version.enhancer.d.ts +8 -0
- package/dist/openapi-version.enhancer.js +46 -0
- package/dist/openapi-version.enhancer.js.map +1 -0
- package/dist/transform.d.ts +19 -0
- package/dist/transform.js +500 -0
- package/dist/transform.js.map +1 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/package.json +72 -0
- package/src/__tests__/openapi-version.enhancer.spec.ts +456 -0
- package/src/__tests__/transform.spec.ts +879 -0
- package/src/index.ts +10 -0
- package/src/keys.ts +9 -0
- package/src/openapi-version.component.ts +27 -0
- package/src/openapi-version.enhancer.ts +61 -0
- package/src/transform.ts +702 -0
- package/src/types.ts +20 -0
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
import {describe, it, expect} from 'vitest';
|
|
2
|
+
import {transformOpenApiSpec, parseVersion} from '../transform';
|
|
3
|
+
|
|
4
|
+
// -------------------------------------------------------------------
|
|
5
|
+
// parseVersion
|
|
6
|
+
// -------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
describe('parseVersion', () => {
|
|
9
|
+
it('parses valid versions', () => {
|
|
10
|
+
expect(parseVersion('3.0.0')).toEqual({major: 3, minor: 0, patch: 0});
|
|
11
|
+
expect(parseVersion('3.1.0')).toEqual({major: 3, minor: 1, patch: 0});
|
|
12
|
+
expect(parseVersion('3.2.0')).toEqual({major: 3, minor: 2, patch: 0});
|
|
13
|
+
expect(parseVersion('3.1.1')).toEqual({major: 3, minor: 1, patch: 1});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('rejects invalid formats', () => {
|
|
17
|
+
expect(() => parseVersion('banana')).toThrow('Invalid OpenAPI version');
|
|
18
|
+
expect(() => parseVersion('3.0')).toThrow('Invalid OpenAPI version');
|
|
19
|
+
expect(() => parseVersion('3')).toThrow('Invalid OpenAPI version');
|
|
20
|
+
expect(() => parseVersion('')).toThrow('Invalid OpenAPI version');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('rejects non-v3 versions', () => {
|
|
24
|
+
expect(() => parseVersion('2.0.0')).toThrow('Unsupported OpenAPI major version');
|
|
25
|
+
expect(() => parseVersion('4.0.0')).toThrow('Unsupported OpenAPI major version');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// -------------------------------------------------------------------
|
|
30
|
+
// Deep clone (no mutation)
|
|
31
|
+
// -------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
describe('deep clone safety', () => {
|
|
34
|
+
it('does not mutate the original spec', () => {
|
|
35
|
+
const original = {
|
|
36
|
+
openapi: '3.0.0',
|
|
37
|
+
info: {title: 'Test', version: '1.0.0'},
|
|
38
|
+
paths: {},
|
|
39
|
+
components: {
|
|
40
|
+
schemas: {
|
|
41
|
+
Item: {
|
|
42
|
+
type: 'object',
|
|
43
|
+
properties: {
|
|
44
|
+
name: {type: 'string', nullable: true},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const frozen = JSON.parse(JSON.stringify(original));
|
|
52
|
+
transformOpenApiSpec(original, {version: '3.1.0'});
|
|
53
|
+
expect(original).toEqual(frozen);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// -------------------------------------------------------------------
|
|
58
|
+
// Nullable: edge cases
|
|
59
|
+
// -------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
describe('nullable edge cases', () => {
|
|
62
|
+
it('handles nullable with oneOf', () => {
|
|
63
|
+
const spec = {
|
|
64
|
+
openapi: '3.0.0',
|
|
65
|
+
info: {title: 'T', version: '1'},
|
|
66
|
+
paths: {},
|
|
67
|
+
components: {
|
|
68
|
+
schemas: {
|
|
69
|
+
Mixed: {
|
|
70
|
+
nullable: true,
|
|
71
|
+
oneOf: [{type: 'string'}, {type: 'integer'}],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
|
|
78
|
+
const mixed = (result.components as Record<string, unknown>)?.schemas as Record<string, unknown>;
|
|
79
|
+
const schema = mixed?.Mixed as Record<string, unknown>;
|
|
80
|
+
expect(schema).not.toHaveProperty('nullable');
|
|
81
|
+
expect(schema.oneOf).toEqual([
|
|
82
|
+
{type: 'string'},
|
|
83
|
+
{type: 'integer'},
|
|
84
|
+
{type: 'null'},
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('handles nullable with anyOf', () => {
|
|
89
|
+
const spec = {
|
|
90
|
+
openapi: '3.0.0',
|
|
91
|
+
info: {title: 'T', version: '1'},
|
|
92
|
+
paths: {},
|
|
93
|
+
components: {
|
|
94
|
+
schemas: {
|
|
95
|
+
Mixed: {
|
|
96
|
+
nullable: true,
|
|
97
|
+
anyOf: [{type: 'string'}],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
|
|
104
|
+
const schema = ((result.components as Record<string, unknown>)?.schemas as Record<string, unknown>)?.Mixed as Record<string, unknown>;
|
|
105
|
+
expect(schema.anyOf).toEqual([{type: 'string'}, {type: 'null'}]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('handles nullable with allOf (wraps in anyOf)', () => {
|
|
109
|
+
const spec = {
|
|
110
|
+
openapi: '3.0.0',
|
|
111
|
+
info: {title: 'T', version: '1'},
|
|
112
|
+
paths: {},
|
|
113
|
+
components: {
|
|
114
|
+
schemas: {
|
|
115
|
+
Mixed: {
|
|
116
|
+
nullable: true,
|
|
117
|
+
allOf: [{type: 'object'}, {properties: {id: {type: 'integer'}}}],
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
|
|
124
|
+
const schema = ((result.components as Record<string, unknown>)?.schemas as Record<string, unknown>)?.Mixed as Record<string, unknown>;
|
|
125
|
+
expect(schema).not.toHaveProperty('allOf');
|
|
126
|
+
expect(schema.anyOf).toEqual([
|
|
127
|
+
{allOf: [{type: 'object'}, {properties: {id: {type: 'integer'}}}]},
|
|
128
|
+
{type: 'null'},
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('handles nullable with no type', () => {
|
|
133
|
+
const spec = {
|
|
134
|
+
openapi: '3.0.0',
|
|
135
|
+
info: {title: 'T', version: '1'},
|
|
136
|
+
paths: {},
|
|
137
|
+
components: {
|
|
138
|
+
schemas: {
|
|
139
|
+
Empty: {nullable: true},
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
|
|
145
|
+
const schema = ((result.components as Record<string, unknown>)?.schemas as Record<string, unknown>)?.Empty as Record<string, unknown>;
|
|
146
|
+
expect(schema.type).toBe('null');
|
|
147
|
+
expect(schema).not.toHaveProperty('nullable');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('downgrades oneOf with null type back to nullable', () => {
|
|
151
|
+
const spec = {
|
|
152
|
+
openapi: '3.1.0',
|
|
153
|
+
info: {title: 'T', version: '1'},
|
|
154
|
+
paths: {},
|
|
155
|
+
components: {
|
|
156
|
+
schemas: {
|
|
157
|
+
Mixed: {
|
|
158
|
+
oneOf: [{type: 'string'}, {type: 'integer'}, {type: 'null'}],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.0.0'});
|
|
165
|
+
const schema = ((result.components as Record<string, unknown>)?.schemas as Record<string, unknown>)?.Mixed as Record<string, unknown>;
|
|
166
|
+
expect(schema.nullable).toBe(true);
|
|
167
|
+
expect(schema.oneOf).toEqual([{type: 'string'}, {type: 'integer'}]);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// -------------------------------------------------------------------
|
|
172
|
+
// Components: parameters, requestBodies, responses, headers
|
|
173
|
+
// -------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
describe('component-level schema traversal', () => {
|
|
176
|
+
it('transforms schemas in components.parameters', () => {
|
|
177
|
+
const spec = {
|
|
178
|
+
openapi: '3.0.0',
|
|
179
|
+
info: {title: 'T', version: '1'},
|
|
180
|
+
paths: {},
|
|
181
|
+
components: {
|
|
182
|
+
parameters: {
|
|
183
|
+
FilterParam: {
|
|
184
|
+
name: 'filter',
|
|
185
|
+
in: 'query',
|
|
186
|
+
schema: {type: 'string', nullable: true},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
|
|
193
|
+
const params = (result.components as Record<string, unknown>)?.parameters as Record<string, unknown>;
|
|
194
|
+
const param = params?.FilterParam as Record<string, unknown>;
|
|
195
|
+
const schema = param?.schema as Record<string, unknown>;
|
|
196
|
+
expect(schema?.type).toEqual(['string', 'null']);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('transforms schemas in components.requestBodies', () => {
|
|
200
|
+
const spec = {
|
|
201
|
+
openapi: '3.0.0',
|
|
202
|
+
info: {title: 'T', version: '1'},
|
|
203
|
+
paths: {},
|
|
204
|
+
components: {
|
|
205
|
+
requestBodies: {
|
|
206
|
+
CreateItem: {
|
|
207
|
+
content: {
|
|
208
|
+
'application/json': {
|
|
209
|
+
schema: {
|
|
210
|
+
type: 'object',
|
|
211
|
+
properties: {
|
|
212
|
+
name: {type: 'string', nullable: true},
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
|
|
223
|
+
const bodies = (result.components as Record<string, unknown>)?.requestBodies as Record<string, unknown>;
|
|
224
|
+
const body = bodies?.CreateItem as Record<string, unknown>;
|
|
225
|
+
const content = body?.content as Record<string, unknown>;
|
|
226
|
+
const json = content?.['application/json'] as Record<string, unknown>;
|
|
227
|
+
const schema = json?.schema as Record<string, unknown>;
|
|
228
|
+
const props = schema?.properties as Record<string, unknown>;
|
|
229
|
+
const name = props?.name as Record<string, unknown>;
|
|
230
|
+
expect(name?.type).toEqual(['string', 'null']);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('transforms schemas in components.responses', () => {
|
|
234
|
+
const spec = {
|
|
235
|
+
openapi: '3.0.0',
|
|
236
|
+
info: {title: 'T', version: '1'},
|
|
237
|
+
paths: {},
|
|
238
|
+
components: {
|
|
239
|
+
responses: {
|
|
240
|
+
ItemResponse: {
|
|
241
|
+
description: 'OK',
|
|
242
|
+
content: {
|
|
243
|
+
'application/json': {
|
|
244
|
+
schema: {type: 'string', nullable: true},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
|
|
253
|
+
const responses = (result.components as Record<string, unknown>)?.responses as Record<string, unknown>;
|
|
254
|
+
const resp = responses?.ItemResponse as Record<string, unknown>;
|
|
255
|
+
const content = resp?.content as Record<string, unknown>;
|
|
256
|
+
const json = content?.['application/json'] as Record<string, unknown>;
|
|
257
|
+
const schema = json?.schema as Record<string, unknown>;
|
|
258
|
+
expect(schema?.type).toEqual(['string', 'null']);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('transforms schemas in components.headers', () => {
|
|
262
|
+
const spec = {
|
|
263
|
+
openapi: '3.0.0',
|
|
264
|
+
info: {title: 'T', version: '1'},
|
|
265
|
+
paths: {},
|
|
266
|
+
components: {
|
|
267
|
+
headers: {
|
|
268
|
+
XRateLimit: {
|
|
269
|
+
schema: {type: 'integer', nullable: true},
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
|
|
276
|
+
const headers = (result.components as Record<string, unknown>)?.headers as Record<string, unknown>;
|
|
277
|
+
const header = headers?.XRateLimit as Record<string, unknown>;
|
|
278
|
+
const schema = header?.schema as Record<string, unknown>;
|
|
279
|
+
expect(schema?.type).toEqual(['integer', 'null']);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// -------------------------------------------------------------------
|
|
284
|
+
// Webhooks
|
|
285
|
+
// -------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
describe('webhooks', () => {
|
|
288
|
+
it('transforms schemas in webhooks', () => {
|
|
289
|
+
const spec = {
|
|
290
|
+
openapi: '3.0.0',
|
|
291
|
+
info: {title: 'T', version: '1'},
|
|
292
|
+
paths: {},
|
|
293
|
+
webhooks: {
|
|
294
|
+
newItem: {
|
|
295
|
+
post: {
|
|
296
|
+
requestBody: {
|
|
297
|
+
content: {
|
|
298
|
+
'application/json': {
|
|
299
|
+
schema: {type: 'string', nullable: true},
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
responses: {'200': {description: 'OK'}},
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
|
|
310
|
+
const webhooks = result.webhooks as Record<string, unknown>;
|
|
311
|
+
const hook = webhooks?.newItem as Record<string, unknown>;
|
|
312
|
+
const post = hook?.post as Record<string, unknown>;
|
|
313
|
+
const reqBody = post?.requestBody as Record<string, unknown>;
|
|
314
|
+
const content = reqBody?.content as Record<string, unknown>;
|
|
315
|
+
const json = content?.['application/json'] as Record<string, unknown>;
|
|
316
|
+
const schema = json?.schema as Record<string, unknown>;
|
|
317
|
+
expect(schema?.type).toEqual(['string', 'null']);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('strips 3.2 features from webhooks when targeting 3.1', () => {
|
|
321
|
+
const spec = {
|
|
322
|
+
openapi: '3.2.0',
|
|
323
|
+
info: {title: 'T', version: '1'},
|
|
324
|
+
paths: {},
|
|
325
|
+
webhooks: {
|
|
326
|
+
newItem: {
|
|
327
|
+
query: {
|
|
328
|
+
responses: {'200': {description: 'OK'}},
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
|
|
335
|
+
const webhooks = result.webhooks as Record<string, unknown>;
|
|
336
|
+
const hook = webhooks?.newItem as Record<string, unknown>;
|
|
337
|
+
expect(hook).not.toHaveProperty('query');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('strips webhooks entirely when targeting 3.0', () => {
|
|
341
|
+
const spec = {
|
|
342
|
+
openapi: '3.2.0',
|
|
343
|
+
info: {title: 'T', version: '1'},
|
|
344
|
+
paths: {},
|
|
345
|
+
webhooks: {
|
|
346
|
+
newItem: {
|
|
347
|
+
post: {
|
|
348
|
+
responses: {'200': {description: 'OK'}},
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.0.0'});
|
|
355
|
+
expect(result).not.toHaveProperty('webhooks');
|
|
356
|
+
expect(warnings.some(w => w.field === 'webhooks')).toBe(true);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// -------------------------------------------------------------------
|
|
361
|
+
// Callbacks
|
|
362
|
+
// -------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
describe('callbacks', () => {
|
|
365
|
+
it('transforms schemas in callbacks', () => {
|
|
366
|
+
const spec = {
|
|
367
|
+
openapi: '3.0.0',
|
|
368
|
+
info: {title: 'T', version: '1'},
|
|
369
|
+
paths: {
|
|
370
|
+
'/subscribe': {
|
|
371
|
+
post: {
|
|
372
|
+
operationId: 'subscribe',
|
|
373
|
+
callbacks: {
|
|
374
|
+
onEvent: {
|
|
375
|
+
'{$request.body#/callbackUrl}': {
|
|
376
|
+
post: {
|
|
377
|
+
requestBody: {
|
|
378
|
+
content: {
|
|
379
|
+
'application/json': {
|
|
380
|
+
schema: {type: 'string', nullable: true},
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
responses: {'200': {description: 'OK'}},
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
responses: {'200': {description: 'OK'}},
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
|
|
396
|
+
const paths = result.paths as Record<string, unknown>;
|
|
397
|
+
const sub = paths?.['/subscribe'] as Record<string, unknown>;
|
|
398
|
+
const post = sub?.post as Record<string, unknown>;
|
|
399
|
+
const callbacks = post?.callbacks as Record<string, unknown>;
|
|
400
|
+
const onEvent = callbacks?.onEvent as Record<string, unknown>;
|
|
401
|
+
const cbPath = onEvent?.['{$request.body#/callbackUrl}'] as Record<string, unknown>;
|
|
402
|
+
const cbPost = cbPath?.post as Record<string, unknown>;
|
|
403
|
+
const reqBody = cbPost?.requestBody as Record<string, unknown>;
|
|
404
|
+
const content = reqBody?.content as Record<string, unknown>;
|
|
405
|
+
const json = content?.['application/json'] as Record<string, unknown>;
|
|
406
|
+
const schema = json?.schema as Record<string, unknown>;
|
|
407
|
+
expect(schema?.type).toEqual(['string', 'null']);
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// -------------------------------------------------------------------
|
|
412
|
+
// Path-level parameters
|
|
413
|
+
// -------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
describe('path-level parameters', () => {
|
|
416
|
+
it('transforms schemas in path-level parameters', () => {
|
|
417
|
+
const spec = {
|
|
418
|
+
openapi: '3.0.0',
|
|
419
|
+
info: {title: 'T', version: '1'},
|
|
420
|
+
paths: {
|
|
421
|
+
'/items/{id}': {
|
|
422
|
+
parameters: [
|
|
423
|
+
{name: 'id', in: 'path', schema: {type: 'string', nullable: true}},
|
|
424
|
+
],
|
|
425
|
+
get: {
|
|
426
|
+
responses: {'200': {description: 'OK'}},
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
|
|
433
|
+
const paths = result.paths as Record<string, unknown>;
|
|
434
|
+
const pathItem = paths?.['/items/{id}'] as Record<string, unknown>;
|
|
435
|
+
const params = pathItem?.parameters as Record<string, unknown>[];
|
|
436
|
+
const schema = params?.[0]?.schema as Record<string, unknown>;
|
|
437
|
+
expect(schema?.type).toEqual(['string', 'null']);
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// -------------------------------------------------------------------
|
|
442
|
+
// Circular references
|
|
443
|
+
// -------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
describe('circular references', () => {
|
|
446
|
+
it('handles circular schema references without infinite loop', () => {
|
|
447
|
+
const node: Record<string, unknown> = {
|
|
448
|
+
type: 'object',
|
|
449
|
+
properties: {},
|
|
450
|
+
};
|
|
451
|
+
// Create circular reference
|
|
452
|
+
(node.properties as Record<string, unknown>).self = node;
|
|
453
|
+
|
|
454
|
+
const spec = {
|
|
455
|
+
openapi: '3.0.0',
|
|
456
|
+
info: {title: 'T', version: '1'},
|
|
457
|
+
paths: {},
|
|
458
|
+
components: {schemas: {Node: node}},
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
// structuredClone breaks on circular refs, so test the walkSchemas
|
|
462
|
+
// logic separately. The transform will clone first which breaks cycles,
|
|
463
|
+
// but the walk itself should handle cycles via WeakSet.
|
|
464
|
+
expect(() => {
|
|
465
|
+
// This would infinite loop without the WeakSet guard
|
|
466
|
+
transformOpenApiSpec(
|
|
467
|
+
JSON.parse(JSON.stringify(spec, (key, value) => {
|
|
468
|
+
// Break circular ref for JSON.stringify
|
|
469
|
+
if (key === 'self' && value === node) return {$ref: '#/components/schemas/Node'};
|
|
470
|
+
return value;
|
|
471
|
+
})),
|
|
472
|
+
{version: '3.1.0'},
|
|
473
|
+
);
|
|
474
|
+
}).not.toThrow();
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// -------------------------------------------------------------------
|
|
479
|
+
// 3.2 specific: jsonSchemaDialect, mediaTypes
|
|
480
|
+
// -------------------------------------------------------------------
|
|
481
|
+
|
|
482
|
+
describe('3.2 additional fields', () => {
|
|
483
|
+
it('strips jsonSchemaDialect when downgrading', () => {
|
|
484
|
+
const spec = {
|
|
485
|
+
openapi: '3.2.0',
|
|
486
|
+
info: {title: 'T', version: '1'},
|
|
487
|
+
paths: {},
|
|
488
|
+
jsonSchemaDialect: 'https://json-schema.org/draft/2020-12/schema',
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.0.0'});
|
|
492
|
+
expect(result).not.toHaveProperty('jsonSchemaDialect');
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('strips components.mediaTypes when downgrading', () => {
|
|
496
|
+
const spec = {
|
|
497
|
+
openapi: '3.2.0',
|
|
498
|
+
info: {title: 'T', version: '1'},
|
|
499
|
+
paths: {},
|
|
500
|
+
components: {
|
|
501
|
+
mediaTypes: {
|
|
502
|
+
JsonResponse: {
|
|
503
|
+
schema: {type: 'object'},
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
|
|
510
|
+
const components = result.components as Record<string, unknown>;
|
|
511
|
+
expect(components).not.toHaveProperty('mediaTypes');
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// -------------------------------------------------------------------
|
|
516
|
+
// Warnings diagnostics
|
|
517
|
+
// -------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
describe('warnings', () => {
|
|
520
|
+
it('emits warnings when stripping 3.2 features', () => {
|
|
521
|
+
const spec = {
|
|
522
|
+
openapi: '3.2.0',
|
|
523
|
+
info: {title: 'T', version: '1'},
|
|
524
|
+
paths: {},
|
|
525
|
+
$self: 'https://example.com/api',
|
|
526
|
+
components: {
|
|
527
|
+
mediaTypes: {JsonResponse: {schema: {type: 'object'}}},
|
|
528
|
+
},
|
|
529
|
+
tags: [{name: 'test', summary: 'Test tag', parent: 'root', kind: 'nav'}],
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const {warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
|
|
533
|
+
expect(warnings.length).toBeGreaterThan(0);
|
|
534
|
+
expect(warnings.some(w => w.field === '$self')).toBe(true);
|
|
535
|
+
expect(warnings.some(w => w.field === 'components.mediaTypes')).toBe(true);
|
|
536
|
+
expect(warnings.some(w => w.field === 'tags')).toBe(true);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('emits no warnings for same-version transform', () => {
|
|
540
|
+
const spec = {
|
|
541
|
+
openapi: '3.0.0',
|
|
542
|
+
info: {title: 'T', version: '1'},
|
|
543
|
+
paths: {},
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
const {warnings} = transformOpenApiSpec(spec, {version: '3.0.0'});
|
|
547
|
+
expect(warnings).toEqual([]);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('emits no warnings for upgrades', () => {
|
|
551
|
+
const spec = {
|
|
552
|
+
openapi: '3.0.0',
|
|
553
|
+
info: {title: 'T', version: '1'},
|
|
554
|
+
paths: {},
|
|
555
|
+
components: {
|
|
556
|
+
schemas: {
|
|
557
|
+
Item: {type: 'string', nullable: true},
|
|
558
|
+
},
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
const {warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
|
|
563
|
+
expect(warnings).toEqual([]);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('emits warnings for jsonSchemaDialect when targeting 3.0', () => {
|
|
567
|
+
const spec = {
|
|
568
|
+
openapi: '3.1.0',
|
|
569
|
+
info: {title: 'T', version: '1'},
|
|
570
|
+
paths: {},
|
|
571
|
+
jsonSchemaDialect: 'https://json-schema.org/draft/2020-12/schema',
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const {warnings} = transformOpenApiSpec(spec, {version: '3.0.0'});
|
|
575
|
+
expect(warnings.some(w => w.field === 'jsonSchemaDialect')).toBe(true);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('preserves jsonSchemaDialect when targeting 3.1', () => {
|
|
579
|
+
const spec = {
|
|
580
|
+
openapi: '3.2.0',
|
|
581
|
+
info: {title: 'T', version: '1'},
|
|
582
|
+
paths: {},
|
|
583
|
+
jsonSchemaDialect: 'https://json-schema.org/draft/2020-12/schema',
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const {spec: result} = transformOpenApiSpec(spec, {version: '3.1.0'});
|
|
587
|
+
expect(result.jsonSchemaDialect).toBe('https://json-schema.org/draft/2020-12/schema');
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
// -------------------------------------------------------------------
|
|
592
|
+
// Version validation
|
|
593
|
+
// -------------------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
describe('version validation', () => {
|
|
596
|
+
it('rejects unsupported minor version 3.99.0', () => {
|
|
597
|
+
const spec = {openapi: '3.0.0', info: {title: 'T', version: '1'}, paths: {}};
|
|
598
|
+
expect(() => transformOpenApiSpec(spec, {version: '3.99.0' as '3.0.0'})).toThrow(
|
|
599
|
+
'Unsupported OpenAPI minor version',
|
|
600
|
+
);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('rejects invalid source version', () => {
|
|
604
|
+
const spec = {openapi: 'banana', info: {title: 'T', version: '1'}, paths: {}};
|
|
605
|
+
expect(() => transformOpenApiSpec(spec, {version: '3.1.0'})).toThrow(
|
|
606
|
+
'Invalid OpenAPI version',
|
|
607
|
+
);
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
// -------------------------------------------------------------------
|
|
612
|
+
// itemEncoding, prefixEncoding (3.2 media fields)
|
|
613
|
+
// -------------------------------------------------------------------
|
|
614
|
+
|
|
615
|
+
describe('3.2 media fields', () => {
|
|
616
|
+
it('strips itemEncoding and prefixEncoding when downgrading', () => {
|
|
617
|
+
const spec = {
|
|
618
|
+
openapi: '3.2.0',
|
|
619
|
+
info: {title: 'T', version: '1'},
|
|
620
|
+
paths: {
|
|
621
|
+
'/stream': {
|
|
622
|
+
get: {
|
|
623
|
+
responses: {
|
|
624
|
+
'200': {
|
|
625
|
+
description: 'OK',
|
|
626
|
+
content: {
|
|
627
|
+
'text/event-stream': {
|
|
628
|
+
schema: {type: 'string'},
|
|
629
|
+
itemSchema: {type: 'object'},
|
|
630
|
+
itemEncoding: {contentType: 'application/json'},
|
|
631
|
+
prefixEncoding: {contentType: 'text/plain'},
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
},
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
|
|
642
|
+
const getOp = (result.paths as Record<string, unknown>)?.['/stream'] as Record<string, unknown>;
|
|
643
|
+
const get = getOp?.get as Record<string, unknown>;
|
|
644
|
+
const resp = (get?.responses as Record<string, unknown>)?.['200'] as Record<string, unknown>;
|
|
645
|
+
const content = resp?.content as Record<string, unknown>;
|
|
646
|
+
const sse = content?.['text/event-stream'] as Record<string, unknown>;
|
|
647
|
+
expect(sse).not.toHaveProperty('itemSchema');
|
|
648
|
+
expect(sse).not.toHaveProperty('itemEncoding');
|
|
649
|
+
expect(sse).not.toHaveProperty('prefixEncoding');
|
|
650
|
+
expect(sse).toHaveProperty('schema');
|
|
651
|
+
expect(warnings.some(w => w.message.includes('itemSchema'))).toBe(true);
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
// -------------------------------------------------------------------
|
|
656
|
+
// Server.name (3.2)
|
|
657
|
+
// -------------------------------------------------------------------
|
|
658
|
+
|
|
659
|
+
describe('Server.name', () => {
|
|
660
|
+
it('strips server name when downgrading from 3.2', () => {
|
|
661
|
+
const spec = {
|
|
662
|
+
openapi: '3.2.0',
|
|
663
|
+
info: {title: 'T', version: '1'},
|
|
664
|
+
paths: {},
|
|
665
|
+
servers: [{url: 'https://api.example.com', name: 'production'}],
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
|
|
669
|
+
const servers = result.servers as Record<string, unknown>[];
|
|
670
|
+
expect(servers[0]).not.toHaveProperty('name');
|
|
671
|
+
expect(servers[0].url).toBe('https://api.example.com');
|
|
672
|
+
expect(warnings.some(w => w.field === 'servers')).toBe(true);
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
// -------------------------------------------------------------------
|
|
677
|
+
// License.identifier (3.1+)
|
|
678
|
+
// -------------------------------------------------------------------
|
|
679
|
+
|
|
680
|
+
describe('License.identifier', () => {
|
|
681
|
+
it('strips license identifier when targeting 3.0', () => {
|
|
682
|
+
const spec = {
|
|
683
|
+
openapi: '3.1.0',
|
|
684
|
+
info: {title: 'T', version: '1', license: {name: 'MIT', identifier: 'MIT'}},
|
|
685
|
+
paths: {},
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.0.0'});
|
|
689
|
+
const info = result.info as Record<string, unknown>;
|
|
690
|
+
const license = info?.license as Record<string, unknown>;
|
|
691
|
+
expect(license).not.toHaveProperty('identifier');
|
|
692
|
+
expect(license?.name).toBe('MIT');
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it('preserves license identifier when targeting 3.1', () => {
|
|
696
|
+
const spec = {
|
|
697
|
+
openapi: '3.2.0',
|
|
698
|
+
info: {title: 'T', version: '1', license: {name: 'MIT', identifier: 'MIT'}},
|
|
699
|
+
paths: {},
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
const {spec: result} = transformOpenApiSpec(spec, {version: '3.1.0'});
|
|
703
|
+
const info = result.info as Record<string, unknown>;
|
|
704
|
+
const license = info?.license as Record<string, unknown>;
|
|
705
|
+
expect(license?.identifier).toBe('MIT');
|
|
706
|
+
});
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
// -------------------------------------------------------------------
|
|
710
|
+
// components.pathItems (3.1+)
|
|
711
|
+
// -------------------------------------------------------------------
|
|
712
|
+
|
|
713
|
+
describe('components.pathItems', () => {
|
|
714
|
+
it('strips pathItems when targeting 3.0', () => {
|
|
715
|
+
const spec = {
|
|
716
|
+
openapi: '3.1.0',
|
|
717
|
+
info: {title: 'T', version: '1'},
|
|
718
|
+
paths: {},
|
|
719
|
+
components: {
|
|
720
|
+
pathItems: {
|
|
721
|
+
CommonItem: {
|
|
722
|
+
get: {responses: {'200': {description: 'OK'}}},
|
|
723
|
+
},
|
|
724
|
+
},
|
|
725
|
+
},
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.0.0'});
|
|
729
|
+
const components = result.components as Record<string, unknown>;
|
|
730
|
+
expect(components).not.toHaveProperty('pathItems');
|
|
731
|
+
expect(warnings.some(w => w.field === 'components.pathItems')).toBe(true);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it('preserves pathItems when targeting 3.1', () => {
|
|
735
|
+
const spec = {
|
|
736
|
+
openapi: '3.2.0',
|
|
737
|
+
info: {title: 'T', version: '1'},
|
|
738
|
+
paths: {},
|
|
739
|
+
components: {
|
|
740
|
+
pathItems: {
|
|
741
|
+
CommonItem: {
|
|
742
|
+
get: {responses: {'200': {description: 'OK'}}},
|
|
743
|
+
},
|
|
744
|
+
},
|
|
745
|
+
},
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
const {spec: result} = transformOpenApiSpec(spec, {version: '3.1.0'});
|
|
749
|
+
const components = result.components as Record<string, unknown>;
|
|
750
|
+
expect(components).toHaveProperty('pathItems');
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
// -------------------------------------------------------------------
|
|
755
|
+
// XML Object text field (3.2)
|
|
756
|
+
// -------------------------------------------------------------------
|
|
757
|
+
|
|
758
|
+
describe('XML Object', () => {
|
|
759
|
+
it('strips xml.text when downgrading from 3.2', () => {
|
|
760
|
+
const spec = {
|
|
761
|
+
openapi: '3.2.0',
|
|
762
|
+
info: {title: 'T', version: '1'},
|
|
763
|
+
paths: {},
|
|
764
|
+
components: {
|
|
765
|
+
schemas: {
|
|
766
|
+
Item: {
|
|
767
|
+
type: 'object',
|
|
768
|
+
xml: {name: 'item', text: true},
|
|
769
|
+
properties: {
|
|
770
|
+
name: {type: 'string', xml: {attribute: true}},
|
|
771
|
+
content: {type: 'string', xml: {text: true}},
|
|
772
|
+
},
|
|
773
|
+
},
|
|
774
|
+
},
|
|
775
|
+
},
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
|
|
779
|
+
const item = (result.components as Record<string, unknown>)?.schemas as Record<string, unknown>;
|
|
780
|
+
const schema = item?.Item as Record<string, unknown>;
|
|
781
|
+
const xml = schema?.xml as Record<string, unknown>;
|
|
782
|
+
expect(xml).not.toHaveProperty('text');
|
|
783
|
+
expect(xml?.name).toBe('item');
|
|
784
|
+
|
|
785
|
+
const props = schema?.properties as Record<string, unknown>;
|
|
786
|
+
const content = props?.content as Record<string, unknown>;
|
|
787
|
+
const contentXml = content?.xml as Record<string, unknown>;
|
|
788
|
+
expect(contentXml).not.toHaveProperty('text');
|
|
789
|
+
|
|
790
|
+
expect(warnings.some(w => w.field === 'xml.text')).toBe(true);
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
// -------------------------------------------------------------------
|
|
795
|
+
// Transform ordering: 3.2 strip before 3.1 strip
|
|
796
|
+
// -------------------------------------------------------------------
|
|
797
|
+
|
|
798
|
+
describe('transform ordering', () => {
|
|
799
|
+
it('strips 3.2 fields from webhooks before webhooks are removed for 3.0', () => {
|
|
800
|
+
const spec = {
|
|
801
|
+
openapi: '3.2.0',
|
|
802
|
+
info: {title: 'T', version: '1'},
|
|
803
|
+
paths: {},
|
|
804
|
+
webhooks: {
|
|
805
|
+
onEvent: {
|
|
806
|
+
query: {responses: {'200': {description: 'OK'}}},
|
|
807
|
+
post: {responses: {'200': {description: 'OK'}}},
|
|
808
|
+
},
|
|
809
|
+
},
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
const {warnings} = transformOpenApiSpec(spec, {version: '3.0.0'});
|
|
813
|
+
const queryWarning = warnings.find(w => w.field.includes('webhooks.onEvent.query'));
|
|
814
|
+
const webhookWarning = warnings.find(w => w.field === 'webhooks');
|
|
815
|
+
expect(queryWarning).toBeDefined();
|
|
816
|
+
expect(webhookWarning).toBeDefined();
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
// -------------------------------------------------------------------
|
|
821
|
+
// Warning deduplication
|
|
822
|
+
// -------------------------------------------------------------------
|
|
823
|
+
|
|
824
|
+
describe('warning deduplication', () => {
|
|
825
|
+
it('dedupes repeated warnings', () => {
|
|
826
|
+
const spec = {
|
|
827
|
+
openapi: '3.2.0',
|
|
828
|
+
info: {title: 'T', version: '1'},
|
|
829
|
+
paths: {
|
|
830
|
+
'/a': {
|
|
831
|
+
get: {
|
|
832
|
+
responses: {
|
|
833
|
+
'200': {
|
|
834
|
+
description: 'OK',
|
|
835
|
+
content: {
|
|
836
|
+
'text/event-stream': {
|
|
837
|
+
schema: {type: 'string'},
|
|
838
|
+
itemSchema: {type: 'object'},
|
|
839
|
+
},
|
|
840
|
+
},
|
|
841
|
+
},
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
},
|
|
845
|
+
'/b': {
|
|
846
|
+
get: {
|
|
847
|
+
responses: {
|
|
848
|
+
'200': {
|
|
849
|
+
description: 'OK',
|
|
850
|
+
content: {
|
|
851
|
+
'text/event-stream': {
|
|
852
|
+
schema: {type: 'string'},
|
|
853
|
+
itemSchema: {type: 'object'},
|
|
854
|
+
},
|
|
855
|
+
},
|
|
856
|
+
},
|
|
857
|
+
},
|
|
858
|
+
},
|
|
859
|
+
},
|
|
860
|
+
},
|
|
861
|
+
servers: [
|
|
862
|
+
{url: 'https://a.example.com', name: 'a'},
|
|
863
|
+
{url: 'https://b.example.com', name: 'b'},
|
|
864
|
+
],
|
|
865
|
+
components: {
|
|
866
|
+
schemas: {
|
|
867
|
+
A: {type: 'object', xml: {text: true}},
|
|
868
|
+
B: {type: 'object', xml: {text: true}},
|
|
869
|
+
},
|
|
870
|
+
},
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
const {warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
|
|
874
|
+
const serverWarnings = warnings.filter(w => w.field === 'servers');
|
|
875
|
+
const xmlWarnings = warnings.filter(w => w.field === 'xml.text');
|
|
876
|
+
expect(serverWarnings.length).toBe(1);
|
|
877
|
+
expect(xmlWarnings.length).toBe(1);
|
|
878
|
+
});
|
|
879
|
+
});
|