diff --git a/src/ruleset/v3/ruleset.ts b/src/ruleset/v3/ruleset.ts index 56f7b6823..724339e84 100644 --- a/src/ruleset/v3/ruleset.ts +++ b/src/ruleset/v3/ruleset.ts @@ -2,6 +2,7 @@ import { AsyncAPIFormats } from '../formats'; import { operationMessagesUnambiguity } from './functions/operationMessagesUnambiguity'; +import { pattern } from '@stoplight/spectral-functions'; export const v3CoreRuleset = { description: 'Core AsyncAPI 3.x.x ruleset.', @@ -24,5 +25,37 @@ export const v3CoreRuleset = { function: operationMessagesUnambiguity, }, }, + 'asyncapi3-required-operation-channel-unambiguity': { + description: 'The "channel" field of an operation under the root "operations" object must always reference a channel under the root "channels" object.', + severity: 'error', + recommended: true, + resolved: false, // We use the JSON pointer to match the channel. + given: '$.operations.*', + then: { + field: 'channel.$ref', + function: pattern, + functionOptions: { + match: '#\\/channels\\/', // If doesn't match, rule fails. + }, + }, + }, + + /** + * Channel Object rules + */ + 'asyncapi3-required-channel-servers-unambiguity': { + description: 'The "servers" field of a channel under the root "channels" object must always reference a subset of the servers under the root "servers" object.', + severity: 'error', + recommended: true, + resolved: false, // We use the JSON pointer to match the channel. + given: '$.channels.*', + then: { + field: '$.servers.*.$ref', + function: pattern, + functionOptions: { + match: '#\\/servers\\/', // If doesn't match, rule fails. + }, + }, + } }, }; diff --git a/test/ruleset/rules/v3/asyncapi3-required-channel-servers-unambiguity.spec.ts b/test/ruleset/rules/v3/asyncapi3-required-channel-servers-unambiguity.spec.ts new file mode 100644 index 000000000..1a299eef9 --- /dev/null +++ b/test/ruleset/rules/v3/asyncapi3-required-channel-servers-unambiguity.spec.ts @@ -0,0 +1,210 @@ +import { testRule, DiagnosticSeverity } from '../../tester'; + +testRule('asyncapi3-required-channel-servers-unambiguity', [ + { + name: 'valid case - required channel (under root) server field points to a subset of required servers (under root)', + document: { + asyncapi: '3.0.0', + info: { + title: 'Account Service', + version: '1.0.0' + }, + servers: { + prod: { + host: 'my-api.com', + protocol: 'ws', + }, + dev: { + host: 'localhost', + protocol: 'ws', + }, + }, + channels: { + UserSignedUp: { + servers: [ + { $ref: '#/servers/prod' }, + { $ref: '#/servers/dev' }, + ] + } + }, + }, + errors: [], + }, + { + name: 'valid case - required channel (under root) server field points to a subset of required servers (under root) from an external doc', + document: { + asyncapi: '3.0.0', + info: { + title: 'Account Service', + version: '1.0.0' + }, + channels: { + UserSignedUp: { + servers: [ + { $ref: 'http://foo.bar/components/file.yml#/servers/prod' }, + { $ref: 'http://foo.bar/components/file.yml#/servers/dev' }, + ] + } + }, + }, + errors: [], + }, + { + name: 'valid case - optional channel (under components) server field points to a subset of required servers (under root)', + document: { + asyncapi: '3.0.0', + info: { + title: 'Account Service', + version: '1.0.0' + }, + servers: { + prod: { + host: 'my-api.com', + protocol: 'ws', + }, + dev: { + host: 'localhost', + protocol: 'ws', + }, + }, + components: { + channels: { + UserSignedUp: { + servers: [ + { $ref: '#/servers/prod' }, + { $ref: '#/servers/dev' }, + ] + } + }, + }, + }, + errors: [], + }, + { + name: 'valid case - optional channel (under components) server field points to a subset of optional servers (under components)', + document: { + asyncapi: '3.0.0', + info: { + title: 'Account Service', + version: '1.0.0' + }, + components: { + servers: { + prod: { + host: 'my-api.com', + protocol: 'ws', + }, + dev: { + host: 'localhost', + protocol: 'ws', + }, + }, + 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 channel (in root) servers field points to a subset of optional servers (under components)', + document: { + asyncapi: '3.0.0', + info: { + title: 'Account Service', + version: '1.0.0' + }, + channels: { + UserSignedUp: { + servers: [ + { $ref: '#/components/servers/prod' }, + { $ref: '#/components/servers/dev' }, + ] + } + }, + components: { + servers: { + prod: { + host: 'my-api.com', + protocol: 'ws', + }, + dev: { + host: 'localhost', + protocol: 'ws', + }, + } + } + }, + errors: [ + { + message: 'The "servers" field of a channel under the root "channels" object must always reference a subset of the servers under the root "servers" object.', + path: ['channels', 'UserSignedUp', 'servers', '0', '$ref'], + severity: DiagnosticSeverity.Error, + }, + { + message: 'The "servers" field of a channel under the root "channels" object must always reference a subset of the servers under the root "servers" object.', + path: ['channels', 'UserSignedUp', 'servers', '1', '$ref'], + severity: DiagnosticSeverity.Error, + } + ], + }, + { + name: 'invalid case - required channel (in root) servers field points to a subset of optional servers (under components) from an external doc', + document: { + asyncapi: '3.0.0', + info: { + title: 'Account Service', + version: '1.0.0' + }, + channels: { + UserSignedUp: { + servers: [ + { $ref: 'http://foo.bar/components/file.yml#/components/servers/prod' }, + { $ref: 'http://foo.bar/components/file.yml#/components/servers/dev' }, + ] + } + } + }, + errors: [ + { + message: 'The "servers" field of a channel under the root "channels" object must always reference a subset of the servers under the root "servers" object.', + path: ['channels', 'UserSignedUp', 'servers', '0', '$ref'], + severity: DiagnosticSeverity.Error, + }, + { + message: 'The "servers" field of a channel under the root "channels" object must always reference a subset of the servers under the root "servers" object.', + path: ['channels', 'UserSignedUp', 'servers', '1', '$ref'], + severity: DiagnosticSeverity.Error, + } + ], + }, +]); 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..44e269bf9 --- /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 - optional 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 an operation under the root "operations" object must always reference a channel under the root "channels" object.', + path: ['operations', 'UserSignedUp', 'channel', '$ref'], + 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 an operation under the root "operations" object must always reference a channel under the root "channels" object.', + path: ['operations', 'UserSignedUp', 'channel', '$ref'], + severity: DiagnosticSeverity.Error, + } + ], + }, +]);