Skip to content

Commit

Permalink
fix: handle complex circular reference cases (#671)
Browse files Browse the repository at this point in the history
  • Loading branch information
magicmatatjahu authored Nov 8, 2022
1 parent 4f18144 commit d9c3be0
Show file tree
Hide file tree
Showing 14 changed files with 251 additions and 106 deletions.
15 changes: 9 additions & 6 deletions src/custom-operations/index.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,33 @@
import { applyTraitsV2 } from './apply-traits';
import { checkCircularRefs } from './check-circular-refs';
import { resolveCircularRefs } from './resolve-circular-refs';
import { parseSchemasV2 } from './parse-schema';
import { anonymousNaming } from './anonymous-naming';

import type { RulesetFunctionContext } from '@stoplight/spectral-core';
import type { Parser } from '../parser';
import type { ParseOptions } from '../parse';
import type { AsyncAPIDocumentInterface } from '../models';
import type { DetailedAsyncAPI } from '../types';

export async function customOperations(parser: Parser, document: AsyncAPIDocumentInterface, detailed: DetailedAsyncAPI, options: ParseOptions): Promise<void> {
export async function customOperations(parser: Parser, document: AsyncAPIDocumentInterface, detailed: DetailedAsyncAPI, inventory: RulesetFunctionContext['documentInventory'], options: ParseOptions): Promise<void> {
switch (detailed.semver.major) {
case 2: return operationsV2(parser, document, detailed, options);
case 2: return operationsV2(parser, document, detailed, inventory, options);
// case 3: return operationsV3(parser, document, detailed, options);
}
}

async function operationsV2(parser: Parser, document: AsyncAPIDocumentInterface, detailed: DetailedAsyncAPI, options: ParseOptions): Promise<void> {
async function operationsV2(parser: Parser, document: AsyncAPIDocumentInterface, detailed: DetailedAsyncAPI, inventory: RulesetFunctionContext['documentInventory'], options: ParseOptions): Promise<void> {
if (options.applyTraits) {
applyTraitsV2(detailed.parsed);
}
if (options.parseSchemas) {
await parseSchemasV2(parser, detailed);
}

// anonymous naming and checking circular refrences should be done after custom schemas parsing
checkCircularRefs(document);
// anonymous naming and resolving circular refrences should be done after custom schemas parsing
if (inventory) {
resolveCircularRefs(document, inventory);
}
anonymousNaming(document);
}

67 changes: 67 additions & 0 deletions src/custom-operations/resolve-circular-refs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { setExtension, toJSONPathArray, retrieveDeepData, findSubArrayIndex } from '../utils';
import { xParserCircular } from '../constants';

import type { RulesetFunctionContext } from '@stoplight/spectral-core';
import type { AsyncAPIDocumentInterface } from '../models';
import type { AsyncAPIObject } from '../types';

interface Context {
document: AsyncAPIObject;
hasCircular: boolean;
inventory: RulesetFunctionContext['documentInventory'];
visited: Set<any>;
}

export function resolveCircularRefs(document: AsyncAPIDocumentInterface, inventory: RulesetFunctionContext['documentInventory']) {
const documentJson = document.json();
const ctx: Context = { document: documentJson, hasCircular: false, inventory, visited: new Set() };
traverse(documentJson, [], null, '', ctx);
if (ctx.hasCircular) {
setExtension(xParserCircular, true, document);
}
}

function traverse(data: any, path: Array<string | number>, parent: any, property: string | number, ctx: Context) {
if (typeof data !== 'object' || !data || ctx.visited.has(data)) {
return;
}

ctx.visited.add(data);
if (Array.isArray(data)) {
data.forEach((item, idx) => traverse(item, [...path, idx], data, idx, ctx));
}
if ('$ref' in data) {
ctx.hasCircular = true;
const resolvedRef = retrieveCircularRef(data, path, ctx);
if (resolvedRef) {
parent[property] = resolvedRef;
}
} else {
for (const p in data) {
traverse(data[p], [...path, p], data, p, ctx);
}
}
ctx.visited.delete(data);
}

function retrieveCircularRef(data: { $ref: string }, path: Array<string | number>, ctx: Context): any {
const $refPath = toJSONPathArray(data.$ref);
const item = ctx.inventory.findAssociatedItemForPath(path, true);

// root document case
if (item === null) {
return retrieveDeepData(ctx.document, $refPath);
}

// referenced document case
if (item) {
const subArrayIndex = findSubArrayIndex(path, $refPath);
let dataPath: Array<string | number> | undefined;
if (subArrayIndex === -1) { // create subarray based on location of the assiociated document - use item.path
dataPath = [...path.slice(0, path.length - item.path.length), ...$refPath];
} else { // create subarray based on $refPath
dataPath = path.slice(0, subArrayIndex + $refPath.length);
}
return retrieveDeepData(ctx.document, dataPath);
}
}
11 changes: 0 additions & 11 deletions src/models/v2/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,14 @@ import { BaseModel } from '../base';

import { xParserSchemaId } from '../../constants';
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);
}

id(): string {
return this.$id() || this._meta.id || this.json(xParserSchemaId as any) as string;
}
Expand Down Expand Up @@ -164,7 +154,6 @@ 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
8 changes: 6 additions & 2 deletions src/old-api/base.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
export abstract class Base<J extends any = any, M extends Record<string, any> = Record<string, any>> {
constructor(
protected readonly _json: J, // TODO: Add error here like in original codebase
protected readonly _json: J,
protected readonly _meta: M = {} as M,
) {}
) {
if (_json === undefined || _json === null) {
throw new Error('Invalid JSON to instantiate the Base object.');
}
}

json<T = J>(): T;
json<K extends keyof J>(key: K): J[K];
Expand Down
3 changes: 1 addition & 2 deletions src/old-api/schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { SpecificationExtensionsModel, createMapOfType, getMapValue, description, hasDescription, hasExternalDocs, externalDocs } from './mixins';
import { xParserCircular, xParserCircularProps } from '../constants';
import { hasRef } from '../utils';

import type { Base } from './base';
import type { v2 } from '../spec-types';
Expand Down Expand Up @@ -236,7 +235,7 @@ export class Schema extends SpecificationExtensionsModel<v2.AsyncAPISchemaObject
}

