Skip to content

Commit

Permalink
feat: add Spectral rule to validate operation messages
Browse files Browse the repository at this point in the history
  • Loading branch information
smoya committed Nov 27, 2023
1 parent 4345060 commit 42ea4ad
Show file tree
Hide file tree
Showing 6 changed files with 312 additions and 0 deletions.
2 changes: 2 additions & 0 deletions src/ruleset/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { coreRuleset, recommendedRuleset } from './ruleset';
import { v2CoreRuleset, v2SchemasRuleset, v2RecommendedRuleset } from './v2';
import { v3CoreRuleset } from './v3';

import type { Parser } from '../parser';
import type { RulesetDefinition } from '@stoplight/spectral-core';
Expand All @@ -18,6 +19,7 @@ export function createRuleset(parser: Parser, options?: RulesetOptions): Ruleset
useCore && v2CoreRuleset,
useCore && v2SchemasRuleset(parser),
useRecommended && v2RecommendedRuleset,
useCore && v3CoreRuleset,
...(options as any || {})?.extends || [],
].filter(Boolean);

Expand Down
44 changes: 44 additions & 0 deletions src/ruleset/v3/functions/operationMessagesUnambiguity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { createRulesetFunction } from '@stoplight/spectral-core';
import type { IFunctionResult } from '@stoplight/spectral-core';
import { SchemaDefinition } from '@stoplight/spectral-core/dist/ruleset/function';

const referenceSchema: SchemaDefinition = {
type: 'object',
properties: {
$ref: {
type: 'string',
format: 'uri-reference'
},
},
};

export const operationMessagesUnambiguity = createRulesetFunction<{ channel?: {'$ref': string}; messages?: [{'$ref': string}] }, null>(
{
input: {
type: 'object',
properties: {
channel: referenceSchema,
messages: {
type: 'array',
items: referenceSchema,
},
},
},
options: null,
},
(targetVal, _, ctx) => {
const results: IFunctionResult[] = [];
const channelPointer = targetVal.channel?.$ref as string; // required

targetVal.messages?.forEach((message, index) => {
if (!message.$ref.startsWith(channelPointer)) {
results.push({
message: 'Operation message does not belong to the specified channel.',
path: [...ctx.path, 'messages', index],
});
}
});

return results;
},
);
1 change: 1 addition & 0 deletions src/ruleset/v3/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './ruleset';
28 changes: 28 additions & 0 deletions src/ruleset/v3/ruleset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/* eslint-disable sonarjs/no-duplicate-string */

import { AsyncAPIFormats } from '../formats';
import { operationMessagesUnambiguity } from './functions/operationMessagesUnambiguity';

