Skip to content

Commit

Permalink
feat: add stringify function (asyncapi#402)
Browse files Browse the repository at this point in the history
  • Loading branch information
magicmatatjahu authored Dec 1, 2021
1 parent bd1423e commit dcee649
Show file tree
Hide file tree
Showing 5 changed files with 250 additions and 2 deletions.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,18 @@ Parser dereferences all circular references by default. In addition, to simplify
- `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.
- `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.

## Stringify

Converting a parsed document to a string may be necessary when saving the parsed document to a database, or similar situations where you need to parse the document just once and then reuse it.

For that, the Parser supports the ability to stringify a parsed AsyncAPI document through the static `AsyncAPIDocument.stringify(...parsedDoc)` method. This method differs from the native `JSON.stringify(...json)` implementation, in that every reference that occurs (at least twice throughout the document) is converted into a [JSON Pointer](https://datatracker.ietf.org/doc/html/rfc6901) path.

To parse a stringified document into an AsyncAPIDocument instance, you must use the static `AsyncAPIDocument.parse(...stringifiedDoc)` method. It isn't compatible with the native `JSON.parse()` method.

A few advantages of this solution:
- The string remains as small as possible due to the use of [JSON Pointers](https://datatracker.ietf.org/doc/html/rfc6901).
- All circular references are preserved.

## Develop

1. Write code and tests.
Expand Down
2 changes: 2 additions & 0 deletions lib/constants.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
const xParserSpecParsed = 'x-parser-spec-parsed';
const xParserSpecStringified = 'x-parser-spec-stringified';
const xParserMessageName = 'x-parser-message-name';
const xParserSchemaId = 'x-parser-schema-id';
const xParserCircle = 'x-parser-circular';
const xParserCircleProps = 'x-parser-circular-props';

module.exports = {
xParserSpecParsed,
xParserSpecStringified,
xParserMessageName,
xParserSchemaId,
xParserCircle,
Expand Down
2 changes: 1 addition & 1 deletion lib/iterators.js
Original file line number Diff line number Diff line change
Expand Up @@ -322,4 +322,4 @@ module.exports = {
SchemaIteratorCallbackType,
SchemaTypesToIterate,
traverseAsyncApiDocument,
};
};
128 changes: 127 additions & 1 deletion lib/models/asyncapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const Components = require('./components');
const MixinExternalDocs = require('../mixins/external-docs');
const MixinTags = require('../mixins/tags');
const MixinSpecificationExtensions = require('../mixins/specification-extensions');
const {xParserSpecParsed, xParserCircle} = require('../constants');
const {xParserSpecParsed, xParserSpecStringified, xParserCircle} = require('../constants');
const {assignNameToAnonymousMessages, assignNameToComponentMessages, assignUidToComponentSchemas, assignUidToParameterSchemas, assignIdToAnonymousSchemas, assignUidToComponentParameterSchemas} = require('../anonymousNaming');
const {traverseAsyncApiDocument} = require('../iterators');

Expand Down Expand Up @@ -234,6 +234,132 @@ class AsyncAPIDocument extends Base {
traverseSchemas(callback, schemaTypesToIterate) {
traverseAsyncApiDocument(this, callback, schemaTypesToIterate);
}

/**
* Converts a valid AsyncAPI document to a JavaScript Object Notation (JSON) string.
* A stringified AsyncAPI document using this function should be parsed via the AsyncAPIDocument.parse() function - the JSON.parse() function is not compatible.
*
* @param {AsyncAPIDocument} doc A valid AsyncAPIDocument instance.
* @param {(number | string)=} space Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.
* @returns {string}
*/
static stringify(doc, space) {
const rawDoc = doc.json();
const copiedDoc = { ...rawDoc };
copiedDoc[String(xParserSpecStringified)] = true;
return JSON.stringify(copiedDoc, refReplacer(), space);
}

/**
* Converts a valid stringified AsyncAPIDocument instance into an AsyncAPIDocument instance.
*
* @param {string} doc A valid stringified AsyncAPIDocument instance.
* @returns {AsyncAPIDocument}
*/
static parse(doc) {
let parsedJSON = doc;
if (typeof doc === 'string') {
parsedJSON = JSON.parse(doc);
} else if (typeof doc === 'object') {
// shall copy
parsedJSON = { ...parsedJSON };
}

// the `doc` must be an AsyncAPI parsed document
if (typeof parsedJSON !== 'object' || !parsedJSON[String(xParserSpecParsed)]) {
throw new Error('Cannot parse invalid AsyncAPI document');
}
// if the `doc` is not stringified via the `stringify` static method then immediately return a model.
if (!parsedJSON[String(xParserSpecStringified)]) {
return new AsyncAPIDocument(parsedJSON);
}
// remove `x-parser-spec-stringified` extension
delete parsedJSON[String(xParserSpecStringified)];

const objToPath = new Map();
const pathToObj = new Map();
traverseStringifiedDoc(parsedJSON, undefined, parsedJSON, objToPath, pathToObj);

return new AsyncAPIDocument(parsedJSON);
}
}

/**
* Replacer function (that transforms the result) for AsyncAPI.stringify() function.
* Handles circular references by replacing it by JSONPath notation.
*
* @private
*/
function refReplacer() {
const modelPaths = new Map();
const paths = new Map();
let init = null;

return function(field, value) {
// `this` points to parent object of given value - some object or array
const pathPart = modelPaths.get(this) + (Array.isArray(this) ? `[${field}]` : `.${ field}`);

// check if `objOrPath` has "reference"
const isComplex = value === Object(value);
if (isComplex) {
modelPaths.set(value, pathPart);
}

const savedPath = paths.get(value) || '';
if (!savedPath && isComplex) {
const valuePath = pathPart.replace(/undefined\.\.?/,'');
paths.set(value, valuePath);
}

const prefixPath = savedPath[0] === '[' ? '$' : '$.';
let val = savedPath ? `$ref:${prefixPath}${savedPath}` : value;
if (init === null) {
init = value;
} else if (val === init) {
val = '$ref:$';
}
return val;
};
}

/**
* Traverses stringified AsyncAPIDocument and replaces all JSON Pointer instance with real object reference.
*
* @private
* @param {Object} parent object
* @param {string} field of parent object
* @param {Object} root reference to the original object
* @param {Map} objToPath
* @param {Map} pathToObj
*/
function traverseStringifiedDoc(parent, field, root, objToPath, pathToObj) {
let objOrPath = parent;
let path = '$ref:$';

if (field !== undefined) {
// here can be string with `$ref` prefix or normal value
objOrPath = parent[String(field)];
const concatenatedPath = field ? `.${field}` : '';
path = objToPath.get(parent) + (Array.isArray(parent) ? `[${field}]` : concatenatedPath);
}

objToPath.set(objOrPath, path);
pathToObj.set(path, objOrPath);

const ref = pathToObj.get(objOrPath);
if (ref) {
parent[String(field)] = ref;
}
if (objOrPath === '$ref:$' || ref === '$ref:$') { // NOSONAR
parent[String(field)] = root;
}

// traverse all keys, only if object is array/object
if (objOrPath === Object(objOrPath)) {
for (const f in objOrPath) {
traverseStringifiedDoc(objOrPath, f, root, objToPath, pathToObj);
}
}
}

module.exports = mix(AsyncAPIDocument, MixinTags, MixinExternalDocs, MixinSpecificationExtensions);
Loading

0 comments on commit dcee649

Please sign in to comment.