Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(reference): add dereference strategy that handles abstract RefEl…
- Loading branch information
Showing
15 changed files
with
741 additions
and
355 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
73 changes: 73 additions & 0 deletions
73
packages/apidom-reference/src/dereference/strategies/apidom/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,73 @@ | ||
import stampit from 'stampit'; | ||
import { defaultTo, propEq } from 'ramda'; | ||
import { Element, isElement, cloneDeep, visit } from '@swagger-api/apidom-core'; | ||
|
||
import DereferenceStrategy from '../DereferenceStrategy'; | ||
import { | ||
DereferenceStrategy as IDereferenceStrategy, | ||
File as IFile, | ||
ReferenceOptions as IReferenceOptions, | ||
} from '../../../types'; | ||
import Reference from '../../../Reference'; | ||
import ReferenceSet from '../../../ReferenceSet'; | ||
import ApiDOMDereferenceVisitor from './visitor'; | ||
|
||
// @ts-ignore | ||
const visitAsync = visit[Symbol.for('nodejs.util.promisify.custom')]; | ||
|
||
const ApiDOMDereferenceStrategy: stampit.Stamp<IDereferenceStrategy> = stampit( | ||
DereferenceStrategy, | ||
{ | ||
init() { | ||
this.name = 'apidom'; | ||
}, | ||
methods: { | ||
canDereference(file: IFile) { | ||
return ( | ||
file.mediaType.startsWith('application/vnd.apidom') && isElement(file.parseResult?.result) | ||
); | ||
}, | ||
|
||
async dereference(file: IFile, options: IReferenceOptions): Promise<Element> { | ||
let refSet = defaultTo(ReferenceSet(), options.dereference.refSet); | ||
let reference; | ||
|
||
// determine the initial reference | ||
if (!refSet.has(file.uri)) { | ||
reference = Reference({ uri: file.uri, value: file.parseResult }); | ||
refSet.add(reference); | ||
} else { | ||
// pre-computed refSet was provided as configuration option | ||
reference = refSet.find(propEq(file.uri, 'uri')); | ||
} | ||
|
||
// clone reference set due the dereferencing process being mutable | ||
if ( | ||
typeof options.dereference.dereferenceOpts.apidom?.clone === 'undefined' || | ||
options.dereference.dereferenceOpts.apidom?.clone | ||
) { | ||
const refsCopy = [...refSet.refs].map((ref) => { | ||
return Reference({ ...ref, value: cloneDeep(ref.value) }); | ||
}); | ||
refSet = ReferenceSet({ refs: refsCopy }); | ||
reference = refSet.find(propEq(file.uri, 'uri')); | ||
} | ||
|
||
const visitor = ApiDOMDereferenceVisitor({ reference, options }); | ||
const dereferencedElement = await visitAsync(refSet.rootRef.value, visitor); | ||
|
||
/** | ||
* Release all memory if this refSet was not provided as an configuration option. | ||
* If provided as configuration option, then provider is responsible for cleanup. | ||
*/ | ||
if (options.dereference.refSet === null) { | ||
refSet.clean(); | ||
} | ||
|
||
return dereferencedElement; | ||
}, | ||
}, | ||
}, | ||
); | ||
|
||
export default ApiDOMDereferenceStrategy; |
32 changes: 32 additions & 0 deletions
32
packages/apidom-reference/src/dereference/strategies/apidom/selectors/element-id.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import { isUndefined } from 'ramda-adjunct'; | ||
import { Element, filter, toValue } from '@swagger-api/apidom-core'; | ||
|
||
import EvaluationElementIdError from '../../../../errors/EvaluationElementIdError'; | ||
|
||
const hasElementID = (element: Element): boolean => !!element.id; | ||
|
||
/** | ||
* Evaluates element ID against ApiDOM fragment. | ||
*/ | ||
export const evaluate = <T extends Element>(elementID: string, element: T): Element | undefined => { | ||
const { cache } = evaluate; | ||
// warm the cache | ||
if (!cache.has(element)) { | ||
const elementsWithID = filter(hasElementID, element); | ||
cache.set(element, Array.from(elementsWithID)); | ||
} | ||
|
||
// search for the matching element | ||
const result = cache.get(element).find((e: Element) => { | ||
return String(toValue(e.id)) === elementID; | ||
}); | ||
|
||
if (isUndefined(result)) { | ||
throw new EvaluationElementIdError(`Evaluation failed on element ID: "${elementID}"`); | ||
} | ||
|
||
return result; | ||
}; | ||
evaluate.cache = new WeakMap(); | ||
|
||
export { EvaluationElementIdError }; |
165 changes: 165 additions & 0 deletions
165
packages/apidom-reference/src/dereference/strategies/apidom/visitor.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,165 @@ | ||
import stampit from 'stampit'; | ||
import { propEq } from 'ramda'; | ||
import { ApiDOMError } from '@swagger-api/apidom-error'; | ||
import { | ||
RefElement, | ||
isElement, | ||
isMemberElement, | ||
isArrayElement, | ||
isObjectElement, | ||
isRefElement, | ||
toValue, | ||
refract, | ||
visit, | ||
} from '@swagger-api/apidom-core'; | ||
import { uriToPointer as uriToElementID } from '@swagger-api/apidom-json-pointer'; | ||
|
||
import { Reference as IReference } from '../../../types'; | ||
import MaximumResolveDepthError from '../../../errors/MaximumResolveDepthError'; | ||
import * as url from '../../../util/url'; | ||
import parse from '../../../parse'; | ||
import Reference from '../../../Reference'; | ||
import { evaluate } from './selectors/element-id'; | ||
|
||
// @ts-ignore | ||
const visitAsync = visit[Symbol.for('nodejs.util.promisify.custom')]; | ||
|
||
/** | ||
* The following rules apply: | ||
* | ||
* 1. When referencing an element in the local document, the id of the element MAY be used | ||
* 2. When referencing remote elements, an absolute URL or relative URL MAY be used | ||
* 3. When a URL fragment exists in the URL given, it references the element with the matching id in the given document. The URL fragment MAY need to be URL decoded before making a match. | ||
* 4. When a URL fragment does not exist, the URL references the root element | ||
* 5. When path is used, it references the given property of the referenced element | ||
* 6. When path is used in an element that includes the data of the pointer (such as with ref), the referenced path MAY need to be converted to a refract structure in order to be valid | ||
* | ||
* WARNING: this implementation only supports referencing elements in the local document. Points 2-4 are not supported. | ||
*/ | ||
|
||
const ApiDOMDereferenceVisitor = stampit({ | ||
props: { | ||
reference: null, | ||
options: null, | ||
}, | ||
init({ reference, options }) { | ||
this.reference = reference; | ||
this.options = options; | ||
}, | ||
methods: { | ||
toBaseURI(uri: string): string { | ||
return url.resolve(this.reference.uri, url.sanitize(url.stripHash(uri))); | ||
}, | ||
|
||
async toReference(uri: string): Promise<IReference> { | ||
// detect maximum depth of resolution | ||
if (this.reference.depth >= this.options.resolve.maxDepth) { | ||
throw new MaximumResolveDepthError( | ||
`Maximum resolution depth of ${this.options.resolve.maxDepth} has been exceeded by file "${this.reference.uri}"`, | ||
); | ||
} | ||
|
||
const baseURI = this.toBaseURI(uri); | ||
const { refSet } = this.reference; | ||
|
||
// we've already processed this Reference in past | ||
if (refSet.has(baseURI)) { | ||
return refSet.find(propEq(baseURI, 'uri')); | ||
} | ||
|
||
const parseResult = await parse(url.unsanitize(baseURI), { | ||
...this.options, | ||
parse: { ...this.options.parse, mediaType: 'text/plain' }, | ||
}); | ||
|
||
// register new Reference with ReferenceSet | ||
const reference = Reference({ | ||
uri: baseURI, | ||
value: parseResult, | ||
depth: this.reference.depth + 1, | ||
}); | ||
|
||
refSet.add(reference); | ||
|
||
return reference; | ||
}, | ||
|
||
async RefElement(refElement: RefElement, key: any, parent: any, path: any, ancestors: any[]) { | ||
const refURI = toValue(refElement); | ||
const refNormalizedURI = url.isURI(refURI) ? refURI : `#${refURI}`; | ||
const retrievalURI = this.toBaseURI(refNormalizedURI); | ||
const isExternal = url.stripHash(this.reference.uri) !== retrievalURI; | ||
|
||
// ignore resolving external Ref Objects | ||
if (!this.options.resolve.external && isExternal) { | ||
// skip traversing this ref element | ||
return false; | ||
} | ||
|
||
const reference = await this.toReference(refNormalizedURI); | ||
const refBaseURI = url.resolve(retrievalURI, refNormalizedURI); | ||
const elementID = uriToElementID(refBaseURI); | ||
let referencedElement: unknown | Element | undefined = evaluate( | ||
elementID, | ||
reference.value.result, | ||
); | ||
|
||
if (!isElement(referencedElement)) { | ||
throw new ApiDOMError(`Referenced element with id="${elementID}" was not found`); | ||
} | ||
|
||
if (refElement === referencedElement) { | ||
throw new ApiDOMError('RefElement cannot reference itself'); | ||
} | ||
|
||
if (isRefElement(referencedElement)) { | ||
throw new ApiDOMError('RefElement cannot reference another RefElement'); | ||
} | ||
|
||
if (isExternal) { | ||
// dive deep into the fragment | ||
const visitor = ApiDOMDereferenceVisitor({ reference, options: this.options }); | ||
referencedElement = await visitAsync(referencedElement, visitor); | ||
} | ||
|
||
/** | ||
* When path is used, it references the given property of the referenced element | ||
*/ | ||
const referencedElementPath: string = toValue(refElement.path); | ||
if (referencedElementPath !== 'element' && isElement(referencedElement)) { | ||
referencedElement = refract(referencedElement[referencedElementPath]); | ||
} | ||
|
||
/** | ||
* Transclusion of a Ref Element SHALL be defined in the if/else block below. | ||
*/ | ||
if (isObjectElement(referencedElement) && isObjectElement(ancestors[ancestors.length - 1])) { | ||
/** | ||
* If the Ref Element is held by an Object Element and references an Object Element, | ||
* its content entries SHALL be inserted in place of the Ref Element. | ||
*/ | ||
parent.splice(key, 1, ...referencedElement.content); | ||
} else if (isArrayElement(referencedElement) && Array.isArray(parent)) { | ||
/** | ||
* If the Ref Element is held by an Array Element and references an Array Element, | ||
* its content entries SHALL be inserted in place of the Ref Element. | ||
*/ | ||
parent.splice(key, 1, ...referencedElement.content); | ||
} else if (isMemberElement(parent)) { | ||
/** | ||
* The Ref Element is substituted by the Element it references. | ||
*/ | ||
parent.value = referencedElement; // eslint-disable-line no-param-reassign | ||
} else if (Array.isArray(parent)) { | ||
/** | ||
* The Ref Element is substituted by the Element it references. | ||
*/ | ||
parent[key] = referencedElement; // eslint-disable-line no-param-reassign | ||
} | ||
|
||
return false; | ||
}, | ||
}, | ||
}); | ||
|
||
export default ApiDOMDereferenceVisitor; |
5 changes: 5 additions & 0 deletions
5
packages/apidom-reference/src/errors/EvaluationElementIdError.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import { ApiDOMError } from '@swagger-api/apidom-error'; | ||
|
||
class EvaluationElementIdError extends ApiDOMError {} | ||
|
||
export default EvaluationElementIdError; |
Oops, something went wrong.