From 42ea4add0d59e627e310c479b7cc5ee0471d2647 Mon Sep 17 00:00:00 2001 From: Sergio Moya <1083296+smoya@users.noreply.github.com> Date: Mon, 27 Nov 2023 12:37:25 +0100 Subject: [PATCH 1/4] feat: add Spectral rule to validate operation messages --- src/ruleset/index.ts | 2 + .../functions/operationMessagesUnambiguity.ts | 44 ++++ src/ruleset/v3/index.ts | 1 + src/ruleset/v3/ruleset.ts | 28 +++ ...ion-messages-from-referred-channel.spec.ts | 234 ++++++++++++++++++ test/ruleset/tester.ts | 3 + 6 files changed, 312 insertions(+) create mode 100644 src/ruleset/v3/functions/operationMessagesUnambiguity.ts create mode 100644 src/ruleset/v3/index.ts create mode 100644 src/ruleset/v3/ruleset.ts create mode 100644 test/ruleset/rules/v3/asyncapi3-operation-messages-from-referred-channel.spec.ts diff --git a/src/ruleset/index.ts b/src/ruleset/index.ts index f80a62f96..b4197dc0f 100644 --- a/src/ruleset/index.ts +++ b/src/ruleset/index.ts @@ -1,5 +1,6 @@ import { coreRuleset, recommendedRuleset } from './ruleset'; import { v2CoreRuleset, v2SchemasRuleset, v2RecommendedRuleset } from './v2'; +import { v3CoreRuleset } from './v3'; import type { Parser } from '../parser'; import type { RulesetDefinition } from '@stoplight/spectral-core'; @@ -18,6 +19,7 @@ export function createRuleset(parser: Parser, options?: RulesetOptions): Ruleset useCore && v2CoreRuleset, useCore && v2SchemasRuleset(parser), useRecommended && v2RecommendedRuleset, + useCore && v3CoreRuleset, ...(options as any || {})?.extends || [], ].filter(Boolean); diff --git a/src/ruleset/v3/functions/operationMessagesUnambiguity.ts b/src/ruleset/v3/functions/operationMessagesUnambiguity.ts new file mode 100644 index 000000000..eb8a57a5c --- /dev/null +++ b/src/ruleset/v3/functions/operationMessagesUnambiguity.ts @@ -0,0 +1,44 @@ +import { createRulesetFunction } from '@stoplight/spectral-core'; +import type { IFunctionResult } from '@stoplight/spectral-core'; +import { SchemaDefinition } from '@stoplight/spectral-core/dist/ruleset/function'; + +const referenceSchema: SchemaDefinition = { + type: 'object', + properties: { + $ref: { + type: 'string', + format: 'uri-reference' + }, + }, +}; + +export const operationMessagesUnambiguity = createRulesetFunction<{ channel?: {'$ref': string}; messages?: [{'$ref': string}] }, null>( + { + input: { + type: 'object', + properties: { + channel: referenceSchema, + messages: { + type: 'array', + items: referenceSchema, + }, + }, + }, + options: null, + }, + (targetVal, _, ctx) => { + const results: IFunctionResult[] = []; + const channelPointer = targetVal.channel?.$ref as string; // required + + targetVal.messages?.forEach((message, index) => { + if (!message.$ref.startsWith(channelPointer)) { + results.push({ + message: 'Operation message does not belong to the specified channel.', + path: [...ctx.path, 'messages', index], + }); + } + }); + + return results; + }, +); diff --git a/src/ruleset/v3/index.ts b/src/ruleset/v3/index.ts new file mode 100644 index 000000000..5acc4189e --- /dev/null +++ b/src/ruleset/v3/index.ts @@ -0,0 +1 @@ +export * from './ruleset'; diff --git a/src/ruleset/v3/ruleset.ts b/src/ruleset/v3/ruleset.ts new file mode 100644 index 000000000..56f7b6823 --- /dev/null +++ b/src/ruleset/v3/ruleset.ts @@ -0,0 +1,28 @@ +/* eslint-disable sonarjs/no-duplicate-string */ + +import { AsyncAPIFormats } from '../formats'; +import { operationMessagesUnambiguity } from './functions/operationMessagesUnambiguity'; + +export const v3CoreRuleset = { + description: 'Core AsyncAPI 3.x.x ruleset.', + formats: AsyncAPIFormats.filterByMajorVersions(['3']).formats(), + rules: { + /** + * Operation Object rules + */ + 'asyncapi3-operation-messages-from-referred-channel': { + description: 'Operation "messages" must be a subset of the messages defined in the channel referenced in this operation.', + message: '{{error}}', + severity: 'error', + recommended: true, + resolved: false, // We use the JSON pointer to match the channel. + given: [ + '$.operations.*', + '$.components.operations.*', + ], + then: { + function: operationMessagesUnambiguity, + }, + }, + }, +}; diff --git a/test/ruleset/rules/v3/asyncapi3-operation-messages-from-referred-channel.spec.ts b/test/ruleset/rules/v3/asyncapi3-operation-messages-from-referred-channel.spec.ts new file mode 100644 index 000000000..975ac32db --- /dev/null +++ b/test/ruleset/rules/v3/asyncapi3-operation-messages-from-referred-channel.spec.ts @@ -0,0 +1,234 @@ +import { testRule, DiagnosticSeverity } from '../../tester'; + +testRule('asyncapi3-operation-messages-from-referred-channel', [ + { + name: 'valid case - required channel', + document: { + asyncapi: '3.0.0', + info: { + title: 'Account Service', + version: '1.0.0' + }, + channels: { + UserSignedUp: { + messages: { + UserSignedUp: { + payload: { + type: 'object', + properties: { + displayName: { + type: 'string' + }, + email: { + type: 'string' + } + } + } + } + } + } + }, + operations: { + UserSignedUp: { + action: 'send', + channel: { + $ref: '#/channels/UserSignedUp' + }, + messages: [ + { + $ref: '#/channels/UserSignedUp/messages/UserSignedUp' + } + ] + } + } + }, + errors: [], + }, + { + name: 'valid case - optional channel', + document: { + asyncapi: '3.0.0', + info: { + title: 'Account Service', + version: '1.0.0' + }, + channels: { + UserSignedUp: { + messages: { + UserSignedUp: { + payload: { + type: 'object', + properties: { + displayName: { + type: 'string' + }, + email: { + type: 'string' + } + } + } + } + } + } + }, + components: { + operations: { + UserSignedUp: { + action: 'send', + channel: { + $ref: '#/channels/UserSignedUp' + }, + messages: [ + { + $ref: '#/channels/UserSignedUp/messages/UserSignedUp' + } + ] + } + }, + } + }, + errors: [], + }, + { + name: 'invalid case - message from operation in root pointing to a message from an optional channel (same name) defined under components', + document: { + asyncapi: '3.0.0', + info: { + title: 'Account Service', + version: '1.0.0' + }, + channels: { + UserSignedUp: { + messages: { + UserSignedUp: { + payload: { + type: 'object', + properties: { + displayName: { + type: 'string' + }, + email: { + type: 'string' + } + } + } + } + } + } + }, + operations: { + UserSignedUp: { + action: 'send', + channel: { + $ref: '#/channels/UserSignedUp' + }, + messages: [ + { + $ref: '#/components/channels/UserSignedUp/messages/UserSignedUp' + } + ] + } + }, + components: { + channels: { + UserSignedUp: { + messages: { + UserSignedUp: { + payload: { + type: 'object', + properties: { + displayName: { + type: 'string' + }, + email: { + type: 'string' + } + } + } + } + } + } + }, + }, + }, + errors: [ + { + message: + 'Operation message does not belong to the specified channel.', + path: ['operations', 'UserSignedUp', 'messages', '0'], + severity: DiagnosticSeverity.Error, + } + ], + }, + { + name: 'invalid case - message from operation in components pointing to a message from a different channel defined under components', + document: { + asyncapi: '3.0.0', + info: { + title: 'Account Service', + version: '1.0.0' + }, + channels: { + UserSignedUp: { + messages: { + UserSignedUp: { + payload: { + type: 'object', + properties: { + displayName: { + type: 'string' + }, + email: { + type: 'string' + } + } + } + } + } + } + }, + components: { + channels: { + UserRemoved: { + messages: { + UserRemoved: { + payload: { + type: 'object', + properties: { + displayName: { + type: 'string' + }, + email: { + type: 'string' + } + } + } + } + } + } + }, + operations: { + UserSignedUp: { + action: 'send', + channel: { + $ref: '#/channels/UserSignedUp' + }, + messages: [ + { + $ref: '#/components/channels/UserRemoved/messages/UserRemoved' + } + ] + } + }, + } + }, + errors: [ + { + message: + 'Operation message does not belong to the specified channel.', + path: ['components', 'operations', 'UserSignedUp', 'messages', '0'], + severity: DiagnosticSeverity.Error, + } + ], + }, +]); diff --git a/test/ruleset/tester.ts b/test/ruleset/tester.ts index df08292aa..65c9971b0 100644 --- a/test/ruleset/tester.ts +++ b/test/ruleset/tester.ts @@ -4,6 +4,7 @@ import { AvroSchemaParser } from '@asyncapi/avro-schema-parser'; // allows testi // rulesets import { coreRuleset, recommendedRuleset } from '../../src/ruleset/ruleset'; import { v2CoreRuleset, v2SchemasRuleset, v2RecommendedRuleset } from '../../src/ruleset/v2'; +import { v3CoreRuleset } from '../../src/ruleset/v3'; import type { ParserOptions } from '../../src/parser'; import type { IRuleResult, RulesetDefinition } from '@stoplight/spectral-core'; @@ -15,6 +16,7 @@ type RuleNames = | RulesetRules | RulesetRules | RulesetRules> + | RulesetRules type Scenario = ReadonlyArray< Readonly<{ @@ -51,6 +53,7 @@ function createParser(rules: Array, options: ParserOptions = {}): Par [recommendedRuleset as RulesetDefinition, 'off'], [v2CoreRuleset as RulesetDefinition, 'off'], [v2RecommendedRuleset as RulesetDefinition, 'off'], + [v3CoreRuleset as RulesetDefinition, 'off'], ], rules: { 'asyncapi2-schemas': 'off', From fe1cb8deeafa8d5b0e210d3982eff8b4ba829b6d Mon Sep 17 00:00:00 2001 From: Sergio Moya <1083296+smoya@users.noreply.github.com> Date: Mon, 27 Nov 2023 12:49:08 +0100 Subject: [PATCH 2/4] fix test example --- test/custom-operations/parse-schema-v3.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/custom-operations/parse-schema-v3.spec.ts b/test/custom-operations/parse-schema-v3.spec.ts index fab592bde..6e56ed10a 100644 --- a/test/custom-operations/parse-schema-v3.spec.ts +++ b/test/custom-operations/parse-schema-v3.spec.ts @@ -26,7 +26,7 @@ describe('custom operations for v3 - parse schemas', function() { }, messages: [ { - $ref: '#/components/messages/message' + $ref: '#/channels/channel/messages/message' } ] } From 6557d78bb5028286f4f47dcc61e7930f4e0c7a93 Mon Sep 17 00:00:00 2001 From: Sergio Moya <1083296+smoya@users.noreply.github.com> Date: Mon, 27 Nov 2023 12:51:56 +0100 Subject: [PATCH 3/4] be more precise when matching the json pointer --- src/ruleset/v3/functions/operationMessagesUnambiguity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ruleset/v3/functions/operationMessagesUnambiguity.ts b/src/ruleset/v3/functions/operationMessagesUnambiguity.ts index eb8a57a5c..75c1d5206 100644 --- a/src/ruleset/v3/functions/operationMessagesUnambiguity.ts +++ b/src/ruleset/v3/functions/operationMessagesUnambiguity.ts @@ -31,7 +31,7 @@ export const operationMessagesUnambiguity = createRulesetFunction<{ channel?: {' const channelPointer = targetVal.channel?.$ref as string; // required targetVal.messages?.forEach((message, index) => { - if (!message.$ref.startsWith(channelPointer)) { + if (!message.$ref.startsWith(`${channelPointer}/messages`)) { results.push({ message: 'Operation message does not belong to the specified channel.', path: [...ctx.path, 'messages', index], From 2e7953ce8e85d0640e7225608b315c769b628409 Mon Sep 17 00:00:00 2001 From: Sergio Moya <1083296+smoya@users.noreply.github.com> Date: Mon, 27 Nov 2023 12:56:44 +0100 Subject: [PATCH 4/4] improve test by testing multiple messages in operation --- ...ion-messages-from-referred-channel.spec.ts | 97 ++++++++++++++++++- 1 file changed, 93 insertions(+), 4 deletions(-) diff --git a/test/ruleset/rules/v3/asyncapi3-operation-messages-from-referred-channel.spec.ts b/test/ruleset/rules/v3/asyncapi3-operation-messages-from-referred-channel.spec.ts index 975ac32db..fc51d47b5 100644 --- a/test/ruleset/rules/v3/asyncapi3-operation-messages-from-referred-channel.spec.ts +++ b/test/ruleset/rules/v3/asyncapi3-operation-messages-from-referred-channel.spec.ts @@ -153,8 +153,7 @@ testRule('asyncapi3-operation-messages-from-referred-channel', [ }, errors: [ { - message: - 'Operation message does not belong to the specified channel.', + message: 'Operation message does not belong to the specified channel.', path: ['operations', 'UserSignedUp', 'messages', '0'], severity: DiagnosticSeverity.Error, } @@ -224,11 +223,101 @@ testRule('asyncapi3-operation-messages-from-referred-channel', [ }, errors: [ { - message: - 'Operation message does not belong to the specified channel.', + message: 'Operation message does not belong to the specified channel.', path: ['components', 'operations', 'UserSignedUp', 'messages', '0'], severity: DiagnosticSeverity.Error, } ], }, + { + name: 'invalid case - multiple messages from operation in components pointing to multiple message from a different channel defined under components', + document: { + asyncapi: '3.0.0', + info: { + title: 'Account Service', + version: '1.0.0' + }, + channels: { + UserSignedUp: { + messages: { + UserSignedUp: { + payload: { + type: 'object', + properties: { + displayName: { + type: 'string' + }, + email: { + type: 'string' + } + } + } + } + } + } + }, + components: { + channels: { + UserRemoved: { + messages: { + UserRemoved: { + payload: { + type: 'object', + properties: { + displayName: { + type: 'string' + }, + email: { + type: 'string' + } + } + } + }, + UserDeleted: { + payload: { + type: 'object', + properties: { + displayName: { + type: 'string' + }, + email: { + type: 'string' + } + } + } + } + } + } + }, + operations: { + UserSignedUp: { + action: 'send', + channel: { + $ref: '#/channels/UserSignedUp' + }, + messages: [ + { + $ref: '#/components/channels/UserRemoved/messages/UserRemoved' + }, + { + $ref: '#/components/channels/UserRemoved/messages/UserDeleted' + } + ] + } + }, + } + }, + errors: [ + { + message: 'Operation message does not belong to the specified channel.', + path: ['components', 'operations', 'UserSignedUp', 'messages', '0'], + severity: DiagnosticSeverity.Error, + }, + { + message: 'Operation message does not belong to the specified channel.', + path: ['components', 'operations', 'UserSignedUp', 'messages', '1'], + severity: DiagnosticSeverity.Error, + } + ], + }, ]);