Skip to content

Commit

Permalink
feat(asyncapi-2-0): add support for Schema cycles
Browse files Browse the repository at this point in the history
Using Referece Object for creating cycles in Schema
Object now works.

Refs #427
  • Loading branch information
char0n committed Jul 11, 2021
1 parent b4deaba commit d7d094d
Show file tree
Hide file tree
Showing 5 changed files with 114 additions and 25 deletions.
1 change: 1 addition & 0 deletions apidom/packages/apidom-ns-asyncapi-2-0/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export {
isReferenceElement,
isReferenceElementExternal,
isSchemaElement,
isBooleanJsonSchemaElement,
isSecurityRequirementElement,
isServerElement,
isServerBindingsElement,
Expand Down
6 changes: 5 additions & 1 deletion apidom/packages/apidom-ns-asyncapi-2-0/src/predicates.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { allPass, either, is, startsWith } from 'ramda';
import { isNonEmptyString } from 'ramda-adjunct';
import { createPredicate } from 'apidom';
import { createPredicate, isBooleanElement } from 'apidom';

import AsyncApi2_0Element from './elements/AsyncApi2-0';
import AsyncApiVersionElement from './elements/AsyncApiVersion';
Expand Down Expand Up @@ -214,6 +214,10 @@ export const isSchemaElement = createPredicate(
},
);

export const isBooleanJsonSchemaElement = (element: any) => {
return isBooleanElement(element) && element.classes.includes('boolean-json-schema');
};

