Skip to content

Commit

Permalink
refactor: add stringify functionality (#486)
Browse files Browse the repository at this point in the history
  • Loading branch information
magicmatatjahu authored and derberg committed Oct 4, 2022
1 parent 33fad58 commit aaef7e6
Show file tree
Hide file tree
Showing 7 changed files with 339 additions and 1 deletion.
11 changes: 11 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -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';
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from './models';

export { stringify, unstringify } from './stringify';
4 changes: 3 additions & 1 deletion src/models/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ export class BaseModel {
private readonly _json: Record<string, any>,
) {}

json(key?: string | number): any {
json<T = Record<string, unknown>>(): T;
json<T = unknown>(key: string | number): T;
json(key?: string | number) {
if (key === undefined) return this._json;
if (!this._json) return;
return this._json[String(key)];
Expand Down
99 changes: 99 additions & 0 deletions src/stringify.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>,
[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 (<Record<string, any>>document)[String(xParserSpecStringified)];

traverseStringifiedDoc(document, undefined, document, new Map(), new Map());
return new AsyncAPIDocument(<Record<string, any>>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<unknown, unknown>, pathToObj: Map<unknown, unknown>) {
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);
}
}
}
38 changes: 38 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
if (typeof maybeDoc !== 'object' || maybeDoc === null) {
return false;
}
return Boolean((maybeDoc as Record<string, unknown>)[xParserSpecParsed]);
}

export function isStringifiedDocument(maybeDoc: unknown): maybeDoc is Record<string, unknown> {
if (typeof maybeDoc !== 'object' || maybeDoc === null) {
return false;
}
return (
Boolean((maybeDoc as Record<string, unknown>)[xParserSpecParsed]) &&
Boolean((maybeDoc as Record<string, unknown>)[xParserSpecStringified])
);
}
61 changes: 61 additions & 0 deletions test/stringify.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
125 changes: 125 additions & 0 deletions test/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});

0 comments on commit aaef7e6

Please sign in to comment.