From bbb9a25eb85f6753c7c3a1b0060013da7666fae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladim=C3=ADr=20Gorej?= Date: Thu, 14 Mar 2024 11:16:33 +0100 Subject: [PATCH] feat(reference): apply dereferencing architecture 2.0 to ApiDOM (#3930) --- packages/apidom-reference/src/ReferenceSet.ts | 4 +- .../dereference/strategies/apidom/index.ts | 59 ++++++++++++++----- .../dereference/strategies/apidom/visitor.ts | 46 +++++++++++---- .../strategies/openapi-3-0/index.ts | 20 +++---- .../src/resolve/strategies/apidom/index.ts | 42 +++++++------ .../src/resolve/strategies/apidom/visitor.ts | 7 --- .../strategies/apidom/local/index.ts | 30 +++------- .../openapi-3-0/reference-object/index.ts | 4 +- 8 files changed, 125 insertions(+), 87 deletions(-) delete mode 100644 packages/apidom-reference/src/resolve/strategies/apidom/visitor.ts diff --git a/packages/apidom-reference/src/ReferenceSet.ts b/packages/apidom-reference/src/ReferenceSet.ts index 09802c709..f8dbe7b1b 100644 --- a/packages/apidom-reference/src/ReferenceSet.ts +++ b/packages/apidom-reference/src/ReferenceSet.ts @@ -51,9 +51,9 @@ const ReferenceSet: stampit.Stamp = 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 = []; }, }, diff --git a/packages/apidom-reference/src/dereference/strategies/apidom/index.ts b/packages/apidom-reference/src/dereference/strategies/apidom/index.ts index 5b0a096ff..98607bdf7 100644 --- a/packages/apidom-reference/src/dereference/strategies/apidom/index.ts +++ b/packages/apidom-reference/src/dereference/strategies/apidom/index.ts @@ -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'; @@ -29,7 +29,7 @@ const ApiDOMDereferenceStrategy: stampit.Stamp = stampit( }, async dereference(file: IFile, options: IReferenceOptions): Promise { - let refSet = defaultTo(ReferenceSet(), options.dereference.refSet); + const refSet = options.dereference.refSet ?? ReferenceSet(); let reference; // determine the initial reference @@ -41,27 +41,54 @@ const ApiDOMDereferenceStrategy: stampit.Stamp = 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; diff --git a/packages/apidom-reference/src/dereference/strategies/apidom/visitor.ts b/packages/apidom-reference/src/dereference/strategies/apidom/visitor.ts index a3be07e21..40dd725c0 100644 --- a/packages/apidom-reference/src/dereference/strategies/apidom/visitor.ts +++ b/packages/apidom-reference/src/dereference/strategies/apidom/visitor.ts @@ -2,6 +2,7 @@ import stampit from 'stampit'; import { propEq } from 'ramda'; import { ApiDOMError } from '@swagger-api/apidom-error'; import { + Element, RefElement, isElement, isMemberElement, @@ -11,6 +12,7 @@ import { toValue, refract, visit, + cloneDeep, } from '@swagger-api/apidom-core'; import { uriToPointer as uriToElementID } from '@swagger-api/apidom-json-pointer'; @@ -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); @@ -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. @@ -163,7 +189,7 @@ const ApiDOMDereferenceVisitor = stampit({ parent[key] = referencedElement; // eslint-disable-line no-param-reassign } - return false; + return !parent ? referencedElement : false; }, }, }); 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 3e2797f37..d0a03b3b7 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 @@ -43,6 +43,7 @@ const OpenApi3_0DereferenceStrategy: stampit.Stamp = 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); @@ -72,7 +73,6 @@ const OpenApi3_0DereferenceStrategy: stampit.Stamp = stamp refSet.clean(); mutableRefs.forEach((ref) => refSet.add(ref)); immutableRefs.forEach((ref) => refSet.add(ref)); - reference = refSet.find((ref) => ref.uri === file.uri); } @@ -82,18 +82,16 @@ const OpenApi3_0DereferenceStrategy: stampit.Stamp = 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) => diff --git a/packages/apidom-reference/src/resolve/strategies/apidom/index.ts b/packages/apidom-reference/src/resolve/strategies/apidom/index.ts index 6e385e5cb..6aa24fff0 100644 --- a/packages/apidom-reference/src/resolve/strategies/apidom/index.ts +++ b/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 { @@ -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 = 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; }, diff --git a/packages/apidom-reference/src/resolve/strategies/apidom/visitor.ts b/packages/apidom-reference/src/resolve/strategies/apidom/visitor.ts deleted file mode 100644 index af0e26fae..000000000 --- a/packages/apidom-reference/src/resolve/strategies/apidom/visitor.ts +++ /dev/null @@ -1,7 +0,0 @@ -import stampit from 'stampit'; - -import ApiDOMDereferenceVisitor from '../../../dereference/strategies/apidom/visitor'; - -const ApiDOMResolveVisitor = stampit(ApiDOMDereferenceVisitor); - -export default ApiDOMResolveVisitor; diff --git a/packages/apidom-reference/test/dereference/strategies/apidom/local/index.ts b/packages/apidom-reference/test/dereference/strategies/apidom/local/index.ts index e44a7e8b3..18621fa0e 100644 --- a/packages/apidom-reference/test/dereference/strategies/apidom/local/index.ts +++ b/packages/apidom-reference/test/dereference/strategies/apidom/local/index.ts @@ -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'))); 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 403eb2d42..44c13fc64 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 @@ -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') }, @@ -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') },