From aaef7e6f648ad306618d3fd9e4a9021c47bd5d44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Urba=C5=84czyk?= Date: Wed, 9 Mar 2022 12:12:16 +0100 Subject: [PATCH] refactor: add stringify functionality (#486) --- src/constants.ts | 11 ++++ src/index.ts | 2 + src/models/base.ts | 4 +- src/stringify.ts | 99 ++++++++++++++++++++++++++++++++ src/utils.ts | 38 +++++++++++++ test/stringify.spec.ts | 61 ++++++++++++++++++++ test/utils.spec.ts | 125 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 src/constants.ts create mode 100644 src/stringify.ts create mode 100644 src/utils.ts create mode 100644 test/stringify.spec.ts create mode 100644 test/utils.spec.ts diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 000000000..aff8af592 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,11 @@ +export const xParserSpecParsed = 'x-parser-spec-parsed'; +export const xParserSpecStringified = 'x-parser-spec-stringified'; + +export const xParserMessageName = 'x-parser-message-name'; +export const xParserSchemaId = 'x-parser-schema-id'; + +export const xParserOriginalSchema = 'x-parser-original-schema'; +export const xParserOriginalSchemaFormat = 'x-parser-original-schema-format'; +export const xParserOriginalTraits = 'x-parser-original-traits'; + +export const xParserCircular = 'x-parser-circular'; diff --git a/src/index.ts b/src/index.ts index e9644dae4..815c4aa3a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,3 @@ export * from './models'; + +export { stringify, unstringify } from './stringify'; diff --git a/src/models/base.ts b/src/models/base.ts index fc465c747..bda573a79 100644 --- a/src/models/base.ts +++ b/src/models/base.ts @@ -3,7 +3,9 @@ export class BaseModel { private readonly _json: Record, ) {} - json(key?: string | number): any { + json>(): T; + json(key: string | number): T; + json(key?: string | number) { if (key === undefined) return this._json; if (!this._json) return; return this._json[String(key)]; diff --git a/src/stringify.ts b/src/stringify.ts new file mode 100644 index 000000000..c2679d0a9 --- /dev/null +++ b/src/stringify.ts @@ -0,0 +1,99 @@ +import { AsyncAPIDocument } from './models'; + +import { isAsyncAPIDocument, isParsedDocument, isStringifiedDocument } from './utils'; +import { xParserSpecStringified } from './constants'; + +export function stringify(document: unknown, space?: string | number): string | undefined { + if (isAsyncAPIDocument(document)) { + document = document.json(); + } else if (isParsedDocument(document)) { + if (isStringifiedDocument(document)) { + return JSON.stringify(document); + } + document = document; + } else { + return; + } + + return JSON.stringify({ + ...document as Record, + [String(xParserSpecStringified)]: true, + }, refReplacer(), space); +} + +export function unstringify(document: unknown): AsyncAPIDocument | undefined { + if (!isStringifiedDocument(document)) { + return; + } + + // shall copy of whole JSON + document = { ...document }; + // remove `x-parser-spec-stringified` extension + delete (>document)[String(xParserSpecStringified)]; + + traverseStringifiedDoc(document, undefined, document, new Map(), new Map()); + return new AsyncAPIDocument(>document); +} + +function refReplacer() { + const modelPaths = new Map(); + const paths = new Map(); + let init: unknown = null; + + return function(this: unknown, field: string, value: unknown) { + // `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; + }; +} + +const refRoot = '$ref:$'; +function traverseStringifiedDoc(parent: any, field: string | undefined, root: any, objToPath: Map, pathToObj: Map) { + let objOrPath = parent; + let path = refRoot; + + 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 === refRoot || ref === refRoot) { + 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); + } + } +} \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 000000000..26370bb48 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,38 @@ +import { AsyncAPIDocument } from './models'; +import { unstringify } from './stringify'; + +import { + xParserSpecParsed, + xParserSpecStringified, +} from './constants'; + +export function toAsyncAPIDocument(maybeDoc: unknown): AsyncAPIDocument | undefined { + if (isAsyncAPIDocument(maybeDoc)) { + return maybeDoc; + } + if (!isParsedDocument(maybeDoc)) { + return; + } + return unstringify(maybeDoc) || new AsyncAPIDocument(maybeDoc); +} + +export function isAsyncAPIDocument(maybeDoc: unknown): maybeDoc is AsyncAPIDocument { + return maybeDoc instanceof AsyncAPIDocument; +} + +export function isParsedDocument(maybeDoc: unknown): maybeDoc is Record { + if (typeof maybeDoc !== 'object' || maybeDoc === null) { + return false; + } + return Boolean((maybeDoc as Record)[xParserSpecParsed]); +} + +export function isStringifiedDocument(maybeDoc: unknown): maybeDoc is Record { + if (typeof maybeDoc !== 'object' || maybeDoc === null) { + return false; + } + return ( + Boolean((maybeDoc as Record)[xParserSpecParsed]) && + Boolean((maybeDoc as Record)[xParserSpecStringified]) + ); +} diff --git a/test/stringify.spec.ts b/test/stringify.spec.ts new file mode 100644 index 000000000..e23e9598d --- /dev/null +++ b/test/stringify.spec.ts @@ -0,0 +1,61 @@ +import { xParserSpecParsed, xParserSpecStringified } from '../src/constants'; +import { AsyncAPIDocument, BaseModel } from '../src/models'; +import { stringify, unstringify } from '../src/stringify'; + +describe('stringify & unstringify', function() { + describe('stringify()', function() { + it('should not stringify normal object', function() { + expect(stringify({})).toEqual(undefined); + }); + + it('should not stringify null object', function() { + expect(stringify(null)).toEqual(undefined); + }); + + it('should not stringify primitive', function() { + expect(stringify('AsyncAPI rocks!')).toEqual(undefined); + }); + + it('should not stringify BaseModel instance', function() { + expect(stringify(new BaseModel({}))).toEqual(undefined); + }); + + it('should stringify parsed document', function() { + expect(typeof stringify({ [xParserSpecParsed]: true })).toEqual('string'); + }); + + it('should stringify (skip) stringified document', function() { + expect(typeof stringify({ [xParserSpecParsed]: true, [xParserSpecStringified]: true })).toEqual('string'); + }); + + it('should stringify AsyncAPIDocument instance', function() { + expect(typeof stringify(new AsyncAPIDocument({ asyncapi: '2.0.0' }))).toEqual('string'); + }); + }); + + describe('unstringify()', function() { + it('should not unstringify normal object', function() { + expect(unstringify({})).toEqual(undefined); + }); + + it('should not unstringify null object', function() { + expect(unstringify(null)).toEqual(undefined); + }); + + it('should not stringify primitive', function() { + expect(unstringify('AsyncAPI rocks!')).toEqual(undefined); + }); + + it('should not stringify BaseModel instance', function() { + expect(unstringify(new BaseModel({}))).toEqual(undefined); + }); + + it('should not unstringify parsed document', function() { + expect(unstringify({ [xParserSpecParsed]: true })).toEqual(undefined); + }); + + it('should unstringify stringified document', function() { + expect(unstringify({ [xParserSpecParsed]: true, [xParserSpecStringified]: true })).not.toEqual(undefined); + }); + }); +}); diff --git a/test/utils.spec.ts b/test/utils.spec.ts new file mode 100644 index 000000000..c2077a2d8 --- /dev/null +++ b/test/utils.spec.ts @@ -0,0 +1,125 @@ +import { xParserSpecParsed, xParserSpecStringified } from '../src/constants'; +import { AsyncAPIDocument, BaseModel } from '../src/models'; +import { toAsyncAPIDocument, isAsyncAPIDocument, isParsedDocument, isStringifiedDocument } from '../src/utils'; + +describe('utils', function() { + describe('toAsyncAPIDocument()', function() { + it('normal object should not return AsyncAPIDocument instance', function() { + expect(toAsyncAPIDocument({})).toEqual(undefined); + }); + + it('null object should not return AsyncAPIDocument instance', function() { + expect(toAsyncAPIDocument(null)).toEqual(undefined); + }); + + it('primitive should not return AsyncAPIDocument instance', function() { + expect(toAsyncAPIDocument('AsyncAPI rocks!')).toEqual(undefined); + }); + + it('BaseModel instance should not return AsyncAPIDocument instance', function() { + expect(toAsyncAPIDocument(new BaseModel({}))).toEqual(undefined); + }); + + it('AsyncAPIDocument instance should return AsyncAPIDocument instance', function() { + expect(toAsyncAPIDocument(new AsyncAPIDocument({ asyncapi: '2.0.0' }))).toBeInstanceOf(AsyncAPIDocument); + }); + + it('parsed document should return AsyncAPIDocument instance', function() { + expect(toAsyncAPIDocument({ [xParserSpecParsed]: true })).toBeInstanceOf(AsyncAPIDocument); + }); + + it('stringified document should return AsyncAPIDocument instance', function() { + expect(toAsyncAPIDocument({ [xParserSpecParsed]: true, [xParserSpecStringified]: true })).toBeInstanceOf(AsyncAPIDocument); + }); + + it('stringified document (with missed parsed extension) should not return AsyncAPIDocument instance', function() { + expect(toAsyncAPIDocument({ [xParserSpecStringified]: true })).toEqual(undefined); + }); + }); + + describe('isAsyncAPIDocument()', function() { + it('normal object should not be AsyncAPI document', function() { + expect(isAsyncAPIDocument({})).toEqual(false); + }); + + it('null object should not be AsyncAPI document', function() { + expect(isAsyncAPIDocument(null)).toEqual(false); + }); + + it('primitive should not be AsyncAPI document', function() { + expect(isAsyncAPIDocument('AsyncAPI rocks!')).toEqual(false); + }); + + it('BaseModel instance should not be AsyncAPI document', function() { + expect(isAsyncAPIDocument(new BaseModel({}))).toEqual(false); + }); + + it('AsyncAPIDocument instance should be AsyncAPI document', function() { + expect(isAsyncAPIDocument(new AsyncAPIDocument({ asyncapi: '2.0.0' }))).toEqual(true); + }); + }); + + describe('isParsedDocument()', function() { + it('normal object should not be parsed document', function() { + expect(isParsedDocument({})).toEqual(false); + }); + + it('null object should not be parsed document', function() { + expect(isParsedDocument(null)).toEqual(false); + }); + + it('primitive should not be parsed document', function() { + expect(isParsedDocument('AsyncAPI rocks!')).toEqual(false); + }); + + it('BaseModel instance should not be AsyncAPI document', function() { + expect(isParsedDocument(new BaseModel({}))).toEqual(false); + }); + + it('AsyncAPIDocument instance should not be parsed document', function() { + expect(isParsedDocument(new AsyncAPIDocument({ asyncapi: '2.0.0' }))).toEqual(false); + }); + + it('AsyncAPIDocument instance with proper extension should not be parsed document', function() { + expect(isParsedDocument(new AsyncAPIDocument({ asyncapi: '2.0.0', [xParserSpecParsed]: true }))).toEqual(false); + }); + + it('object with proper extension should be parsed document', function() { + expect(isParsedDocument({ [xParserSpecParsed]: true })).toEqual(true); + }); + }); + + describe('isStringifiedDocument()', function() { + it('normal object should not be parsed document', function() { + expect(isStringifiedDocument({})).toEqual(false); + }); + + it('null object should not be parsed document', function() { + expect(isStringifiedDocument(null)).toEqual(false); + }); + + it('primitive should not be parsed document', function() { + expect(isStringifiedDocument('AsyncAPI rocks!')).toEqual(false); + }); + + it('BaseModel instance should not be AsyncAPI document', function() { + expect(isStringifiedDocument(new BaseModel({}))).toEqual(false); + }); + + it('AsyncAPIDocument instance should not be parsed document', function() { + expect(isStringifiedDocument(new AsyncAPIDocument({ asyncapi: '2.0.0' }))).toEqual(false); + }); + + it('AsyncAPIDocument instance with proper extension should not be parsed document', function() { + expect(isStringifiedDocument(new AsyncAPIDocument({ asyncapi: '2.0.0', [xParserSpecParsed]: true, [xParserSpecStringified]: true }))).toEqual(false); + }); + + it('object with only stringified extension should not be parsed document', function() { + expect(isStringifiedDocument({ [xParserSpecStringified]: true })).toEqual(false); + }); + + it('object with proper extensions should be parsed document', function() { + expect(isStringifiedDocument({ [xParserSpecParsed]: true, [xParserSpecStringified]: true })).toEqual(true); + }); + }); +});