@speclynx/apidom-reference 3.0.0 → 3.2.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 (53) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +97 -0
  3. package/dist/apidom-reference.browser.js +3862 -3241
  4. package/dist/apidom-reference.browser.min.js +1 -1
  5. package/package.json +48 -25
  6. package/src/dereference/index.cjs +4 -0
  7. package/src/dereference/index.mjs +4 -0
  8. package/src/dereference/strategies/apidom/visitor.cjs +139 -59
  9. package/src/dereference/strategies/apidom/visitor.mjs +142 -62
  10. package/src/dereference/strategies/arazzo-1/index.cjs +1 -4
  11. package/src/dereference/strategies/arazzo-1/index.mjs +2 -4
  12. package/src/dereference/strategies/arazzo-1/visitor.cjs +289 -199
  13. package/src/dereference/strategies/arazzo-1/visitor.mjs +292 -203
  14. package/src/dereference/strategies/asyncapi-2/index.cjs +1 -4
  15. package/src/dereference/strategies/asyncapi-2/index.mjs +2 -4
  16. package/src/dereference/strategies/asyncapi-2/visitor.cjs +325 -229
  17. package/src/dereference/strategies/asyncapi-2/visitor.mjs +328 -233
  18. package/src/dereference/strategies/openapi-2/index.cjs +1 -4
  19. package/src/dereference/strategies/openapi-2/index.mjs +2 -4
  20. package/src/dereference/strategies/openapi-2/visitor.cjs +420 -318
  21. package/src/dereference/strategies/openapi-2/visitor.mjs +425 -324
  22. package/src/dereference/strategies/openapi-3-0/index.cjs +1 -4
  23. package/src/dereference/strategies/openapi-3-0/index.mjs +2 -4
  24. package/src/dereference/strategies/openapi-3-0/visitor.cjs +405 -286
  25. package/src/dereference/strategies/openapi-3-0/visitor.mjs +409 -291
  26. package/src/dereference/strategies/openapi-3-1/index.cjs +1 -4
  27. package/src/dereference/strategies/openapi-3-1/index.mjs +2 -4
  28. package/src/dereference/strategies/openapi-3-1/visitor.cjs +598 -484
  29. package/src/dereference/strategies/openapi-3-1/visitor.mjs +602 -489
  30. package/src/errors/DereferenceError.cjs +1 -1
  31. package/src/errors/DereferenceError.mjs +2 -2
  32. package/src/errors/ResolveError.cjs +1 -1
  33. package/src/errors/ResolveError.mjs +2 -2
  34. package/src/errors/UnresolvableReferenceError.cjs +11 -0
  35. package/src/errors/UnresolvableReferenceError.mjs +6 -0
  36. package/src/index.cjs +3 -1
  37. package/src/index.mjs +1 -0
  38. package/src/options/index.cjs +10 -1
  39. package/src/options/index.mjs +10 -1
  40. package/src/util/plugins.cjs +1 -6
  41. package/src/util/plugins.mjs +2 -5
  42. package/types/apidom-reference.d.ts +10 -2
  43. package/types/dereference/strategies/apidom/visitor.d.ts +10 -0
  44. package/types/dereference/strategies/arazzo-1/visitor.d.ts +19 -5
  45. package/types/dereference/strategies/asyncapi-2/visitor.d.ts +21 -7
  46. package/types/dereference/strategies/openapi-2/visitor.d.ts +21 -8
  47. package/types/dereference/strategies/openapi-3-0/visitor.d.ts +21 -7
  48. package/types/dereference/strategies/openapi-3-1/visitor.d.ts +21 -7
  49. package/types/errors/DereferenceError.d.ts +2 -2
  50. package/types/errors/ResolveError.d.ts +2 -2
  51. package/types/errors/UnresolvableReferenceError.d.ts +7 -0
  52. package/types/index.d.ts +1 -0
  53. package/types/options/index.d.ts +2 -0
@@ -1,56 +1,65 @@
1
1
  import { propEq } from 'ramda';
2
2
  import { isUndefined } from 'ramda-adjunct';
3
- import { isPrimitiveElement, isStringElement, isElement, RefElement, cloneShallow, cloneDeep } from '@speclynx/apidom-datamodel';
4
- import { IdentityManager, toValue } from '@speclynx/apidom-core';
5
- import { ApiDOMError } from '@speclynx/apidom-error';
6
- import { traverseAsync, find } from '@speclynx/apidom-traverse';
3
+ import { isStringElement, isElement, RefElement, cloneShallow, cloneDeep } from '@speclynx/apidom-datamodel';
4
+ import { toValue, toYAML } from '@speclynx/apidom-core';
5
+ import { ApiDOMStructuredError } from '@speclynx/apidom-error';
6
+ import { traverse, traverseAsync, find } from '@speclynx/apidom-traverse';
7
7
  import { evaluate, URIFragmentIdentifier } from '@speclynx/apidom-json-pointer';
8
8
  import { isReferenceElement, isOperationElement, isPathItemElement, isReferenceLikeElement, refract, refractReference, refractPathItem, refractOperation } from '@speclynx/apidom-ns-openapi-3-0';
9
+ import UnresolvableReferenceError from "../../../errors/UnresolvableReferenceError.mjs";
9
10
  import MaximumDereferenceDepthError from "../../../errors/MaximumDereferenceDepthError.mjs";
10
11
  import MaximumResolveDepthError from "../../../errors/MaximumResolveDepthError.mjs";
11
12
  import * as url from "../../../util/url.mjs";
12
13
  import parse from "../../../parse/index.mjs";
13
14
  import Reference from "../../../Reference.mjs";
14
15
  import { AncestorLineage } from "../../util.mjs";
