Skip to content

Commit

Permalink
feat: iterate through all the schemas and handle circular references (a…
Browse files Browse the repository at this point in the history
  • Loading branch information
magicmatatjahu authored Nov 23, 2021
1 parent dbb0c55 commit 3834186
Show file tree
Hide file tree
Showing 12 changed files with 948 additions and 235 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,14 +209,14 @@ The parser uses custom extensions to define additional information about the spe
- `x-parser-original-schema-format` holds information about the original schema format of the payload. You can use different schema formats with the AsyncAPI documents and the parser converts them to AsyncAPI schema. This is why different schema format is set, and the original one is preserved in the extension.
- `x-parser-original-payload` holds the original payload of the message. You can use different formats for payloads with the AsyncAPI documents and the parser converts them to. For example, it converts payload described with Avro schema to AsyncAPI schema. The original payload is preserved in the extension.
- [`x-parser-circular`](#circular-references)
- [`x-parser-circular-props`](#circular-references)
> **NOTE**: All extensions added by the parser (including all properties) should be retrieved using special functions. Names of extensions and their location may change, and their eventual changes will not be announced.
## Circular references
Parser dereferences all circular references by default. In addition, to simplify interactions with the parser, the following is added:
- `x-parser-circular` property is added to the root of the AsyncAPI document to indicate that the document contains circular references. Tooling developer that doesn't want to support circular references can use the `hasCircular()` function to check the document and provide a proper message to the user.
- `x-parser-circular` property is added to every schema of array type that is circular. To check if schema is circular or not, you should use `isCircular()` function on a Schema model like `document.components().schema('RecursiveSelf').properties()['selfChildren'].isCircular()`.
- `x-parser-circular-props` property is added to every schema of object type with a list of properties that are circular. To check if a schema has properties with circular references, you should use `hasCircularProps()` function. To get a list of properties with circular references, you should use `circularProps()` function.
- `isCircular()` function is added to the [Schema Model](./lib/models/schema.js) to determine if a given schema is circular with respect to previously occurring schemas in the tree.

## Develop

Expand Down
321 changes: 206 additions & 115 deletions lib/iterators.js

Large diffs are not rendered by default.

48 changes: 2 additions & 46 deletions lib/models/asyncapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ const Components = require('./components');
const MixinExternalDocs = require('../mixins/external-docs');
const MixinTags = require('../mixins/tags');
const MixinSpecificationExtensions = require('../mixins/specification-extensions');
const {xParserSpecParsed, xParserCircle, xParserCircleProps} = require('../constants');
const {xParserSpecParsed, xParserCircle} = require('../constants');
const {assignNameToAnonymousMessages, assignNameToComponentMessages, assignUidToComponentSchemas, assignUidToParameterSchemas, assignIdToAnonymousSchemas, assignUidToComponentParameterSchemas} = require('../anonymousNaming');
const {traverseAsyncApiDocument, SchemaIteratorCallbackType} = require('../iterators');
const {traverseAsyncApiDocument} = require('../iterators');

/**
* Implements functions to deal with the AsyncAPI document.
Expand All @@ -36,7 +36,6 @@ class AsyncAPIDocument extends Base {
assignNameToComponentMessages(this);
assignNameToAnonymousMessages(this);

markCircularSchemas(this);
assignUidToComponentSchemas(this);
assignUidToComponentParameterSchemas(this);
assignUidToParameterSchemas(this);
Expand Down Expand Up @@ -237,47 +236,4 @@ class AsyncAPIDocument extends Base {
}
}

/**
* Marks all recursive schemas as recursive.
*
* @private
* @param {AsyncAPIDocument} doc
*/
function markCircularSchemas(doc) {
const seenObj = [];
const lastSchema = [];

//Mark the schema as recursive
const markCircular = (schema, prop) => {
if (schema.type() === 'array') return schema.json()[String(xParserCircle)] = true;
const circPropsList = schema.json()[String(xParserCircleProps)] || [];
if (prop !== undefined) {
circPropsList.push(prop);
}
schema.json()[String(xParserCircleProps)] = circPropsList;
};

//callback to use for iterating through the schemas
const circularCheckCallback = (schema, propName, type) => {
switch (type) {
case SchemaIteratorCallbackType.END_SCHEMA:
lastSchema.pop();
seenObj.pop();
break;
case SchemaIteratorCallbackType.NEW_SCHEMA:
const schemaJson = schema.json();
if (seenObj.includes(schemaJson)) {
const schemaToUse = lastSchema.length > 0 ? lastSchema[lastSchema.length - 1] : schema;
markCircular(schemaToUse, propName);
return false;
}
//Save a list of seen objects and last schema which should be marked if its recursive
seenObj.push(schemaJson);
lastSchema.push(schema);
return true;
}
};
traverseAsyncApiDocument(doc, circularCheckCallback);
}

module.exports = mix(AsyncAPIDocument, MixinTags, MixinExternalDocs, MixinSpecificationExtensions);
2 changes: 1 addition & 1 deletion lib/models/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const ParserError = require('../errors/parser-error');
* @returns {Base}
*/
class Base {
constructor (json) {
constructor(json) {
if (json === undefined || json === null) throw new ParserError(`Invalid JSON to instantiate the ${this.constructor.name} object.`);
this._json = json;
}
Expand Down
88 changes: 67 additions & 21 deletions lib/models/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const { createMapOfType, getMapValueOfType, mix } = require('./utils');

const Base = require('./base');

const {xParserCircle, xParserCircleProps} = require('../constants');
const MixinDescription = require('../mixins/description');
const MixinExternalDocs = require('../mixins/external-docs');
const MixinSpecificationExtensions = require('../mixins/specification-extensions');
Expand All @@ -17,6 +18,14 @@ const MixinSpecificationExtensions = require('../mixins/specification-extensions
* @returns {Schema}
*/
class Schema extends Base {
/**
* @constructor
*/
constructor(json, options) {
super(json);
this.options = options || {};
}

/**
* @returns {string}
*/
Expand Down Expand Up @@ -148,31 +157,31 @@ class Schema extends Base {
*/
allOf() {
if (!this._json.allOf) return null;
return this._json.allOf.map(s => new Schema(s));
return this._json.allOf.map(s => new Schema(s, { parent: this }));
}

/**
* @returns {Schema[]}
*/
oneOf() {
if (!this._json.oneOf) return null;
return this._json.oneOf.map(s => new Schema(s));
return this._json.oneOf.map(s => new Schema(s, { parent: this }));
}

/**
* @returns {Schema[]}
*/
anyOf() {
if (!this._json.anyOf) return null;
return this._json.anyOf.map(s => new Schema(s));
return this._json.anyOf.map(s => new Schema(s, { parent: this }));
}

/**
* @returns {Schema}
*/
not() {
if (!this._json.not) return null;
return new Schema(this._json.not);
return new Schema(this._json.not, { parent: this });
}

/**
Expand All @@ -181,24 +190,24 @@ class Schema extends Base {
items() {
if (!this._json.items) return null;
if (Array.isArray(this._json.items)) {
return this._json.items.map(s => new Schema(s));
return this._json.items.map(s => new Schema(s, { parent: this }));
}
return new Schema(this._json.items);
return new Schema(this._json.items, { parent: this });
}

/**
* @returns {Object<string, Schema>}
*/
properties() {
return createMapOfType(this._json.properties, Schema);
return createMapOfType(this._json.properties, Schema, { parent: this });
}

/**
* @param {string} name - Name of the property.
* @returns {Schema}
*/
property(name) {
return getMapValueOfType(this._json.properties, name, Schema);
return getMapValueOfType(this._json.properties, name, Schema, { parent: this });
}

/**
Expand All @@ -208,7 +217,7 @@ class Schema extends Base {
const ap = this._json.additionalProperties;
if (ap === undefined || ap === null) return;
if (typeof ap === 'boolean') return ap;
return new Schema(ap);
return new Schema(ap, { parent: this });
}

/**
Expand All @@ -217,14 +226,14 @@ class Schema extends Base {
additionalItems() {
const ai = this._json.additionalItems;
if (ai === undefined || ai === null) return;
return new Schema(ai);
return new Schema(ai, { parent: this });
}

/**
* @returns {Object<string, Schema>}
*/
patternProperties() {
return createMapOfType(this._json.patternProperties, Schema);
return createMapOfType(this._json.patternProperties, Schema, { parent: this });
}

/**
Expand All @@ -239,7 +248,7 @@ class Schema extends Base {
*/
contains() {
if (!this._json.contains) return null;
return new Schema(this._json.contains);
return new Schema(this._json.contains, { parent: this });
}

/**
Expand All @@ -249,7 +258,7 @@ class Schema extends Base {
if (!this._json.dependencies) return null;
const result = {};
Object.entries(this._json.dependencies).forEach(([key, value]) => {
result[String(key)] = !Array.isArray(value) ? new Schema(value) : value;
result[String(key)] = !Array.isArray(value) ? new Schema(value, { parent: this }) : value;
});
return result;
}
Expand All @@ -259,31 +268,31 @@ class Schema extends Base {
*/
propertyNames() {
if (!this._json.propertyNames) return null;
return new Schema(this._json.propertyNames);
return new Schema(this._json.propertyNames, { parent: this });
}

/**
* @returns {Schema}
*/
if() {
if (!this._json.if) return null;
return new Schema(this._json.if);
return new Schema(this._json.if, { parent: this });
}

/**
* @returns {Schema}
*/
then() {
if (!this._json.then) return null;
return new Schema(this._json.then);
return new Schema(this._json.then, { parent: this });
}

/**
* @returns {Schema}
*/
else() {
if (!this._json.else) return null;
return new Schema(this._json.else);
return new Schema(this._json.else, { parent: this });
}

/**
Expand Down Expand Up @@ -311,7 +320,7 @@ class Schema extends Base {
* @returns {Object<string, Schema>}
*/
definitions() {
return createMapOfType(this._json.definitions, Schema);
return createMapOfType(this._json.definitions, Schema, { parent: this });
}

/**
Expand Down Expand Up @@ -373,21 +382,58 @@ class Schema extends Base {
* @returns {boolean}
*/
isCircular() {
return !!this.ext('x-parser-circular');
if (!!this.ext(xParserCircle)) {
return true;
}

let parent = this.options.parent;
while (parent) {
if (parent._json === this._json) return true;
parent = parent.options && parent.options.parent;
}
return false;
}

/**
* @returns {Schema}
*/
circularSchema() {
let parent = this.options.parent;
while (parent) {
if (parent._json === this._json) return parent;
parent = parent.options && parent.options.parent;
}
}

/**
* @deprecated
* @returns {boolean}
*/
hasCircularProps() {
return !!this.ext('x-parser-circular-props');
if (Array.isArray(this.ext(xParserCircleProps))) {
return this.ext(xParserCircleProps).length > 0;
}
return Object.entries(this.properties() || {})
.map(([propertyName, property]) => {
if (property.isCircular()) return propertyName;
})
.filter(Boolean)
.length > 0;
}

/**
* @deprecated
* @returns {string[]}
*/
circularProps() {
return this.ext('x-parser-circular-props');
if (Array.isArray(this.ext(xParserCircleProps))) {
return this.ext(xParserCircleProps);
}
return Object.entries(this.properties() || {})
.map(([propertyName, property]) => {
if (property.isCircular()) return propertyName;
})
.filter(Boolean);
}
}

Expand Down
14 changes: 8 additions & 6 deletions lib/models/utils.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
const utils = module.exports;

const getMapValue = (obj, key, Type) => {
const getMapValue = (obj, key, Type, options) => {
if (typeof key !== 'string' || !obj) return null;
const v = obj[String(key)];
if (v === undefined) return null;
return Type ? new Type(v) : v;
return Type ? new Type(v, options) : v;
};

/**
* Creates map of given type from object.
* @private
* @param {Object} obj
* @param {Any} Type
* @param {Object} options
*/
utils.createMapOfType = (obj, Type) => {
utils.createMapOfType = (obj, Type, options) => {
const result = {};
if (!obj) return result;

Object.entries(obj).forEach(([key, value]) => {
result[String(key)] = new Type(value);
result[String(key)] = new Type(value, options);
});

return result;
Expand All @@ -30,9 +31,10 @@ utils.createMapOfType = (obj, Type) => {
* @param {Object} obj
* @param {string} key
* @param {Any} Type
* @param {Object} options
*/
utils.getMapValueOfType = (obj, key, Type) => {
return getMapValue(obj, key, Type);
utils.getMapValueOfType = (obj, key, Type, options) => {
return getMapValue(obj, key, Type, options);
};

/**
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"prepublishOnly": "npm run bundle && npm run docs && npm run types",
"release": "semantic-release",
"lint": "eslint --max-warnings 0 --config \".eslintrc\" \".\"",
"lint:fix": "eslint --max-warnings 0 --config \".eslintrc\" \".\" --fix",
"test:lib": "nyc --silent --no-clean mocha --exclude \"test/browser_test.js\" --exclude \"test/parseFromUrl_test.js\" --recursive",
"test:parseFromUrl": "nyc --silent --no-clean start-server-and-test \"http-server test/sample_browser --cors -s\" 8080 \"mocha test/parseFromUrl_test.js\"",
"cover:report": "nyc report --reporter=text --reporter=html",
Expand Down
Loading

0 comments on commit 3834186

Please sign in to comment.