Skip to content

Commit

Permalink
feat(rulesets): check uniqueness of AsyncAPI operations (#2121)
Browse files Browse the repository at this point in the history
  • Loading branch information
magicmatatjahu committed May 30, 2022
1 parent 4447d81 commit 8b3cce4
Show file tree
Hide file tree
Showing 5 changed files with 302 additions and 0 deletions.
30 changes: 30 additions & 0 deletions docs/reference/asyncapi-rules.md
Expand Up @@ -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.
Expand Down
@@ -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: [],
},
]);
@@ -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;
},
);
36 changes: 36 additions & 0 deletions 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,
};
}
}
}
11 changes: 11 additions & 0 deletions packages/rulesets/src/asyncapi/index.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down

0 comments on commit 8b3cce4

Please sign in to comment.