export const v3CoreRuleset = {
description: 'Core AsyncAPI 3.x.x ruleset.',
formats: AsyncAPIFormats.filterByMajorVersions(['3']).formats(),
rules: {
/**
* Operation Object rules
*/
'asyncapi3-operation-messages-from-referred-channel': {
description: 'Operation "messages" must be a subset of the messages defined in the channel referenced in this operation.',
message: '{{error}}',
severity: 'error',
recommended: true,
resolved: false, // We use the JSON pointer to match the channel.
given: [
'$.operations.*',
'$.components.operations.*',
],
then: {
function: operationMessagesUnambiguity,
},
},
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import { testRule, DiagnosticSeverity } from '../../tester';

testRule('asyncapi3-operation-messages-from-referred-channel', [
{
name: 'valid case - required channel',
document: {
asyncapi: '3.0.0',
info: {
title: 'Account Service',
version: '1.0.0'
},
channels: {
UserSignedUp: {
messages: {
UserSignedUp: {
payload: {
type: 'object',
properties: {
displayName: {
type: 'string'
},
email: {
type: 'string'
}
}
}
}
}
}
},
operations: {
UserSignedUp: {
action: 'send',
channel: {
$ref: '#/channels/UserSignedUp'
},
messages: [
{
$ref: '#/channels/UserSignedUp/messages/UserSignedUp'
}
]
}
}
},
errors: [],
},
{
name: 'valid case - optional channel',
document: {
asyncapi: '3.0.0',
info: {
title: 'Account Service',
version: '1.0.0'
},
channels: {
UserSignedUp: {
messages: {
UserSignedUp: {
payload: {
type: 'object',
properties: {
displayName: {
type: 'string'
},
email: {
type: 'string'
}
}
}
}
}
}
},
components: {
operations: {
UserSignedUp: {
action: 'send',
channel: {
$ref: '#/channels/UserSignedUp'
},
messages: [
{
$ref: '#/channels/UserSignedUp/messages/UserSignedUp'
}
]
}
},
}
},
errors: [],
},
{
name: 'invalid case - message from operation in root pointing to a message from an optional channel (same name) defined under components',
document: {
asyncapi: '3.0.0',
info: {
title: 'Account Service',
version: '1.0.0'
},
channels: {
UserSignedUp: {
messages: {
UserSignedUp: {
payload: {
type: 'object',
properties: {
displayName: {
type: 'string'
},
email: {
type: 'string'
}
}
}
}
}
}
},
operations: {
UserSignedUp: {
action: 'send',
channel: {
$ref: '#/channels/UserSignedUp'
},
messages: [
{
$ref: '#/components/channels/UserSignedUp/messages/UserSignedUp'
}
]
}
},
components: {
channels: {
UserSignedUp: {
messages: {
UserSignedUp: {
payload: {
type: 'object',
properties: {
displayName: {
type: 'string'
},
email: {
type: 'string'
}
}
}
}
}
}
},
},
},
errors: [
{
message:
'Operation message does not belong to the specified channel.',
path: ['operations', 'UserSignedUp', 'messages', '0'],
severity: DiagnosticSeverity.Error,
}
],
},
{
name: 'invalid case - message from operation in components pointing to a message from a different channel defined under components',
document: {
asyncapi: '3.0.0',
info: {
title: 'Account Service',
version: '1.0.0'
},
channels: {
UserSignedUp: {
messages: {
UserSignedUp: {
payload: {
type: 'object',
properties: {
displayName: {
type: 'string'
},
email: {
type: 'string'
}
}
}
}
}
}
},
components: {
channels: {
UserRemoved: {
messages: {
UserRemoved: {
payload: {
type: 'object',
properties: {
displayName: {
type: 'string'
},
email: {
type: 'string'
}
}
}
}
}
}
},
operations: {
UserSignedUp: {
action: 'send',
channel: {
$ref: '#/channels/UserSignedUp'
},
messages: [
{
$ref: '#/components/channels/UserRemoved/messages/UserRemoved'
}
]
}
},
}
},
errors: [
{
message:
'Operation message does not belong to the specified channel.',
path: ['components', 'operations', 'UserSignedUp', 'messages', '0'],
severity: DiagnosticSeverity.Error,
}
],
},
]);
3 changes: 3 additions & 0 deletions test/ruleset/tester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AvroSchemaParser } from '@asyncapi/avro-schema-parser'; // allows testi
// rulesets
import { coreRuleset, recommendedRuleset } from '../../src/ruleset/ruleset';
import { v2CoreRuleset, v2SchemasRuleset, v2RecommendedRuleset } from '../../src/ruleset/v2';
import { v3CoreRuleset } from '../../src/ruleset/v3';

import type { ParserOptions } from '../../src/parser';
import type { IRuleResult, RulesetDefinition } from '@stoplight/spectral-core';
Expand All @@ -15,6 +16,7 @@ type RuleNames =
| RulesetRules<typeof v2CoreRuleset>
| RulesetRules<typeof v2RecommendedRuleset>
| RulesetRules<ReturnType<typeof v2SchemasRuleset>>
| RulesetRules<typeof v3CoreRuleset>

type Scenario = ReadonlyArray<
Readonly<{
Expand Down Expand Up @@ -51,6 +53,7 @@ function createParser(rules: Array<RuleNames>, options: ParserOptions = {}): Par
[recommendedRuleset as RulesetDefinition, 'off'],
[v2CoreRuleset as RulesetDefinition, 'off'],
[v2RecommendedRuleset as RulesetDefinition, 'off'],
[v3CoreRuleset as RulesetDefinition, 'off'],
],
rules: {
'asyncapi2-schemas': 'off',
Expand Down

0 comments on commit 42ea4ad

Please sign in to comment.