Repository: stoplightio/spectral (ts)

Issue:
Add rule to check uniqueness of `operationId`
**User story.**
As a user, I want to know if the operations defined in my AsyncAPI document have a unique `operationId`.

**Describe the solution you'd like**
The following AsyncAPI document is invalid and Spectral should throw errors due to non-uniqueness of `operationId`.

```yaml
asyncapi: '2.3.0'
...

channels:
  someChannel:
    publish:
      operationId: someId
      ...
    subscribe:
      operationId: someId
      ...
```

**Additional context**
Part of #2100 


Pull request:
feat(rulesets): add rule to check uniqueness operationId of AsyncAPI operations
Fixes #2102

**Checklist**

- [X] Tests added / updated
- [X] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [X] No

**Additional context**

Part of #2100 

Add rule to check uniqueness `operationId` of AsyncAPI operations.

**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
```

Patch:
diff --git a/docs/reference/asyncapi-rules.md b/docs/reference/asyncapi-rules.md
index c88945b0..18639960 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/functions/asyncApi2OperationIdUniqueness.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2OperationIdUniqueness.ts
new file mode 100644
index 00000000..92d081ad
--- /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<string, { subscribe: Record<string, unknown>; publish: Record<string, unknown> }> },
+  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 00000000..3c689ea7
--- /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<string, { subscribe?: Record<string, unknown>; publish?: Record<string, unknown> }>;
+};
+type Operation = { path: JsonPath; kind: 'subscribe' | 'publish'; operation: Record<string, unknown> };
+
+export function* getAllOperations(asyncapi: AsyncAPI): IterableIterator<Operation> {
+  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 a683fb80..930a92ab 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',


Test patch:
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 00000000..a8314664
--- /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: [],
+  },
+]);