Skip to content

Commit

Permalink
feat(reference): add dereference strategy that handles abstract RefEl…
Browse files Browse the repository at this point in the history
…ement (#3887)

Refs #3881
  • Loading branch information
char0n committed Mar 4, 2024
1 parent 342ee3f commit a2eabb6
Show file tree
Hide file tree
Showing 15 changed files with 741 additions and 355 deletions.
2 changes: 2 additions & 0 deletions packages/@types/minim.d.ts
Expand Up @@ -45,6 +45,8 @@ declare module 'minim' {
clone(): Element;

primitive(): string | undefined;

[key: string]: unknown;
}

interface Type<T> extends Element {
Expand Down
36 changes: 28 additions & 8 deletions packages/apidom-reference/README.md
Expand Up @@ -1388,6 +1388,20 @@ const dereferenced = await dereferenceApiDOM(apidom, {
Dereference strategy determines how a document is internally or externally dereferenced. Depending on document `mediaType` option,
every strategy differs significantly. `Dereference component` comes with four (4) default dereference strategies.

#### [apidom](https://github.com/swagger-api/apidom/tree/main/packages/apidom-reference/src/dereference/strategies/apidom)

Dereference strategy for dereferencing ApiDOM using [Ref Element](https://apielements.org/en/latest/element-definitions.html?highlight=referencing#ref-element).
Ref Element MAY be used to reference elements in remote documents or elements in the local document.
The ref element transcludes the contents of the element into the document in which it is referenced.

Supported media types:

```js
[
'application/vnd.apidom'
]
```

##### [asyncapi-2](https://github.com/swagger-api/apidom/tree/main/packages/apidom-reference/src/dereference/strategies/asyncapi-2)

Dereference strategy for dereferencing [AsyncApi 2.x.y](https://github.com/asyncapi/spec/blob/master/spec/asyncapi.md) definitions.
Expand Down Expand Up @@ -1487,6 +1501,7 @@ returns `true` or until entire list of strategies is exhausted (throws error).
OpenApi3_0DereferenceStrategy(),
OpenApi3_1DereferenceStrategy(),
AsyncApi2DereferenceStrategy(),
ApiDOMDereferenceStrategy(),
]
```
Most specific strategies are listed first, most generic are listed last.
Expand All @@ -1495,27 +1510,31 @@ It's possible to **change** strategies **order globally** by mutating global `de

```js
import { options } from '@swagger-api/apidom-reference';
import AsyncApi2DereferenceStrategy from '@swagger-api/apidom-reference/dereference/strategies/asyncapi-2'
import OpenApi2DereferenceStrategy from '@swagger-api/apidom-reference/dereference/strategies/openapi-2'
import OpenApi3_0DereferenceStrategy from '@swagger-api/apidom-reference/dereference/strategies/openapi-3-0'
import OpenApi3_1DereferenceStrategy from '@swagger-api/apidom-reference/dereference/strategies/openapi-3-1'
import AsyncApi2DereferenceStrategy from '@swagger-api/apidom-reference/dereference/strategies/asyncapi-2';
import OpenApi2DereferenceStrategy from '@swagger-api/apidom-reference/dereference/strategies/openapi-2';
import OpenApi3_0DereferenceStrategy from '@swagger-api/apidom-reference/dereference/strategies/openapi-3-0';
import OpenApi3_1DereferenceStrategy from '@swagger-api/apidom-reference/dereference/strategies/openapi-3-1';
import ApiDOMDereferenceStrategy from '@swagger-api/apidom-reference/dereference/strategies/apidom';

options.dereference.strategies = [
OpenApi2DereferenceStrategy(),
OpenApi3_0DereferenceStrategy(),
OpenApi3_1DereferenceStrategy(),
AsyncApi2DereferenceStrategy(),
ApiDOMDereferenceStrategy(),
];
```

To **change** the strategies **order** on ad-hoc basis:

```js
import { dereference } from '@swagger-api/apidom-reference';
import AsyncApi2DereferenceStrategy from '@swagger-api/apidom-reference/dereference/strategies/asyncapi-2'
import OpenApi2DereferenceStrategy from '@swagger-api/apidom-reference/dereference/strategies/openapi-2'
import OpenApi3_0DereferenceStrategy from '@swagger-api/apidom-reference/dereference/strategies/openapi-3-0'
import OpenApi3_1DereferenceStrategy from '@swagger-api/apidom-reference/dereference/strategies/openapi-3-1'
import AsyncApi2DereferenceStrategy from '@swagger-api/apidom-reference/dereference/strategies/asyncapi-2';
import OpenApi2DereferenceStrategy from '@swagger-api/apidom-reference/dereference/strategies/openapi-2';
import OpenApi3_0DereferenceStrategy from '@swagger-api/apidom-reference/dereference/strategies/openapi-3-0';
import OpenApi3_1DereferenceStrategy from '@swagger-api/apidom-reference/dereference/strategies/openapi-3-1';
import ApiDOMDereferenceStrategy from '@swagger-api/apidom-reference/dereference/strategies/apidom';


await dereference('/home/user/oas.json', {
parse: {
Expand All @@ -1527,6 +1546,7 @@ await dereference('/home/user/oas.json', {
OpenApi2DereferenceStrategy(),
OpenApi3_0DereferenceStrategy(),
OpenApi3_1DereferenceStrategy(),
ApiDOMDereferenceStrategy(),
]
}
});
Expand Down
10 changes: 10 additions & 0 deletions packages/apidom-reference/package.json
Expand Up @@ -155,6 +155,16 @@
"require": "./cjs/parse/parsers/yaml-1-2/index.cjs",
"types": "./types/parse/parsers/yaml-1-2/index.d.ts"
},
"./dereference/strategies/apidom": {
"import": "./es/dereference/strategies/apidom/index.mjs",
"require": "./cjs/dereference/strategies/apidom/index.cjs",
"types": "./types/dereference/strategies/apidom/index.d.ts"
},
"./dereference/strategies/apidom/selectors/element-id": {
"import": "./es/dereference/strategies/apidom/selectors/element-id.mjs",
"require": "./cjs/dereference/strategies/apidom/selectors/element-id.cjs",
"types": "./types/dereference/strategies/apidom/selectors/element-id.d.ts"
},
"./dereference/strategies/asyncapi-2": {
"import": "./es/dereference/strategies/asyncapi-2/index.mjs",
"require": "./cjs/dereference/strategies/asyncapi-2/index.cjs",
Expand Down
2 changes: 2 additions & 0 deletions packages/apidom-reference/src/configuration/saturated.ts
Expand Up @@ -19,6 +19,7 @@ import WorkflowsYaml1Parser from '../parse/parsers/workflows-yaml-1';
import JsonParser from '../parse/parsers/json';
import YamlParser from '../parse/parsers/yaml-1-2';
import BinaryParser from '../parse/parsers/binary/index-node';
import ApiDOMDereferenceStrategy from '../dereference/strategies/apidom';
import OpenApi2DereferenceStrategy from '../dereference/strategies/openapi-2';
import OpenApi3_0DereferenceStrategy from '../dereference/strategies/openapi-3-0';
import OpenApi3_1DereferenceStrategy from '../dereference/strategies/openapi-3-1';
Expand Down Expand Up @@ -61,6 +62,7 @@ options.dereference.strategies = [
OpenApi3_0DereferenceStrategy(),
OpenApi3_1DereferenceStrategy(),
AsyncApi2DereferenceStrategy(),
ApiDOMDereferenceStrategy(),
];

options.bundle.strategies = [OpenApi3_1BundleStrategy()];
Expand Down
@@ -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;
@@ -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 packages/apidom-reference/src/dereference/strategies/apidom/visitor.ts
@@ -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;
@@ -0,0 +1,5 @@
import { ApiDOMError } from '@swagger-api/apidom-error';

class EvaluationElementIdError extends ApiDOMError {}

export default EvaluationElementIdError;

0 comments on commit a2eabb6

Please sign in to comment.