Skip to content

Commit

Permalink
feat(reference): apply dereferencing architecture 2.0 to ApiDOM (#3930)
Browse files Browse the repository at this point in the history
  • Loading branch information
char0n committed Mar 14, 2024
1 parent 8078ea6 commit bbb9a25
Show file tree
Hide file tree
Showing 8 changed files with 125 additions and 87 deletions.
4 changes: 2 additions & 2 deletions packages/apidom-reference/src/ReferenceSet.ts
Expand Up @@ -51,9 +51,9 @@ const ReferenceSet: stampit.Stamp<IReferenceSet> = stampit({

clean() {
this.refs.forEach((ref: IReference) => {
// eslint-disable-next-line no-param-reassign
ref.refSet = null;
ref.refSet = null; // eslint-disable-line no-param-reassign
});
this.rootRef = null;
this.refs = [];
},
},
Expand Down
@@ -1,5 +1,5 @@
import stampit from 'stampit';
import { defaultTo, propEq } from 'ramda';
import { propEq } from 'ramda';
import { Element, isElement, cloneDeep, visit } from '@swagger-api/apidom-core';

import DereferenceStrategy from '../DereferenceStrategy';
Expand Down Expand Up @@ -29,7 +29,7 @@ const ApiDOMDereferenceStrategy: stampit.Stamp<IDereferenceStrategy> = stampit(
},

async dereference(file: IFile, options: IReferenceOptions): Promise<Element> {
let refSet = defaultTo(ReferenceSet(), options.dereference.refSet);
const refSet = options.dereference.refSet ?? ReferenceSet();
let reference;

// determine the initial reference
Expand All @@ -41,27 +41,54 @@ const ApiDOMDereferenceStrategy: stampit.Stamp<IDereferenceStrategy> = stampit(
reference = refSet.find(propEq(file.uri, 'uri'));
}

// clone reference set due the dereferencing process being mutable
if (
typeof options.dereference.strategyOpts.apidom?.clone === 'undefined' ||
options.dereference.strategyOpts.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'));
/**
* 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 = 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) {
/**
* Release all memory if this refSet was not provided as a configuration option.
* If provided as configuration option, then provider is responsible for cleanup.
*/
refSet.clean();
} else if (options.dereference.immutable) {
/**
* If immutable option is set, then we need to remove mutable refs from the refSet.
*/
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;
Expand Down
Expand Up @@ -2,6 +2,7 @@ import stampit from 'stampit';
import { propEq } from 'ramda';
import { ApiDOMError } from '@swagger-api/apidom-error';
import {
Element,
RefElement,
isElement,
isMemberElement,
Expand All @@ -11,6 +12,7 @@ import {
toValue,
refract,
visit,
cloneDeep,
} from '@swagger-api/apidom-core';
import { uriToPointer as uriToElementID } from '@swagger-api/apidom-json-pointer';

Expand Down Expand Up @@ -72,19 +74,34 @@ const ApiDOMDereferenceVisitor = 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;
},

async RefElement(refElement: RefElement, key: any, parent: any, path: any, ancestors: any[]) {
async RefElement(
refElement: RefElement,
key: string | number,
parent: Element | undefined,
path: (string | number)[],
ancestors: [Element | Element[]],
) {
const refURI = toValue(refElement);
const refNormalizedURI = refURI.includes('#') ? refURI : `#${refURI}`;
const retrievalURI = this.toBaseURI(refNormalizedURI);
Expand Down Expand Up @@ -139,13 +156,22 @@ const ApiDOMDereferenceVisitor = stampit({
/**
* Transclusion of a Ref Element SHALL be defined in the if/else block below.
*/
if (isObjectElement(referencedElement) && isObjectElement(ancestors[ancestors.length - 1])) {
if (
isObjectElement(referencedElement) &&
isObjectElement(ancestors[ancestors.length - 1]) &&
Array.isArray(parent) &&
typeof key === 'number'
) {
/**
* 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)) {
} else if (
isArrayElement(referencedElement) &&
Array.isArray(parent) &&
typeof key === 'number'
) {
/**
* 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.
Expand All @@ -163,7 +189,7 @@ const ApiDOMDereferenceVisitor = stampit({
parent[key] = referencedElement; // eslint-disable-line no-param-reassign
}

return false;
return !parent ? referencedElement : false;
},
},
});
Expand Down
Expand Up @@ -43,6 +43,7 @@ const OpenApi3_0DereferenceStrategy: stampit.Stamp<IDereferenceStrategy> = stamp
const refSet = options.dereference.refSet ?? ReferenceSet();
let reference;

// determine the initial reference
if (!refSet.has(file.uri)) {
reference = Reference({ uri: file.uri, value: file.parseResult });
refSet.add(reference);
Expand Down Expand Up @@ -72,7 +73,6 @@ const OpenApi3_0DereferenceStrategy: stampit.Stamp<IDereferenceStrategy> = stamp
refSet.clean();
mutableRefs.forEach((ref) => refSet.add(ref));
immutableRefs.forEach((ref) => refSet.add(ref));

reference = refSet.find((ref) => ref.uri === file.uri);
}

Expand All @@ -82,18 +82,16 @@ const OpenApi3_0DereferenceStrategy: stampit.Stamp<IDereferenceStrategy> = stamp
nodeTypeGetter: getNodeType,
});

/**
* 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) {
/**
* Release all memory if this refSet was not provided as a configuration option.
* If provided as configuration option, then provider is responsible for cleanup.
*/
refSet.clean();
}

/**
* If immutable option is set, then we need to remove mutable refs from the refSet.
*/
if (options.dereference.immutable) {
} else if (options.dereference.immutable) {
/**
* If immutable option is set, then we need to remove mutable refs from the refSet.
*/
const immutableRefs = refSet.refs
.filter((ref) => ref.uri.startsWith('immutable://'))
.map((ref) =>
Expand Down
42 changes: 25 additions & 17 deletions packages/apidom-reference/src/resolve/strategies/apidom/index.ts
@@ -1,5 +1,4 @@
import stampit from 'stampit';
import { isElement, visit, cloneDeep } from '@swagger-api/apidom-core';

import ResolveStrategy from '../ResolveStrategy';
import {
Expand All @@ -8,35 +7,44 @@ import {
ResolveStrategy as IResolveStrategy,
} from '../../../types';
import ReferenceSet from '../../../ReferenceSet';
import Reference from '../../../Reference';
import { merge as mergeOptions } from '../../../options/util';
import ApiDOMResolveVisitor from './visitor';

// @ts-ignore
const visitAsync = visit[Symbol.for('nodejs.util.promisify.custom')];
import UnmatchedDereferenceStrategyError from '../../../errors/UnmatchedDereferenceStrategyError';

const ApiDOMResolveStrategy: stampit.Stamp<IResolveStrategy> = stampit(ResolveStrategy, {
init() {
this.name = 'apidom';
},
methods: {
canResolve(file: IFile) {
return (
file.mediaType.startsWith('application/vnd.apidom') && isElement(file.parseResult?.result)
canResolve(file: IFile, options: IReferenceOptions): boolean {
const dereferenceStrategy = options.dereference.strategies.find(
(strategy: any) => strategy.name === 'apidom',
);

if (dereferenceStrategy === undefined) {
return false;
}

return dereferenceStrategy.canDereference(file, options);
},

async resolve(file: IFile, options: IReferenceOptions) {
const referenceValue = options.resolve.strategyOpts.apidom?.clone
? cloneDeep(file.parseResult)
: file.parseResult;
const reference = Reference({ uri: file.uri, value: referenceValue });
const mergedOptions = mergeOptions(options, { resolve: { internal: false } });
const visitor = ApiDOMResolveVisitor({ reference, options: mergedOptions });
const dereferenceStrategy = options.dereference.strategies.find(
(strategy: any) => strategy.name === 'apidom',
);

if (dereferenceStrategy === undefined) {
throw new UnmatchedDereferenceStrategyError(
'"apidom" dereference strategy is not available.',
);
}

const refSet = ReferenceSet();
refSet.add(reference);
const mergedOptions = mergeOptions(options, {
resolve: { internal: false },
dereference: { refSet },
});

await visitAsync(refSet.rootRef.value, visitor);
await dereferenceStrategy.dereference(file, mergedOptions);

return refSet;
},
Expand Down

This file was deleted.

Expand Up @@ -132,46 +132,32 @@ describe('dereference', function () {
assert.strictEqual(expected.level1, expected.level1.level2a.level3.ref2);
});

context('given clone option', function () {
specify(
'should not mutate the original element when clone options is not specified',
async function () {
const element = new ObjectElement({
element: new StringElement('test', { id: 'unique-id' }),
ref: new RefElement('unique-id'),
});
const actual = await dereferenceApiDOM(element, {
parse: { mediaType: 'application/vnd.apidom' },
dereference: { strategyOpts: { apidom: { clone: true } } },
});

assert.isTrue(isRefElement(element.get('ref')));
assert.isFalse(isRefElement(actual.get('ref')));
},
);

specify('should not mutate the original element when clone=true', async function () {
context('given immutable=true', function () {
specify('should not mutate original ApiDOM tree', async function () {
const element = new ObjectElement({
element: new StringElement('test', { id: 'unique-id' }),
ref: new RefElement('unique-id'),
});
element.freeze();
const actual = await dereferenceApiDOM(element, {
parse: { mediaType: 'application/vnd.apidom' },
dereference: { strategyOpts: { apidom: { clone: true } } },
dereference: { immutable: true },
});

assert.isTrue(isRefElement(element.get('ref')));
assert.isFalse(isRefElement(actual.get('ref')));
});
});

specify('should mutate the original element on clone=false', async function () {
context('given immutable=false', function () {
specify('should mutate original ApiDOM tree', async function () {
const element = new ObjectElement({
element: new StringElement('test', { id: 'unique-id' }),
ref: new RefElement('unique-id'),
});
const actual = await dereferenceApiDOM(element, {
parse: { mediaType: 'application/vnd.apidom' },
dereference: { strategyOpts: { apidom: { clone: false } } },
dereference: { immutable: false },
});

assert.isFalse(isRefElement(element.get('ref')));
Expand Down
Expand Up @@ -222,7 +222,7 @@ describe('dereference', function () {
});

context('given immutable=true', function () {
specify('should dereference frozen ApiDOM tree', async function () {
specify('should not mutate original ApiDOM tree', async function () {
const rootFilePath = path.join(fixturePath, 'root.json');
const refSet = await resolve(rootFilePath, {
parse: { mediaType: mediaTypes.latest('json') },
Expand All @@ -242,7 +242,7 @@ describe('dereference', function () {
});

context('given immutable=false', function () {
specify('should throw', async function () {
specify('should mutate original ApiDOM tree', async function () {
const rootFilePath = path.join(fixturePath, 'root.json');
const refSet = await resolve(rootFilePath, {
parse: { mediaType: mediaTypes.latest('json') },
Expand Down

0 comments on commit bbb9a25

Please sign in to comment.