From 37cdacef83cfdc4998f1b84c235dd34b31119b2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Urba=C5=84czyk?= Date: Sun, 2 Apr 2023 15:36:23 +0200 Subject: [PATCH] feat: new trait behaviour --- package-lock.json | 17 +- package.json | 2 +- src/custom-operations/anonymous-naming.ts | 2 +- src/custom-operations/apply-traits.ts | 83 ++++++-- src/custom-operations/index.ts | 20 +- .../custom-operations/apply-traits-v2.spec.ts | 189 ++++++++++++++++++ .../custom-operations/apply-traits-v3.spec.ts | 159 +++++++++++++++ 7 files changed, 443 insertions(+), 29 deletions(-) create mode 100644 test/custom-operations/apply-traits-v2.spec.ts create mode 100644 test/custom-operations/apply-traits-v3.spec.ts diff --git a/package-lock.json b/package-lock.json index 428a09cd4..07a781109 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.0.0", "license": "Apache-2.0", "dependencies": { - "@asyncapi/specs": "^3.1.0", + "@asyncapi/specs": "^5.0.0-next-major-spec.18", "@openapi-contrib/openapi-schema-to-json-schema": "^3.2.0", "@stoplight/json-ref-resolver": "^3.1.4", "@stoplight/spectral-core": "^1.14.1", @@ -72,8 +72,12 @@ } }, "node_modules/@asyncapi/specs": { - "version": "3.1.0", - "license": "Apache-2.0" + "version": "5.0.0-next-major-spec.18", + "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-5.0.0-next-major-spec.18.tgz", + "integrity": "sha512-WwJxQnAb734a/qe6shxe8GXhf7VgDJCIwex2ejjiYkzNU+xjM+GSpaZYkMBID7WNQtoo8+6rnSE63I9qd6gzUQ==", + "dependencies": { + "@types/json-schema": "^7.0.11" + } }, "node_modules/@babel/code-frame": { "version": "7.16.7", @@ -17694,7 +17698,12 @@ } }, "@asyncapi/specs": { - "version": "3.1.0" + "version": "5.0.0-next-major-spec.18", + "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-5.0.0-next-major-spec.18.tgz", + "integrity": "sha512-WwJxQnAb734a/qe6shxe8GXhf7VgDJCIwex2ejjiYkzNU+xjM+GSpaZYkMBID7WNQtoo8+6rnSE63I9qd6gzUQ==", + "requires": { + "@types/json-schema": "^7.0.11" + } }, "@babel/code-frame": { "version": "7.16.7", diff --git a/package.json b/package.json index 74c5ead08..fab1c59c1 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "release": "semantic-release" }, "dependencies": { - "@asyncapi/specs": "^3.1.0", + "@asyncapi/specs": "^5.0.0-next-major-spec.18", "@openapi-contrib/openapi-schema-to-json-schema": "^3.2.0", "@stoplight/json-ref-resolver": "^3.1.4", "@stoplight/spectral-core": "^1.14.1", diff --git a/src/custom-operations/anonymous-naming.ts b/src/custom-operations/anonymous-naming.ts index fa94171ab..d29b9e917 100644 --- a/src/custom-operations/anonymous-naming.ts +++ b/src/custom-operations/anonymous-naming.ts @@ -29,7 +29,7 @@ function assignNameToAnonymousMessages(document: AsyncAPIDocumentInterface) { let anonymousMessageCounter = 0; document.messages().forEach(message => { if (message.name() === undefined && message.extensions().get(xParserMessageName)?.value() === undefined) { - setExtension(xParserMessageName, ``, message); + setExtension(xParserMessageName, message.id() || ``, message); } }); } diff --git a/src/custom-operations/apply-traits.ts b/src/custom-operations/apply-traits.ts index cc6191857..990aca601 100644 --- a/src/custom-operations/apply-traits.ts +++ b/src/custom-operations/apply-traits.ts @@ -2,7 +2,7 @@ import { JSONPath } from 'jsonpath-plus'; import { mergePatch } from '../utils'; -import type { v2 } from '../spec-types'; +import type { v2, v3 } from '../spec-types'; const v2TraitPaths = [ // operations @@ -17,42 +17,87 @@ 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: v2.AsyncAPIObject) { // TODO: Change type when we will have implemented types for v3 - applyAllTraits(asyncapi, v3TraitPaths); +export function applyTraitsV3(asyncapi: v3.AsyncAPIObject) { + applyAllTraitsV3(asyncapi, v3TraitPaths); } -function applyAllTraits(asyncapi: Record, paths: string[]) { +function applyAllTraitsV3(asyncapi: Record, paths: string[]) { + const visited: Set = new Set(); paths.forEach(path => { JSONPath({ path, json: asyncapi, resultType: 'value', - callback(value) { applyTraits(value); }, + callback(value) { + if (visited.has(value)) { + return; + } + visited.add(value); + applyTraitsToObjectV3(value); + }, }); }); } -function applyTraits(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)]); - } +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 6f64035d2..058675a99 100644 --- a/src/custom-operations/index.ts +++ b/src/custom-operations/index.ts @@ -1,4 +1,4 @@ -import { applyTraitsV2 } from './apply-traits'; +import { applyTraitsV2, applyTraitsV3 } from './apply-traits'; import { checkCircularRefs } from './check-circular-refs'; import { parseSchemasV2 } from './parse-schema'; import { anonymousNaming } from './anonymous-naming'; @@ -7,18 +7,17 @@ import type { Parser } from '../parser'; import type { ParseOptions } from '../parse'; import type { AsyncAPIDocumentInterface } from '../models'; import type { DetailedAsyncAPI } from '../types'; -import type { v2 } from '../spec-types'; +import type { v2, v3 } from '../spec-types'; export async function customOperations(parser: Parser, document: AsyncAPIDocumentInterface, detailed: DetailedAsyncAPI, options: ParseOptions): Promise { switch (detailed.semver.major) { case 2: return operationsV2(parser, document, detailed, options); - // case 3: return operationsV3(parser, document, detailed, options); + case 3: return operationsV3(parser, document, detailed, options); } } async function operationsV2(parser: Parser, document: AsyncAPIDocumentInterface, detailed: DetailedAsyncAPI, options: ParseOptions): Promise { checkCircularRefs(document); - anonymousNaming(document); if (options.applyTraits) { applyTraitsV2(detailed.parsed as v2.AsyncAPIObject); @@ -26,5 +25,18 @@ async function operationsV2(parser: Parser, document: AsyncAPIDocumentInterface, if (options.parseSchemas) { await parseSchemasV2(parser, detailed); } + anonymousNaming(document); } +async function operationsV3(parser: Parser, document: AsyncAPIDocumentInterface, detailed: DetailedAsyncAPI, 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/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..eebed6a89 --- /dev/null +++ b/test/custom-operations/apply-traits-v3.spec.ts @@ -0,0 +1,159 @@ +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 } = 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' }); + }); +});