Skip to content

Commit

Permalink
refactor: handle circular references in schemas (#591)
Browse files Browse the repository at this point in the history
  • Loading branch information
magicmatatjahu authored and derberg committed Oct 4, 2022
1 parent 9896fd7 commit 7e99ff2
Show file tree
Hide file tree
Showing 10 changed files with 383 additions and 187 deletions.
41 changes: 41 additions & 0 deletions src/document.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { newAsyncAPIDocument, AsyncAPIDocumentV2, AsyncAPIDocumentV3 } from './models';
import { unstringify } from './stringify';
import { createDetailedAsyncAPI } from './utils';

import {
xParserSpecParsed,
xParserSpecStringified,
} from './constants';

import type { AsyncAPIDocumentInterface } from './models';

export function toAsyncAPIDocument(maybeDoc: unknown): AsyncAPIDocumentInterface | undefined {
if (isAsyncAPIDocument(maybeDoc)) {
return maybeDoc;
}
if (!isParsedDocument(maybeDoc)) {
return;
}
return unstringify(maybeDoc) || newAsyncAPIDocument(createDetailedAsyncAPI(maybeDoc, maybeDoc as any));
}

export function isAsyncAPIDocument(maybeDoc: unknown): maybeDoc is AsyncAPIDocumentInterface {
return maybeDoc instanceof AsyncAPIDocumentV2 || maybeDoc instanceof AsyncAPIDocumentV3;
}

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])
);
}
3 changes: 2 additions & 1 deletion src/lint.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Document } from '@stoplight/spectral-core';
import { Yaml } from '@stoplight/spectral-parsers';
import { toAsyncAPIDocument, normalizeInput, hasWarningDiagnostic, hasErrorDiagnostic } from './utils';
import { toAsyncAPIDocument } from './document';
import { normalizeInput, hasWarningDiagnostic, hasErrorDiagnostic } from './utils';

import type { IRunOpts } from '@stoplight/spectral-core';
import type { Parser } from './parser';
Expand Down
11 changes: 11 additions & 0 deletions src/models/v2/schema.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import { BaseModel } from '../base';

import { extensions, hasExternalDocs, externalDocs } from './mixins';
import { retrievePossibleRef, hasRef } from '../../utils';

import type { ModelMetadata } from '../base';
import type { ExtensionsInterface } from '../extensions';
import type { ExternalDocumentationInterface } from '../external-docs';
import type { SchemaInterface } from '../schema';

import type { v2 } from '../../spec-types';