export const isSecurityRequirementElement = createPredicate(
({ hasBasicElementProps, isElementType, primitiveEq }) => {
const isElementTypeSecurityRequirement = isElementType('securityRequirement');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
isReferenceLikeElement,
keyMap,
ReferenceElement,
SchemaElement,
isReferenceElementExternal,
} from 'apidom-ns-asyncapi-2-0';

Expand All @@ -22,12 +23,20 @@ const visitAsync = visit[Symbol.for('nodejs.util.promisify.custom')];
const AsyncApi2_0DereferenceVisitor = stampit({
props: {
indirections: [],
visited: null,
namespace: null,
reference: null,
options: null,
},
init({ reference, namespace, indirections = [], options }) {
init({
indirections = [],
visited = { SchemaElement: new WeakSet(), ReferenceElement: new WeakSet() },
reference,
namespace,
options,
}) {
this.indirections = indirections;
this.visited = visited;
this.namespace = namespace;
this.reference = reference;
this.options = options;
Expand All @@ -53,7 +62,10 @@ const AsyncApi2_0DereferenceVisitor = stampit({
return refSet.find(propEq('uri', baseURI));
}

const parseResult = await parse(baseURI, this.options);
const parseResult = await parse(baseURI, {
...this.options,
parse: { ...this.options.parse, mediaType: 'text/plain' },
});

// register new Reference with ReferenceSet
const reference = Reference({
Expand All @@ -67,39 +79,53 @@ const AsyncApi2_0DereferenceVisitor = stampit({
return reference;
},

async ReferenceElement(referenceElement: ReferenceElement) {
async ReferenceElement(referencingElement: ReferenceElement) {
/**
* Skip traversal for already visited ReferenceElement.
* visit function detects cycles in path automatically.
*/
if (this.visited.ReferenceElement.has(referencingElement)) {
return undefined;
}

// ignore resolving external Reference Objects
if (!this.options.resolve.external && isReferenceElementExternal(referenceElement)) {
return false;
if (!this.options.resolve.external && isReferenceElementExternal(referencingElement)) {
// mark current referencing schema as visited
this.visited.ReferenceElement.add(referencingElement);
// skip traversing this schema but traverse all it's child schemas
return undefined;
}

// @ts-ignore
const reference = await this.toReference(referenceElement.$ref.toValue());
const reference = await this.toReference(referencingElement.$ref.toValue());

this.indirections.push(referenceElement);
this.indirections.push(referencingElement);

const jsonPointer = uriToPointer(referenceElement.$ref.toValue());
const jsonPointer = uriToPointer(referencingElement.$ref.toValue());

// possibly non-semantic fragment
let fragment = evaluate(jsonPointer, reference.value.result);
let referencedElement = evaluate(jsonPointer, reference.value.result);

// applying semantics to a fragment
if (isPrimitiveElement(fragment)) {
const referencedElementType = referenceElement.meta.get('referenced-element').toValue();
if (isPrimitiveElement(referencedElement)) {
const referencedElementType = referencingElement.meta.get('referenced-element').toValue();

if (isReferenceLikeElement(fragment)) {
if (isReferenceLikeElement(referencedElement)) {
// handling indirect references
fragment = ReferenceElement.refract(fragment);
fragment.setMetaProperty('referenced-element', referencedElementType);
referencedElement = ReferenceElement.refract(referencedElement);
referencedElement.setMetaProperty('referenced-element', referencedElementType);
} else {
// handling direct references
const ElementClass = this.namespace.getElementClass(referencedElementType);
fragment = ElementClass.refract(fragment);
referencedElement = ElementClass.refract(referencedElement);
}
}

// mark current ReferenceElement as visited
this.visited.ReferenceElement.add(referencingElement);

// detect direct or circular reference
if (this.indirections.includes(fragment)) {
if (this.indirections.includes(referencedElement)) {
throw new Error('Recursive JSON Pointer detected');
}

Expand All @@ -116,19 +142,44 @@ const AsyncApi2_0DereferenceVisitor = stampit({
namespace: this.namespace,
indirections: [...this.indirections],
options: this.options,
// ReferenceElement must be reset for deep dive, as we want to dereference all indirections
visited: { SchemaElement: this.visited.SchemaElement, ReferenceElement: new WeakSet() },
});
fragment = await visitAsync(fragment, visitor, { keyMap, nodeTypeGetter: getNodeType });

// annotate fragment with info about original Reference element
fragment = fragment.clone();
fragment.setMetaProperty('ref-fields', {
$ref: referenceElement.$ref.toValue(),
referencedElement = await visitAsync(referencedElement, visitor, {
keyMap,
nodeTypeGetter: getNodeType,
});

this.indirections.pop();

// transclude the element for a fragment
return fragment;
// @ts-ignore
referencedElement = new referencedElement.constructor( // shallow clone of the referenced element
referencedElement.content,
referencedElement.meta.clone(),
referencedElement.attributes.clone(),
);

// annotate referenced element with info about original referencing element
referencedElement.setMetaProperty('ref-fields', {
$ref: referencingElement.$ref.toValue(),
});

// transclude referencing element with merged referenced element
return referencedElement;
},

async SchemaElement(schemaElement: SchemaElement) {
/**
* Skip traversal for already visited schemas and all their child schemas.
* visit function detects cycles in path automatically.
*/
if (this.visited.SchemaElement.has(schemaElement)) {
return false;
}

this.visited.SchemaElement.add(schemaElement);

return undefined;
},
},
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"asyncapi": "2.0.0",
"components": {
"schemas": {
"User": {
"type": "object",
"properties": {
"parent": {
"$ref": "#/components/schemas/User"
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,24 @@ describe('dereference', function () {
});
});

context('given Reference 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: 'application/vnd.aai.asyncapi+json;version=2.0.0' },
});
const parent = evaluate('/0/components/schemas/User/properties/parent', dereferenced);
const cyclicParent = evaluate(
'/0/components/schemas/User/properties/parent/properties/parent',
dereferenced,
);

assert.strictEqual(parent, cyclicParent);
});
});

context('given Reference Objects with external resolution disabled', function () {
const fixturePath = path.join(rootFixturePath, 'ignore-external');

Expand Down

0 comments on commit d7d094d

Please sign in to comment.