diff --git a/docs/reference/asyncapi-rules.md b/docs/reference/asyncapi-rules.md index c88945b09..186399604 100644 --- a/docs/reference/asyncapi-rules.md +++ b/docs/reference/asyncapi-rules.md @@ -138,6 +138,36 @@ Operation objects should have a description. **Recommended:** Yes +### asyncapi-operation-operationId-uniqueness + +`operationId` must be unique across all the operations (except these one defined in the components). + +**Recommended:** Yes + +**Bad Example** + +```yaml +channels: + smartylighting.streetlights.1.0.action.{streetlightId}.turn.on: + publish: + operationId: turn + smartylighting.streetlights.1.0.action.{streetlightId}.turn.off: + publish: + operationId: turn +``` + +**Good Example** + +```yaml +channels: + smartylighting.streetlights.1.0.action.{streetlightId}.turn.on: + publish: + operationId: turnOn + smartylighting.streetlights.1.0.action.{streetlightId}.turn.off: + publish: + operationId: turnOff +``` + ### asyncapi-operation-operationId This operation ID is essentially a reference for the operation. Tools may use it for defining function names, class method names, and even URL hashes in documentation systems. 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 new file mode 100644 index 000000000..a8314664d --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-operation-operationId-uniqueness.test.ts @@ -0,0 +1,172 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-operation-operationId-uniqueness', [ + { + name: 'validate a correct object', + document: { + asyncapi: '2.0.0', + channels: { + someChannel1: { + subscribe: { + operationId: 'id1', + }, + }, + someChannel2: { + subscribe: { + operationId: 'id2', + }, + publish: { + operationId: 'id3', + }, + }, + }, + }, + errors: [], + }, + + { + name: 'return errors on different operations same id', + document: { + asyncapi: '2.0.0', + channels: { + someChannel1: { + subscribe: { + 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 same id', + document: { + asyncapi: '2.0.0', + channels: { + someChannel1: { + subscribe: { + operationId: 'id1', + }, + }, + someChannel2: { + subscribe: { + operationId: 'id2', + }, + publish: { + operationId: 'id2', + }, + }, + }, + }, + errors: [ + { + message: '"operationId" must be unique across all the operations.', + path: ['channels', 'someChannel2', 'publish', 'operationId'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'return errors on different operations same id (more than two operations)', + document: { + asyncapi: '2.0.0', + channels: { + someChannel1: { + subscribe: { + operationId: 'id1', + }, + }, + someChannel2: { + subscribe: { + operationId: 'id2', + }, + publish: { + operationId: 'id1', + }, + }, + someChannel3: { + subscribe: { + operationId: 'id1', + }, + publish: { + operationId: 'id1', + }, + }, + }, + }, + errors: [ + { + message: '"operationId" must be unique across all the operations.', + path: ['channels', 'someChannel2', 'publish', 'operationId'], + severity: DiagnosticSeverity.Error, + }, + { + message: '"operationId" must be unique across all the operations.', + path: ['channels', 'someChannel3', 'subscribe', 'operationId'], + severity: DiagnosticSeverity.Error, + }, + { + message: '"operationId" must be unique across all the operations.', + path: ['channels', 'someChannel3', 'publish', 'operationId'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'do not check operationId in the components', + document: { + asyncapi: '2.3.0', + channels: { + someChannel1: { + subscribe: { + operationId: 'id1', + }, + }, + someChannel2: { + subscribe: { + operationId: 'id2', + }, + publish: { + operationId: 'id3', + }, + }, + }, + components: { + channels: { + someChannel1: { + subscribe: { + operationId: 'id1', + }, + }, + someChannel2: { + subscribe: { + operationId: 'id2', + }, + publish: { + operationId: 'id1', + }, + }, + }, + }, + }, + errors: [], + }, +]); diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2OperationIdUniqueness.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2OperationIdUniqueness.ts new file mode 100644 index 000000000..92d081ad6 --- /dev/null +++ b/packages/rulesets/src/asyncapi/functions/asyncApi2OperationIdUniqueness.ts @@ -0,0 +1,53 @@ +import { createRulesetFunction } from '@stoplight/spectral-core'; + +import { getAllOperations } from './utils/getAllOperations'; + +import type { IFunctionResult } from '@stoplight/spectral-core'; + +export default createRulesetFunction< + { channels: Record; publish: Record }> }, + null +>( + { + input: { + type: 'object', + properties: { + channels: { + type: 'object', + properties: { + subscribe: { + type: 'object', + }, + publish: { + type: 'object', + }, + }, + }, + }, + }, + options: null, + }, + function asyncApi2OperationIdUniqueness(targetVal, _) { + const results: IFunctionResult[] = []; + const operations = getAllOperations(targetVal); + + const seenIds: unknown[] = []; + for (const { path, operation } of operations) { + if (!('operationId' in operation)) { + continue; + } + + const operationId = (operation as { operationId: string }).operationId; + if (seenIds.includes(operationId)) { + results.push({ + message: '"operationId" must be unique across all the operations.', + path: [...path, 'operationId'], + }); + } else { + seenIds.push(operationId); + } + } + + return results; + }, +); diff --git a/packages/rulesets/src/asyncapi/functions/utils/getAllOperations.ts b/packages/rulesets/src/asyncapi/functions/utils/getAllOperations.ts new file mode 100644 index 000000000..3c689ea74 --- /dev/null +++ b/packages/rulesets/src/asyncapi/functions/utils/getAllOperations.ts @@ -0,0 +1,36 @@ +import { isPlainObject } from '@stoplight/json'; + +import type { JsonPath } from '@stoplight/types'; + +type AsyncAPI = { + channels?: Record; publish?: Record }>; +}; +type Operation = { path: JsonPath; kind: 'subscribe' | 'publish'; operation: Record }; + +export function* getAllOperations(asyncapi: AsyncAPI): IterableIterator { + const channels = asyncapi?.channels; + if (!isPlainObject(channels)) { + return []; + } + + for (const [channelAddress, channel] of Object.entries(channels)) { + if (!isPlainObject(channel)) { + continue; + } + + if (isPlainObject(channel.subscribe)) { + yield { + path: ['channels', channelAddress, 'subscribe'], + kind: 'subscribe', + operation: channel.subscribe, + }; + } + if (isPlainObject(channel.publish)) { + yield { + path: ['channels', channelAddress, 'publish'], + kind: 'publish', + operation: channel.publish, + }; + } + } +} diff --git a/packages/rulesets/src/asyncapi/index.ts b/packages/rulesets/src/asyncapi/index.ts index a683fb80b..930a92aba 100644 --- a/packages/rulesets/src/asyncapi/index.ts +++ b/packages/rulesets/src/asyncapi/index.ts @@ -10,6 +10,7 @@ import { import asyncApi2ChannelParameters from './functions/asyncApi2ChannelParameters'; import asyncApi2DocumentSchema from './functions/asyncApi2DocumentSchema'; +import asyncApi2OperationIdUniqueness from './functions/asyncApi2OperationIdUniqueness'; import asyncApi2SchemaValidation from './functions/asyncApi2SchemaValidation'; import asyncApi2PayloadValidation from './functions/asyncApi2PayloadValidation'; import asyncApi2ServerVariables from './functions/asyncApi2ServerVariables'; @@ -166,6 +167,16 @@ export default { function: truthy, }, }, + 'asyncapi-operation-operationId-uniqueness': { + description: '"operationId" must be unique across all the operations.', + severity: 'error', + recommended: true, + type: 'validation', + given: '$', + then: { + function: asyncApi2OperationIdUniqueness, + }, + }, 'asyncapi-operation-operationId': { description: 'Operation must have "operationId".', severity: 'error',