From 40d8e7ab640890e726d6e69d24c079989a3b3610 Mon Sep 17 00:00:00 2001 From: Sergio Moya <1083296+smoya@users.noreply.github.com> Date: Tue, 28 Nov 2023 10:26:31 +0100 Subject: [PATCH 1/5] feat: add Spectral rule to validate required operation channel field --- .../requiredOperationChannelUnambiguity.ts | 42 +++ src/ruleset/v3/ruleset.ts | 14 + ...ired-operation-channel-unambiguity.spec.ts | 261 ++++++++++++++++++ 3 files changed, 317 insertions(+) create mode 100644 src/ruleset/v3/functions/requiredOperationChannelUnambiguity.ts create mode 100644 test/ruleset/rules/v3/asyncapi3-required-operation-channel-unambiguity.spec.ts diff --git a/src/ruleset/v3/functions/requiredOperationChannelUnambiguity.ts b/src/ruleset/v3/functions/requiredOperationChannelUnambiguity.ts new file mode 100644 index 000000000..6b2e71fe7 --- /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('#/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..ee46a3502 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 { requiredOperationChannelUnambiguity } from './functions/requiredOperationChannelUnambiguity'; export const v3CoreRuleset = { description: 'Core AsyncAPI 3.x.x ruleset.', @@ -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, + } + ], + }, +]); From e17e7cdc9fab43d7188639d07a3a968ff108435a Mon Sep 17 00:00:00 2001 From: Sergio Moya <1083296+smoya@users.noreply.github.com> Date: Tue, 28 Nov 2023 11:24:17 +0100 Subject: [PATCH 2/5] Use Spectral `pattern` function --- .../requiredOperationChannelUnambiguity.ts | 42 ------------------- src/ruleset/v3/ruleset.ts | 16 +++---- ...ired-operation-channel-unambiguity.spec.ts | 8 ++-- 3 files changed, 13 insertions(+), 53 deletions(-) delete mode 100644 src/ruleset/v3/functions/requiredOperationChannelUnambiguity.ts diff --git a/src/ruleset/v3/functions/requiredOperationChannelUnambiguity.ts b/src/ruleset/v3/functions/requiredOperationChannelUnambiguity.ts deleted file mode 100644 index 6b2e71fe7..000000000 --- a/src/ruleset/v3/functions/requiredOperationChannelUnambiguity.ts +++ /dev/null @@ -1,42 +0,0 @@ -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('#/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 ee46a3502..f395be7cd 100644 --- a/src/ruleset/v3/ruleset.ts +++ b/src/ruleset/v3/ruleset.ts @@ -2,7 +2,8 @@ import { AsyncAPIFormats } from '../formats'; import { operationMessagesUnambiguity } from './functions/operationMessagesUnambiguity'; -import { requiredOperationChannelUnambiguity } from './functions/requiredOperationChannelUnambiguity'; +// import { requiredOperationChannelUnambiguity } from './functions/requiredOperationChannelUnambiguity'; +import { pattern } from '@stoplight/spectral-functions'; export const v3CoreRuleset = { description: 'Core AsyncAPI 3.x.x ruleset.', @@ -26,16 +27,17 @@ export const v3CoreRuleset = { }, }, 'asyncapi3-required-operation-channel-unambiguity': { - description: 'Required operation (under root channels) "channel" must reference to a required channel (under root channels).', - message: '{{error}}', + 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.*', - ], + given: '$.operations.*', then: { - function: requiredOperationChannelUnambiguity, + field: 'channel.$ref', + function: pattern, + functionOptions: { + match: '#\\/channels\\/', // If doesn't match, rule fails. + }, }, } }, 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 index 153a572ae..9707139ef 100644 --- a/test/ruleset/rules/v3/asyncapi3-required-operation-channel-unambiguity.spec.ts +++ b/test/ruleset/rules/v3/asyncapi3-required-operation-channel-unambiguity.spec.ts @@ -222,8 +222,8 @@ testRule('asyncapi3-required-operation-channel-unambiguity', [ }, errors: [ { - message: 'The channel field of a required operation should point to a required channel.', - path: ['operations', 'UserSignedUp', 'channel'], + 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, } ], @@ -252,8 +252,8 @@ testRule('asyncapi3-required-operation-channel-unambiguity', [ }, errors: [ { - message: 'The channel field of a required operation should point to a required channel.', - path: ['operations', 'UserSignedUp', 'channel'], + 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, } ], From 983b469f18b0fc05684dc2deb5316702528aa0d1 Mon Sep 17 00:00:00 2001 From: Sergio Moya <1083296+smoya@users.noreply.github.com> Date: Tue, 28 Nov 2023 11:29:13 +0100 Subject: [PATCH 3/5] remove import --- src/ruleset/v3/ruleset.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ruleset/v3/ruleset.ts b/src/ruleset/v3/ruleset.ts index f395be7cd..fbb7adee8 100644 --- a/src/ruleset/v3/ruleset.ts +++ b/src/ruleset/v3/ruleset.ts @@ -2,7 +2,6 @@ import { AsyncAPIFormats } from '../formats'; import { operationMessagesUnambiguity } from './functions/operationMessagesUnambiguity'; -// import { requiredOperationChannelUnambiguity } from './functions/requiredOperationChannelUnambiguity'; import { pattern } from '@stoplight/spectral-functions'; export const v3CoreRuleset = { From 99e38cdb433ef355823a9f7b0e32b3e949475eb2 Mon Sep 17 00:00:00 2001 From: Sergio Moya <1083296+smoya@users.noreply.github.com> Date: Tue, 28 Nov 2023 13:22:16 +0100 Subject: [PATCH 4/5] feat: add Spectral rule to validate required channel servers field --- src/ruleset/v3/ruleset.ts | 18 ++ ...quired-channel-servers-unambiguity.spec.ts | 210 ++++++++++++++++++ ...ired-operation-channel-unambiguity.spec.ts | 2 +- 3 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 test/ruleset/rules/v3/asyncapi3-required-channel-servers-unambiguity.spec.ts diff --git a/src/ruleset/v3/ruleset.ts b/src/ruleset/v3/ruleset.ts index fbb7adee8..59e293053 100644 --- a/src/ruleset/v3/ruleset.ts +++ b/src/ruleset/v3/ruleset.ts @@ -38,6 +38,24 @@ export const v3CoreRuleset = { 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 to 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..23c3e8014 --- /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 to 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 to 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 to 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 to 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 index 9707139ef..44e269bf9 100644 --- a/test/ruleset/rules/v3/asyncapi3-required-operation-channel-unambiguity.spec.ts +++ b/test/ruleset/rules/v3/asyncapi3-required-operation-channel-unambiguity.spec.ts @@ -88,7 +88,7 @@ testRule('asyncapi3-required-operation-channel-unambiguity', [ errors: [], }, { - name: 'valid case - required operation (under components) channel field points to a required channel (under root)', + name: 'valid case - optional operation (under components) channel field points to a required channel (under root)', document: { asyncapi: '3.0.0', info: { From 238dcdace4463312afde5246bcdd193deb4fc401 Mon Sep 17 00:00:00 2001 From: Sergio Moya <1083296+smoya@users.noreply.github.com> Date: Tue, 28 Nov 2023 13:49:28 +0100 Subject: [PATCH 5/5] fix typo --- src/ruleset/v3/ruleset.ts | 2 +- ...asyncapi3-required-channel-servers-unambiguity.spec.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ruleset/v3/ruleset.ts b/src/ruleset/v3/ruleset.ts index 59e293053..724339e84 100644 --- a/src/ruleset/v3/ruleset.ts +++ b/src/ruleset/v3/ruleset.ts @@ -44,7 +44,7 @@ export const v3CoreRuleset = { * Channel Object rules */ 'asyncapi3-required-channel-servers-unambiguity': { - description: 'The "servers" field of a channel under the root "channels" object must always reference to a subset of the servers under the root "servers" object.', + 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. 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 index 23c3e8014..1a299eef9 100644 --- a/test/ruleset/rules/v3/asyncapi3-required-channel-servers-unambiguity.spec.ts +++ b/test/ruleset/rules/v3/asyncapi3-required-channel-servers-unambiguity.spec.ts @@ -166,12 +166,12 @@ testRule('asyncapi3-required-channel-servers-unambiguity', [ }, errors: [ { - message: 'The "servers" field of a channel under the root "channels" object must always reference to a subset of the servers under the root "servers" object.', + 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 to a subset of the servers under the root "servers" object.', + 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, } @@ -196,12 +196,12 @@ testRule('asyncapi3-required-channel-servers-unambiguity', [ }, errors: [ { - message: 'The "servers" field of a channel under the root "channels" object must always reference to a subset of the servers under the root "servers" object.', + 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 to a subset of the servers under the root "servers" object.', + 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, }