diff --git a/src/ruleset/v3/functions/requiredOperationChannelUnambiguity.ts b/src/ruleset/v3/functions/requiredOperationChannelUnambiguity.ts new file mode 100644 index 000000000..871136965 --- /dev/null +++ b/src/ruleset/v3/functions/requiredOperationChannelUnambiguity.ts @@ -0,0 +1,42 @@ +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 requiredOperationChannelUnambiguity = 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 + + if (channelPointer.includes('#/components/channels')) { + results.push({ + message: 'The channel field of a required operation should point to a required channel.', + path: [...ctx.path, 'channel'], + }); + } + + return results; + }, +); diff --git a/src/ruleset/v3/ruleset.ts b/src/ruleset/v3/ruleset.ts index 56f7b6823..e196ea52a 100644 --- a/src/ruleset/v3/ruleset.ts +++ b/src/ruleset/v3/ruleset.ts @@ -1,6 +1,7 @@ /* eslint-disable sonarjs/no-duplicate-string */ import { AsyncAPIFormats } from '../formats'; +import { requiredOperationChannelUnambiguity } from './functions/requiredOperationChannelUnambiguity'; import { operationMessagesUnambiguity } from './functions/operationMessagesUnambiguity'; export const v3CoreRuleset = { @@ -24,5 +25,18 @@ export const v3CoreRuleset = { function: operationMessagesUnambiguity, }, }, + 'asyncapi3-required-operation-channel-unambiguity': { + description: 'Required operation (under root channels) "channel" must reference to a required channel (under root channels).', + message: '{{error}}', + severity: 'error', + recommended: true, + resolved: false, // We use the JSON pointer to match the channel. + given: [ + '$.operations.*', + ], + then: { + function: requiredOperationChannelUnambiguity, + }, + } }, }; diff --git a/test/ruleset/rules/v3/asyncapi3-required-operation-channel-unambiguity.spec.ts b/test/ruleset/rules/v3/asyncapi3-required-operation-channel-unambiguity.spec.ts new file mode 100644 index 000000000..153a572ae --- /dev/null +++ b/test/ruleset/rules/v3/asyncapi3-required-operation-channel-unambiguity.spec.ts @@ -0,0 +1,261 @@ +import { testRule, DiagnosticSeverity } from '../../tester'; + +testRule('asyncapi3-required-operation-channel-unambiguity', [ + { + name: 'valid case - required operation (under root) channel field points to a required channel (under root)', + 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 - required operation (under root) channel field points to a required channel (under root) from an external doc', + 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: 'http://foo.bar/components/file.yml#/channels/UserSignedUp' + }, + messages: [ + { + $ref: 'http://foo.bar/components/file.yml#/channels/UserSignedUp/messages/UserSignedUp' + } + ] + } + } + }, + errors: [], + }, + { + name: 'valid case - required operation (under components) channel field points to a required channel (under root)', + 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: 'valid case - optional operation (under components) channel field points to an optional channel (under components)', + document: { + asyncapi: '3.0.0', + info: { + title: 'Account Service', + version: '1.0.0' + }, + components: { + channels: { + UserSignedUp: { + messages: { + UserSignedUp: { + payload: { + type: 'object', + properties: { + displayName: { + type: 'string' + }, + email: { + type: 'string' + } + } + } + } + } + } + }, + operations: { + UserSignedUp: { + action: 'send', + channel: { + $ref: '#/components/channels/UserSignedUp' + }, + messages: [ + { + $ref: '#/components/channels/UserSignedUp/messages/UserSignedUp' + } + ] + } + } + } + }, + errors: [], + }, + { + name: 'invalid case - required operation (in root) channel field points to an optional channel (under components)', + document: { + asyncapi: '3.0.0', + info: { + title: 'Account Service', + version: '1.0.0' + }, + operations: { + UserSignedUp: { + action: 'send', + channel: { + $ref: '#/components/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: 'The channel field of a required operation should point to a required channel.', + path: ['operations', 'UserSignedUp', 'channel'], + severity: DiagnosticSeverity.Error, + } + ], + }, + { + name: 'invalid case - required operation (in root) channel field points to an optional channel (under components) from an external doc', + document: { + asyncapi: '3.0.0', + info: { + title: 'Account Service', + version: '1.0.0' + }, + operations: { + UserSignedUp: { + action: 'send', + channel: { + $ref: 'http://foo.bar/components/file.yml#/components/channels/UserSignedUp' + }, + messages: [ + { + $ref: 'http://foo.bar/components/file.yml#/components/channels/UserSignedUp/messages/UserSignedUp' + } + ] + } + } + }, + errors: [ + { + message: 'The channel field of a required operation should point to a required channel.', + path: ['operations', 'UserSignedUp', 'channel'], + severity: DiagnosticSeverity.Error, + } + ], + }, +]);