isCircular() {
if (hasRef(this._json) || this.ext(xParserCircular)) {
if (this.ext(xParserCircular)) {
return true;
}

Expand Down
6 changes: 3 additions & 3 deletions src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { createDetailedAsyncAPI, mergePatch, setExtension, createUncaghtDiagnost

import { xParserSpecParsed } from './constants';

import type { Spectral, Document } from '@stoplight/spectral-core';
import type { Spectral, Document, RulesetFunctionContext } from '@stoplight/spectral-core';
import type { Parser } from './parser';
import type { ResolverOptions } from './resolver';
import type { ValidateOptions } from './validate';
Expand Down Expand Up @@ -54,14 +54,14 @@ export async function parse(parser: Parser, spectral: Spectral, asyncapi: Input,
}

spectralDocument = extras.document;
const inventory: RulesetFunctionContext['documentInventory'] = (spectralDocument as any).__documentInventory;

// unfreeze the object - Spectral makes resolved document "freezed"
const validatedDoc = copy(validated as Record<string, any>);

const detailed = createDetailedAsyncAPI(validatedDoc, asyncapi as DetailedAsyncAPI['input'], options.source);
const document = createAsyncAPIDocument(detailed);
setExtension(xParserSpecParsed, true, document);
await customOperations(parser, document, detailed, options);
await customOperations(parser, document, detailed, inventory, options);

return {
document,
Expand Down
5 changes: 4 additions & 1 deletion src/spectral.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ function asyncApi2IsAsyncApi(): RuleDefinition {
input: null,
options: null,
},
(targetVal) => {
(targetVal, _, { document, documentInventory }) => {
// adding document inventory in document - we need it in custom operations to resolve all circular refs
(document as any).__documentInventory = documentInventory;

if (!isObject(targetVal) || typeof targetVal.asyncapi !== 'string') {
return [
{
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface AsyncAPISemver {

export interface DetailedAsyncAPI {
source?: string;
input: string | MaybeAsyncAPI | AsyncAPIObject;
input?: string | MaybeAsyncAPI | AsyncAPIObject;
parsed: AsyncAPIObject;
semver: AsyncAPISemver;
}
Expand Down
32 changes: 17 additions & 15 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { ISpectralDiagnostic } from '@stoplight/spectral-core';
import type { BaseModel } from './models';
import type { AsyncAPISemver, AsyncAPIObject, DetailedAsyncAPI, MaybeAsyncAPI, Diagnostic } from './types';

export function createDetailedAsyncAPI(parsed: AsyncAPIObject, input: string | MaybeAsyncAPI | AsyncAPIObject, source?: string): DetailedAsyncAPI {
export function createDetailedAsyncAPI(parsed: AsyncAPIObject, input?: string | MaybeAsyncAPI | AsyncAPIObject, source?: string): DetailedAsyncAPI {
return {
source,
input,
Expand Down Expand Up @@ -87,7 +87,7 @@ export function hasRef(value: unknown): value is { $ref: string } {
}

export function toJSONPathArray(jsonPath: string): Array<string | number> {
return jsonPath.split('/').map(untilde);
return splitPath(serializePath(jsonPath));
}

export function createUncaghtDiagnostic(err: unknown, message: string, document?: Document): Diagnostic[] {
Expand Down Expand Up @@ -127,22 +127,24 @@ export function untilde(str: string) {
});
}

export function retrievePossibleRef(data: any, 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;
export function findSubArrayIndex(arr: Array<any>, subarr: Array<any>, fromIndex = 0) {
let i, found, j;
for (i = fromIndex; i < 1 + (arr.length - subarr.length); ++i) {
found = true;
for (j = 0; j < subarr.length; ++j) {
if (arr[i + j] !== subarr[j]) {
found = false;
break;
}
}
if (found) {
return i;
}
}
return data;
return -1;
}

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

Expand Down
4 changes: 2 additions & 2 deletions src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export interface ValidateOutput {
validated: unknown;
diagnostics: Diagnostic[];
extras: {
document: Document,
document: Document;
}
}

Expand All @@ -52,7 +52,7 @@ export async function validate(parser: Parser, parserSpectral: Spectral, asyncap

const spectral = options.__unstable?.resolver ? createSpectral(parser, options.__unstable?.resolver) : parserSpectral;
// eslint-disable-next-line prefer-const
let { resolved: validated, results } = await spectral.runWithResolved(document);
let { resolved: validated, results } = await spectral.runWithResolved(document, { });

if (
(!allowedSeverity?.error && hasErrorDiagnostic(results)) ||
Expand Down
61 changes: 0 additions & 61 deletions test/custom-operations/check-circular-refs.spec.ts

This file was deleted.

Loading

0 comments on commit d9c3be0

Please sign in to comment.