export class Schema extends BaseModel<v2.AsyncAPISchemaObject, { id?: string, parent?: Schema }> implements SchemaInterface {
constructor(
_json: v2.AsyncAPISchemaObject,
_meta: ModelMetadata & { id: string, parent?: Schema } = {} as any,
) {
_json = retrievePossibleRef(_json, _meta.pointer, _meta.asyncapi?.parsed);
super(_json, _meta);
}

uid(): string {
return this._meta.id as any;
}
Expand Down Expand Up @@ -151,6 +161,7 @@ export class Schema extends BaseModel<v2.AsyncAPISchemaObject, { id?: string, pa
}

isCircular(): boolean {
if (hasRef(this._json)) return true;
let parent = this._meta.parent;
while (parent) {
if (parent._json === this._json) return true;
Expand Down
6 changes: 4 additions & 2 deletions src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { AsyncAPIDocumentInterface, newAsyncAPIDocument } from './models';

import { customOperations } from './custom-operations';
import { validate } from './lint';
import { createDetailedAsyncAPI, normalizeInput, toAsyncAPIDocument, unfreezeObject } from './utils';
import { unfreeze } from './stringify';
import { toAsyncAPIDocument } from './document';
import { createDetailedAsyncAPI, normalizeInput } from './utils';

import { xParserSpecParsed } from './constants';

Expand Down Expand Up @@ -47,7 +49,7 @@ export async function parse(parser: Parser, asyncapi: ParseInput, options?: Pars
}

// unfreeze the object - Spectral makes resolved document "freezed"
const validatedDoc = unfreezeObject(validated);
const validatedDoc = unfreeze(validated as Record<string, any>);
validatedDoc[String(xParserSpecParsed)] = true;

const detailed = createDetailedAsyncAPI(asyncapi as string | Record<string, unknown>, validatedDoc);
Expand Down
16 changes: 12 additions & 4 deletions src/stringify.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AsyncAPIDocumentInterface, newAsyncAPIDocument } from './models';

import { createDetailedAsyncAPI, isAsyncAPIDocument, isParsedDocument, isStringifiedDocument } from './utils';
import { isAsyncAPIDocument, isParsedDocument, isStringifiedDocument } from './document';
import { createDetailedAsyncAPI } from './utils';
import { xParserSpecStringified } from './constants';

import type { DetailedAsyncAPI } from './types';
Expand Down Expand Up @@ -45,10 +46,17 @@ export function unstringify(document: unknown): AsyncAPIDocumentInterface | unde
// remove `x-parser-spec-stringified` extension
delete (<Record<string, any>>parsed)[String(xParserSpecStringified)];

traverseStringifiedDoc(document, undefined, document, new Map(), new Map());
traverseStringifiedData(document, undefined, document, new Map(), new Map());
return newAsyncAPIDocument(createDetailedAsyncAPI(document as string, parsed as DetailedAsyncAPI['parsed']));
}

export function unfreeze(data: Record<string, any>) {
const stringifiedData = JSON.stringify(data, refReplacer());
const unstringifiedData = JSON.parse(stringifiedData);
traverseStringifiedData(unstringifiedData, undefined, unstringifiedData, new Map(), new Map());
return unstringifiedData;
}

function refReplacer() {
const modelPaths = new Map();
const paths = new Map();
Expand Down Expand Up @@ -82,7 +90,7 @@ function refReplacer() {
}

const refRoot = '$ref:$';
function traverseStringifiedDoc(parent: any, field: string | undefined, root: any, objToPath: Map<unknown, unknown>, pathToObj: Map<unknown, unknown>) {
function traverseStringifiedData(parent: any, field: string | undefined, root: any, objToPath: Map<unknown, unknown>, pathToObj: Map<unknown, unknown>) {
let objOrPath = parent;
let path = refRoot;

Expand All @@ -107,7 +115,7 @@ function traverseStringifiedDoc(parent: any, field: string | undefined, root: an
// traverse all keys, only if object is array/object
if (objOrPath === Object(objOrPath)) {
for (const f in objOrPath) {
traverseStringifiedDoc(objOrPath, f, root, objToPath, pathToObj);
traverseStringifiedData(objOrPath, f, root, objToPath, pathToObj);
}
}
}
78 changes: 39 additions & 39 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,7 @@
import { DiagnosticSeverity } from '@stoplight/types';
import { newAsyncAPIDocument, AsyncAPIDocumentInterface, AsyncAPIDocumentV2, AsyncAPIDocumentV3 } from './models';
import { unstringify } from './stringify';

import {
xParserSpecParsed,
xParserSpecStringified,
} from './constants';

import type { ISpectralDiagnostic } from '@stoplight/spectral-core';
import type { AsyncAPISemver, AsyncAPIObject, DetailedAsyncAPI, MaybeAsyncAPI } from 'types';

export function toAsyncAPIDocument(maybeDoc: unknown): AsyncAPIDocumentInterface | undefined {
if (isAsyncAPIDocument(maybeDoc)) {
return maybeDoc;
}
if (!isParsedDocument(maybeDoc)) {
return;
}
return unstringify(maybeDoc) || newAsyncAPIDocument(createDetailedAsyncAPI(maybeDoc, maybeDoc as any));
}

export function isAsyncAPIDocument(maybeDoc: unknown): maybeDoc is AsyncAPIDocumentInterface {
return maybeDoc instanceof AsyncAPIDocumentV2 || maybeDoc instanceof AsyncAPIDocumentV3;
}

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])
);
}
import type { AsyncAPISemver, AsyncAPIObject, DetailedAsyncAPI, MaybeAsyncAPI } from './types';

