Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: handle complex circular reference cases #671

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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