diff --git a/package-lock.json b/package-lock.json index b54760ed1..b2253ad39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.1.0-next-major-spec.1", "license": "Apache-2.0", "dependencies": { - "@asyncapi/specs": "^5.1.0", + "@asyncapi/specs": "^6.0.0-next-major-spec.2", "@openapi-contrib/openapi-schema-to-json-schema": "~3.2.0", "@stoplight/json-ref-resolver": "^3.1.5", "@stoplight/spectral-core": "^1.16.1", @@ -79,9 +79,9 @@ } }, "node_modules/@asyncapi/specs": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-5.1.0.tgz", - "integrity": "sha512-yffhETqehkim43luMnPKOwzY0D0YtU4bKpORIXIaid6p5Y5kDLrMGJaEPkNieQp03HMjhjFrnUPtT8kvqe0+aQ==", + "version": "6.0.0-next-major-spec.2", + "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-6.0.0-next-major-spec.2.tgz", + "integrity": "sha512-kKFdVZkWnkmcNWADg3lt9AExM9jGGpAIrUcIYSbHNSSQgjZC6rr0urqctAaXXtBVrJNBI9LNG/Us70R/0MOLbw==", "dependencies": { "@types/json-schema": "^7.0.11" } @@ -10958,15 +10958,6 @@ "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", "dev": true }, - "node_modules/word-wrap": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz", - "integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -11125,9 +11116,9 @@ } }, "@asyncapi/specs": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-5.1.0.tgz", - "integrity": "sha512-yffhETqehkim43luMnPKOwzY0D0YtU4bKpORIXIaid6p5Y5kDLrMGJaEPkNieQp03HMjhjFrnUPtT8kvqe0+aQ==", + "version": "6.0.0-next-major-spec.2", + "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-6.0.0-next-major-spec.2.tgz", + "integrity": "sha512-kKFdVZkWnkmcNWADg3lt9AExM9jGGpAIrUcIYSbHNSSQgjZC6rr0urqctAaXXtBVrJNBI9LNG/Us70R/0MOLbw==", "requires": { "@types/json-schema": "^7.0.11" } @@ -19500,15 +19491,9 @@ } }, "wildcard": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.0.tgz", - "integrity": "sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==", - "dev": true - }, - "word-wrap": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz", - "integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", "dev": true }, "wrap-ansi": { diff --git a/package.json b/package.json index 2572ff354..5fcc5f9c2 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "prepublishOnly": "npm run generate:assets" }, "dependencies": { - "@asyncapi/specs": "^5.1.0", + "@asyncapi/specs": "^6.0.0-next-major-spec.2", "@openapi-contrib/openapi-schema-to-json-schema": "~3.2.0", "@stoplight/json-ref-resolver": "^3.1.5", "@stoplight/spectral-core": "^1.16.1", diff --git a/src/custom-operations/apply-traits.ts b/src/custom-operations/apply-traits.ts index a57e3dcf7..990aca601 100644 --- a/src/custom-operations/apply-traits.ts +++ b/src/custom-operations/apply-traits.ts @@ -17,26 +17,54 @@ const v2TraitPaths = [ ]; export function applyTraitsV2(asyncapi: v2.AsyncAPIObject) { - applyAllTraits(asyncapi, v2TraitPaths); + applyAllTraitsV2(asyncapi, v2TraitPaths); +} + +function applyAllTraitsV2(asyncapi: Record, paths: string[]) { + const visited: Set = new Set(); + paths.forEach(path => { + JSONPath({ + path, + json: asyncapi, + resultType: 'value', + callback(value) { + if (visited.has(value)) { + return; + } + visited.add(value); + applyTraitsToObjectV2(value); + }, + }); + }); +} + +function applyTraitsToObjectV2(value: Record) { + if (Array.isArray(value.traits)) { + for (const trait of value.traits) { + for (const key in trait) { + value[String(key)] = mergePatch(value[String(key)], trait[String(key)]); + } + } + } } const v3TraitPaths = [ // operations - '$.channels.*.[publish,subscribe]', - '$.components.channels.*.[publish,subscribe]', + '$.operations.*', + '$.components.operations.*', // messages - '$.channels.*.[publish,subscribe].message', - '$.channels.*.[publish,subscribe].message.oneOf.*', - '$.components.channels.*.[publish,subscribe].message', - '$.components.channels.*.[publish,subscribe].message.oneOf.*', + '$.channels.*.messages.*', + '$.operations.*.messages.*', + '$.components.channels.*.messages.*', + '$.components.operations.*.messages.*', '$.components.messages.*', ]; export function applyTraitsV3(asyncapi: v3.AsyncAPIObject) { - applyAllTraits(asyncapi, v3TraitPaths); + applyAllTraitsV3(asyncapi, v3TraitPaths); } -function applyAllTraits(asyncapi: Record, paths: string[]) { +function applyAllTraitsV3(asyncapi: Record, paths: string[]) { const visited: Set = new Set(); paths.forEach(path => { JSONPath({ @@ -48,18 +76,28 @@ function applyAllTraits(asyncapi: Record, paths: string[]) { return; } visited.add(value); - applyTraits(value); + applyTraitsToObjectV3(value); }, }); }); } -function applyTraits(value: Record & { traits?: any[] }) { - if (Array.isArray(value.traits)) { - for (const trait of value.traits) { - for (const key in trait) { - value[String(key)] = mergePatch(value[String(key)], trait[String(key)]); - } +function applyTraitsToObjectV3(value: Record) { + if (!Array.isArray(value.traits)) { + return; + } + + // shallow copy of object + const copy = { ...value }; + // reset the object but preserve the reference + for (const key in value) { + delete value[key]; + } + + // merge root object at the end + for (const trait of [...copy.traits as any[], copy]) { + for (const key in trait) { + value[String(key)] = mergePatch(value[String(key)], trait[String(key)]); } } -} +} \ No newline at end of file diff --git a/src/custom-operations/index.ts b/src/custom-operations/index.ts index 2ff6501ac..e360848b9 100644 --- a/src/custom-operations/index.ts +++ b/src/custom-operations/index.ts @@ -1,23 +1,26 @@ -import { applyTraitsV2 } from './apply-traits'; -import { resolveCircularRefs } from './resolve-circular-refs'; +import { applyTraitsV2, applyTraitsV3 } from './apply-traits'; +import { checkCircularRefs } from './check-circular-refs'; import { parseSchemasV2 } from './parse-schema'; import { anonymousNaming } from './anonymous-naming'; +import { resolveCircularRefs } from './resolve-circular-refs'; 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'; -import { v2 } from 'spec-types'; +import type { v2, v3 } from '../spec-types'; 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, inventory, options); - // case 3: return operationsV3(parser, document, detailed, options); + case 3: return operationsV3(parser, document, detailed, inventory, options); } } async function operationsV2(parser: Parser, document: AsyncAPIDocumentInterface, detailed: DetailedAsyncAPI, inventory: RulesetFunctionContext['documentInventory'], options: ParseOptions): Promise { + checkCircularRefs(document); + if (options.applyTraits) { applyTraitsV2(detailed.parsed as v2.AsyncAPIObject); } @@ -32,3 +35,15 @@ async function operationsV2(parser: Parser, document: AsyncAPIDocumentInterface, anonymousNaming(document); } +async function operationsV3(parser: Parser, document: AsyncAPIDocumentInterface, detailed: DetailedAsyncAPI, inventory: RulesetFunctionContext['documentInventory'], options: ParseOptions): Promise { + checkCircularRefs(document); + + if (options.applyTraits) { + applyTraitsV3(detailed.parsed as v3.AsyncAPIObject); + } + // TODO: Support schema parsing in v3 + // if (options.parseSchemas) { + // await parseSchemasV2(parser, detailed); + // } + anonymousNaming(document); +} diff --git a/src/document.ts b/src/document.ts index 16f00f813..887fd02b9 100644 --- a/src/document.ts +++ b/src/document.ts @@ -11,14 +11,14 @@ import { import type { AsyncAPIDocumentInterface } from './models'; import type { OldAsyncAPIDocument } from './old-api'; import type { DetailedAsyncAPI, AsyncAPIObject } from './types'; -import { v2 } from 'spec-types'; +import { v2, v3 } from 'spec-types'; export function createAsyncAPIDocument(asyncapi: DetailedAsyncAPI): AsyncAPIDocumentInterface { switch (asyncapi.semver.major) { case 2: return new AsyncAPIDocumentV2(asyncapi.parsed as v2.AsyncAPIObject, { asyncapi, pointer: '/' }); - // case 3: - // return new AsyncAPIDocumentV3(asyncapi.parsed, { asyncapi, pointer: '/' }); + case 3: + return new AsyncAPIDocumentV3(asyncapi.parsed as v3.AsyncAPIObject, { asyncapi, pointer: '/' }); default: throw new Error(`Unsupported AsyncAPI version: ${asyncapi.semver.version}`); } diff --git a/test/custom-operations/apply-traits-v2.spec.ts b/test/custom-operations/apply-traits-v2.spec.ts new file mode 100644 index 000000000..4eb2800fc --- /dev/null +++ b/test/custom-operations/apply-traits-v2.spec.ts @@ -0,0 +1,189 @@ +import { AsyncAPIDocumentV2 } from '../../src/models'; +import { Parser } from '../../src/parser'; + +import type { v2 } from '../../src/spec-types'; + +describe('custom operations - apply traits v3', function() { + const parser = new Parser(); + + it('should apply traits to operations', async function() { + const documentRaw = { + asyncapi: '2.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: { + channel: { + publish: { + operationId: 'publishId', + traits: [ + { + operationId: 'anotherPubId', + description: 'some description' + }, + { + description: 'another description' + } + ] + }, + subscribe: { + operationId: 'subscribeId', + traits: [ + { + operationId: 'anotherSubId', + description: 'some description' + }, + { + description: 'another description' + } + ] + } + } + } + }; + const { document, diagnostics } = await parser.parse(documentRaw); + + const v2Document = document as AsyncAPIDocumentV2; + expect(v2Document).toBeInstanceOf(AsyncAPIDocumentV2); + expect(diagnostics.length > 0).toEqual(true); + + const publish = v2Document?.json()?.channels?.channel?.publish; + delete publish?.traits; + expect(publish).toEqual({ operationId: 'anotherPubId', description: 'another description' }); + + const subscribe = v2Document?.json()?.channels?.channel?.subscribe; + delete subscribe?.traits; + expect(subscribe).toEqual({ operationId: 'anotherSubId', description: 'another description' }); + }); + + it('should apply traits to messages', async function() { + const documentRaw = { + asyncapi: '2.4.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: { + channel: { + publish: { + operationId: 'operationId', + message: { + messageId: 'messageId', + traits: [ + { + messageId: 'anotherMessageId1', + description: 'some description' + }, + { + description: 'another description' + } + ] + } + }, + subscribe: { + message: { + oneOf: [ + { + messageId: 'messageId', + traits: [ + { + messageId: 'anotherMessageId2', + description: 'some description' + }, + { + description: 'another description' + } + ] + }, + { + messageId: 'messageId', + traits: [ + { + messageId: 'anotherId', + description: 'some description' + }, + { + description: 'another description' + }, + { + messageId: 'anotherMessageId3', + description: 'simple description' + } + ] + } + ], + } + } + } + } + }; + const { document, diagnostics } = await parser.parse(documentRaw); + + const v2Document = document as AsyncAPIDocumentV2; + expect(v2Document).toBeInstanceOf(AsyncAPIDocumentV2); + expect(diagnostics.length > 0).toEqual(true); + + const message = v2Document?.json()?.channels?.channel?.publish?.message; + delete (message as v2.MessageObject)?.traits; + expect(message).toEqual({ messageId: 'anotherMessageId1', description: 'another description', 'x-parser-message-name': 'anotherMessageId1' }); + + const messageOneOf1 = (v2Document?.json()?.channels?.channel?.subscribe?.message as { oneOf: Array }).oneOf[0]; + delete messageOneOf1?.traits; + expect(messageOneOf1).toEqual({ messageId: 'anotherMessageId2', description: 'another description', 'x-parser-message-name': 'anotherMessageId2' }); + + const messageOneOf2 = (v2Document?.json()?.channels?.channel?.subscribe?.message as { oneOf: Array }).oneOf[1]; + delete messageOneOf2?.traits; + expect(messageOneOf2).toEqual({ messageId: 'anotherMessageId3', description: 'simple description', 'x-parser-message-name': 'anotherMessageId3' }); + }); + + it('should preserve this same references', async function() { + const documentRaw = { + asyncapi: '2.4.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: { + channel: { + publish: { + operationId: 'publishId', + message: { + $ref: '#/components/messages/message', + } + }, + } + }, + components: { + messages: { + message: { + messageId: 'messageId', + traits: [ + { + messageId: 'anotherId', + description: 'some description' + }, + { + description: 'another description' + }, + { + messageId: 'anotherMessageId3', + description: 'simple description' + } + ] + } + } + } + }; + const { document, diagnostics } = await parser.parse(documentRaw); + + const v2Document = document as AsyncAPIDocumentV2; + expect(v2Document).toBeInstanceOf(AsyncAPIDocumentV2); + expect(diagnostics.length > 0).toEqual(true); + + const message = v2Document?.json()?.channels?.channel?.publish?.message; + delete (message as v2.MessageObject)?.traits; + expect(message).toEqual({ messageId: 'anotherMessageId3', description: 'simple description', 'x-parser-message-name': 'anotherMessageId3' }); + expect(message === v2Document?.json()?.components?.messages?.message).toEqual(true); + }); +}); diff --git a/test/custom-operations/apply-traits-v3.spec.ts b/test/custom-operations/apply-traits-v3.spec.ts new file mode 100644 index 000000000..19f627c57 --- /dev/null +++ b/test/custom-operations/apply-traits-v3.spec.ts @@ -0,0 +1,158 @@ +import { AsyncAPIDocumentV3 } from '../../src/models'; +import { Parser } from '../../src/parser'; + +import type { v3 } from '../../src/spec-types'; + +describe('custom operations - apply traits v3', function() { + const parser = new Parser(); + + it('should apply traits to operations', async function() { + const documentRaw = { + asyncapi: '3.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + operations: { + someOperation1: { + traits: [ + { + description: 'some description' + }, + { + description: 'another description' + } + ] + }, + someOperation2: { + description: 'root description', + traits: [ + { + description: 'some description' + }, + { + description: 'another description' + } + ] + } + } + }; + const { document, diagnostics } = await parser.parse(documentRaw); + const v3Document = document as AsyncAPIDocumentV3; + expect(v3Document).toBeInstanceOf(AsyncAPIDocumentV3); + + const someOperation1 = v3Document?.json()?.operations?.someOperation1; + delete someOperation1?.traits; + expect(someOperation1).toEqual({ description: 'another description' }); + + const someOperation2 = v3Document?.json()?.operations?.someOperation2; + delete someOperation2?.traits; + expect(someOperation2).toEqual({ description: 'root description' }); + }); + + it('should apply traits to messages (channels)', async function() { + const documentRaw = { + asyncapi: '3.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: { + someChannel1: { + messages: [ + { + traits: [ + { + messageId: 'traitMessageId', + description: 'some description' + }, + { + description: 'another description' + } + ] + } + ] + }, + someChannel2: { + messages: [ + { + messageId: 'rootMessageId', + description: 'root description', + traits: [ + { + messageId: 'traitMessageId', + description: 'some description' + }, + { + description: 'another description' + } + ] + } + ] + } + } + }; + const { document } = await parser.parse(documentRaw); + + const v3Document = document as AsyncAPIDocumentV3; + expect(v3Document).toBeInstanceOf(AsyncAPIDocumentV3); + + const message1 = v3Document?.json()?.channels?.someChannel1?.messages?.[0]; + delete (message1 as v3.MessageObject)?.traits; + expect(message1).toEqual({ messageId: 'traitMessageId', description: 'another description', 'x-parser-message-name': 'traitMessageId' }); + + const message2 = v3Document?.json()?.channels?.someChannel2?.messages?.[0]; + delete (message2 as v3.MessageObject)?.traits; + expect(message2).toEqual({ messageId: 'rootMessageId', description: 'root description', 'x-parser-message-name': 'rootMessageId' }); + }); + + it('should apply traits to messages (components)', async function() { + const documentRaw = { + asyncapi: '3.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + components: { + messages: { + someMessage1: { + traits: [ + { + messageId: 'traitMessageId', + description: 'some description' + }, + { + description: 'another description' + } + ] + }, + someMessage2: { + messageId: 'rootMessageId', + description: 'root description', + traits: [ + { + messageId: 'traitMessageId', + description: 'some description' + }, + { + description: 'another description' + } + ] + } + } + } + }; + const { document } = await parser.parse(documentRaw); + + const v3Document = document as AsyncAPIDocumentV3; + expect(v3Document).toBeInstanceOf(AsyncAPIDocumentV3); + + const message1 = v3Document?.json()?.components?.messages?.someMessage1; + delete (message1 as v3.MessageObject)?.traits; + expect(message1).toEqual({ messageId: 'traitMessageId', description: 'another description', 'x-parser-message-name': 'traitMessageId' }); + + const message2 = v3Document?.json()?.components?.messages?.someMessage2; + delete (message2 as v3.MessageObject)?.traits; + expect(message2).toEqual({ messageId: 'rootMessageId', description: 'root description', 'x-parser-message-name': 'rootMessageId' }); + }); +}); diff --git a/test/document.spec.ts b/test/document.spec.ts index 3fd419bcb..64b963218 100644 --- a/test/document.spec.ts +++ b/test/document.spec.ts @@ -1,5 +1,5 @@ import { xParserApiVersion, xParserSpecParsed, xParserSpecStringified } from '../src/constants'; -import { BaseModel, AsyncAPIDocumentV2 } from '../src/models'; +import { AsyncAPIDocumentInterface, BaseModel, AsyncAPIDocumentV2, AsyncAPIDocumentV3 } from '../src/models'; import { convertToOldAPI } from '../src/old-api'; import { Parser } from '../src/parser'; import { @@ -17,12 +17,17 @@ describe('utils', function() { class Model extends BaseModel {} describe('createAsyncAPIDocument()', function() { - it('should create a valid document from v2.0.0', function() { - const doc = { asyncapi: '2.0.0' }; + const cases = [ + [2, AsyncAPIDocumentV2], + [3, AsyncAPIDocumentV3], + ]; + + test.each(cases)('should create a valid document from a v%p.0.0 source', (majorVersion, expected) => { + const doc = { asyncapi: `${majorVersion}.0.0` }; const detailed = createDetailedAsyncAPI(doc as any, doc as any); const d = createAsyncAPIDocument(detailed); expect(d.version()).toEqual(doc.asyncapi); - expect(d).toBeInstanceOf(AsyncAPIDocumentV2); + expect(d).toBeInstanceOf(expected); }); it('should fail trying to create a document from a non supported spec version', function() {