export function createDetailedAsyncAPI(source: string | Record<string, unknown>, parsed: AsyncAPIObject): DetailedAsyncAPI {
return {
Expand Down Expand Up @@ -105,6 +67,10 @@ export function isObject(value: unknown): value is Record<string, any> {
return Boolean(value) && typeof value === 'object' && Array.isArray(value) === false;
}

export function hasRef(value: unknown): value is { $ref: string } {
return isObject(value) && '$ref' in value && typeof value.$ref === 'string';
}

export function tilde(str: string) {
return str.replace(/[~/]{1}/g, (sub) => {
switch (sub) {
Expand All @@ -124,4 +90,38 @@ export function untilde(str: string) {
}
return sub;
});
}

export function retrievePossibleRef(data: any & { $ref?: string }, pathOfData: string, spec: any = {}): any {
if (!hasRef(data)) {
return data;
}

const refPath = serializePath(data.$ref);
if (pathOfData.startsWith(refPath)) { // starts by given path
return retrieveDeepData(spec, splitPath(refPath)) || data;
} else if (pathOfData.includes(refPath)) { // circular path in substring of path
const substringPath = pathOfData.split(refPath)[0];
return retrieveDeepData(spec, splitPath(`${substringPath}${refPath}`)) || data;
}
return data;
}

function retrieveDeepData(value: Record<string, any>, path: string[]) {
let index = 0;
const length = path.length;

while (typeof value === 'object' && value && index < length) {
value = value[path[index++]];
}
return index === length ? value : undefined;
}

function serializePath(path: string) {
if (path.startsWith('#')) return path.substring(1);
return path;
}

function splitPath(path: string): string[] {
return path.split('/').filter(Boolean).map(untilde);
}
145 changes: 145 additions & 0 deletions test/document.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { xParserSpecParsed, xParserSpecStringified } from '../src/constants';
import { newAsyncAPIDocument, BaseModel, AsyncAPIDocumentV2 } from '../src/models';
import {
toAsyncAPIDocument,
isAsyncAPIDocument,
isParsedDocument,
isStringifiedDocument,
} from '../src/document';
import { createDetailedAsyncAPI } from '../src/utils';

describe('utils', function() {
class Model extends BaseModel {}

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 Model({}))).toEqual(undefined);
});

it('AsyncAPIDocument instance should return AsyncAPIDocument instance', function() {
const doc = { asyncapi: '2.0.0' };
const detailed = createDetailedAsyncAPI(doc, doc as any);
expect(toAsyncAPIDocument(newAsyncAPIDocument(detailed))).toBeInstanceOf(AsyncAPIDocumentV2);
});

it('parsed document should return AsyncAPIDocument instance', function() {
expect(toAsyncAPIDocument({ asyncapi: '2.0.0', [xParserSpecParsed]: true })).toBeInstanceOf(AsyncAPIDocumentV2);
});

it('stringified document should return AsyncAPIDocument instance', function() {
expect(toAsyncAPIDocument({ asyncapi: '2.0.0', [xParserSpecParsed]: true, [xParserSpecStringified]: true })).toBeInstanceOf(AsyncAPIDocumentV2);
});

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 Model({}))).toEqual(false);
});

it('AsyncAPIDocument instance should be AsyncAPI document', function() {
const doc = { asyncapi: '2.0.0' };
const detailed = createDetailedAsyncAPI(doc, doc as any);
expect(isAsyncAPIDocument(newAsyncAPIDocument(detailed))).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 Model({}))).toEqual(false);
});

it('AsyncAPIDocument instance should not be parsed document', function() {
const doc = { asyncapi: '2.0.0' };
const detailed = createDetailedAsyncAPI(doc, doc as any);
expect(isParsedDocument(newAsyncAPIDocument(detailed))).toEqual(false);
});

it('AsyncAPIDocument instance with proper extension should not be parsed document', function() {
const doc = { asyncapi: '2.0.0', [xParserSpecParsed]: true };
const detailed = createDetailedAsyncAPI(doc, doc as any);
expect(isParsedDocument(newAsyncAPIDocument(detailed))).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 Model({}))).toEqual(false);
});

it('AsyncAPIDocument instance should not be parsed document', function() {
const doc = { asyncapi: '2.0.0' };
const detailed = createDetailedAsyncAPI(doc, doc as any);
expect(isStringifiedDocument(newAsyncAPIDocument(detailed))).toEqual(false);
});

it('AsyncAPIDocument instance with proper extension should not be parsed document', function() {
const doc = { asyncapi: '2.0.0', [xParserSpecParsed]: true, [xParserSpecStringified]: true };
const detailed = createDetailedAsyncAPI(doc, doc as any);
expect(isStringifiedDocument(newAsyncAPIDocument(detailed))).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);
});
});
});
Loading

0 comments on commit 7e99ff2

Please sign in to comment.