diff --git a/packages/apidom-reference/src/dereference/index.ts b/packages/apidom-reference/src/dereference/index.ts index 46a4f75bd..eb38cfc42 100644 --- a/packages/apidom-reference/src/dereference/index.ts +++ b/packages/apidom-reference/src/dereference/index.ts @@ -79,7 +79,13 @@ const dereference = async ( parseResult = await parse(uri, options); } - const mergedOptions = mergeOptions(options, { resolve: { baseURI: sanitizedURI } }); + const mergedOptions = mergeOptions(options, { + resolve: { baseURI: sanitizedURI }, + dereference: { + // if refSet was not provided, then we can work in mutable mode + immutable: options.dereference.immutable && refSet !== null, + }, + }); return dereferenceApiDOM(parseResult, mergedOptions); }; diff --git a/packages/apidom-reference/src/dereference/strategies/openapi-3-0/index.ts b/packages/apidom-reference/src/dereference/strategies/openapi-3-0/index.ts index bafd0ac5a..3e2797f37 100644 --- a/packages/apidom-reference/src/dereference/strategies/openapi-3-0/index.ts +++ b/packages/apidom-reference/src/dereference/strategies/openapi-3-0/index.ts @@ -1,6 +1,5 @@ import stampit from 'stampit'; -import { defaultTo, propEq } from 'ramda'; -import { createNamespace, visit, Element } from '@swagger-api/apidom-core'; +import { Element, createNamespace, visit, cloneDeep } from '@swagger-api/apidom-core'; import openApi3_0Namespace, { getNodeType, isOpenApi3_0Element, @@ -41,7 +40,7 @@ const OpenApi3_0DereferenceStrategy: stampit.Stamp = stamp async dereference(file: IFile, options: IReferenceOptions): Promise { const namespace = createNamespace(openApi3_0Namespace); - const refSet = defaultTo(ReferenceSet(), options.dereference.refSet); + const refSet = options.dereference.refSet ?? ReferenceSet(); let reference; if (!refSet.has(file.uri)) { @@ -49,7 +48,32 @@ const OpenApi3_0DereferenceStrategy: stampit.Stamp = stamp refSet.add(reference); } else { // pre-computed refSet was provided as configuration option - reference = refSet.find(propEq(file.uri, 'uri')); + reference = refSet.find((ref) => ref.uri === file.uri); + } + + /** + * Clone refSet due the dereferencing process being mutable. + * We don't want to mutate the original refSet and the references. + */ + if (options.dereference.immutable) { + const immutableRefs = refSet.refs.map((ref) => + Reference({ + ...ref, + uri: `immutable://${ref.uri}`, + }), + ); + const mutableRefs = refSet.refs.map((ref) => + Reference({ + ...ref, + value: cloneDeep(ref.value), + }), + ); + + refSet.clean(); + mutableRefs.forEach((ref) => refSet.add(ref)); + immutableRefs.forEach((ref) => refSet.add(ref)); + + reference = refSet.find((ref) => ref.uri === file.uri); } const visitor = OpenApi3_0DereferenceVisitor({ reference, namespace, options }); @@ -59,13 +83,30 @@ const OpenApi3_0DereferenceStrategy: stampit.Stamp = stamp }); /** - * Release all memory if this refSet was not provided as an configuration option. + * Release all memory if this refSet was not provided as a configuration option. * If provided as configuration option, then provider is responsible for cleanup. */ if (options.dereference.refSet === null) { refSet.clean(); } + /** + * If immutable option is set, then we need to remove mutable refs from the refSet. + */ + if (options.dereference.immutable) { + const immutableRefs = refSet.refs + .filter((ref) => ref.uri.startsWith('immutable://')) + .map((ref) => + Reference({ + ...ref, + uri: ref.uri.replace(/^immutable:\/\//, ''), + }), + ); + + refSet.clean(); + immutableRefs.forEach((ref) => refSet.add(ref)); + } + return dereferencedElement; }, }, diff --git a/packages/apidom-reference/src/dereference/strategies/openapi-3-0/visitor.ts b/packages/apidom-reference/src/dereference/strategies/openapi-3-0/visitor.ts index c2ca4a87c..9e01cbcca 100644 --- a/packages/apidom-reference/src/dereference/strategies/openapi-3-0/visitor.ts +++ b/packages/apidom-reference/src/dereference/strategies/openapi-3-0/visitor.ts @@ -13,28 +13,30 @@ import { cloneDeep, toValue, Element, + RefElement, } from '@swagger-api/apidom-core'; import { ApiDOMError } from '@swagger-api/apidom-error'; import { evaluate, uriToPointer } from '@swagger-api/apidom-json-pointer'; import { getNodeType, - isReferenceLikeElement, keyMap, ReferenceElement, ExampleElement, LinkElement, OperationElement, PathItemElement, + isReferenceElement, isOperationElement, + isReferenceLikeElement, } from '@swagger-api/apidom-ns-openapi-3-0'; import { Reference as IReference } from '../../../types'; import MaximumDereferenceDepthError from '../../../errors/MaximumDereferenceDepthError'; import MaximumResolveDepthError from '../../../errors/MaximumResolveDepthError'; -import { AncestorLineage } from '../../util'; import * as url from '../../../util/url'; import parse from '../../../parse'; import Reference from '../../../Reference'; +import { AncestorLineage } from '../../util'; // @ts-ignore const visitAsync = visit[Symbol.for('nodejs.util.promisify.custom')]; @@ -42,18 +44,6 @@ const visitAsync = visit[Symbol.for('nodejs.util.promisify.custom')]; // initialize element identity manager const identityManager = IdentityManager(); -/** - * Predicate for detecting if element was created by merging referencing - * element with particular element identity with a referenced element. - */ -const wasReferencedBy = - (referencingElement: T) => - (element: U) => - element.meta.hasKey('ref-referencing-element-id') && - element.meta - .get('ref-referencing-element-id') - .equals(toValue(identityManager.identify(referencingElement))); - // eslint-disable-next-line @typescript-eslint/naming-convention const OpenApi3_0DereferenceVisitor = stampit({ props: { @@ -61,23 +51,23 @@ const OpenApi3_0DereferenceVisitor = stampit({ namespace: null, reference: null, options: null, - ancestors: null, refractCache: null, + ancestors: null, }, init({ indirections = [], reference, namespace, options, - ancestors = new AncestorLineage(), refractCache = new Map(), + ancestors = new AncestorLineage(), }) { this.indirections = indirections; this.namespace = namespace; this.reference = reference; this.options = options; - this.ancestors = new AncestorLineage(...ancestors); this.refractCache = refractCache; + this.ancestors = ancestors; }, methods: { toBaseURI(uri: string): string { @@ -105,19 +95,28 @@ const OpenApi3_0DereferenceVisitor = stampit({ parse: { ...this.options.parse, mediaType: 'text/plain' }, }); - // register new Reference with ReferenceSet - const reference = Reference({ + // register new mutable reference with a refSet + const mutableReference = Reference({ uri: baseURI, - value: parseResult, + value: cloneDeep(parseResult), depth: this.reference.depth + 1, }); + refSet.add(mutableReference); + + if (this.options.dereference.immutable) { + // register new immutable reference with a refSet + const immutableReference = Reference({ + uri: `immutable://${baseURI}`, + value: parseResult, + depth: this.reference.depth + 1, + }); + refSet.add(immutableReference); + } - refSet.add(reference); - - return reference; + return mutableReference; }, - toAncestorLineage(ancestors) { + toAncestorLineage(ancestors: [Element | Element[]]) { /** * Compute full ancestors lineage. * Ancestors are flatten to unwrap all Element instances. @@ -130,18 +129,13 @@ const OpenApi3_0DereferenceVisitor = stampit({ async ReferenceElement( referencingElement: ReferenceElement, - key: any, - parent: any, - path: any, - ancestors: any[], + key: string | number, + parent: Element | undefined, + path: (string | number)[], + ancestors: [Element | Element[]], ) { const [ancestorsLineage, directAncestors] = this.toAncestorLineage([...ancestors, parent]); - // detect possible cycle in traversal and avoid it - if (ancestorsLineage.includesCycle(referencingElement)) { - return false; - } - const retrievalURI = this.toBaseURI(toValue(referencingElement.$ref)); const isInternalReference = url.stripHash(this.reference.uri) === retrievalURI; const isExternalReference = !isInternalReference; @@ -166,8 +160,11 @@ const OpenApi3_0DereferenceVisitor = stampit({ // possibly non-semantic fragment let referencedElement = evaluate(jsonPointer, reference.value.result); + referencedElement.id = identityManager.identify(referencedElement); - // applying semantics to a fragment + /** + * Applying semantics to a referenced element if semantics are missing. + */ if (isPrimitiveElement(referencedElement)) { const referencedElementType = toValue(referencingElement.meta.get('referenced-element')); const cacheKey = `${referencedElementType}-${toValue(identityManager.identify(referencedElement))}`; @@ -199,85 +196,115 @@ const OpenApi3_0DereferenceVisitor = stampit({ ); } - // append referencing reference to ancestors lineage - directAncestors.add(referencingElement); - - // dive deep into the fragment - const visitor = OpenApi3_0DereferenceVisitor({ - reference, - namespace: this.namespace, - indirections: [...this.indirections], - options: this.options, - ancestors: ancestorsLineage, - refractCache: this.refractCache, - }); - referencedElement = await visitAsync(referencedElement, visitor, { - keyMap, - nodeTypeGetter: getNodeType, - }); + // detect second deep dive into the same fragment and avoid it + if (ancestorsLineage.includes(referencedElement)) { + if (this.options.dereference.circular === 'error') { + throw new ApiDOMError('Circular reference detected'); + } else if (this.options.dereference.circular !== 'ignore') { + const refElement = new RefElement(referencedElement.id, { + type: 'reference', + uri: reference.uri, + $ref: toValue(referencingElement.$ref), + }); + const replacer = + this.options.dereference.strategyOpts['openapi-3-0']?.circularReplacer || + this.options.dereference.circularReplacer; + const replacement = replacer(refElement); + + if (isMemberElement(parent)) { + parent.value = replacement; // eslint-disable-line no-param-reassign + } else if (Array.isArray(parent)) { + parent[key] = replacement; // eslint-disable-line no-param-reassign + } - // remove referencing reference from ancestors lineage - directAncestors.delete(referencingElement); + reference.refSet.circular = true; - this.indirections.pop(); - - const mergeAndAnnotateReferencedElement = (refedElement: T): T => { - const copy = cloneShallow(refedElement); + return !parent ? replacement : false; + } + } - // annotate referenced element with info about original referencing element - copy.setMetaProperty('ref-fields', { - // @ts-ignore - $ref: toValue(referencingElement.$ref), + /** + * Dive deep into the fragment. + * + * Cases to consider: + * 1. We're crossing document boundary + * 2. Fragment is a Reference Object. We need to follow it to get the eventual value + * 3. We are dereferencing the fragment lazily/eagerly depending on circular mode + */ + if ( + isExternalReference || + isReferenceElement(referencedElement) || + ['error', 'replace'].includes(this.options.dereference.circular) + ) { + // append referencing reference to ancestors lineage + directAncestors.add(referencingElement); + + const visitor = OpenApi3_0DereferenceVisitor({ + reference, + namespace: this.namespace, + indirections: [...this.indirections], + options: this.options, + refractCache: this.refractCache, + ancestors: ancestorsLineage, + }); + referencedElement.setMetaProperty('traversed', true); + referencedElement = await visitAsync(referencedElement, visitor, { + keyMap, + nodeTypeGetter: getNodeType, }); - // annotate fragment with info about origin - copy.setMetaProperty('ref-origin', reference.uri); - // annotate fragment with info about referencing element - copy.setMetaProperty( - 'ref-referencing-element-id', - cloneDeep(identityManager.identify(referencingElement)), - ); - return copy; - }; + // remove referencing reference from ancestors lineage + directAncestors.delete(referencingElement); + } - // attempting to create cycle - if ( - ancestorsLineage.includes(referencingElement) || - ancestorsLineage.includes(referencedElement) - ) { - const replaceWith = - ancestorsLineage.findItem(wasReferencedBy(referencingElement)) ?? - mergeAndAnnotateReferencedElement(referencedElement); - if (isMemberElement(parent)) { - parent.value = replaceWith; // eslint-disable-line no-param-reassign - } else if (Array.isArray(parent)) { - parent[key] = replaceWith; // eslint-disable-line no-param-reassign - } - return false; + this.indirections.pop(); + + /** + * Creating a new version of referenced element to avoid modifying the original one. + */ + const mergedElement = cloneShallow(referencedElement); + // assign unique id to merged element + mergedElement.setMetaProperty('id', identityManager.generateId()); + // annotate referenced element with info about original referencing element + mergedElement.setMetaProperty('ref-fields', { + $ref: toValue(referencingElement.$ref), + }); + // annotate fragment with info about origin + mergedElement.setMetaProperty('ref-origin', reference.uri); + // annotate fragment with info about referencing element + mergedElement.setMetaProperty( + 'ref-referencing-element-id', + cloneDeep(identityManager.identify(referencingElement)), + ); + + /** + * Transclude referencing element with merged referenced element. + */ + if (isMemberElement(parent)) { + parent.value = mergedElement; // eslint-disable-line no-param-reassign + } else if (Array.isArray(parent)) { + parent[key] = mergedElement; // eslint-disable-line no-param-reassign } - // transclude referencing element with merged referenced element - return mergeAndAnnotateReferencedElement(referencedElement); + /** + * We're at the root of the tree, so we're just replacing the entire tree. + */ + return !parent ? mergedElement : false; }, async PathItemElement( referencingElement: PathItemElement, - key: any, - parent: any, - path: any, - ancestors: any[], + key: string | number, + parent: Element | undefined, + path: (string | number)[], + ancestors: [Element | Element[]], ) { - const [ancestorsLineage, directAncestors] = this.toAncestorLineage([...ancestors, parent]); - // ignore PathItemElement without $ref field if (!isStringElement(referencingElement.$ref)) { return undefined; } - // detect possible cycle in traversal and avoid it - if (ancestorsLineage.includesCycle(referencingElement)) { - return false; - } + const [ancestorsLineage, directAncestors] = this.toAncestorLineage([...ancestors, parent]); const retrievalURI = this.toBaseURI(toValue(referencingElement.$ref)); const isInternalReference = url.stripHash(this.reference.uri) === retrievalURI; @@ -303,8 +330,11 @@ const OpenApi3_0DereferenceVisitor = stampit({ // possibly non-semantic referenced element let referencedElement = evaluate(jsonPointer, reference.value.result); + referencedElement.id = identityManager.identify(referencedElement); - // applying semantics to a referenced element + /** + * Applying semantics to a referenced element if semantics are missing. + */ if (isPrimitiveElement(referencedElement)) { const cacheKey = `pathItem-${toValue(identityManager.identify(referencedElement))}`; @@ -328,80 +358,114 @@ const OpenApi3_0DereferenceVisitor = stampit({ ); } - // append referencing path item to ancestors lineage - directAncestors.add(referencingElement); - - // dive deep into the referenced element - const visitor: any = OpenApi3_0DereferenceVisitor({ - reference, - namespace: this.namespace, - indirections: [...this.indirections], - options: this.options, - ancestors: ancestorsLineage, - refractCache: this.refractCache, - }); - referencedElement = await visitAsync(referencedElement, visitor, { - keyMap, - nodeTypeGetter: getNodeType, - }); + // detect second deep dive into the same fragment and avoid it + if (ancestorsLineage.includes(referencedElement)) { + if (this.options.dereference.circular === 'error') { + throw new ApiDOMError('Circular reference detected'); + } else if (this.options.dereference.circular !== 'ignore') { + const refElement = new RefElement(referencedElement.id, { + type: 'path-item', + uri: reference.uri, + $ref: toValue(referencingElement.$ref), + }); + const replacer = + this.options.dereference.strategyOpts['openapi-3-0']?.circularReplacer || + this.options.dereference.circularReplacer; + const replacement = replacer(refElement); + + if (isMemberElement(parent)) { + parent.value = replacement; // eslint-disable-line no-param-reassign + } else if (Array.isArray(parent)) { + parent[key] = replacement; // eslint-disable-line no-param-reassign + } - // remove referencing path item from ancestors lineage - directAncestors.delete(referencingElement); + reference.refSet.circular = true; - this.indirections.pop(); + return !parent ? replacement : false; + } + } - const mergeAndAnnotateReferencedElement = ( - refedElement: T, - ): PathItemElement => { - // merge fields from referenced Path Item with referencing one - const mergedElement = new PathItemElement( - [...refedElement.content] as any, - cloneDeep(referencedElement.meta), - cloneDeep(referencedElement.attributes), - ); - // existing keywords from referencing PathItemElement overrides ones from referenced element - referencingElement.forEach((value: Element, keyElement: Element, item: Element) => { - mergedElement.remove(toValue(keyElement)); - mergedElement.content.push(item); + /** + * Dive deep into the fragment. + * + * Cases to consider: + * 1. We're crossing document boundary + * 2. Fragment is a Reference Object. We need to follow it to get the eventual value + * 3. We are dereferencing the fragment lazily/eagerly depending on circular mode + */ + if ( + isExternalReference || + isStringElement(referencedElement.$ref) || + ['error', 'replace'].includes(this.options.dereference.circular) + ) { + // append referencing reference to ancestors lineage + directAncestors.add(referencingElement); + + const visitor = OpenApi3_0DereferenceVisitor({ + reference, + namespace: this.namespace, + indirections: [...this.indirections], + options: this.options, + refractCache: this.refractCache, + ancestors: ancestorsLineage, }); - mergedElement.remove('$ref'); - - // annotate referenced element with info about original referencing element - mergedElement.setMetaProperty('ref-fields', { - $ref: toValue(referencingElement.$ref), + referencedElement.setMetaProperty('traversed', true); + referencedElement = await visitAsync(referencedElement, visitor, { + keyMap, + nodeTypeGetter: getNodeType, }); - // annotate referenced element with info about origin - mergedElement.setMetaProperty('ref-origin', reference.uri); - // annotate fragment with info about referencing element - mergedElement.setMetaProperty( - 'ref-referencing-element-id', - cloneDeep(identityManager.identify(referencingElement)), - ); - return mergedElement; - }; + // remove referencing reference from ancestors lineage + directAncestors.delete(referencingElement); + } - // attempting to create cycle - if ( - ancestorsLineage.includes(referencingElement) || - ancestorsLineage.includes(referencedElement) - ) { - const replaceWith = - ancestorsLineage.findItem(wasReferencedBy(referencingElement)) ?? - mergeAndAnnotateReferencedElement(referencedElement); - if (isMemberElement(parent)) { - parent.value = replaceWith; // eslint-disable-line no-param-reassign - } else if (Array.isArray(parent)) { - parent[key] = replaceWith; // eslint-disable-line no-param-reassign - } - return false; + this.indirections.pop(); + + /** + * Creating a new version of Path Item by mergeing fields from referenced Path Item with referencing one. + */ + const mergedElement = new PathItemElement( + [...referencedElement.content] as any, + cloneDeep(referencedElement.meta), + cloneDeep(referencedElement.attributes), + ); + // assign unique id to merged element + mergedElement.setMetaProperty('id', identityManager.generateId()); + // existing keywords from referencing PathItemElement overrides ones from referenced element + referencingElement.forEach((value: Element, keyElement: Element, item: Element) => { + mergedElement.remove(toValue(keyElement)); + mergedElement.content.push(item); + }); + mergedElement.remove('$ref'); + + // annotate referenced element with info about original referencing element + mergedElement.setMetaProperty('ref-fields', { + $ref: toValue(referencingElement.$ref), + }); + // annotate referenced element with info about origin + mergedElement.setMetaProperty('ref-origin', reference.uri); + // annotate fragment with info about referencing element + mergedElement.setMetaProperty( + 'ref-referencing-element-id', + cloneDeep(identityManager.identify(referencingElement)), + ); + + /** + * Transclude referencing element with merged referenced element. + */ + if (isMemberElement(parent)) { + parent.value = mergedElement; // eslint-disable-line no-param-reassign + } else if (Array.isArray(parent)) { + parent[key] = mergedElement; // eslint-disable-line no-param-reassign } - // transclude referencing element with merged referenced element - return mergeAndAnnotateReferencedElement(referencedElement); + /** + * We're at the root of the tree, so we're just replacing the entire tree. + */ + return !parent ? mergedElement : undefined; }, - async LinkElement(linkElement: LinkElement) { + async LinkElement(linkElement: LinkElement, key: string | number, parent: Element | undefined) { // ignore LinkElement without operationRef or operationId field if (!isStringElement(linkElement.operationRef) && !isStringElement(linkElement.operationId)) { return undefined; @@ -455,7 +519,20 @@ const OpenApi3_0DereferenceVisitor = stampit({ const linkElementCopy = cloneShallow(linkElement); linkElementCopy.operationRef?.meta.set('operation', operationElement); - return linkElementCopy; + + /** + * Transclude Link Object containing Operation Object in its meta. + */ + if (isMemberElement(parent)) { + parent.value = linkElementCopy; // eslint-disable-line no-param-reassign + } else if (Array.isArray(parent)) { + parent[key] = linkElementCopy; // eslint-disable-line no-param-reassign + } + + /** + * We're at the root of the tree, so we're just replacing the entire tree. + */ + return !parent ? linkElementCopy : undefined; } if (isStringElement(linkElement.operationId)) { @@ -473,7 +550,20 @@ const OpenApi3_0DereferenceVisitor = stampit({ const linkElementCopy = cloneShallow(linkElement); linkElementCopy.operationId?.meta.set('operation', operationElement); - return linkElementCopy; + + /** + * Transclude Link Object containing Operation Object in its meta. + */ + if (isMemberElement(parent)) { + parent.value = linkElementCopy; // eslint-disable-line no-param-reassign + } else if (Array.isArray(parent)) { + parent[key] = linkElementCopy; // eslint-disable-line no-param-reassign + } + + /** + * We're at the root of the tree, so we're just replacing the entire tree. + */ + return !parent ? linkElementCopy : undefined; } return undefined; @@ -481,23 +571,14 @@ const OpenApi3_0DereferenceVisitor = stampit({ async ExampleElement( exampleElement: ExampleElement, - key: any, - parent: any, - path: any, - ancestors: any[], + key: string | number, + parent: Element | undefined, ) { - const [ancestorsLineage] = this.toAncestorLineage([...ancestors, parent]); - // ignore ExampleElement without externalValue field if (!isStringElement(exampleElement.externalValue)) { return undefined; } - // detect possible cycle in traversal and avoid it - if (ancestorsLineage.includesCycle(exampleElement)) { - return false; - } - // value and externalValue fields are mutually exclusive if (exampleElement.hasKey('value') && isStringElement(exampleElement.externalValue)) { throw new ApiDOMError( @@ -529,7 +610,20 @@ const OpenApi3_0DereferenceVisitor = stampit({ const exampleElementCopy = cloneShallow(exampleElement); exampleElementCopy.value = valueElement; - return exampleElementCopy; + + /** + * Transclude Example Object containing external value. + */ + if (isMemberElement(parent)) { + parent.value = exampleElementCopy; // eslint-disable-line no-param-reassign + } else if (Array.isArray(parent)) { + parent[key] = exampleElementCopy; // eslint-disable-line no-param-reassign + } + + /** + * We're at the root of the tree, so we're just replacing the entire tree. + */ + return !parent ? exampleElementCopy : undefined; }, }, }); diff --git a/packages/apidom-reference/src/options/index.ts b/packages/apidom-reference/src/options/index.ts index eff521611..1499e0852 100644 --- a/packages/apidom-reference/src/options/index.ts +++ b/packages/apidom-reference/src/options/index.ts @@ -1,3 +1,5 @@ +import { identity } from 'ramda'; + import { ReferenceOptions as IReferenceOptions } from '../types'; const defaultOptions: IReferenceOptions = { @@ -105,6 +107,28 @@ const defaultOptions: IReferenceOptions = { * is exceeded by this option. */ maxDepth: +Infinity, + /** + * Determines how circular references are handled. + * + * "ignore" - circular reference are allowed + * "replace" - circular references are not allowed and are translated to RefElement + * "error" - circular references are not allowed and will throw an error + */ + circular: 'ignore', + /** + * This function is used to replace circular references when `circular` option is set to "replace". + * By default, it's an identity function. It means that circular references are replaced with RefElement. + */ + circularReplacer: identity, + /** + * Determines whether the dereferencing process will be immutable. + * By default, the dereferencing process is immutable, which means that the original + * ApiDOM passed to the dereference process is NOT modified. + * + * true - the dereferencing process will be immutable (deep cloning of ApiDOM is involved) + * false - the dereferencing process will be mutable + */ + immutable: true, }, bundle: { /** diff --git a/packages/apidom-reference/src/resolve/strategies/openapi-3-0/index.ts b/packages/apidom-reference/src/resolve/strategies/openapi-3-0/index.ts index 45516a1f0..0b893aa84 100644 --- a/packages/apidom-reference/src/resolve/strategies/openapi-3-0/index.ts +++ b/packages/apidom-reference/src/resolve/strategies/openapi-3-0/index.ts @@ -1,11 +1,4 @@ import stampit from 'stampit'; -import { createNamespace, visit } from '@swagger-api/apidom-core'; -import openapi3_0Namespace, { - getNodeType, - isOpenApi3_0Element, - keyMap, - mediaTypes, -} from '@swagger-api/apidom-ns-openapi-3-0'; import ResolveStrategy from '../ResolveStrategy'; import { @@ -14,12 +7,8 @@ import { ResolveStrategy as IResolveStrategy, } from '../../../types'; import ReferenceSet from '../../../ReferenceSet'; -import Reference from '../../../Reference'; import { merge as mergeOptions } from '../../../options/util'; -import OpenApi3_0ResolveVisitor from './visitor'; - -// @ts-ignore -const visitAsync = visit[Symbol.for('nodejs.util.promisify.custom')]; +import UnmatchedDereferenceStrategyError from '../../../errors/UnmatchedDereferenceStrategyError'; // eslint-disable-next-line @typescript-eslint/naming-convention const OpenApi3_0ResolveStrategy: stampit.Stamp = stampit(ResolveStrategy, { @@ -27,29 +16,37 @@ const OpenApi3_0ResolveStrategy: stampit.Stamp = stampit(Resol this.name = 'openapi-3-0'; }, methods: { - canResolve(file: IFile) { - // assert by media type - if (file.mediaType !== 'text/plain') { - return mediaTypes.includes(file.mediaType); + canResolve(file: IFile, options: IReferenceOptions): boolean { + const dereferenceStrategy = options.dereference.strategies.find( + (strategy: any) => strategy.name === 'openapi-3-0', + ); + + if (dereferenceStrategy === undefined) { + return false; } - // assert by inspecting ApiDOM - return isOpenApi3_0Element(file.parseResult?.api); + return dereferenceStrategy.canDereference(file, options); }, async resolve(file: IFile, options: IReferenceOptions) { - const namespace = createNamespace(openapi3_0Namespace); - const reference = Reference({ uri: file.uri, value: file.parseResult }); - const mergedOptions = mergeOptions(options, { resolve: { internal: false } }); - const visitor = OpenApi3_0ResolveVisitor({ reference, namespace, options: mergedOptions }); - const refSet = ReferenceSet(); - refSet.add(reference); + const dereferenceStrategy = options.dereference.strategies.find( + (strategy: any) => strategy.name === 'openapi-3-0', + ); + + if (dereferenceStrategy === undefined) { + throw new UnmatchedDereferenceStrategyError( + '"openapi-3-0" dereference strategy is not available.', + ); + } - await visitAsync(refSet.rootRef.value, visitor, { - keyMap, - nodeTypeGetter: getNodeType, + const refSet = ReferenceSet(); + const mergedOptions = mergeOptions(options, { + resolve: { internal: false }, + dereference: { refSet }, }); + await dereferenceStrategy.dereference(file, mergedOptions); + return refSet; }, }, diff --git a/packages/apidom-reference/src/resolve/strategies/openapi-3-0/visitor.ts b/packages/apidom-reference/src/resolve/strategies/openapi-3-0/visitor.ts deleted file mode 100644 index 0b30dd45b..000000000 --- a/packages/apidom-reference/src/resolve/strategies/openapi-3-0/visitor.ts +++ /dev/null @@ -1,8 +0,0 @@ -import stampit from 'stampit'; - -import OpenApi3_0DereferenceVisitor from '../../../dereference/strategies/openapi-3-0/visitor'; - -// eslint-disable-next-line @typescript-eslint/naming-convention -const OpenApi3_0ResolveVisitor = stampit(OpenApi3_0DereferenceVisitor); - -export default OpenApi3_0ResolveVisitor; diff --git a/packages/apidom-reference/src/types.ts b/packages/apidom-reference/src/types.ts index 2734c92e5..3313ec24e 100644 --- a/packages/apidom-reference/src/types.ts +++ b/packages/apidom-reference/src/types.ts @@ -1,4 +1,4 @@ -import { ParseResultElement, Element } from '@swagger-api/apidom-core'; +import { Element, ParseResultElement, RefElement } from '@swagger-api/apidom-core'; export interface File { uri: string; @@ -45,7 +45,7 @@ export interface ResolveStrategy { } export interface DereferenceStrategy { - canDereference(file: File): boolean; + canDereference(file: File, options: ReferenceOptions): boolean; dereference(file: File, options: ReferenceOptions): Promise; } @@ -102,6 +102,9 @@ export interface ReferenceDereferenceOptions { strategyOpts: Record; refSet: null | ReferenceSet; maxDepth: number; + circular: 'ignore' | 'replace' | 'error'; + circularReplacer: (ref: RefElement) => unknown; + immutable: boolean; } export interface ReferenceBundleOptions { diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-0/link-object/index.ts b/packages/apidom-reference/test/dereference/strategies/openapi-3-0/link-object/index.ts index 7d9586a51..72d346528 100644 --- a/packages/apidom-reference/test/dereference/strategies/openapi-3-0/link-object/index.ts +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-0/link-object/index.ts @@ -207,16 +207,12 @@ describe('dereference', function () { specify( 'should set Operation Object as metadata of Link.operationId field', async function () { - try { - const dereferenced = await dereference(rootFilePath, { - parse: { mediaType: mediaTypes.latest('json') }, - }); - const link1 = evaluate('/0/components/links/link1', dereferenced) as LinkElement; - - assert.isTrue(isOperationElement(link1.operationId?.meta.get('operation'))); - } catch (e) { - console.dir(e); - } + const dereferenced = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + const link1 = evaluate('/0/components/links/link1', dereferenced) as LinkElement; + + assert.isTrue(isOperationElement(link1.operationId?.meta.get('operation'))); }, ); }); diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-0/path-item-object/fixtures/cycle-internal/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-0/path-item-object/fixtures/cycle-internal/root.json new file mode 100644 index 000000000..79ed99ece --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-0/path-item-object/fixtures/cycle-internal/root.json @@ -0,0 +1,16 @@ +{ + "openapi": "3.0.3", + "paths": { + "/uri": { + "get": { + "callbacks": { + "myCallback": { + "{$request.query.queryUrl}": { + "$ref": "#/paths/~1uri" + } + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-0/path-item-object/index.ts b/packages/apidom-reference/test/dereference/strategies/openapi-3-0/path-item-object/index.ts index 5aba3f6e2..193b8a093 100644 --- a/packages/apidom-reference/test/dereference/strategies/openapi-3-0/path-item-object/index.ts +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-0/path-item-object/index.ts @@ -1,11 +1,13 @@ import path from 'node:path'; +import sinon from 'sinon'; import { assert } from 'chai'; -import { toValue } from '@swagger-api/apidom-core'; +import { identity } from 'ramda'; +import { isParseResultElement, isRefElement, toValue } from '@swagger-api/apidom-core'; import { mediaTypes } from '@swagger-api/apidom-ns-openapi-3-0'; import { evaluate } from '@swagger-api/apidom-json-pointer'; import { loadJsonFile } from '../../../../helpers'; -import { dereference } from '../../../../../src'; +import { dereference, resolve } from '../../../../../src'; import DereferenceError from '../../../../../src/errors/DereferenceError'; import MaximumDereferenceDepthError from '../../../../../src/errors/MaximumDereferenceDepthError'; @@ -44,6 +46,126 @@ describe('dereference', function () { }); }); + context('given PathItem Objects with internal cycles', function () { + const fixturePath = path.join(rootFixturePath, 'cycle-internal'); + + specify('should dereference', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const dereferenced = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + const pathItem = evaluate('/0/paths/~1uri/get', dereferenced); + const cyclicPathItem = evaluate( + '/0/paths/~1uri/get/callbacks/myCallback/{$request.query.queryUrl}/get', + dereferenced, + ); + + assert.strictEqual(pathItem, cyclicPathItem); + }); + + context('given circular=ignore', function () { + specify('should dereference and create cycles', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const dereferenced = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { circular: 'ignore' }, + }); + + assert.throws(() => JSON.stringify(toValue(dereferenced))); + }); + }); + + context('given circular=replace', function () { + specify('should dereference and eliminate all cycles', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const dereferenced = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { circular: 'replace' }, + }); + + assert.doesNotThrow(() => JSON.stringify(toValue(dereferenced))); + }); + }); + + context('given circular=replace and custom replacer is provided', function () { + specify('should dereference and eliminate all cycles', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const circularReplacer = sinon.spy(identity); + + await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { + circular: 'replace', + circularReplacer, + }, + }); + + assert.isTrue(circularReplacer.calledOnce); + assert.isTrue(isRefElement(circularReplacer.getCall(0).args[0])); + assert.isTrue(isRefElement(circularReplacer.getCall(0).returnValue)); + }); + }); + + context('given circular=error', function () { + specify('should dereference and throw on first detected cycle', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + + try { + await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { circular: 'error' }, + }); + assert.fail('should throw DereferenceError'); + } catch (e) { + assert.instanceOf(e, DereferenceError); + } + }); + }); + + context('given immutable=true', function () { + specify('should dereference frozen ApiDOM tree', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const refSet = await resolve(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + refSet.refs.forEach((ref) => ref.value.freeze()); + const dereferenced = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + resolve: { baseURI: rootFilePath }, + dereference: { + refSet, + immutable: true, + }, + }); + + assert.isTrue(isParseResultElement(dereferenced)); + }); + }); + + context('given immutable=false', function () { + specify('should throw', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const refSet = await resolve(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + refSet.refs.forEach((ref) => ref.value.freeze()); + try { + await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + resolve: { baseURI: rootFilePath }, + dereference: { + refSet, + immutable: false, + }, + }); + assert.fail('should throw DereferenceError'); + } catch (e) { + assert.instanceOf(e, DereferenceError); + } + }); + }); + }); + context('given $ref field pointing externally only', function () { const fixturePath = path.join(rootFixturePath, 'external-only'); @@ -123,9 +245,13 @@ describe('dereference', function () { const dereferenced = await dereference(rootFilePath, { parse: { mediaType: mediaTypes.latest('json') }, }); - const parent = evaluate('/0/paths/~1path1/get', dereferenced); + + const parent = evaluate( + '/0/paths/~1path1/get/callbacks/myCallback/{$request.query.queryUrl}', + dereferenced, + ); const cyclicParent = evaluate( - '/0/paths/~1path1/get/callbacks/myCallback/{$request.query.queryUrl}/get', + '/0/paths/~1path1/get/callbacks/myCallback/{$request.query.queryUrl}/get/callbacks/myCallback/{$request.query.queryUrl}', dereferenced, ); diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-0/reference-object/index.ts b/packages/apidom-reference/test/dereference/strategies/openapi-3-0/reference-object/index.ts index a6dea9108..403eb2d42 100644 --- a/packages/apidom-reference/test/dereference/strategies/openapi-3-0/reference-object/index.ts +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-0/reference-object/index.ts @@ -1,6 +1,8 @@ import path from 'node:path'; +import sinon from 'sinon'; import { assert } from 'chai'; -import { toValue } from '@swagger-api/apidom-core'; +import { identity } from 'ramda'; +import { toValue, isRefElement, isParseResultElement } from '@swagger-api/apidom-core'; import { isParameterElement, mediaTypes } from '@swagger-api/apidom-ns-openapi-3-0'; import { evaluate } from '@swagger-api/apidom-json-pointer'; @@ -93,11 +95,11 @@ describe('dereference', function () { parse: { mediaType: mediaTypes.latest('json') }, }); const parent = evaluate( - '/0/components/schemas/externalSchema/properties', + '/0/components/schemas/externalSchema/properties/parent', dereferenced, ); const cyclicParent = evaluate( - '/0/components/schemas/externalSchema/properties/parent/properties', + '/0/components/schemas/externalSchema/properties/parent/properties/parent', dereferenced, ); @@ -159,6 +161,108 @@ describe('dereference', function () { assert.strictEqual(parent, cyclicParent); }); + + context('given circular=ignore', function () { + specify('should dereference and create cycles', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const dereferenced = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { circular: 'ignore' }, + }); + + assert.throws(() => JSON.stringify(toValue(dereferenced))); + }); + }); + + context('given circular=replace', function () { + specify('should dereference and eliminate all cycles', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const dereferenced = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { circular: 'replace' }, + }); + + assert.doesNotThrow(() => JSON.stringify(toValue(dereferenced))); + }); + }); + + context('given circular=replace and custom replacer is provided', function () { + specify('should dereference and eliminate all cycles', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const circularReplacer = sinon.spy(identity); + + await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { + circular: 'replace', + circularReplacer, + }, + }); + + assert.isTrue(circularReplacer.calledOnce); + assert.isTrue(isRefElement(circularReplacer.getCall(0).args[0])); + assert.isTrue(isRefElement(circularReplacer.getCall(0).returnValue)); + }); + }); + + context('given circular=error', function () { + specify('should dereference and throw on first detected cycle', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + + try { + await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { circular: 'error' }, + }); + assert.fail('should throw DereferenceError'); + } catch (e) { + assert.instanceOf(e, DereferenceError); + } + }); + }); + + context('given immutable=true', function () { + specify('should dereference frozen ApiDOM tree', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const refSet = await resolve(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + refSet.refs.forEach((ref) => ref.value.freeze()); + const dereferenced = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + resolve: { baseURI: rootFilePath }, + dereference: { + refSet, + immutable: true, + }, + }); + + assert.isTrue(isParseResultElement(dereferenced)); + }); + }); + + context('given immutable=false', function () { + specify('should throw', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const refSet = await resolve(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + refSet.refs.forEach((ref) => ref.value.freeze()); + try { + await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + resolve: { baseURI: rootFilePath }, + dereference: { + refSet, + immutable: false, + }, + }); + assert.fail('should throw DereferenceError'); + } catch (e) { + assert.instanceOf(e, DereferenceError); + } + }); + }); }); context('given Reference Objects with external resolution disabled', function () {