incur 0.3.21 → 0.3.23
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/dist/Cli.d.ts +1 -1
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +3 -3
- package/dist/Cli.js.map +1 -1
- package/dist/Mcp.d.ts +16 -4
- package/dist/Mcp.d.ts.map +1 -1
- package/dist/Mcp.js +7 -6
- package/dist/Mcp.js.map +1 -1
- package/dist/Openapi.d.ts.map +1 -1
- package/dist/Openapi.js +2 -2
- package/dist/Openapi.js.map +1 -1
- package/dist/internal/dereference.d.ts +12 -0
- package/dist/internal/dereference.d.ts.map +1 -0
- package/dist/internal/dereference.js +71 -0
- package/dist/internal/dereference.js.map +1 -0
- package/package.json +3 -3
- package/src/Cli.ts +4 -5
- package/src/Mcp.test.ts +16 -0
- package/src/Mcp.ts +15 -9
- package/src/Openapi.ts +2 -2
- package/src/e2e.test.ts +41 -0
- package/src/internal/dereference.test.ts +695 -0
- package/src/internal/dereference.ts +75 -0
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { dereference } from './dereference.js'
|
|
4
|
+
|
|
5
|
+
describe('dereference', () => {
|
|
6
|
+
test('resolves basic $ref', () => {
|
|
7
|
+
const spec = {
|
|
8
|
+
paths: {
|
|
9
|
+
'/users': {
|
|
10
|
+
get: {
|
|
11
|
+
responses: {
|
|
12
|
+
'200': {
|
|
13
|
+
content: {
|
|
14
|
+
'application/json': {
|
|
15
|
+
schema: { $ref: '#/components/schemas/User' },
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
components: {
|
|
24
|
+
schemas: {
|
|
25
|
+
User: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: { name: { type: 'string' } },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
const result = dereference(spec) as any
|
|
33
|
+
expect(result.paths['/users'].get.responses['200'].content['application/json'].schema).toEqual({
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties: { name: { type: 'string' } },
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('resolves nested $ref (ref target contains another ref)', () => {
|
|
40
|
+
const spec = {
|
|
41
|
+
components: {
|
|
42
|
+
schemas: {
|
|
43
|
+
Name: { type: 'string' },
|
|
44
|
+
User: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: { name: { $ref: '#/components/schemas/Name' } },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
root: { $ref: '#/components/schemas/User' },
|
|
51
|
+
}
|
|
52
|
+
const result = dereference(spec) as any
|
|
53
|
+
expect(result.root).toEqual({
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: { name: { type: 'string' } },
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('handles circular $ref without infinite loop', () => {
|
|
60
|
+
const spec = {
|
|
61
|
+
components: {
|
|
62
|
+
schemas: {
|
|
63
|
+
Node: {
|
|
64
|
+
type: 'object',
|
|
65
|
+
properties: {
|
|
66
|
+
value: { type: 'string' },
|
|
67
|
+
child: { $ref: '#/components/schemas/Node' },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
root: { $ref: '#/components/schemas/Node' },
|
|
73
|
+
}
|
|
74
|
+
const result = dereference(spec) as any
|
|
75
|
+
// Should resolve without hanging
|
|
76
|
+
expect(result.root.type).toBe('object')
|
|
77
|
+
expect(result.root.properties.value).toEqual({ type: 'string' })
|
|
78
|
+
// Circular ref should point back to the same resolved object
|
|
79
|
+
expect(result.root.properties.child).toBe(result.root)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('resolves multiple refs to same target (shares identity)', () => {
|
|
83
|
+
const spec = {
|
|
84
|
+
components: { schemas: { Id: { type: 'number' } } },
|
|
85
|
+
a: { $ref: '#/components/schemas/Id' },
|
|
86
|
+
b: { $ref: '#/components/schemas/Id' },
|
|
87
|
+
}
|
|
88
|
+
const result = dereference(spec) as any
|
|
89
|
+
expect(result.a).toEqual({ type: 'number' })
|
|
90
|
+
expect(result.a).toBe(result.b)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('resolves $ref in arrays', () => {
|
|
94
|
+
const spec = {
|
|
95
|
+
components: { schemas: { Tag: { type: 'string' } } },
|
|
96
|
+
items: [{ $ref: '#/components/schemas/Tag' }, { $ref: '#/components/schemas/Tag' }],
|
|
97
|
+
}
|
|
98
|
+
const result = dereference(spec) as any
|
|
99
|
+
expect(result.items[0]).toEqual({ type: 'string' })
|
|
100
|
+
expect(result.items[1]).toEqual({ type: 'string' })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('handles deeply nested path', () => {
|
|
104
|
+
const spec = {
|
|
105
|
+
a: { b: { c: { d: { value: 42 } } } },
|
|
106
|
+
ref: { $ref: '#/a/b/c/d' },
|
|
107
|
+
}
|
|
108
|
+
const result = dereference(spec) as any
|
|
109
|
+
expect(result.ref).toEqual({ value: 42 })
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('handles JSON Pointer escaping (~0 for ~, ~1 for /)', () => {
|
|
113
|
+
const spec = {
|
|
114
|
+
'a/b': { 'c~d': { value: 'escaped' } },
|
|
115
|
+
ref: { $ref: '#/a~1b/c~0d' },
|
|
116
|
+
}
|
|
117
|
+
const result = dereference(spec) as any
|
|
118
|
+
expect(result.ref).toEqual({ value: 'escaped' })
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('throws on unresolvable $ref', () => {
|
|
122
|
+
const spec = { ref: { $ref: '#/does/not/exist' } }
|
|
123
|
+
expect(() => dereference(spec)).toThrow('Cannot resolve $ref')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('passes through primitives unchanged', () => {
|
|
127
|
+
expect(dereference('hello')).toBe('hello')
|
|
128
|
+
expect(dereference(42)).toBe(42)
|
|
129
|
+
expect(dereference(null)).toBe(null)
|
|
130
|
+
expect(dereference(true)).toBe(true)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('does not mutate original object', () => {
|
|
134
|
+
const spec = {
|
|
135
|
+
components: { schemas: { User: { type: 'object' } } },
|
|
136
|
+
ref: { $ref: '#/components/schemas/User' },
|
|
137
|
+
}
|
|
138
|
+
const original = JSON.stringify(spec)
|
|
139
|
+
dereference(spec)
|
|
140
|
+
expect(JSON.stringify(spec)).toBe(original)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('resolves $ref: "#" to root', () => {
|
|
144
|
+
const spec = { type: 'object', self: { $ref: '#' } }
|
|
145
|
+
const result = dereference(spec) as any
|
|
146
|
+
expect(result.self.type).toBe('object')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('realistic OpenAPI spec with shared parameter and request body refs', () => {
|
|
150
|
+
const spec = {
|
|
151
|
+
openapi: '3.0.0',
|
|
152
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
153
|
+
paths: {
|
|
154
|
+
'/users/{id}': {
|
|
155
|
+
get: {
|
|
156
|
+
operationId: 'getUser',
|
|
157
|
+
parameters: [{ $ref: '#/components/parameters/UserId' }],
|
|
158
|
+
responses: {
|
|
159
|
+
'200': {
|
|
160
|
+
content: {
|
|
161
|
+
'application/json': {
|
|
162
|
+
schema: { $ref: '#/components/schemas/User' },
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
put: {
|
|
169
|
+
operationId: 'updateUser',
|
|
170
|
+
parameters: [{ $ref: '#/components/parameters/UserId' }],
|
|
171
|
+
requestBody: {
|
|
172
|
+
content: {
|
|
173
|
+
'application/json': {
|
|
174
|
+
schema: { $ref: '#/components/schemas/UserInput' },
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
components: {
|
|
182
|
+
parameters: {
|
|
183
|
+
UserId: {
|
|
184
|
+
name: 'id',
|
|
185
|
+
in: 'path',
|
|
186
|
+
required: true,
|
|
187
|
+
schema: { type: 'number' },
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
schemas: {
|
|
191
|
+
User: {
|
|
192
|
+
type: 'object',
|
|
193
|
+
properties: {
|
|
194
|
+
id: { type: 'number' },
|
|
195
|
+
name: { type: 'string' },
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
UserInput: {
|
|
199
|
+
type: 'object',
|
|
200
|
+
properties: {
|
|
201
|
+
name: { type: 'string' },
|
|
202
|
+
},
|
|
203
|
+
required: ['name'],
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
const result = dereference(spec) as any
|
|
209
|
+
const getParams = result.paths['/users/{id}'].get.parameters
|
|
210
|
+
expect(getParams[0].name).toBe('id')
|
|
211
|
+
expect(getParams[0].in).toBe('path')
|
|
212
|
+
// Both GET and PUT share the same resolved parameter
|
|
213
|
+
const putParams = result.paths['/users/{id}'].put.parameters
|
|
214
|
+
expect(putParams[0]).toBe(getParams[0])
|
|
215
|
+
// Request body schema resolved
|
|
216
|
+
const bodySchema =
|
|
217
|
+
result.paths['/users/{id}'].put.requestBody.content['application/json'].schema
|
|
218
|
+
expect(bodySchema.properties.name).toEqual({ type: 'string' })
|
|
219
|
+
expect(bodySchema.required).toEqual(['name'])
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('mutual circular refs', () => {
|
|
223
|
+
const spec = {
|
|
224
|
+
components: {
|
|
225
|
+
schemas: {
|
|
226
|
+
A: {
|
|
227
|
+
type: 'object',
|
|
228
|
+
properties: { b: { $ref: '#/components/schemas/B' } },
|
|
229
|
+
},
|
|
230
|
+
B: {
|
|
231
|
+
type: 'object',
|
|
232
|
+
properties: { a: { $ref: '#/components/schemas/A' } },
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
root: { $ref: '#/components/schemas/A' },
|
|
237
|
+
}
|
|
238
|
+
const result = dereference(spec) as any
|
|
239
|
+
expect(result.root.type).toBe('object')
|
|
240
|
+
expect(result.root.properties.b.type).toBe('object')
|
|
241
|
+
expect(result.root.properties.b.properties.a).toBe(result.root)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('$ref target is a primitive (string)', () => {
|
|
245
|
+
const spec = {
|
|
246
|
+
components: { values: { name: 'Alice' } },
|
|
247
|
+
ref: { $ref: '#/components/values/name' },
|
|
248
|
+
}
|
|
249
|
+
const result = dereference(spec) as any
|
|
250
|
+
expect(result.ref).toBe('Alice')
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
test('$ref target is a primitive (number)', () => {
|
|
254
|
+
const spec = {
|
|
255
|
+
components: { values: { count: 42 } },
|
|
256
|
+
ref: { $ref: '#/components/values/count' },
|
|
257
|
+
}
|
|
258
|
+
const result = dereference(spec) as any
|
|
259
|
+
expect(result.ref).toBe(42)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('$ref target is null', () => {
|
|
263
|
+
const spec = {
|
|
264
|
+
components: { values: { empty: null } },
|
|
265
|
+
ref: { $ref: '#/components/values/empty' },
|
|
266
|
+
}
|
|
267
|
+
const result = dereference(spec) as any
|
|
268
|
+
expect(result.ref).toBe(null)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
test('$ref target is an array', () => {
|
|
272
|
+
const spec = {
|
|
273
|
+
components: { values: { tags: ['a', 'b', 'c'] } },
|
|
274
|
+
ref: { $ref: '#/components/values/tags' },
|
|
275
|
+
}
|
|
276
|
+
const result = dereference(spec) as any
|
|
277
|
+
expect(result.ref).toEqual(['a', 'b', 'c'])
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('$ref to array element by index', () => {
|
|
281
|
+
const spec = {
|
|
282
|
+
items: [{ name: 'first' }, { name: 'second' }],
|
|
283
|
+
ref: { $ref: '#/items/1' },
|
|
284
|
+
}
|
|
285
|
+
const result = dereference(spec) as any
|
|
286
|
+
expect(result.ref).toEqual({ name: 'second' })
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('chain of refs (A -> B -> C)', () => {
|
|
290
|
+
const spec = {
|
|
291
|
+
a: { $ref: '#/b' },
|
|
292
|
+
b: { $ref: '#/c' },
|
|
293
|
+
c: { value: 'end' },
|
|
294
|
+
}
|
|
295
|
+
const result = dereference(spec) as any
|
|
296
|
+
expect(result.a).toEqual({ value: 'end' })
|
|
297
|
+
expect(result.b).toEqual({ value: 'end' })
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('triple circular (A -> B -> C -> A)', () => {
|
|
301
|
+
const spec = {
|
|
302
|
+
components: {
|
|
303
|
+
schemas: {
|
|
304
|
+
A: { type: 'A', next: { $ref: '#/components/schemas/B' } },
|
|
305
|
+
B: { type: 'B', next: { $ref: '#/components/schemas/C' } },
|
|
306
|
+
C: { type: 'C', next: { $ref: '#/components/schemas/A' } },
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
root: { $ref: '#/components/schemas/A' },
|
|
310
|
+
}
|
|
311
|
+
const result = dereference(spec) as any
|
|
312
|
+
expect(result.root.type).toBe('A')
|
|
313
|
+
expect(result.root.next.type).toBe('B')
|
|
314
|
+
expect(result.root.next.next.type).toBe('C')
|
|
315
|
+
expect(result.root.next.next.next).toBe(result.root)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
test('$ref with sibling properties (OpenAPI 3.1 style)', () => {
|
|
319
|
+
const spec = {
|
|
320
|
+
components: {
|
|
321
|
+
schemas: {
|
|
322
|
+
User: { type: 'object', properties: { name: { type: 'string' } } },
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
ref: {
|
|
326
|
+
$ref: '#/components/schemas/User',
|
|
327
|
+
description: 'A user object',
|
|
328
|
+
},
|
|
329
|
+
}
|
|
330
|
+
const result = dereference(spec) as any
|
|
331
|
+
// siblings are dropped (ref replaces the whole node)
|
|
332
|
+
expect(result.ref.type).toBe('object')
|
|
333
|
+
expect(result.ref.description).toBeUndefined()
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
test('root is an array', () => {
|
|
337
|
+
const root = [{ a: 1 }, { b: 2 }]
|
|
338
|
+
const result = dereference(root) as any
|
|
339
|
+
expect(result).toEqual([{ a: 1 }, { b: 2 }])
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
test('empty object', () => {
|
|
343
|
+
expect(dereference({})).toEqual({})
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
test('$ref "#/" resolves to root', () => {
|
|
347
|
+
const spec = { type: 'root', self: { $ref: '#/' } }
|
|
348
|
+
const result = dereference(spec) as any
|
|
349
|
+
expect(result.self.type).toBe('root')
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
test('falsy primitive targets (false, 0, empty string)', () => {
|
|
353
|
+
const spec = {
|
|
354
|
+
vals: { a: false, b: 0, c: '' },
|
|
355
|
+
refA: { $ref: '#/vals/a' },
|
|
356
|
+
refB: { $ref: '#/vals/b' },
|
|
357
|
+
refC: { $ref: '#/vals/c' },
|
|
358
|
+
}
|
|
359
|
+
const result = dereference(spec) as any
|
|
360
|
+
expect(result.refA).toBe(false)
|
|
361
|
+
expect(result.refB).toBe(0)
|
|
362
|
+
expect(result.refC).toBe('')
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
test('$ref target is an empty array', () => {
|
|
366
|
+
const spec = {
|
|
367
|
+
vals: { empty: [] as unknown[] },
|
|
368
|
+
ref: { $ref: '#/vals/empty' },
|
|
369
|
+
}
|
|
370
|
+
const result = dereference(spec) as any
|
|
371
|
+
expect(result.ref).toEqual([])
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
test('$ref target is an empty object', () => {
|
|
375
|
+
const spec = {
|
|
376
|
+
vals: { empty: {} },
|
|
377
|
+
ref: { $ref: '#/vals/empty' },
|
|
378
|
+
}
|
|
379
|
+
const result = dereference(spec) as any
|
|
380
|
+
expect(result.ref).toEqual({})
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
test('chained ref to primitive (A -> B -> string)', () => {
|
|
384
|
+
const spec = {
|
|
385
|
+
vals: { greeting: 'hello' },
|
|
386
|
+
b: { $ref: '#/vals/greeting' },
|
|
387
|
+
a: { $ref: '#/b' },
|
|
388
|
+
}
|
|
389
|
+
const result = dereference(spec) as any
|
|
390
|
+
expect(result.a).toBe('hello')
|
|
391
|
+
expect(result.b).toBe('hello')
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
test('$ref with non-string value is treated as normal object', () => {
|
|
395
|
+
const spec = { obj: { $ref: 123, other: 'value' } }
|
|
396
|
+
const result = dereference(spec) as any
|
|
397
|
+
expect(result.obj).toEqual({ $ref: 123, other: 'value' })
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
test('$ref that does not start with # is left as-is', () => {
|
|
401
|
+
const spec = { obj: { $ref: 'http://example.com/schema.json' } }
|
|
402
|
+
const result = dereference(spec) as any
|
|
403
|
+
expect(result.obj).toEqual({ $ref: 'http://example.com/schema.json' })
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
test('$ref inside array inside a $ref target', () => {
|
|
407
|
+
const spec = {
|
|
408
|
+
components: {
|
|
409
|
+
schemas: {
|
|
410
|
+
Tag: { type: 'string' },
|
|
411
|
+
User: {
|
|
412
|
+
type: 'object',
|
|
413
|
+
properties: {
|
|
414
|
+
tags: {
|
|
415
|
+
type: 'array',
|
|
416
|
+
items: { $ref: '#/components/schemas/Tag' },
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
root: { $ref: '#/components/schemas/User' },
|
|
423
|
+
}
|
|
424
|
+
const result = dereference(spec) as any
|
|
425
|
+
expect(result.root.properties.tags.items).toEqual({ type: 'string' })
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
test('allOf/oneOf/anyOf with $ref items', () => {
|
|
429
|
+
const spec = {
|
|
430
|
+
components: {
|
|
431
|
+
schemas: {
|
|
432
|
+
Name: { type: 'string' },
|
|
433
|
+
Age: { type: 'number' },
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
root: {
|
|
437
|
+
allOf: [
|
|
438
|
+
{ $ref: '#/components/schemas/Name' },
|
|
439
|
+
{ $ref: '#/components/schemas/Age' },
|
|
440
|
+
],
|
|
441
|
+
},
|
|
442
|
+
}
|
|
443
|
+
const result = dereference(spec) as any
|
|
444
|
+
expect(result.root.allOf[0]).toEqual({ type: 'string' })
|
|
445
|
+
expect(result.root.allOf[1]).toEqual({ type: 'number' })
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
test('$ref inside deeply nested arrays', () => {
|
|
449
|
+
const spec = {
|
|
450
|
+
vals: { x: { value: 1 } },
|
|
451
|
+
nested: [[{ $ref: '#/vals/x' }]],
|
|
452
|
+
}
|
|
453
|
+
const result = dereference(spec) as any
|
|
454
|
+
expect(result.nested[0][0]).toEqual({ value: 1 })
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
test('circular ref inside an array (items ref self)', () => {
|
|
458
|
+
const spec = {
|
|
459
|
+
components: {
|
|
460
|
+
schemas: {
|
|
461
|
+
Tree: {
|
|
462
|
+
type: 'object',
|
|
463
|
+
properties: {
|
|
464
|
+
children: {
|
|
465
|
+
type: 'array',
|
|
466
|
+
items: { $ref: '#/components/schemas/Tree' },
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
root: { $ref: '#/components/schemas/Tree' },
|
|
473
|
+
}
|
|
474
|
+
const result = dereference(spec) as any
|
|
475
|
+
expect(result.root.type).toBe('object')
|
|
476
|
+
expect(result.root.properties.children.items).toBe(result.root)
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
test('$ref target is a boolean true', () => {
|
|
480
|
+
const spec = {
|
|
481
|
+
vals: { flag: true },
|
|
482
|
+
ref: { $ref: '#/vals/flag' },
|
|
483
|
+
}
|
|
484
|
+
const result = dereference(spec) as any
|
|
485
|
+
expect(result.ref).toBe(true)
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
test('same $ref used in different subtrees resolves identically', () => {
|
|
489
|
+
const spec = {
|
|
490
|
+
components: { schemas: { S: { type: 'object' } } },
|
|
491
|
+
tree: {
|
|
492
|
+
left: { schema: { $ref: '#/components/schemas/S' } },
|
|
493
|
+
right: { schema: { $ref: '#/components/schemas/S' } },
|
|
494
|
+
},
|
|
495
|
+
}
|
|
496
|
+
const result = dereference(spec) as any
|
|
497
|
+
expect(result.tree.left.schema).toBe(result.tree.right.schema)
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
test('ref target with array value containing refs', () => {
|
|
501
|
+
const spec = {
|
|
502
|
+
components: {
|
|
503
|
+
schemas: { Tag: { type: 'string' } },
|
|
504
|
+
lists: {
|
|
505
|
+
tags: [{ $ref: '#/components/schemas/Tag' }, { literal: true }],
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
ref: { $ref: '#/components/lists/tags' },
|
|
509
|
+
}
|
|
510
|
+
const result = dereference(spec) as any
|
|
511
|
+
expect(result.ref[0]).toEqual({ type: 'string' })
|
|
512
|
+
expect(result.ref[1]).toEqual({ literal: true })
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
test('root object is itself a $ref (self-referential)', () => {
|
|
516
|
+
const spec = { $ref: '#', type: 'object' }
|
|
517
|
+
const result = dereference(spec) as any
|
|
518
|
+
// $ref takes precedence, siblings (type) are dropped per OpenAPI 3.0.
|
|
519
|
+
// Circular self-ref resolves without infinite loop.
|
|
520
|
+
expect(result).toBeDefined()
|
|
521
|
+
expect(result.type).toBeUndefined()
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
test('non-local $ref is preserved (not resolved)', () => {
|
|
525
|
+
const spec = {
|
|
526
|
+
a: { $ref: 'https://example.com/schema.json#/Foo' },
|
|
527
|
+
b: { $ref: './other.yaml#/Bar' },
|
|
528
|
+
c: { $ref: 'relative.json' },
|
|
529
|
+
}
|
|
530
|
+
const result = dereference(spec) as any
|
|
531
|
+
expect(result.a.$ref).toBe('https://example.com/schema.json#/Foo')
|
|
532
|
+
expect(result.b.$ref).toBe('./other.yaml#/Bar')
|
|
533
|
+
expect(result.c.$ref).toBe('relative.json')
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
test('$ref target contains a non-local $ref (preserved after deref)', () => {
|
|
537
|
+
const spec = {
|
|
538
|
+
components: {
|
|
539
|
+
schemas: {
|
|
540
|
+
External: { type: 'object', nested: { $ref: 'https://example.com/other.json' } },
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
root: { $ref: '#/components/schemas/External' },
|
|
544
|
+
}
|
|
545
|
+
const result = dereference(spec) as any
|
|
546
|
+
expect(result.root.type).toBe('object')
|
|
547
|
+
expect(result.root.nested.$ref).toBe('https://example.com/other.json')
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
test('forward reference (A uses B, B defined after A)', () => {
|
|
551
|
+
const spec = {
|
|
552
|
+
components: {
|
|
553
|
+
schemas: {
|
|
554
|
+
A: { type: 'object', child: { $ref: '#/components/schemas/B' } },
|
|
555
|
+
B: { type: 'string' },
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
root: { $ref: '#/components/schemas/A' },
|
|
559
|
+
}
|
|
560
|
+
const result = dereference(spec) as any
|
|
561
|
+
expect(result.root.child).toEqual({ type: 'string' })
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
test('deep chain of refs (A -> B -> C -> D -> E -> value)', () => {
|
|
565
|
+
const spec = {
|
|
566
|
+
a: { $ref: '#/b' },
|
|
567
|
+
b: { $ref: '#/c' },
|
|
568
|
+
c: { $ref: '#/d' },
|
|
569
|
+
d: { $ref: '#/e' },
|
|
570
|
+
e: { value: 'deep' },
|
|
571
|
+
}
|
|
572
|
+
const result = dereference(spec) as any
|
|
573
|
+
expect(result.a).toEqual({ value: 'deep' })
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
test('deep chain of refs to array', () => {
|
|
577
|
+
const spec = {
|
|
578
|
+
a: { $ref: '#/b' },
|
|
579
|
+
b: { $ref: '#/c' },
|
|
580
|
+
c: [1, 2, 3],
|
|
581
|
+
}
|
|
582
|
+
const result = dereference(spec) as any
|
|
583
|
+
expect(result.a).toEqual([1, 2, 3])
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
test('combined ~0 and ~1 escaping in same pointer segment', () => {
|
|
587
|
+
const spec = {
|
|
588
|
+
'a~/b': { value: 'complex' },
|
|
589
|
+
ref: { $ref: '#/a~0~1b' },
|
|
590
|
+
}
|
|
591
|
+
const result = dereference(spec) as any
|
|
592
|
+
expect(result.ref).toEqual({ value: 'complex' })
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
test('$ref to nested value inside a ref target', () => {
|
|
596
|
+
const spec = {
|
|
597
|
+
components: {
|
|
598
|
+
schemas: {
|
|
599
|
+
User: {
|
|
600
|
+
type: 'object',
|
|
601
|
+
properties: { name: { type: 'string', maxLength: 100 } },
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
nameSchema: { $ref: '#/components/schemas/User/properties/name' },
|
|
606
|
+
}
|
|
607
|
+
const result = dereference(spec) as any
|
|
608
|
+
expect(result.nameSchema).toEqual({ type: 'string', maxLength: 100 })
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
test('multiple independent circular cycles', () => {
|
|
612
|
+
const spec = {
|
|
613
|
+
components: {
|
|
614
|
+
schemas: {
|
|
615
|
+
X: { type: 'X', self: { $ref: '#/components/schemas/X' } },
|
|
616
|
+
Y: { type: 'Y', self: { $ref: '#/components/schemas/Y' } },
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
refX: { $ref: '#/components/schemas/X' },
|
|
620
|
+
refY: { $ref: '#/components/schemas/Y' },
|
|
621
|
+
}
|
|
622
|
+
const result = dereference(spec) as any
|
|
623
|
+
expect(result.refX.type).toBe('X')
|
|
624
|
+
expect(result.refX.self).toBe(result.refX)
|
|
625
|
+
expect(result.refY.type).toBe('Y')
|
|
626
|
+
expect(result.refY.self).toBe(result.refY)
|
|
627
|
+
// X and Y are distinct
|
|
628
|
+
expect(result.refX).not.toBe(result.refY)
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
test('object with constructor/toString keys (no prototype issues)', () => {
|
|
632
|
+
const spec = {
|
|
633
|
+
vals: { constructor: { value: 1 }, toString: { value: 2 } },
|
|
634
|
+
a: { $ref: '#/vals/constructor' },
|
|
635
|
+
b: { $ref: '#/vals/toString' },
|
|
636
|
+
}
|
|
637
|
+
const result = dereference(spec) as any
|
|
638
|
+
expect(result.a).toEqual({ value: 1 })
|
|
639
|
+
expect(result.b).toEqual({ value: 2 })
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
test('ref to boolean nested inside object', () => {
|
|
643
|
+
const spec = {
|
|
644
|
+
config: { features: { enabled: true, disabled: false } },
|
|
645
|
+
a: { $ref: '#/config/features/enabled' },
|
|
646
|
+
b: { $ref: '#/config/features/disabled' },
|
|
647
|
+
}
|
|
648
|
+
const result = dereference(spec) as any
|
|
649
|
+
expect(result.a).toBe(true)
|
|
650
|
+
expect(result.b).toBe(false)
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
test('array of $refs to different types', () => {
|
|
654
|
+
const spec = {
|
|
655
|
+
vals: { str: 'hello', num: 42, obj: { x: 1 }, arr: [1, 2] },
|
|
656
|
+
refs: [
|
|
657
|
+
{ $ref: '#/vals/str' },
|
|
658
|
+
{ $ref: '#/vals/num' },
|
|
659
|
+
{ $ref: '#/vals/obj' },
|
|
660
|
+
{ $ref: '#/vals/arr' },
|
|
661
|
+
],
|
|
662
|
+
}
|
|
663
|
+
const result = dereference(spec) as any
|
|
664
|
+
expect(result.refs[0]).toBe('hello')
|
|
665
|
+
expect(result.refs[1]).toBe(42)
|
|
666
|
+
expect(result.refs[2]).toEqual({ x: 1 })
|
|
667
|
+
expect(result.refs[3]).toEqual([1, 2])
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
test('circular ref where first encounter is NOT via $ref', () => {
|
|
671
|
+
// Schema defines Node inline (not behind a $ref), but Node's child uses $ref
|
|
672
|
+
const spec = {
|
|
673
|
+
components: {
|
|
674
|
+
schemas: {
|
|
675
|
+
Node: {
|
|
676
|
+
type: 'object',
|
|
677
|
+
properties: {
|
|
678
|
+
child: { $ref: '#/components/schemas/Node' },
|
|
679
|
+
},
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
},
|
|
683
|
+
// Access Node directly through the tree walk, not via $ref
|
|
684
|
+
direct: {
|
|
685
|
+
schema: {
|
|
686
|
+
type: 'wrapper',
|
|
687
|
+
inner: { $ref: '#/components/schemas/Node' },
|
|
688
|
+
},
|
|
689
|
+
},
|
|
690
|
+
}
|
|
691
|
+
const result = dereference(spec) as any
|
|
692
|
+
expect(result.direct.schema.inner.type).toBe('object')
|
|
693
|
+
expect(result.direct.schema.inner.properties.child).toBe(result.direct.schema.inner)
|
|
694
|
+
})
|
|
695
|
+
})
|