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 authored 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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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: [],
},
]);
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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.