Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(rulesets): add rule to check uniqueness operationId of AsyncAPI operations #2121

Merged
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/reference/asyncapi-rules.md
Expand Up @@ -132,6 +132,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;
},
);
@@ -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 @@ -9,6 +9,7 @@ import {
} from '@stoplight/spectral-functions';

import asyncApi2DocumentSchema from './functions/asyncApi2DocumentSchema';
import asyncApi2OperationIdUniqueness from './functions/asyncApi2OperationIdUniqueness';
import asyncApi2SchemaValidation from './functions/asyncApi2SchemaValidation';
import asyncApi2PayloadValidation from './functions/asyncApi2PayloadValidation';

Expand Down Expand Up @@ -152,6 +153,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