15
- // initialize element identity manager
16
- const identityManager = new IdentityManager();
17
-
18
16
  /**
19
17
  * @public
20
18
  */
21
-
22
19
  /**
23
20
  * @public
24
21
  */
25
22
  class OpenAPI3_0DereferenceVisitor {
26
23
  indirections;
27
- namespace;
28
24
  reference;
29
25
  options;
30
- ancestors;
31
26
  refractCache;
27
+
28
+ /**
29
+ * Tracks element ancestors across dive-deep traversal boundaries.
30
+ * Used for cycle detection: if a referenced element is found in
31
+ * the ancestor lineage, a circular reference is detected.
32
+ */
33
+ ancestors;
32
34
  constructor({
33
35
  reference,
34
- namespace,
35
36
  options,
36
37
  indirections = [],
37
38
  ancestors = new AncestorLineage(),
38
- refractCache = new Map()
39
+ refractCache = new WeakMap()
39
40
  }) {
40
41
  this.indirections = indirections;
41
- this.namespace = namespace;
42
42
  this.reference = reference;
43
43
  this.options = options;
44
44
  this.ancestors = new AncestorLineage(...ancestors);
45
45
  this.refractCache = refractCache;
46
46
  }
47
+ toAncestorLineage(path) {
48
+ const ancestorNodes = path.getAncestorNodes();
49
+ const directAncestors = new Set(ancestorNodes.filter(isElement));
50
+ const ancestorsLineage = new AncestorLineage(...this.ancestors, directAncestors);
51
+ return [ancestorsLineage, directAncestors];
52
+ }
47
53
  toBaseURI(uri) {
48
54
  return url.resolve(this.reference.uri, url.sanitize(url.stripHash(uri)));
49
55
  }
50
56
  async toReference(uri) {
51
57
  // detect maximum depth of resolution
52
58
  if (this.reference.depth >= this.options.resolve.maxDepth) {
53
- throw new MaximumResolveDepthError(`Maximum resolution depth of ${this.options.resolve.maxDepth} has been exceeded by file "${this.reference.uri}"`);
59
+ throw new MaximumResolveDepthError(`Maximum resolution depth of ${this.options.resolve.maxDepth} has been exceeded by file "${this.reference.uri}"`, {
60
+ maxDepth: this.options.resolve.maxDepth,
61
+ uri: this.reference.uri
62
+ });
54
63
  }
55
64
  const baseURI = this.toBaseURI(uri);
56
65
  const {
@@ -70,9 +79,23 @@ class OpenAPI3_0DereferenceVisitor {
70
79
  });
71
80
 
72
81
  // register new mutable reference with a refSet
82
+ //
83
+ // NOTE(known limitation): the mutable reference is mutated in place during traversal
84
+ // (via `{ mutable: true }`). When an external document evaluates a JSON pointer back
85
+ // into this document, it may receive an already-resolved element instead of the original
86
+ // $ref. That resolved element was produced using the entry document's resolution context
87
+ // (ancestors, indirections), which may differ from the external document's context.
88
+ // This can affect cycle detection in rare cross-document circular reference patterns.
89
+ //
90
+ // Remediation: evaluate JSON pointers against the immutable (original) parse tree
91
+ // instead of the mutable working copy. The `immutable://` reference below preserves
92
+ // the original tree and could be used for pointer evaluation, ensuring every resolution
93
+ // context always sees raw, unresolved elements and processes them with its own
94
+ // ancestors/indirections. The trade-off is that elements referenced by multiple
95
+ // documents would be resolved once per context instead of being reused.
73
96
  const mutableReference = new Reference({
74
97
  uri: baseURI,
75
- value: cloneDeep(parseResult),
98
+ value: this.options.dereference.immutable ? cloneDeep(parseResult) : parseResult,
76
99
  depth: this.reference.depth + 1
77
100
  });
78
101
  refSet.add(mutableReference);
@@ -87,15 +110,77 @@ class OpenAPI3_0DereferenceVisitor {
87
110
  }
88
111
  return mutableReference;
89
112
  }
90
- toAncestorLineage(path) {
91
- /**
92
- * Compute full ancestors lineage.
93
- * Ancestors are flatten to unwrap all Element instances.
94
- */
95
- const ancestorNodes = path.getAncestorNodes();
96
- const directAncestors = new Set(ancestorNodes.filter(isElement));
97
- const ancestorsLineage = new AncestorLineage(...this.ancestors, directAncestors);
98
- return [ancestorsLineage, directAncestors];
113
+
114
+ /**
115
+ * Handles an error according to the continueOnError option.
116
+ *
117
+ * For new errors: wraps in UnresolvableReferenceError with structured context
118
+ * (type, uri, location, codeFrame, refFieldName, refFieldValue, trace).
119
+ * For errors already wrapped by a nested visitor: prepends the current hop to the trace.
120
+ *
121
+ * Inner/intermediate visitors always throw to let the trace accumulate.
122
+ * Only the entry document visitor respects continueOnError (callback/swallow/throw).
123
+ */
124
+ handleError(message, error, referencingElement, refFieldName, refFieldValue, visitorPath) {
125
+ const {
126
+ continueOnError
127
+ } = this.options.dereference;
128
+ const isEntryDocument = url.stripHash(this.reference.refSet?.rootRef?.uri ?? '') === this.reference.uri;
129
+ const uri = this.reference.uri;
130
+ const type = referencingElement.element;
131
+ const codeFrame = toYAML(referencingElement);
132
+
133
+ // find element location: tree search for entry documents, visitor path for external
134
+ let location;
135
+ traverse(this.reference.value.result, {
136
+ enter: p => {
137
+ if (p.node === referencingElement || this.refractCache.get(p.node) === referencingElement) {
138
+ location = p.formatPath();
139
+ p.stop();
140
+ }
141
+ }
142
+ });
143
+ location ??= visitorPath.formatPath();
144
+ const hop = {
145
+ uri,
146
+ type,
147
+ refFieldName,
148
+ refFieldValue,
149
+ location,
150
+ codeFrame
151
+ };
152
+
153
+ // enrich existing error from nested visitor or create new one
154
+ let unresolvedError;
155
+ if (error instanceof UnresolvableReferenceError) {
156
+ // prefix relative locations for entries belonging to the referenced document
157
+ const refBaseURI = this.toBaseURI(refFieldValue);
158
+ const fragment = URIFragmentIdentifier.fromURIReference(refFieldValue);
159
+ if (fragment) {
160
+ if (refBaseURI === error.uri && error.location) {
161
+ error.location = fragment + error.location;
162
+ }
163
+ for (const h of error.trace) {
164
+ if (h.uri === refBaseURI && h.location) h.location = fragment + h.location;
165
+ }
166
+ }
167
+ // @ts-ignore
168
+ error.trace = [hop, ...error.trace];
169
+ unresolvedError = error;
170
+ } else {
171
+ unresolvedError = new UnresolvableReferenceError(message, {
172
+ cause: error,
173
+ type,
174
+ uri,
175
+ location,
176
+ codeFrame,
177
+ refFieldName,
178
+ refFieldValue,
179
+ trace: []
180
+ });
181
+ }
182
+ if (!isEntryDocument || continueOnError === false) throw unresolvedError;
183
+ if (typeof continueOnError === 'function') continueOnError(unresolvedError);
99
184
  }
100
185
  async ReferenceElement(path) {
101
186
  const referencingElement = path.node;
@@ -105,133 +190,140 @@ class OpenAPI3_0DereferenceVisitor {
105
190
  path.skip();
106
191
  return;
107
192
  }
108
- const [ancestorsLineage, directAncestors] = this.toAncestorLineage(path);
109
193
  const retrievalURI = this.toBaseURI(toValue(referencingElement.$ref));
110
194
  const isInternalReference = url.stripHash(this.reference.uri) === retrievalURI;
111
195
  const isExternalReference = !isInternalReference;
112
196
 
113
197
  // ignore resolving internal Reference Objects
114
198
  if (!this.options.resolve.internal && isInternalReference) {
115
- // skip traversing this reference element
199
+ // skip traversing this reference element and all it's child elements
116
200
  path.skip();
117
201
  return;
118
202
  }
119
203
  // ignore resolving external Reference Objects
120
204
  if (!this.options.resolve.external && isExternalReference) {
121
- // skip traversing this reference element
205
+ // skip traversing this reference element and all it's child elements
122
206
  path.skip();
123
207
  return;
124
208
  }
125
- const reference = await this.toReference(toValue(referencingElement.$ref));
126
209
  const $refBaseURI = url.resolve(retrievalURI, toValue(referencingElement.$ref));
127
- this.indirections.push(referencingElement);
128
- const jsonPointer = URIFragmentIdentifier.fromURIReference($refBaseURI);
210
+ const indirectionsSize = this.indirections.length;
211
+ try {
212
+ const reference = await this.toReference(toValue(referencingElement.$ref));
213
+ this.indirections.push(referencingElement);
214
+ const jsonPointer = URIFragmentIdentifier.fromURIReference($refBaseURI);
129
215
 
130
- // possibly non-semantic fragment
131
- let referencedElement = evaluate(reference.value.result, jsonPointer);
132
- referencedElement.id = identityManager.identify(referencedElement);
216
+ // possibly non-semantic fragment
217
+ let referencedElement = evaluate(reference.value.result, jsonPointer);
133
218
 
134
- /**
135
- * Applying semantics to a referenced element if semantics are missing.
136
- */
137
- if (isPrimitiveElement(referencedElement)) {
219
+ // applying semantics to a fragment
138
220
  const referencedElementType = referencingElement.meta.get('referenced-element');
139
- const cacheKey = `${referencedElementType}-${identityManager.identify(referencedElement)}`;
140
- if (this.refractCache.has(cacheKey)) {
141
- referencedElement = this.refractCache.get(cacheKey);
142
- } else if (isReferenceLikeElement(referencedElement)) {
143
- // handling indirect references
144
- referencedElement = refractReference(referencedElement);
145
- referencedElement.meta.set('referenced-element', referencedElementType);
146
- this.refractCache.set(cacheKey, referencedElement);
147
- } else {
148
- // handling direct references
149
- referencedElement = refract(referencedElement, {
150
- element: referencedElementType
221
+ if (referencedElement.element !== referencedElementType && !isReferenceElement(referencedElement)) {
222
+ if (this.refractCache.has(referencedElement)) {
223
+ referencedElement = this.refractCache.get(referencedElement);
224
+ } else if (isReferenceLikeElement(referencedElement)) {
225
+ // handling generic indirect references
226
+ const sourceElement = referencedElement;
227
+ referencedElement = refractReference(referencedElement);
228
+ referencedElement.meta.set('referenced-element', referencedElementType);
229
+ this.refractCache.set(sourceElement, referencedElement);
230
+ } else {
231
+ // handling direct references
232
+ const sourceElement = referencedElement;
233
+ referencedElement = refract(referencedElement, {
234
+ element: referencedElementType
235
+ });
236
+ this.refractCache.set(sourceElement, referencedElement);
237
+ }
238
+ }
239
+
240
+ // detect direct or indirect reference
241
+ if (referencingElement === referencedElement) {
242
+ throw new ApiDOMStructuredError('Recursive Reference Object detected', {
243
+ $ref: toValue(referencingElement.$ref)
151
244
  });
152
- this.refractCache.set(cacheKey, referencedElement);
153
245
  }
154
- }
155
246
 
156
- // detect direct or circular reference
157
- if (referencingElement === referencedElement) {
158
- throw new ApiDOMError('Recursive Reference Object detected');
159
- }
247
+ // detect maximum depth of dereferencing
248
+ if (this.indirections.length > this.options.dereference.maxDepth) {
249
+ throw new MaximumDereferenceDepthError(`Maximum dereference depth of "${this.options.dereference.maxDepth}" has been exceeded in file "${this.reference.uri}"`, {
250
+ maxDepth: this.options.dereference.maxDepth,
251
+ uri: this.reference.uri
252
+ });
253
+ }
160
254
 
161
- // detect maximum depth of dereferencing
162
- if (this.indirections.length > this.options.dereference.maxDepth) {
163
- throw new MaximumDereferenceDepthError(`Maximum dereference depth of "${this.options.dereference.maxDepth}" has been exceeded in file "${this.reference.uri}"`);
164
- }
255
+ // detect second deep dive into the same fragment and avoid it
256
+ const [ancestorsLineage, directAncestors] = this.toAncestorLineage(path);
257
+ if (ancestorsLineage.includes(referencedElement)) {
258
+ reference.refSet.circular = true;
259
+ if (this.options.dereference.circular === 'error') {
260
+ throw new ApiDOMStructuredError('Circular reference detected', {
261
+ $ref: toValue(referencingElement.$ref)
262
+ });
263
+ } else if (this.options.dereference.circular === 'replace') {
264
+ const refElement = new RefElement($refBaseURI, {
265
+ type: referencingElement.element,
266
+ uri: reference.uri,
267
+ $ref: toValue(referencingElement.$ref)
268
+ });
269
+ const replacer = this.options.dereference.strategyOpts['openapi-3-0']?.circularReplacer ?? this.options.dereference.circularReplacer;
270
+ const replacement = replacer(refElement);
271
+ path.replaceWith(replacement);
272
+ return;
273
+ }
274
+ }
165
275
 
166
- // detect second deep dive into the same fragment and avoid it
167
- if (ancestorsLineage.includes(referencedElement)) {
168
- reference.refSet.circular = true;
169
- if (this.options.dereference.circular === 'error') {
170
- throw new ApiDOMError('Circular reference detected');
171
- } else if (this.options.dereference.circular === 'replace') {
172
- const refElement = new RefElement(referencedElement.id, {
173
- type: 'reference',
174
- uri: reference.uri,
175
- $ref: toValue(referencingElement.$ref)
276
+ /**
277
+ * Dive deep into the fragment.
278
+ *
279
+ * Cases to consider:
280
+ * 1. We're crossing document boundary
281
+ * 2. Fragment is from non-entry document
282
+ * 3. Fragment is a Reference Object. We need to follow it to get the eventual value
283
+ * 4. We are dereferencing the fragment lazily/eagerly depending on circular mode
284
+ */
285
+ const isNonEntryDocument = url.stripHash(reference.refSet.rootRef.uri) !== reference.uri;
286
+ const shouldDetectCircular = ['error', 'replace'].includes(this.options.dereference.circular);
287
+ if ((isExternalReference || isNonEntryDocument || isReferenceElement(referencedElement) || shouldDetectCircular) && !ancestorsLineage.includesCycle(referencedElement)) {
288
+ // append referencing reference to ancestors lineage
289
+ directAncestors.add(referencingElement);
290
+ const visitor = new OpenAPI3_0DereferenceVisitor({
291
+ reference,
292
+ indirections: [...this.indirections],
293
+ options: this.options,
294
+ refractCache: this.refractCache,
295
+ ancestors: ancestorsLineage
176
296
  });
177
- const replacer = this.options.dereference.strategyOpts['openapi-3-0']?.circularReplacer ?? this.options.dereference.circularReplacer;
178
- const replacement = replacer(refElement);
179
- this.indirections.pop();
180
- path.replaceWith(replacement);
181
- return;
297
+ referencedElement = await traverseAsync(referencedElement, visitor, {
298
+ mutable: true
299
+ });
300
+
301
+ // remove referencing reference from ancestors lineage
302
+ directAncestors.delete(referencingElement);
182
303
  }
183
- }
184
304
 
185
- /**
186
- * Dive deep into the fragment.
187
- *
188
- * Cases to consider:
189
- * 1. We're crossing document boundary
190
- * 2. Fragment is from non-entry document
191
- * 3. Fragment is a Reference Object. We need to follow it to get the eventual value
192
- * 4. We are dereferencing the fragment lazily/eagerly depending on circular mode
193
- */
194
- const isNonEntryDocument = url.stripHash(reference.refSet.rootRef.uri) !== reference.uri;
195
- const shouldDetectCircular = ['error', 'replace'].includes(this.options.dereference.circular);
196
- if ((isExternalReference || isNonEntryDocument || isReferenceElement(referencedElement) || shouldDetectCircular) && !ancestorsLineage.includesCycle(referencedElement)) {
197
- // append referencing reference to ancestors lineage
198
- directAncestors.add(referencingElement);
199
- const visitor = new OpenAPI3_0DereferenceVisitor({
200
- reference,
201
- namespace: this.namespace,
202
- indirections: [...this.indirections],
203
- options: this.options,
204
- refractCache: this.refractCache,
205
- ancestors: ancestorsLineage
206
- });
207
- referencedElement = await traverseAsync(referencedElement, visitor, {
208
- mutable: true
305
+ /**
306
+ * Creating a new version of referenced element to avoid modifying the original one.
307
+ */
308
+ const mergedElement = cloneShallow(referencedElement);
309
+ // annotate referenced element with info about original referencing element
310
+ mergedElement.meta.set('ref-fields', {
311
+ $ref: toValue(referencingElement.$ref)
209
312
  });
313
+ // annotate fragment with info about origin
314
+ mergedElement.meta.set('ref-origin', reference.uri);
315
+ mergedElement.meta.set('ref-type', referencingElement.element);
210
316
 
211
- // remove referencing reference from ancestors lineage
212
- directAncestors.delete(referencingElement);
317
+ /**
318
+ * Transclude referencing element with merged referenced element.
319
+ */
320
+ path.replaceWith(mergedElement);
321
+ } catch (error) {
322
+ const $ref = toValue(referencingElement.$ref);
323
+ this.handleError(`Error while dereferencing Reference Object. Cannot resolve $ref "${$ref}": ${error.message}`, error, referencingElement, '$ref', $ref, path);
324
+ } finally {
325
+ if (this.indirections.length > indirectionsSize) this.indirections.pop();
213
326
  }
214
- this.indirections.pop();
215
-
216
- /**
217
- * Creating a new version of referenced element to avoid modifying the original one.
218
- */
219
- const mergedElement = cloneShallow(referencedElement);
220
- // assign unique id to merged element
221
- mergedElement.meta.set('id', identityManager.generateId());
222
- // annotate referenced element with info about original referencing element
223
- mergedElement.meta.set('ref-fields', {
224
- $ref: toValue(referencingElement.$ref)
225
- });
226
- // annotate fragment with info about origin
227
- mergedElement.meta.set('ref-origin', reference.uri);
228
- // annotate fragment with info about referencing element
229
- mergedElement.meta.set('ref-referencing-element-id', identityManager.identify(referencingElement));
230
-
231
- /**
232
- * Transclude referencing element with merged referenced element.
233
- */
234
- path.replaceWith(mergedElement);
235
327
  }
236
328
  async PathItemElement(path) {
237
329
  const referencingElement = path.node;
@@ -246,7 +338,6 @@ class OpenAPI3_0DereferenceVisitor {
246
338
  path.skip();
247
339
  return;
248
340
  }
249
- const [ancestorsLineage, directAncestors] = this.toAncestorLineage(path);
250
341
  const retrievalURI = this.toBaseURI(toValue(referencingElement.$ref));
251
342
  const isInternalReference = url.stripHash(this.reference.uri) === retrievalURI;
252
343
  const isExternalReference = !isInternalReference;
@@ -261,117 +352,124 @@ class OpenAPI3_0DereferenceVisitor {
261
352
  // skip traversing this Path Item element but traverse all it's child elements
262
353
  return;
263
354
  }
264
- const reference = await this.toReference(toValue(referencingElement.$ref));
265
355
  const $refBaseURI = url.resolve(retrievalURI, toValue(referencingElement.$ref));
266
- this.indirections.push(referencingElement);
267
- const jsonPointer = URIFragmentIdentifier.fromURIReference($refBaseURI);
268
-
269
- // possibly non-semantic referenced element
270
- let referencedElement = evaluate(reference.value.result, jsonPointer);
271
- referencedElement.id = identityManager.identify(referencedElement);
272
-
273
- /**
274
- * Applying semantics to a referenced element if semantics are missing.
275
- */
276
- if (!isPathItemElement(referencedElement)) {
277
- const cacheKey = `path-item-${identityManager.identify(referencedElement)}`;
278
- if (this.refractCache.has(cacheKey)) {
279
- referencedElement = this.refractCache.get(cacheKey);
280
- } else {
281
- referencedElement = refractPathItem(referencedElement);
282
- this.refractCache.set(cacheKey, referencedElement);
283
- }
284
- }
356
+ const indirectionsSize = this.indirections.length;
357
+ try {
358
+ const reference = await this.toReference(toValue(referencingElement.$ref));
359
+ this.indirections.push(referencingElement);
360
+ const jsonPointer = URIFragmentIdentifier.fromURIReference($refBaseURI);
285
361
 
286
- // detect direct or circular reference
287
- if (referencingElement === referencedElement) {
288
- throw new ApiDOMError('Recursive Path Item Object reference detected');
289
- }
362
+ // possibly non-semantic referenced element
363
+ let referencedElement = evaluate(reference.value.result, jsonPointer);
290
364
 
291
- // detect maximum depth of dereferencing
292
- if (this.indirections.length > this.options.dereference.maxDepth) {
293
- throw new MaximumDereferenceDepthError(`Maximum dereference depth of "${this.options.dereference.maxDepth}" has been exceeded in file "${this.reference.uri}"`);
294
- }
365
+ // applying semantics to a referenced element
366
+ if (!isPathItemElement(referencedElement)) {
367
+ if (this.refractCache.has(referencedElement)) {
368
+ referencedElement = this.refractCache.get(referencedElement);
369
+ } else {
370
+ const sourceElement = referencedElement;
371
+ referencedElement = refractPathItem(referencedElement);
372
+ this.refractCache.set(sourceElement, referencedElement);
373
+ }
374
+ }
295
375
 
296
- // detect second deep dive into the same fragment and avoid it
297
- if (ancestorsLineage.includes(referencedElement)) {
298
- reference.refSet.circular = true;
299
- if (this.options.dereference.circular === 'error') {
300
- throw new ApiDOMError('Circular reference detected');
301
- } else if (this.options.dereference.circular === 'replace') {
302
- const refElement = new RefElement(referencedElement.id, {
303
- type: 'path-item',
304
- uri: reference.uri,
376
+ // detect direct or indirect reference
377
+ if (referencingElement === referencedElement) {
378
+ throw new ApiDOMStructuredError('Recursive Path Item Object reference detected', {
305
379
  $ref: toValue(referencingElement.$ref)
306
380
  });
307
- const replacer = this.options.dereference.strategyOpts['openapi-3-0']?.circularReplacer ?? this.options.dereference.circularReplacer;
308
- const replacement = replacer(refElement);
309
- this.indirections.pop();
310
- path.replaceWith(replacement);
311
- return;
312
381
  }
313
- }
314
382
 
315
- /**
316
- * Dive deep into the fragment.
317
- *
318
- * Cases to consider:
319
- * 1. We're crossing document boundary
320
- * 2. Fragment is from non-entry document
321
- * 3. Fragment is a Path Item Object with $ref field. We need to follow it to get the eventual value
322
- * 4. We are dereferencing the fragment lazily/eagerly depending on circular mode
323
- */
324
- const isNonEntryDocument = url.stripHash(reference.refSet.rootRef.uri) !== reference.uri;
325
- const shouldDetectCircular = ['error', 'replace'].includes(this.options.dereference.circular);
326
- if ((isExternalReference || isNonEntryDocument || isPathItemElement(referencedElement) && isStringElement(referencedElement.$ref) || shouldDetectCircular) && !ancestorsLineage.includesCycle(referencedElement)) {
327
- // append referencing reference to ancestors lineage
328
- directAncestors.add(referencingElement);
329
- const visitor = new OpenAPI3_0DereferenceVisitor({
330
- reference,
331
- namespace: this.namespace,
332
- indirections: [...this.indirections],
333
- options: this.options,
334
- refractCache: this.refractCache,
335
- ancestors: ancestorsLineage
336
- });
337
- referencedElement = await traverseAsync(referencedElement, visitor, {
338
- mutable: true
339
- });
383
+ // detect maximum depth of dereferencing
384
+ if (this.indirections.length > this.options.dereference.maxDepth) {
385
+ throw new MaximumDereferenceDepthError(`Maximum dereference depth of "${this.options.dereference.maxDepth}" has been exceeded in file "${this.reference.uri}"`, {
386
+ maxDepth: this.options.dereference.maxDepth,
387
+ uri: this.reference.uri
388
+ });
389
+ }
340
390
 
341
- // remove referencing reference from ancestors lineage
342
- directAncestors.delete(referencingElement);
343
- }
344
- this.indirections.pop();
391
+ // detect cross-boundary cycle
392
+ const [ancestorsLineage, directAncestors] = this.toAncestorLineage(path);
393
+ if (ancestorsLineage.includes(referencedElement)) {
394
+ reference.refSet.circular = true;
395
+ if (this.options.dereference.circular === 'error') {
396
+ throw new ApiDOMStructuredError('Circular reference detected', {
397
+ $ref: toValue(referencingElement.$ref)
398
+ });
399
+ } else if (this.options.dereference.circular === 'replace') {
400
+ const refElement = new RefElement($refBaseURI, {
401
+ type: referencingElement.element,
402
+ uri: reference.uri,
403
+ $ref: toValue(referencingElement.$ref)
404
+ });
405
+ const replacer = this.options.dereference.strategyOpts['openapi-3-0']?.circularReplacer ?? this.options.dereference.circularReplacer;
406
+ const replacement = replacer(refElement);
407
+ path.replaceWith(replacement);
408
+ return;
409
+ }
410
+ }
345
411
 
346
- /**
347
- * Creating a new version of Path Item by merging fields from referenced Path Item with referencing one.
348
- */
349
- if (isPathItemElement(referencedElement)) {
350
- const mergedElement = cloneShallow(referencedElement);
351
- // assign unique id to merged element
352
- mergedElement.meta.set('id', identityManager.generateId());
353
- // existing keywords from referencing PathItemElement overrides ones from referenced element
354
- referencingElement.forEach((value, keyElement, item) => {
355
- mergedElement.remove(toValue(keyElement));
356
- mergedElement.content.push(item);
357
- });
358
- mergedElement.remove('$ref');
412
+ /**
413
+ * Dive deep into the fragment.
414
+ *
415
+ * Cases to consider:
416
+ * 1. We're crossing document boundary
417
+ * 2. Fragment is from non-entry document
418
+ * 3. Fragment is a Path Item Object with $ref field. We need to follow it to get the eventual value
419
+ * 4. We are dereferencing the fragment lazily/eagerly depending on circular mode
420
+ */
421
+ const isNonEntryDocument = url.stripHash(reference.refSet.rootRef.uri) !== reference.uri;
422
+ const shouldDetectCircular = ['error', 'replace'].includes(this.options.dereference.circular);
423
+ if ((isExternalReference || isNonEntryDocument || isPathItemElement(referencedElement) && isStringElement(referencedElement.$ref) || shouldDetectCircular) && !ancestorsLineage.includesCycle(referencedElement)) {
424
+ // append referencing reference to ancestors lineage
425
+ directAncestors.add(referencingElement);
426
+ const visitor = new OpenAPI3_0DereferenceVisitor({
427
+ reference,
428
+ indirections: [...this.indirections],
429
+ options: this.options,
430
+ refractCache: this.refractCache,
431
+ ancestors: ancestorsLineage
432
+ });
433
+ referencedElement = await traverseAsync(referencedElement, visitor, {
434
+ mutable: true
435
+ });
359
436
 
360
- // annotate referenced element with info about original referencing element
361
- mergedElement.meta.set('ref-fields', {
362
- $ref: toValue(referencingElement.$ref)
363
- });
364
- // annotate referenced element with info about origin
365
- mergedElement.meta.set('ref-origin', reference.uri);
366
- // annotate fragment with info about referencing element
367
- mergedElement.meta.set('ref-referencing-element-id', identityManager.identify(referencingElement));
368
- referencedElement = mergedElement;
369
- }
437
+ // remove referencing reference from ancestors lineage
438
+ directAncestors.delete(referencingElement);
439
+ }
440
+
441
+ /**
442
+ * Creating a new version of Path Item by merging fields from referenced Path Item with referencing one.
443
+ */
444
+ if (isPathItemElement(referencedElement)) {
445
+ const mergedElement = cloneShallow(referencedElement);
446
+ // existing keywords from referencing PathItemElement overrides ones from referenced element
447
+ referencingElement.forEach((value, keyElement, item) => {
448
+ mergedElement.remove(toValue(keyElement));
449
+ mergedElement.content.push(item);
450
+ });
451
+ mergedElement.remove('$ref');
452
+
453
+ // annotate referenced element with info about original referencing element
454
+ mergedElement.meta.set('ref-fields', {
455
+ $ref: toValue(referencingElement.$ref)
456
+ });
457
+ // annotate referenced element with info about origin and type
458
+ mergedElement.meta.set('ref-origin', reference.uri);
459
+ mergedElement.meta.set('ref-type', referencingElement.element);
460
+ referencedElement = mergedElement;
461
+ }
370
462
 
371
- /**
372
- * Transclude referencing element with merged referenced element.
373
- */
374
- path.replaceWith(referencedElement);
463
+ /**
464
+ * Transclude referencing element with merged referenced element.
465
+ */
466
+ path.replaceWith(referencedElement);
467
+ } catch (error) {
468
+ const $ref = toValue(referencingElement.$ref);
469
+ this.handleError(`Error while dereferencing Path Item Object. Cannot resolve $ref "${$ref}": ${error.message}`, error, referencingElement, '$ref', $ref, path);
470
+ } finally {
471
+ if (this.indirections.length > indirectionsSize) this.indirections.pop();
472
+ }
375
473
  }
376
474
  async LinkElement(path) {
377
475
  const linkElement = path.node;
@@ -383,67 +481,78 @@ class OpenAPI3_0DereferenceVisitor {
383
481
 
384
482
  // operationRef and operationId fields are mutually exclusive
385
483
  if (isStringElement(linkElement.operationRef) && isStringElement(linkElement.operationId)) {
386
- throw new ApiDOMError('LinkElement operationRef and operationId fields are mutually exclusive');
484
+ throw new ApiDOMStructuredError('LinkElement operationRef and operationId fields are mutually exclusive', {
485
+ operationRef: toValue(linkElement.operationRef),
486
+ operationId: toValue(linkElement.operationId)
487
+ });
387
488
  }
388
- let operationElement;
389
- if (isStringElement(linkElement.operationRef)) {
390
- // possibly non-semantic referenced element
391
- const jsonPointer = URIFragmentIdentifier.fromURIReference(toValue(linkElement.operationRef));
392
- const retrievalURI = this.toBaseURI(toValue(linkElement.operationRef));
393
- const isInternalReference = url.stripHash(this.reference.uri) === retrievalURI;
394
- const isExternalReference = !isInternalReference;
395
-
396
- // ignore resolving internal Operation Object reference
397
- if (!this.options.resolve.internal && isInternalReference) {
398
- // skip traversing this Link element but traverse all it's child elements
399
- return;
400
- }
401
- // ignore resolving external Operation Object reference
402
- if (!this.options.resolve.external && isExternalReference) {
403
- // skip traversing this Link element but traverse all it's child elements
489
+ try {
490
+ let operationElement;
491
+ if (isStringElement(linkElement.operationRef)) {
492
+ // possibly non-semantic referenced element
493
+ const jsonPointer = URIFragmentIdentifier.fromURIReference(toValue(linkElement.operationRef));
494
+ const retrievalURI = this.toBaseURI(toValue(linkElement.operationRef));
495
+ const isInternalReference = url.stripHash(this.reference.uri) === retrievalURI;
496
+ const isExternalReference = !isInternalReference;
497
+
498
+ // ignore resolving internal Operation Object reference
499
+ if (!this.options.resolve.internal && isInternalReference) {
500
+ // skip traversing this Link element but traverse all it's child elements
501
+ return;
502
+ }
503
+ // ignore resolving external Operation Object reference
504
+ if (!this.options.resolve.external && isExternalReference) {
505
+ // skip traversing this Link element but traverse all it's child elements
506
+ return;
507
+ }
508
+ const reference = await this.toReference(toValue(linkElement.operationRef));
509
+ operationElement = evaluate(reference.value.result, jsonPointer);
510
+ // applying semantics to a referenced element
511
+ if (!isOperationElement(operationElement)) {
512
+ if (this.refractCache.has(operationElement)) {
513
+ operationElement = this.refractCache.get(operationElement);
514
+ } else {
515
+ const sourceElement = operationElement;
516
+ operationElement = refractOperation(operationElement);
517
+ this.refractCache.set(sourceElement, operationElement);
518
+ }
519
+ }
520
+ // create shallow clone to be able to annotate with metadata
521
+ operationElement = cloneShallow(operationElement);
522
+ // annotate operation element with info about origin
523
+ operationElement.meta.set('ref-origin', reference.uri);
524
+ operationElement.meta.set('ref-type', linkElement.element);
525
+ const linkElementCopy = cloneShallow(linkElement);
526
+ linkElementCopy.operationRef?.meta.set('operation', operationElement);
527
+
528
+ /**
529
+ * Transclude Link Object containing Operation Object in its meta.
530
+ */
531
+ path.replaceWith(linkElementCopy);
404
532
  return;
405
533
  }
406
- const reference = await this.toReference(toValue(linkElement.operationRef));
407
- operationElement = evaluate(reference.value.result, jsonPointer);
408
- // applying semantics to a referenced element
409
- if (isPrimitiveElement(operationElement)) {
410
- const cacheKey = `operation-${identityManager.identify(operationElement)}`;
411
- if (this.refractCache.has(cacheKey)) {
412
- operationElement = this.refractCache.get(cacheKey);
413
- } else {
414
- operationElement = refractOperation(operationElement);
415
- this.refractCache.set(cacheKey, operationElement);
534
+ if (isStringElement(linkElement.operationId)) {
535
+ const operationId = toValue(linkElement.operationId);
536
+ const reference = await this.toReference(url.unsanitize(this.reference.uri));
537
+ operationElement = find(reference.value.result, e => isOperationElement(e) && isElement(e.operationId) && e.operationId.equals(operationId));
538
+ // OperationElement not found by its operationId
539
+ if (isUndefined(operationElement)) {
540
+ throw new ApiDOMStructuredError(`OperationElement(operationId=${operationId}) not found`, {
541
+ operationId
542
+ });
416
543
  }
417
- }
418
- // create shallow clone to be able to annotate with metadata
419
- operationElement = cloneShallow(operationElement);
420
- // annotate operation element with info about origin
421
- operationElement.meta.set('ref-origin', reference.uri);
422
- const linkElementCopy = cloneShallow(linkElement);
423
- linkElementCopy.operationRef?.meta.set('operation', operationElement);
544
+ const linkElementCopy = cloneShallow(linkElement);
545
+ linkElementCopy.operationId?.meta.set('operation', operationElement);
424
546
 
425
- /**
426
- * Transclude Link Object containing Operation Object in its meta.
427
- */
428
- path.replaceWith(linkElementCopy);
429
- return;
430
- }
431
- if (isStringElement(linkElement.operationId)) {
432
- const operationId = toValue(linkElement.operationId);
433
- const reference = await this.toReference(url.unsanitize(this.reference.uri));
434
- operationElement = find(reference.value.result, e => isOperationElement(e) && isElement(e.operationId) && e.operationId.equals(operationId));
435
- // OperationElement not found by its operationId
436
- if (isUndefined(operationElement)) {
437
- throw new ApiDOMError(`OperationElement(operationId=${operationId}) not found`);
547
+ /**
548
+ * Transclude Link Object containing Operation Object in its meta.
549
+ */
550
+ path.replaceWith(linkElementCopy);
438
551
  }
439
- const linkElementCopy = cloneShallow(linkElement);
440
- linkElementCopy.operationId?.meta.set('operation', operationElement);
441
-
442
- /**
443
- * Transclude Link Object containing Operation Object in its meta.
444
- */
445
- path.replaceWith(linkElementCopy);
446
- return;
552
+ } catch (error) {
553
+ const refFieldName = isStringElement(linkElement.operationRef) ? 'operationRef' : 'operationId';
554
+ const refFieldValue = isStringElement(linkElement.operationRef) ? toValue(linkElement.operationRef) : toValue(linkElement.operationId);
555
+ this.handleError(`Error while dereferencing Link Object. Cannot resolve ${refFieldName} "${refFieldValue}": ${error.message}`, error, linkElement, refFieldName, refFieldValue, path);
447
556
  }
448
557
  }
449
558
  async ExampleElement(path) {
@@ -456,7 +565,10 @@ class OpenAPI3_0DereferenceVisitor {
456
565
 
457
566
  // value and externalValue fields are mutually exclusive
458
567
  if (exampleElement.hasKey('value') && isStringElement(exampleElement.externalValue)) {
459
- throw new ApiDOMError('ExampleElement value and externalValue fields are mutually exclusive');
568
+ throw new ApiDOMStructuredError('ExampleElement value and externalValue fields are mutually exclusive', {
569
+ value: toValue(exampleElement.value),
570
+ externalValue: toValue(exampleElement.externalValue)
571
+ });
460
572
  }
461
573
  const retrievalURI = this.toBaseURI(toValue(exampleElement.externalValue));
462
574
  const isInternalReference = url.stripHash(this.reference.uri) === retrievalURI;
@@ -472,19 +584,25 @@ class OpenAPI3_0DereferenceVisitor {
472
584
  // skip traversing this Example element but traverse all it's child elements
473
585
  return;
474
586
  }
475
- const reference = await this.toReference(toValue(exampleElement.externalValue));
476
-
477
- // shallow clone of the referenced element
478
- const valueElement = cloneShallow(reference.value.result);
479
- // annotate operation element with info about origin
480
- valueElement.meta.set('ref-origin', reference.uri);
481
- const exampleElementCopy = cloneShallow(exampleElement);
482
- exampleElementCopy.value = valueElement;
483
-
484
- /**
485
- * Transclude Example Object containing external value.
486
- */
487
- path.replaceWith(exampleElementCopy);
587
+ try {
588
+ const reference = await this.toReference(toValue(exampleElement.externalValue));
589
+
590
+ // shallow clone of the referenced element
591
+ const valueElement = cloneShallow(reference.value.result);
592
+ // annotate operation element with info about origin
593
+ valueElement.meta.set('ref-origin', reference.uri);
594
+ valueElement.meta.set('ref-type', exampleElement.element);
595
+ const exampleElementCopy = cloneShallow(exampleElement);
596
+ exampleElementCopy.value = valueElement;
597
+
598
+ /**
599
+ * Transclude Example Object containing external value.
600
+ */
601
+ path.replaceWith(exampleElementCopy);
602
+ } catch (error) {
603
+ const externalValue = toValue(exampleElement.externalValue);
604
+ this.handleError(`Error while dereferencing Example Object. Cannot resolve externalValue "${externalValue}": ${error.message}`, error, exampleElement, 'externalValue', externalValue, path);
605
+ }
488
606
  }
489
607
  }
490
608
  export default OpenAPI3_0DereferenceVisitor;