diff --git a/src/custom-operations/index.ts b/src/custom-operations/index.ts index ee7b33167..521bbc50a 100644 --- a/src/custom-operations/index.ts +++ b/src/custom-operations/index.ts @@ -1,21 +1,22 @@ 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 { +export async function customOperations(parser: Parser, document: AsyncAPIDocumentInterface, detailed: DetailedAsyncAPI, inventory: RulesetFunctionContext['documentInventory'], options: ParseOptions): Promise { 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 { +async function operationsV2(parser: Parser, document: AsyncAPIDocumentInterface, detailed: DetailedAsyncAPI, inventory: RulesetFunctionContext['documentInventory'], options: ParseOptions): Promise { if (options.applyTraits) { applyTraitsV2(detailed.parsed); } @@ -23,8 +24,10 @@ async function operationsV2(parser: Parser, document: AsyncAPIDocumentInterface, 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); } diff --git a/src/custom-operations/resolve-circular-refs.ts b/src/custom-operations/resolve-circular-refs.ts new file mode 100644 index 000000000..388c2d811 --- /dev/null +++ b/src/custom-operations/resolve-circular-refs.ts @@ -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; +} + +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, 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, 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 | 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); + } +} diff --git a/src/models/v2/schema.ts b/src/models/v2/schema.ts index ade229a75..20ae87529 100644 --- a/src/models/v2/schema.ts +++ b/src/models/v2/schema.ts @@ -2,9 +2,7 @@ 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'; @@ -12,14 +10,6 @@ import type { SchemaInterface } from '../schema'; import type { v2 } from '../../spec-types'; export class Schema extends BaseModel 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; } @@ -164,7 +154,6 @@ export class Schema extends BaseModel = Record> { 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; json(key: K): J[K]; diff --git a/src/old-api/schema.ts b/src/old-api/schema.ts index d99125bc8..ef0ee9218 100644 --- a/src/old-api/schema.ts +++ b/src/old-api/schema.ts @@ -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'; @@ -236,7 +235,7 @@ export class Schema extends SpecificationExtensionsModel); - 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, diff --git a/src/spectral.ts b/src/spectral.ts index cd5a87717..6ee99fd31 100644 --- a/src/spectral.ts +++ b/src/spectral.ts @@ -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 [ { diff --git a/src/types.ts b/src/types.ts index d9e76339e..46a575e10 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,7 +13,7 @@ export interface AsyncAPISemver { export interface DetailedAsyncAPI { source?: string; - input: string | MaybeAsyncAPI | AsyncAPIObject; + input?: string | MaybeAsyncAPI | AsyncAPIObject; parsed: AsyncAPIObject; semver: AsyncAPISemver; } diff --git a/src/utils.ts b/src/utils.ts index c6b152749..e5150b756 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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, @@ -87,7 +87,7 @@ export function hasRef(value: unknown): value is { $ref: string } { } export function toJSONPathArray(jsonPath: string): Array { - return jsonPath.split('/').map(untilde); + return splitPath(serializePath(jsonPath)); } export function createUncaghtDiagnostic(err: unknown, message: string, document?: Document): Diagnostic[] { @@ -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, subarr: Array, 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, path: string[]) { +export function retrieveDeepData(value: Record, path: Array) { let index = 0; const length = path.length; diff --git a/src/validate.ts b/src/validate.ts index 70334b75e..6eb51f91b 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -26,7 +26,7 @@ export interface ValidateOutput { validated: unknown; diagnostics: Diagnostic[]; extras: { - document: Document, + document: Document; } } @@ -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)) || diff --git a/test/custom-operations/check-circular-refs.spec.ts b/test/custom-operations/check-circular-refs.spec.ts deleted file mode 100644 index 89bfdb92c..000000000 --- a/test/custom-operations/check-circular-refs.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Parser } from '../../src/parser'; -import { xParserCircular } from '../../src/constants'; - -describe('custom operations - check circular references', function() { - const parser = new Parser(); - - it('should not assign x-parser-circular extension when document has not circular schemas', async function() { - const { document } = await parser.parse({ - asyncapi: '2.0.0', - info: { - title: 'Valid AsyncApi document', - version: '1.0', - }, - channels: { - channel: { - publish: { - operationId: 'operation', - message: { - payload: { - property1: { - $ref: '#/channels/channel/publish/message/payload/property2' - }, - property2: { - type: 'string', - } - }, - } - } - } - } - }); - - expect(document?.extensions().get(xParserCircular)?.value()).toEqual(undefined); - }); - - it('should assign x-parser-circular extension when document has circular schemas', async function() { - const { document } = await parser.parse({ - asyncapi: '2.0.0', - info: { - title: 'Valid AsyncApi document', - version: '1.0', - }, - channels: { - channel: { - publish: { - operationId: 'operation', - message: { - payload: { - property: { - $ref: '#/channels/channel/publish/message/payload' - }, - }, - } - } - } - } - }); - - expect(document?.extensions().get(xParserCircular)?.value()).toEqual(true); - }); -}); diff --git a/test/custom-operations/resolve-circular-refs.spec.ts b/test/custom-operations/resolve-circular-refs.spec.ts new file mode 100644 index 000000000..a1005a0e4 --- /dev/null +++ b/test/custom-operations/resolve-circular-refs.spec.ts @@ -0,0 +1,124 @@ +import { Parser } from '../../src/parser'; +import { xParserCircular } from '../../src/constants'; + +describe('custom operations - check circular references', function() { + const parser = new Parser(); + + it('should not assign x-parser-circular extension when document has not circular schemas', async function() { + const { document } = await parser.parse({ + asyncapi: '2.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: { + channel: { + publish: { + operationId: 'operation', + message: { + payload: { + properties: { + property1: { + $ref: '#/channels/channel/publish/message/payload/property2' + }, + property2: { + type: 'string', + } + } + }, + } + } + } + } + }); + + expect(document?.extensions().get(xParserCircular)?.value()).toEqual(undefined); + }); + + it('should assign x-parser-circular extension when document has circular schemas', async function() { + const { document } = await parser.parse({ + asyncapi: '2.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: { + channel: { + publish: { + operationId: 'operation', + message: { + payload: { + properties: { + property: { + $ref: '#/channels/channel/publish/message/payload' + } + }, + }, + } + } + } + } + }); + + const documentData = document?.json() as any; + expect(document?.extensions().get(xParserCircular)?.value()).toEqual(true); + expect(documentData.channels.channel.publish.message.payload.properties.property).not.toBeUndefined(); + expect(documentData.channels.channel.publish.message.payload.properties.property === documentData.channels.channel.publish.message.payload).toEqual(true); + }); + + it('should assign x-parser-circular extension when document has circular schemas on external files', async function() { + const { document } = await parser.parse({ + asyncapi: '2.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: { + channel: { + publish: { + operationId: 'operation', + message: { + payload: { + $ref: '../mocks/parse/circular-ref.yaml' + }, + } + } + } + } + }, { source: __filename }); + + const documentData = document?.json() as any; + expect(document?.extensions().get(xParserCircular)?.value()).toEqual(true); + expect(documentData.channels.channel.publish.message.payload.properties.deepProperty.properties.circular).not.toBeUndefined(); + expect(documentData.channels.channel.publish.message.payload.properties.deepProperty.properties.circular === documentData.channels.channel.publish.message.payload.properties.deepProperty).toEqual(true); + }); + + it('should assign x-parser-circular extension when document has circular schemas on external files (deep case)', async function() { + const { document, diagnostics } = await parser.parse({ + asyncapi: '2.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: { + channel: { + publish: { + operationId: 'operation', + message: { + payload: { + $ref: '../mocks/parse/circular-ref-deep.yaml' + }, + } + } + } + } + }, { source: __filename }); + + const documentData = document?.json() as any; + expect(document?.extensions().get(xParserCircular)?.value()).toEqual(true); + expect(documentData.channels.channel.publish.message.payload.properties.ExternalFile.properties.testExt).not.toBeUndefined(); + expect(documentData.channels.channel.publish.message.payload.properties.ExternalFile.properties.testExt === documentData.channels.channel.publish.message.payload.properties.YetAnother).toEqual(true); + expect(documentData.channels.channel.publish.message.payload.properties.YetAnother.properties.children.items).not.toBeUndefined(); + expect(documentData.channels.channel.publish.message.payload.properties.YetAnother.properties.children.items === documentData.channels.channel.publish.message.payload.properties.ExternalFile).toEqual(true); + }); +}); diff --git a/test/mocks/parse/circular-ref-deep.yaml b/test/mocks/parse/circular-ref-deep.yaml new file mode 100644 index 000000000..b6955451a --- /dev/null +++ b/test/mocks/parse/circular-ref-deep.yaml @@ -0,0 +1,14 @@ +type: object +properties: + ExternalFile: + type: object + properties: + testExt: + $ref: '#/properties/YetAnother' + YetAnother: + type: object + properties: + children: + type: array + items: + $ref: '#/properties/ExternalFile' \ No newline at end of file diff --git a/test/old-api/asyncapi.spec.ts b/test/old-api/asyncapi.spec.ts index 6741e5758..a67d2c78a 100644 --- a/test/old-api/asyncapi.spec.ts +++ b/test/old-api/asyncapi.spec.ts @@ -951,7 +951,7 @@ describe('AsyncAPIDocument', function() { const nestedSchemas = fs.readFileSync(source, 'utf8'); const { document } = await parser.parse(nestedSchemas, { source }); const doc = convertToOldAPI(document!); - const output = {asyncapi: '2.0.0',info: {title: 'Test API',version: '1.0.0'},channels: {mychannel: {publish: {message: {payload: {type: 'object',properties: {name: {type: 'string','x-parser-schema-id': ''}},'x-parser-schema-id': ''},'x-parser-message-name': '','x-parser-original-schema-format': 'application/vnd.aai.asyncapi;version=2.0.0',schemaFormat: 'application/vnd.aai.asyncapi;version=2.0.0','x-parser-original-payload': '$ref:$.channels.mychannel.publish.message.payload','x-parser-message-parsed': true}}}},components: {messages: {testMessage: {payload: {type: 'object',properties: {name: {type: 'string','x-parser-schema-id': ''},deep: {type: 'object',properties: {circular: {$ref: '#/components/schemas/testSchema','x-parser-schema-id': ''}},'x-parser-schema-id': ''}},'x-parser-schema-id': 'testSchema'},'x-parser-message-name': 'testMessage','x-parser-original-schema-format': 'application/vnd.aai.asyncapi;version=2.0.0',schemaFormat: 'application/vnd.aai.asyncapi;version=2.0.0','x-parser-original-payload': '$ref:$.components.messages.testMessage.payload','x-parser-message-parsed': true}},schemas: {testSchema: '$ref:$.components.messages.testMessage.payload'}},'x-parser-spec-parsed': true,'x-parser-circular': true,'x-parser-spec-stringified': true}; + const output = {asyncapi: '2.0.0',info: {title: 'Test API',version: '1.0.0'},channels: {mychannel: {publish: {message: {payload: {type: 'object',properties: {name: {type: 'string','x-parser-schema-id': ''}},'x-parser-schema-id': ''},'x-parser-message-name': '','x-parser-original-schema-format': 'application/vnd.aai.asyncapi;version=2.0.0',schemaFormat: 'application/vnd.aai.asyncapi;version=2.0.0','x-parser-original-payload': '$ref:$.channels.mychannel.publish.message.payload','x-parser-message-parsed': true}}}},components: {messages: {testMessage: {payload: {type: 'object',properties: {name: {type: 'string','x-parser-schema-id': ''},deep: {type: 'object',properties: {circular: '$ref:$.components.messages.testMessage.payload'},'x-parser-schema-id': ''}},'x-parser-schema-id': 'testSchema'},'x-parser-message-name': 'testMessage','x-parser-original-schema-format': 'application/vnd.aai.asyncapi;version=2.0.0',schemaFormat: 'application/vnd.aai.asyncapi;version=2.0.0','x-parser-original-payload': '$ref:$.components.messages.testMessage.payload','x-parser-message-parsed': true}},schemas: {testSchema: '$ref:$.components.messages.testMessage.payload'}},'x-parser-spec-parsed': true,'x-parser-circular': true,'x-parser-spec-stringified': true}; const stringified = AsyncAPIDocument.stringify(doc); expect(stringified).toEqual(JSON.stringify(output)); @@ -963,6 +963,7 @@ describe('AsyncAPIDocument', function() { const { document } = await parser.parse(nestedSchemas, { source }); const doc = convertToOldAPI(document!); const stringified = AsyncAPIDocument.stringify(doc) as string; + expect(doc.json()[xParserSpecStringified]).toEqual(undefined); expect(JSON.parse(stringified)[xParserSpecStringified]).toEqual(true); }); @@ -996,7 +997,7 @@ describe('AsyncAPIDocument', function() { }); it('should parse stringified document with circular references', async function() { - const circularOutput = {asyncapi: '2.0.0',info: {title: 'Test API',version: '1.0.0'},channels: {mychannel: {publish: {message: {payload: {type: 'object',properties: {name: {type: 'string','x-parser-schema-id': ''}},'x-parser-schema-id': ''},'x-parser-message-name': '','x-parser-original-schema-format': 'application/vnd.aai.asyncapi;version=2.0.0',schemaFormat: 'application/vnd.aai.asyncapi;version=2.0.0','x-parser-original-payload': '$ref:$.channels.mychannel.publish.message.payload','x-parser-message-parsed': true}}}},components: {messages: {testMessage: {payload: {type: 'object',properties: {name: {type: 'string','x-parser-schema-id': ''},deep: {type: 'object',properties: {circular: {$ref: '#/components/schemas/testSchema','x-parser-schema-id': ''}},'x-parser-schema-id': ''}},'x-parser-schema-id': 'testSchema'},'x-parser-message-name': 'testMessage','x-parser-original-schema-format': 'application/vnd.aai.asyncapi;version=2.0.0',schemaFormat: 'application/vnd.aai.asyncapi;version=2.0.0','x-parser-original-payload': '$ref:$.components.messages.testMessage.payload','x-parser-message-parsed': true}},schemas: {testSchema: '$ref:$.components.messages.testMessage.payload'}},'x-parser-spec-parsed': true,'x-parser-circular': true,'x-parser-spec-stringified': true}; + const circularOutput = {asyncapi: '2.0.0',info: {title: 'Test API',version: '1.0.0'},channels: {mychannel: {publish: {message: {payload: {type: 'object',properties: {name: {type: 'string','x-parser-schema-id': ''}},'x-parser-schema-id': ''},'x-parser-message-name': '','x-parser-original-schema-format': 'application/vnd.aai.asyncapi;version=2.0.0',schemaFormat: 'application/vnd.aai.asyncapi;version=2.0.0','x-parser-original-payload': '$ref:$.channels.mychannel.publish.message.payload','x-parser-message-parsed': true}}}},components: {messages: {testMessage: {payload: {type: 'object',properties: {name: {type: 'string','x-parser-schema-id': ''},deep: {type: 'object',properties: {circular: '$ref:$.components.messages.testMessage.payload'},'x-parser-schema-id': ''}},'x-parser-schema-id': 'testSchema'},'x-parser-message-name': 'testMessage','x-parser-original-schema-format': 'application/vnd.aai.asyncapi;version=2.0.0',schemaFormat: 'application/vnd.aai.asyncapi;version=2.0.0','x-parser-original-payload': '$ref:$.components.messages.testMessage.payload','x-parser-message-parsed': true}},schemas: {testSchema: '$ref:$.components.messages.testMessage.payload'}},'x-parser-spec-parsed': true,'x-parser-circular': true,'x-parser-spec-stringified': true}; const result = AsyncAPIDocument.parse(circularOutput) as AsyncAPIDocument; expect(result.hasCircular()).toEqual(true);