diff --git a/docs/reference/asyncapi-rules.md b/docs/reference/asyncapi-rules.md index d22874787..26a4a33f2 100644 --- a/docs/reference/asyncapi-rules.md +++ b/docs/reference/asyncapi-rules.md @@ -219,6 +219,40 @@ components: **Recommended:** Yes +### asyncapi-message-messageId-uniqueness + +`messageId` must be unique across all the messages (except those one defined in the components). + +**Recommended:** Yes + +**Bad Example** + +```yaml +channels: + smartylighting.streetlights.1.0.action.{streetlightId}.turn.on: + publish: + message: + messageId: turnMessage + smartylighting.streetlights.1.0.action.{streetlightId}.turn.off: + publish: + message: + messageId: turnMessage +``` + +**Good Example** + +```yaml +channels: + smartylighting.streetlights.1.0.action.{streetlightId}.turn.on: + publish: + message: + messageId: turnOnMessage + smartylighting.streetlights.1.0.action.{streetlightId}.turn.off: + publish: + message: + messageId: turnOffMessage +``` + ### asyncapi-operation-description Operation objects should have a description. diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-message-messageId-uniqueness.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-message-messageId-uniqueness.test.ts new file mode 100644 index 000000000..eae050249 --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-message-messageId-uniqueness.test.ts @@ -0,0 +1,553 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-message-messageId-uniqueness', [ + { + name: 'validate a correct object', + document: { + asyncapi: '2.4.0', + channels: { + someChannel1: { + subscribe: { + message: { + messageId: 'id1', + }, + }, + }, + someChannel2: { + subscribe: { + message: { + messageId: 'id2', + }, + }, + publish: { + message: { + messageId: 'id3', + }, + }, + }, + }, + }, + errors: [], + }, + + { + name: 'validate a correct object (oneOf case)', + document: { + asyncapi: '2.4.0', + channels: { + someChannel1: { + subscribe: { + message: { + messageId: 'id1', + }, + }, + }, + someChannel2: { + subscribe: { + message: { + oneOf: [ + { + messageId: 'id2', + }, + { + messageId: 'id3', + }, + ], + }, + }, + publish: { + message: { + messageId: 'id4', + }, + }, + }, + }, + }, + errors: [], + }, + + { + name: 'validate a correct object (using traits)', + document: { + asyncapi: '2.4.0', + channels: { + someChannel1: { + subscribe: { + message: { + messageId: 'id1', + }, + }, + }, + someChannel2: { + subscribe: { + message: { + messageId: 'id2', + }, + }, + publish: { + message: { + messageId: 'id1', + traits: [ + { + messageId: 'id2', + }, + { + messageId: 'id3', + }, + ], + }, + }, + }, + }, + }, + errors: [], + }, + + { + name: 'validate a correct object (oneOf case using traits)', + document: { + asyncapi: '2.4.0', + channels: { + someChannel1: { + subscribe: { + message: { + messageId: 'id1', + }, + }, + }, + someChannel2: { + subscribe: { + message: { + oneOf: [ + { + messageId: 'id1', + traits: [ + { + messageId: 'id3', + }, + { + messageId: 'id2', + }, + ], + }, + { + messageId: 'id3', + }, + ], + }, + }, + publish: { + message: { + messageId: 'id4', + }, + }, + }, + }, + }, + errors: [], + }, + + { + name: 'return errors on different messages same id', + document: { + asyncapi: '2.4.0', + channels: { + someChannel1: { + subscribe: { + message: { + messageId: 'id1', + }, + }, + }, + someChannel2: { + subscribe: { + message: { + messageId: 'id2', + }, + }, + publish: { + message: { + messageId: 'id1', + }, + }, + }, + }, + }, + errors: [ + { + message: '"messageId" must be unique across all the messages.', + path: ['channels', 'someChannel2', 'publish', 'message', 'messageId'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'return errors on same path messages same id', + document: { + asyncapi: '2.4.0', + channels: { + someChannel1: { + subscribe: { + message: { + messageId: 'id1', + }, + }, + }, + someChannel2: { + subscribe: { + message: { + messageId: 'id2', + }, + }, + publish: { + message: { + messageId: 'id2', + }, + }, + }, + }, + }, + errors: [ + { + message: '"messageId" must be unique across all the messages.', + path: ['channels', 'someChannel2', 'publish', 'message', 'messageId'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'return errors on different messages (using traits) same id', + document: { + asyncapi: '2.4.0', + channels: { + someChannel1: { + subscribe: { + message: { + messageId: 'id3', + traits: [ + { + messageId: 'id4', + }, + { + messageId: 'id1', + }, + ], + }, + }, + }, + someChannel2: { + subscribe: { + message: { + messageId: 'id2', + }, + }, + publish: { + message: { + messageId: 'id1', + }, + }, + }, + }, + }, + errors: [ + { + message: '"messageId" must be unique across all the messages.', + path: ['channels', 'someChannel2', 'publish', 'message', 'messageId'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'return errors on same path messages (using traits) same id', + document: { + asyncapi: '2.0.0', + channels: { + someChannel1: { + subscribe: { + message: { + messageId: 'id1', + }, + }, + }, + someChannel2: { + subscribe: { + message: { + messageId: 'id2', + }, + }, + publish: { + message: { + messageId: 'id3', + traits: [ + { + messageId: 'id4', + }, + { + messageId: 'id2', + }, + ], + }, + }, + }, + }, + }, + errors: [ + { + message: '"messageId" must be unique across all the messages.', + path: ['channels', 'someChannel2', 'publish', 'message', 'traits', '1', 'messageId'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'return errors on different messages (oneOf case using traits) same id', + document: { + asyncapi: '2.0.0', + channels: { + someChannel1: { + subscribe: { + message: { + messageId: 'id1', + }, + }, + }, + someChannel2: { + subscribe: { + message: { + messageId: 'id2', + }, + }, + publish: { + message: { + oneOf: [ + { + messageId: 'id3', + traits: [ + { + messageId: 'id4', + }, + { + messageId: 'id2', + }, + ], + }, + { + messageId: 'id3', + traits: [ + { + messageId: 'id1', + }, + ], + }, + ], + }, + }, + }, + }, + }, + errors: [ + { + message: '"messageId" must be unique across all the messages.', + path: ['channels', 'someChannel2', 'publish', 'message', 'oneOf', '0', 'traits', '1', 'messageId'], + severity: DiagnosticSeverity.Error, + }, + { + message: '"messageId" must be unique across all the messages.', + path: ['channels', 'someChannel2', 'publish', 'message', 'oneOf', '1', 'traits', '0', 'messageId'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'return errors on different messages same id (more than two messages)', + document: { + asyncapi: '2.4.0', + channels: { + someChannel1: { + subscribe: { + message: { + messageId: 'id1', + }, + }, + }, + someChannel2: { + subscribe: { + message: { + messageId: 'id2', + }, + }, + publish: { + message: { + messageId: 'id1', + }, + }, + }, + someChannel3: { + subscribe: { + message: { + messageId: 'id1', + }, + }, + publish: { + message: { + messageId: 'id1', + }, + }, + }, + }, + }, + errors: [ + { + message: '"messageId" must be unique across all the messages.', + path: ['channels', 'someChannel2', 'publish', 'message', 'messageId'], + severity: DiagnosticSeverity.Error, + }, + { + message: '"messageId" must be unique across all the messages.', + path: ['channels', 'someChannel3', 'subscribe', 'message', 'messageId'], + severity: DiagnosticSeverity.Error, + }, + { + message: '"messageId" must be unique across all the messages.', + path: ['channels', 'someChannel3', 'publish', 'message', 'messageId'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'return errors on different messages same id (more than two messages and using traits)', + document: { + asyncapi: '2.0.0', + channels: { + someChannel1: { + subscribe: { + message: { + messageId: 'id1', + }, + }, + }, + someChannel2: { + subscribe: { + message: { + messageId: 'id2', + }, + }, + publish: { + message: { + messageId: 'id3', + traits: [ + { + messageId: 'id4', + }, + { + messageId: 'id1', + }, + ], + }, + }, + }, + someChannel3: { + subscribe: { + message: { + messageId: 'id1', + traits: [ + { + messageId: 'id5', + }, + { + messageId: 'id2', + }, + ], + }, + }, + publish: { + message: { + messageId: 'id2', + traits: [ + { + messageId: 'id1', + }, + ], + }, + }, + }, + }, + }, + errors: [ + { + message: '"messageId" must be unique across all the messages.', + path: ['channels', 'someChannel2', 'publish', 'message', 'traits', '1', 'messageId'], + severity: DiagnosticSeverity.Error, + }, + { + message: '"messageId" must be unique across all the messages.', + path: ['channels', 'someChannel3', 'subscribe', 'message', 'traits', '1', 'messageId'], + severity: DiagnosticSeverity.Error, + }, + { + message: '"messageId" must be unique across all the messages.', + path: ['channels', 'someChannel3', 'publish', 'message', 'traits', '0', 'messageId'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'do not check messageId in the components', + document: { + asyncapi: '2.4.0', + channels: { + someChannel1: { + subscribe: { + message: { + messageId: 'id1', + }, + }, + }, + someChannel2: { + subscribe: { + message: { + messageId: 'id2', + }, + }, + publish: { + message: { + messageId: 'id3', + }, + }, + }, + }, + components: { + channels: { + someChannel1: { + subscribe: { + message: { + messageId: 'id1', + }, + }, + }, + someChannel2: { + subscribe: { + message: { + messageId: 'id2', + }, + }, + publish: { + message: { + messageId: 'id1', + }, + }, + }, + }, + }, + }, + errors: [], + }, +]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-operation-operationId-uniqueness.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-operation-operationId-uniqueness.test.ts index a8314664d..c9380211b 100644 --- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-operation-operationId-uniqueness.test.ts +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-operation-operationId-uniqueness.test.ts @@ -25,6 +25,37 @@ testRule('asyncapi-operation-operationId-uniqueness', [ errors: [], }, + { + name: 'validate a correct object (using traits)', + document: { + asyncapi: '2.0.0', + channels: { + someChannel1: { + subscribe: { + operationId: 'id1', + }, + }, + someChannel2: { + subscribe: { + operationId: 'id2', + }, + publish: { + operationId: 'id1', + traits: [ + { + operationId: 'id2', + }, + { + operationId: 'id3', + }, + ], + }, + }, + }, + }, + errors: [], + }, + { name: 'return errors on different operations same id', document: { @@ -83,6 +114,80 @@ testRule('asyncapi-operation-operationId-uniqueness', [ ], }, + { + name: 'return errors on different operations (using traits) same id', + document: { + asyncapi: '2.0.0', + channels: { + someChannel1: { + subscribe: { + operationId: 'id3', + traits: [ + { + operationId: 'id4', + }, + { + operationId: 'id1', + }, + ], + }, + }, + someChannel2: { + subscribe: { + operationId: 'id2', + }, + publish: { + operationId: 'id1', + }, + }, + }, + }, + errors: [ + { + message: '"operationId" must be unique across all the operations.', + path: ['channels', 'someChannel2', 'publish', 'operationId'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'return errors on same path operations (using traits) same id', + document: { + asyncapi: '2.0.0', + channels: { + someChannel1: { + subscribe: { + operationId: 'id1', + }, + }, + someChannel2: { + subscribe: { + operationId: 'id2', + }, + publish: { + operationId: 'id3', + traits: [ + { + operationId: 'id4', + }, + { + operationId: 'id2', + }, + ], + }, + }, + }, + }, + errors: [ + { + message: '"operationId" must be unique across all the operations.', + path: ['channels', 'someChannel2', 'publish', 'traits', '1', 'operationId'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { name: 'return errors on different operations same id (more than two operations)', document: { @@ -130,6 +235,74 @@ testRule('asyncapi-operation-operationId-uniqueness', [ ], }, + { + name: 'return errors on different operations same id (more than two operations and using traits)', + document: { + asyncapi: '2.0.0', + channels: { + someChannel1: { + subscribe: { + operationId: 'id1', + }, + }, + someChannel2: { + subscribe: { + operationId: 'id2', + }, + publish: { + operationId: 'id3', + traits: [ + { + operationId: 'id4', + }, + { + operationId: 'id1', + }, + ], + }, + }, + someChannel3: { + subscribe: { + operationId: 'id1', + traits: [ + { + operationId: 'id5', + }, + { + operationId: 'id2', + }, + ], + }, + publish: { + operationId: 'id2', + traits: [ + { + operationId: 'id1', + }, + ], + }, + }, + }, + }, + errors: [ + { + message: '"operationId" must be unique across all the operations.', + path: ['channels', 'someChannel2', 'publish', 'traits', '1', 'operationId'], + severity: DiagnosticSeverity.Error, + }, + { + message: '"operationId" must be unique across all the operations.', + path: ['channels', 'someChannel3', 'subscribe', 'traits', '1', 'operationId'], + severity: DiagnosticSeverity.Error, + }, + { + message: '"operationId" must be unique across all the operations.', + path: ['channels', 'someChannel3', 'publish', 'traits', '0', 'operationId'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { name: 'do not check operationId in the components', document: { diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2MessageIdUniqueness.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2MessageIdUniqueness.ts new file mode 100644 index 000000000..47b125e17 --- /dev/null +++ b/packages/rulesets/src/asyncapi/functions/asyncApi2MessageIdUniqueness.ts @@ -0,0 +1,111 @@ +import { createRulesetFunction } from '@stoplight/spectral-core'; +import { isPlainObject } from '@stoplight/json'; + +import { getAllMessages } from './utils/getAllMessages'; + +import type { IFunctionResult } from '@stoplight/spectral-core'; +import type { JsonPath } from '@stoplight/types'; + +function retrieveMessageId(message: { + messageId?: string; + traits?: Array<{ messageId?: string }>; +}): { messageId: string; path: JsonPath } | undefined { + if (Array.isArray(message.traits)) { + for (let i = message.traits.length - 1; i >= 0; i--) { + const trait = message.traits[i]; + if (isPlainObject(trait) && typeof trait.messageId === 'string') { + return { + messageId: trait.messageId, + path: ['traits', i, 'messageId'], + }; + } + } + } + + if (typeof message.messageId === 'string') { + return { + messageId: message.messageId, + path: ['messageId'], + }; + } + + return undefined; +} + +export default createRulesetFunction< + { channels: Record; publish: Record }> }, + null +>( + { + input: { + type: 'object', + properties: { + channels: { + type: 'object', + properties: { + subscribe: { + type: 'object', + properties: { + message: { + oneOf: [ + { type: 'object' }, + { + type: 'object', + properties: { + oneOf: { + type: 'array', + }, + }, + }, + ], + }, + }, + }, + publish: { + type: 'object', + properties: { + message: { + oneOf: [ + { type: 'object' }, + { + type: 'object', + properties: { + oneOf: { + type: 'array', + }, + }, + }, + ], + }, + }, + }, + }, + }, + }, + }, + options: null, + }, + function asyncApi2MessageIdUniqueness(targetVal, _) { + const results: IFunctionResult[] = []; + const messages = getAllMessages(targetVal); + + const seenIds: unknown[] = []; + for (const { path, message } of messages) { + const maybeMessageId = retrieveMessageId(message); + if (maybeMessageId === undefined) { + continue; + } + + if (seenIds.includes(maybeMessageId.messageId)) { + results.push({ + message: '"messageId" must be unique across all the messages.', + path: [...path, ...maybeMessageId.path], + }); + } else { + seenIds.push(maybeMessageId.messageId); + } + } + + return results; + }, +); diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2OperationIdUniqueness.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2OperationIdUniqueness.ts index 92d081ad6..23a1bfc2a 100644 --- a/packages/rulesets/src/asyncapi/functions/asyncApi2OperationIdUniqueness.ts +++ b/packages/rulesets/src/asyncapi/functions/asyncApi2OperationIdUniqueness.ts @@ -1,8 +1,36 @@ import { createRulesetFunction } from '@stoplight/spectral-core'; +import { isPlainObject } from '@stoplight/json'; import { getAllOperations } from './utils/getAllOperations'; import type { IFunctionResult } from '@stoplight/spectral-core'; +import type { JsonPath } from '@stoplight/types'; + +function retrieveOperationId(operation: { + operationId?: string; + traits?: Array<{ operationId?: string }>; +}): { operationId: string; path: JsonPath } | undefined { + if (Array.isArray(operation.traits)) { + for (let i = operation.traits.length - 1; i >= 0; i--) { + const trait = operation.traits[i]; + if (isPlainObject(trait) && typeof trait.operationId === 'string') { + return { + operationId: trait.operationId, + path: ['traits', i, 'operationId'], + }; + } + } + } + + if (typeof operation.operationId === 'string') { + return { + operationId: operation.operationId, + path: ['operationId'], + }; + } + + return undefined; +} export default createRulesetFunction< { channels: Record; publish: Record }> }, @@ -33,18 +61,18 @@ export default createRulesetFunction< const seenIds: unknown[] = []; for (const { path, operation } of operations) { - if (!('operationId' in operation)) { + const maybeOperationId = retrieveOperationId(operation); + if (maybeOperationId === undefined) { continue; } - const operationId = (operation as { operationId: string }).operationId; - if (seenIds.includes(operationId)) { + if (seenIds.includes(maybeOperationId.operationId)) { results.push({ message: '"operationId" must be unique across all the operations.', - path: [...path, 'operationId'], + path: [...path, ...maybeOperationId.path], }); } else { - seenIds.push(operationId); + seenIds.push(maybeOperationId.operationId); } } diff --git a/packages/rulesets/src/asyncapi/functions/utils/getAllMessages.ts b/packages/rulesets/src/asyncapi/functions/utils/getAllMessages.ts new file mode 100644 index 000000000..f7a7bb60f --- /dev/null +++ b/packages/rulesets/src/asyncapi/functions/utils/getAllMessages.ts @@ -0,0 +1,40 @@ +import { isPlainObject } from '@stoplight/json'; + +import { getAllOperations } from './getAllOperations'; + +import type { JsonPath } from '@stoplight/types'; + +type MessageObject = Record; +type AsyncAPI = { + channels?: Record; publish?: Record }>; +}; +type Result = { path: JsonPath; message: MessageObject }; + +export function* getAllMessages(asyncapi: AsyncAPI): IterableIterator { + for (const { path, operation } of getAllOperations(asyncapi)) { + if (!isPlainObject(operation)) { + continue; + } + + const maybeMessage = operation.message; + if (!isPlainObject(maybeMessage)) { + continue; + } + + if (Array.isArray(maybeMessage.oneOf)) { + for (const [index, message] of maybeMessage.oneOf.entries()) { + if (isPlainObject(message)) { + yield { + path: [...path, 'message', 'oneOf', index], + message, + }; + } + } + } else { + yield { + path: [...path, 'message'], + message: maybeMessage, + }; + } + } +} diff --git a/packages/rulesets/src/asyncapi/functions/utils/getAllOperations.ts b/packages/rulesets/src/asyncapi/functions/utils/getAllOperations.ts index 3c689ea74..c22ca30d5 100644 --- a/packages/rulesets/src/asyncapi/functions/utils/getAllOperations.ts +++ b/packages/rulesets/src/asyncapi/functions/utils/getAllOperations.ts @@ -2,15 +2,16 @@ import { isPlainObject } from '@stoplight/json'; import type { JsonPath } from '@stoplight/types'; +type OperationObject = Record; type AsyncAPI = { - channels?: Record; publish?: Record }>; + channels?: Record; }; -type Operation = { path: JsonPath; kind: 'subscribe' | 'publish'; operation: Record }; +type Result = { path: JsonPath; kind: 'subscribe' | 'publish'; operation: OperationObject }; -export function* getAllOperations(asyncapi: AsyncAPI): IterableIterator { +export function* getAllOperations(asyncapi: AsyncAPI): IterableIterator { const channels = asyncapi?.channels; if (!isPlainObject(channels)) { - return []; + return {}; } for (const [channelAddress, channel] of Object.entries(channels)) { diff --git a/packages/rulesets/src/asyncapi/index.ts b/packages/rulesets/src/asyncapi/index.ts index 1596cf823..e7d387bc4 100644 --- a/packages/rulesets/src/asyncapi/index.ts +++ b/packages/rulesets/src/asyncapi/index.ts @@ -12,6 +12,7 @@ import asyncApi2ChannelParameters from './functions/asyncApi2ChannelParameters'; import asyncApi2ChannelServers from './functions/asyncApi2ChannelServers'; import asyncApi2DocumentSchema from './functions/asyncApi2DocumentSchema'; import asyncApi2MessageExamplesValidation from './functions/asyncApi2MessageExamplesValidation'; +import asyncApi2MessageIdUniqueness from './functions/asyncApi2MessageIdUniqueness'; import asyncApi2OperationIdUniqueness from './functions/asyncApi2OperationIdUniqueness'; import asyncApi2SchemaValidation from './functions/asyncApi2SchemaValidation'; import asyncApi2PayloadValidation from './functions/asyncApi2PayloadValidation'; @@ -196,6 +197,16 @@ export default { function: asyncApi2MessageExamplesValidation, }, }, + 'asyncapi-message-messageId-uniqueness': { + description: '"messageId" must be unique across all the messages.', + severity: 'error', + recommended: true, + type: 'validation', + given: '$', + then: { + function: asyncApi2MessageIdUniqueness, + }, + }, 'asyncapi-operation-description': { description: 'Operation "description" must be present and non-empty string.', recommended: true,