Skip to content

Commit aaef7e6

Browse files
magicmatatjahuderberg
authored andcommitted
refactor: add stringify functionality (#486)
1 parent 33fad58 commit aaef7e6

File tree

7 files changed

+339
-1
lines changed

7 files changed

+339
-1
lines changed

src/constants.ts

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export const xParserSpecParsed = 'x-parser-spec-parsed';
2+
export const xParserSpecStringified = 'x-parser-spec-stringified';
3+
4+
export const xParserMessageName = 'x-parser-message-name';
5+
export const xParserSchemaId = 'x-parser-schema-id';
6+
7+
export const xParserOriginalSchema = 'x-parser-original-schema';
8+
export const xParserOriginalSchemaFormat = 'x-parser-original-schema-format';
9+
export const xParserOriginalTraits = 'x-parser-original-traits';
10+
11+
export const xParserCircular = 'x-parser-circular';

src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
export * from './models';
2+
3+
export { stringify, unstringify } from './stringify';

src/models/base.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ export class BaseModel {
33
private readonly _json: Record<string, any>,
44
) {}
55

6-
json(key?: string | number): any {
6+
json<T = Record<string, unknown>>(): T;
7+
json<T = unknown>(key: string | number): T;
8+
json(key?: string | number) {
79
if (key === undefined) return this._json;
810
if (!this._json) return;
911
return this._json[String(key)];

src/stringify.ts

+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { AsyncAPIDocument } from './models';
2+
3+
import { isAsyncAPIDocument, isParsedDocument, isStringifiedDocument } from './utils';
4+
import { xParserSpecStringified } from './constants';
5+
6+
export function stringify(document: unknown, space?: string | number): string | undefined {
7+
if (isAsyncAPIDocument(document)) {
8+
document = document.json();
9+
} else if (isParsedDocument(document)) {
10+
if (isStringifiedDocument(document)) {
11+
return JSON.stringify(document);
12+
}
13+
document = document;
14+
} else {
15+
return;
16+
}
17+
18+
return JSON.stringify({
19+
...document as Record<string, unknown>,
20+
[String(xParserSpecStringified)]: true,
21+
}, refReplacer(), space);
22+
}
23+
24+
export function unstringify(document: unknown): AsyncAPIDocument | undefined {
25+
if (!isStringifiedDocument(document)) {
26+
return;
27+
}
28+
29+
// shall copy of whole JSON
30+
document = { ...document };
31+
// remove `x-parser-spec-stringified` extension
32+
delete (<Record<string, any>>document)[String(xParserSpecStringified)];
33+
34+
traverseStringifiedDoc(document, undefined, document, new Map(), new Map());
35+
return new AsyncAPIDocument(<Record<string, any>>document);
36+
}
37+
38+
function refReplacer() {
39+
const modelPaths = new Map();
40+
const paths = new Map();
41+
let init: unknown = null;
42+
43+
return function(this: unknown, field: string, value: unknown) {
44+
// `this` points to parent object of given value - some object or array
45+
const pathPart = modelPaths.get(this) + (Array.isArray(this) ? `[${field}]` : `.${field}`);
46+
47+
// check if `objOrPath` has "reference"
48+
const isComplex = value === Object(value);
49+
if (isComplex) {
50+
modelPaths.set(value, pathPart);
51+
}
52+
53+
const savedPath = paths.get(value) || '';
54+
if (!savedPath && isComplex) {
55+
const valuePath = pathPart.replace(/undefined\.\.?/,'');
56+
paths.set(value, valuePath);
57+
}
58+
59+
const prefixPath = savedPath[0] === '[' ? '$' : '$.';
60+
let val = savedPath ? `$ref:${prefixPath}${savedPath}` : value;
61+
if (init === null) {
62+
init = value;
63+
} else if (val === init) {
64+
val = '$ref:$';
65+
}
66+
return val;
67+
};
68+
}
69+
70+
const refRoot = '$ref:$';
71+
function traverseStringifiedDoc(parent: any, field: string | undefined, root: any, objToPath: Map<unknown, unknown>, pathToObj: Map<unknown, unknown>) {
72+
let objOrPath = parent;
73+
let path = refRoot;
74+
75+
if (field !== undefined) {
76+
// here can be string with `$ref` prefix or normal value
77+
objOrPath = parent[String(field)];
78+
const concatenatedPath = field ? `.${field}` : '';
79+
path = objToPath.get(parent) + (Array.isArray(parent) ? `[${field}]` : concatenatedPath);
80+
}
81+
82+
objToPath.set(objOrPath, path);
83+
pathToObj.set(path, objOrPath);
84+
85+
const ref = pathToObj.get(objOrPath);
86+
if (ref) {
87+
parent[String(field)] = ref;
88+
}
89+
if (objOrPath === refRoot || ref === refRoot) {
90+
parent[String(field)] = root;
91+
}
92+
93+
// traverse all keys, only if object is array/object
94+
if (objOrPath === Object(objOrPath)) {
95+
for (const f in objOrPath) {
96+
traverseStringifiedDoc(objOrPath, f, root, objToPath, pathToObj);
97+
}
98+
}
99+
}

src/utils.ts

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { AsyncAPIDocument } from './models';
2+
import { unstringify } from './stringify';
3+
4+
import {
5+
xParserSpecParsed,
6+
xParserSpecStringified,
7+
} from './constants';
8+
9+
export function toAsyncAPIDocument(maybeDoc: unknown): AsyncAPIDocument | undefined {
10+
if (isAsyncAPIDocument(maybeDoc)) {
11+
return maybeDoc;
12+
}
13+
if (!isParsedDocument(maybeDoc)) {
14+
return;
15+
}
16+
return unstringify(maybeDoc) || new AsyncAPIDocument(maybeDoc);
17+
}
18+
19+
export function isAsyncAPIDocument(maybeDoc: unknown): maybeDoc is AsyncAPIDocument {
20+
return maybeDoc instanceof AsyncAPIDocument;
21+
}
22+
23+
export function isParsedDocument(maybeDoc: unknown): maybeDoc is Record<string, unknown> {
24+
if (typeof maybeDoc !== 'object' || maybeDoc === null) {
25+
return false;
26+
}
27+
return Boolean((maybeDoc as Record<string, unknown>)[xParserSpecParsed]);
28+
}
29+
30+
export function isStringifiedDocument(maybeDoc: unknown): maybeDoc is Record<string, unknown> {
31+
if (typeof maybeDoc !== 'object' || maybeDoc === null) {
32+
return false;
33+
}
34+
return (
35+
Boolean((maybeDoc as Record<string, unknown>)[xParserSpecParsed]) &&
36+
Boolean((maybeDoc as Record<string, unknown>)[xParserSpecStringified])
37+
);
38+
}

test/stringify.spec.ts

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { xParserSpecParsed, xParserSpecStringified } from '../src/constants';
2+
import { AsyncAPIDocument, BaseModel } from '../src/models';
3+
import { stringify, unstringify } from '../src/stringify';
4+
5+
describe('stringify & unstringify', function() {
6+
describe('stringify()', function() {
7+
it('should not stringify normal object', function() {
8+
expect(stringify({})).toEqual(undefined);
9+
});
10+
11+
it('should not stringify null object', function() {
12+
expect(stringify(null)).toEqual(undefined);
13+
});
14+
15+
it('should not stringify primitive', function() {
16+
expect(stringify('AsyncAPI rocks!')).toEqual(undefined);
17+
});
18+
19+
it('should not stringify BaseModel instance', function() {
20+
expect(stringify(new BaseModel({}))).toEqual(undefined);
21+
});
22+
23+
it('should stringify parsed document', function() {
24+
expect(typeof stringify({ [xParserSpecParsed]: true })).toEqual('string');
25+
});
26+
27+
it('should stringify (skip) stringified document', function() {
28+
expect(typeof stringify({ [xParserSpecParsed]: true, [xParserSpecStringified]: true })).toEqual('string');
29+
});
30+
31+
it('should stringify AsyncAPIDocument instance', function() {
32+
expect(typeof stringify(new AsyncAPIDocument({ asyncapi: '2.0.0' }))).toEqual('string');
33+
});
34+
});
35+
36+
describe('unstringify()', function() {
37+
it('should not unstringify normal object', function() {
38+
expect(unstringify({})).toEqual(undefined);
39+
});
40+
41+
it('should not unstringify null object', function() {
42+
expect(unstringify(null)).toEqual(undefined);
43+
});
44+
45+
it('should not stringify primitive', function() {
46+
expect(unstringify('AsyncAPI rocks!')).toEqual(undefined);
47+
});
48+
49+
it('should not stringify BaseModel instance', function() {
50+
expect(unstringify(new BaseModel({}))).toEqual(undefined);
51+
});
52+
53+
it('should not unstringify parsed document', function() {
54+
expect(unstringify({ [xParserSpecParsed]: true })).toEqual(undefined);
55+
});
56+
57+
it('should unstringify stringified document', function() {
58+
expect(unstringify({ [xParserSpecParsed]: true, [xParserSpecStringified]: true })).not.toEqual(undefined);
59+
});
60+
});
61+
});

test/utils.spec.ts

+125
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { xParserSpecParsed, xParserSpecStringified } from '../src/constants';
2+
import { AsyncAPIDocument, BaseModel } from '../src/models';
3+
import { toAsyncAPIDocument, isAsyncAPIDocument, isParsedDocument, isStringifiedDocument } from '../src/utils';
4+
5+
describe('utils', function() {
6+
describe('toAsyncAPIDocument()', function() {
7+
it('normal object should not return AsyncAPIDocument instance', function() {
8+
expect(toAsyncAPIDocument({})).toEqual(undefined);
9+
});
10+
11+
it('null object should not return AsyncAPIDocument instance', function() {
12+
expect(toAsyncAPIDocument(null)).toEqual(undefined);
13+
});
14+
15+
it('primitive should not return AsyncAPIDocument instance', function() {
16+
expect(toAsyncAPIDocument('AsyncAPI rocks!')).toEqual(undefined);
17+
});
18+
19+
it('BaseModel instance should not return AsyncAPIDocument instance', function() {
20+
expect(toAsyncAPIDocument(new BaseModel({}))).toEqual(undefined);
21+
});
22+
23+
it('AsyncAPIDocument instance should return AsyncAPIDocument instance', function() {
24+
expect(toAsyncAPIDocument(new AsyncAPIDocument({ asyncapi: '2.0.0' }))).toBeInstanceOf(AsyncAPIDocument);
25+
});
26+
27+
it('parsed document should return AsyncAPIDocument instance', function() {
28+
expect(toAsyncAPIDocument({ [xParserSpecParsed]: true })).toBeInstanceOf(AsyncAPIDocument);
29+
});
30+
31+
it('stringified document should return AsyncAPIDocument instance', function() {
32+
expect(toAsyncAPIDocument({ [xParserSpecParsed]: true, [xParserSpecStringified]: true })).toBeInstanceOf(AsyncAPIDocument);
33+
});
34+
35+
it('stringified document (with missed parsed extension) should not return AsyncAPIDocument instance', function() {
36+
expect(toAsyncAPIDocument({ [xParserSpecStringified]: true })).toEqual(undefined);
37+
});
38+
});
39+
40+
describe('isAsyncAPIDocument()', function() {
41+
it('normal object should not be AsyncAPI document', function() {
42+
expect(isAsyncAPIDocument({})).toEqual(false);
43+
});
44+
45+
it('null object should not be AsyncAPI document', function() {
46+
expect(isAsyncAPIDocument(null)).toEqual(false);
47+
});
48+
49+
it('primitive should not be AsyncAPI document', function() {
50+
expect(isAsyncAPIDocument('AsyncAPI rocks!')).toEqual(false);
51+
});
52+
53+
it('BaseModel instance should not be AsyncAPI document', function() {
54+
expect(isAsyncAPIDocument(new BaseModel({}))).toEqual(false);
55+
});
56+
57+
it('AsyncAPIDocument instance should be AsyncAPI document', function() {
58+
expect(isAsyncAPIDocument(new AsyncAPIDocument({ asyncapi: '2.0.0' }))).toEqual(true);
59+
});
60+
});
61+
62+
describe('isParsedDocument()', function() {
63+
it('normal object should not be parsed document', function() {
64+
expect(isParsedDocument({})).toEqual(false);
65+
});
66+
67+
it('null object should not be parsed document', function() {
68+
expect(isParsedDocument(null)).toEqual(false);
69+
});
70+
71+
it('primitive should not be parsed document', function() {
72+
expect(isParsedDocument('AsyncAPI rocks!')).toEqual(false);
73+
});
74+
75+
it('BaseModel instance should not be AsyncAPI document', function() {
76+
expect(isParsedDocument(new BaseModel({}))).toEqual(false);
77+
});
78+
79+
it('AsyncAPIDocument instance should not be parsed document', function() {
80+
expect(isParsedDocument(new AsyncAPIDocument({ asyncapi: '2.0.0' }))).toEqual(false);
81+
});
82+
83+
it('AsyncAPIDocument instance with proper extension should not be parsed document', function() {
84+
expect(isParsedDocument(new AsyncAPIDocument({ asyncapi: '2.0.0', [xParserSpecParsed]: true }))).toEqual(false);
85+
});
86+
87+
it('object with proper extension should be parsed document', function() {
88+
expect(isParsedDocument({ [xParserSpecParsed]: true })).toEqual(true);
89+
});
90+
});
91+
92+
describe('isStringifiedDocument()', function() {
93+
it('normal object should not be parsed document', function() {
94+
expect(isStringifiedDocument({})).toEqual(false);
95+
});
96+
97+
it('null object should not be parsed document', function() {
98+
expect(isStringifiedDocument(null)).toEqual(false);
99+
});
100+
101+
it('primitive should not be parsed document', function() {
102+
expect(isStringifiedDocument('AsyncAPI rocks!')).toEqual(false);
103+
});
104+
105+
it('BaseModel instance should not be AsyncAPI document', function() {
106+
expect(isStringifiedDocument(new BaseModel({}))).toEqual(false);
107+
});
108+
109+
it('AsyncAPIDocument instance should not be parsed document', function() {
110+
expect(isStringifiedDocument(new AsyncAPIDocument({ asyncapi: '2.0.0' }))).toEqual(false);
111+
});
112+
113+
it('AsyncAPIDocument instance with proper extension should not be parsed document', function() {
114+
expect(isStringifiedDocument(new AsyncAPIDocument({ asyncapi: '2.0.0', [xParserSpecParsed]: true, [xParserSpecStringified]: true }))).toEqual(false);
115+
});
116+
117+
it('object with only stringified extension should not be parsed document', function() {
118+
expect(isStringifiedDocument({ [xParserSpecStringified]: true })).toEqual(false);
119+
});
120+
121+
it('object with proper extensions should be parsed document', function() {
122+
expect(isStringifiedDocument({ [xParserSpecParsed]: true, [xParserSpecStringified]: true })).toEqual(true);
123+
});
124+
});
125+
});

0 commit comments

Comments
 (0)