Skip to content

Commit 93270f8

Browse files
fix: add resolved-document validation rules for v3 channel/server references
1 parent 6736463 commit 93270f8

File tree

5 files changed

+407
-0
lines changed

5 files changed

+407
-0
lines changed
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { createRulesetFunction } from '@stoplight/spectral-core';
2+
import type { IFunctionResult } from '@stoplight/spectral-core';
3+
4+
type ServerObject = Record<string, unknown>;
5+
type ChannelObject = {
6+
servers?: ServerObject[];
7+
[key: string]: unknown;
8+
};
9+
type AsyncAPIDocument = {
10+
servers?: Record<string, ServerObject>;
11+
channels?: Record<string, ChannelObject>;
12+
[key: string]: unknown;
13+
};
14+
15+
/**
16+
* This function validates that channels under the root "channels" object
17+
* reference servers that are defined in the root "servers" object.
18+
*
19+
* This validation runs on the RESOLVED document, meaning all $refs have been
20+
* dereferenced. This is necessary to catch cases where an external file's
21+
* channel is referenced, and that channel has servers pointing to components
22+
* instead of root servers.
23+
*/
24+
export const requiredChannelServersUnambiguity = createRulesetFunction<AsyncAPIDocument, null>(
25+
{
26+
input: {
27+
type: 'object',
28+
properties: {
29+
servers: {
30+
type: 'object',
31+
},
32+
channels: {
33+
type: 'object',
34+
},
35+
},
36+
},
37+
options: null,
38+
},
39+
(targetVal) => {
40+
const results: IFunctionResult[] = [];
41+
42+
if (!targetVal.channels) {
43+
return results;
44+
}
45+
46+
const rootServers = targetVal.servers ?? {};
47+
const rootServerValues = Object.values(rootServers);
48+
49+
Object.entries(targetVal.channels).forEach(([channelName, channel]) => {
50+
if (!channel.servers || !Array.isArray(channel.servers)) {
51+
return;
52+
}
53+
54+
channel.servers.forEach((server, index) => {
55+
// After resolution, each server in the array should be the actual server object
56+
// We need to check if this resolved server object is one of the root servers
57+
// by comparing object references (after resolution, they should be the same object)
58+
const isRootServer = rootServerValues.some(
59+
(rootServer) => rootServer === server
60+
);
61+
62+
if (!isRootServer) {
63+
results.push({
64+
message: 'Channel references a server that is not defined in the root "servers" object.',
65+
path: ['channels', channelName, 'servers', index],
66+
});
67+
}
68+
});
69+
});
70+
71+
return results;
72+
},
73+
);
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { createRulesetFunction } from '@stoplight/spectral-core';
2+
import type { IFunctionResult } from '@stoplight/spectral-core';
3+
4+
type ChannelObject = Record<string, unknown>;
5+
type OperationObject = {
6+
channel?: ChannelObject;
7+
[key: string]: unknown;
8+
};
9+
type AsyncAPIDocument = {
10+
channels?: Record<string, ChannelObject>;
11+
operations?: Record<string, OperationObject>;
12+
[key: string]: unknown;
13+
};
14+
15+
/**
16+
* This function validates that operations under the root "operations" object
17+
* reference channels that are defined in the root "channels" object.
18+
*
19+
* This validation runs on the RESOLVED document, meaning all $refs have been
20+
* dereferenced. This is necessary to catch cases where an external file's
21+
* channel is referenced, and that channel points to components instead of root channels.
22+
*/
23+
export const requiredOperationChannelUnambiguity = createRulesetFunction<AsyncAPIDocument, null>(
24+
{
25+
input: {
26+
type: 'object',
27+
properties: {
28+
channels: {
29+
type: 'object',
30+
},
31+
operations: {
32+
type: 'object',
33+
},
34+
},
35+
},
36+
options: null,
37+
},
38+
(targetVal) => {
39+
const results: IFunctionResult[] = [];
40+
41+
if (!targetVal.operations) {
42+
return results;
43+
}
44+
45+
const rootChannels = targetVal.channels ?? {};
46+
const rootChannelValues = Object.values(rootChannels);
47+
48+
Object.entries(targetVal.operations).forEach(([operationName, operation]) => {
49+
if (!operation.channel) {
50+
return;
51+
}
52+
53+
// After resolution, operation.channel should be the actual channel object
54+
// We need to check if this resolved channel object is one of the root channels
55+
const resolvedChannel = operation.channel;
56+
57+
// Check if the resolved channel is actually one of the root channels
58+
// by comparing object references (after resolution, they should be the same object)
59+
const isRootChannel = rootChannelValues.some(
60+
(rootChannel) => rootChannel === resolvedChannel
61+
);
62+
63+
if (!isRootChannel) {
64+
results.push({
65+
message: 'Operation references a channel that is not defined in the root "channels" object.',
66+
path: ['operations', operationName, 'channel'],
67+
});
68+
}
69+
});
70+
71+
return results;
72+
},
73+
);

packages/parser/src/ruleset/v3/ruleset.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
import { AsyncAPIFormats } from '../formats';
55
import { operationMessagesUnambiguity } from './functions/operationMessagesUnambiguity';
6+
import { requiredOperationChannelUnambiguity } from './functions/requiredOperationChannelUnambiguity';
7+
import { requiredChannelServersUnambiguity } from './functions/requiredChannelServersUnambiguity';
68
import { pattern } from '@stoplight/spectral-functions';
79
import { channelServers } from '../functions/channelServers';
810

@@ -41,6 +43,22 @@ export const v3CoreRuleset = {
4143
},
4244
},
4345
},
46+
/**
47+
* This rule runs on the RESOLVED document to catch cases where external file
48+
* references resolve to channels that are not in the root channels object.
49+
* See: https://github.com/asyncapi/parser-js/issues/924
50+
*/
51+
'asyncapi3-required-operation-channel-unambiguity-resolved': {
52+
description: 'The "channel" field of an operation under the root "operations" object must resolve to a channel defined in the root "channels" object.',
53+
message: '{{error}}',
54+
severity: 'error',
55+
recommended: true,
56+
resolved: true, // Run on resolved document to catch external file references
57+
given: '$',
58+
then: {
59+
function: requiredOperationChannelUnambiguity,
60+
},
61+
},
4462

4563
/**
4664
* Channel Object rules
@@ -59,6 +77,22 @@ export const v3CoreRuleset = {
5977
},
6078
},
6179
},
80+
/**
81+
* This rule runs on the RESOLVED document to catch cases where external file
82+
* references resolve to servers that are not in the root servers object.
83+
* See: https://github.com/asyncapi/parser-js/issues/924
84+
*/
85+
'asyncapi3-required-channel-servers-unambiguity-resolved': {
86+
description: 'The "servers" field of a channel under the root "channels" object must resolve to servers defined in the root "servers" object.',
87+
message: '{{error}}',
88+
severity: 'error',
89+
recommended: true,
90+
resolved: true, // Run on resolved document to catch external file references
91+
given: '$',
92+
then: {
93+
function: requiredChannelServersUnambiguity,
94+
},
95+
},
6296
'asyncapi3-channel-servers': {
6397
description: 'Channel servers must be defined in the "servers" object.',
6498
message: '{{error}}',
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { testRule, DiagnosticSeverity } from '../../tester';
2+
3+
testRule('asyncapi3-required-channel-servers-unambiguity-resolved', [
4+
{
5+
name: 'valid case - root channel servers resolve to root servers',
6+
document: {
7+
asyncapi: '3.0.0',
8+
info: {
9+
title: 'Account Service',
10+
version: '1.0.0'
11+
},
12+
servers: {
13+
prod: {
14+
host: 'my-api.com',
15+
protocol: 'ws',
16+
},
17+
dev: {
18+
host: 'localhost',
19+
protocol: 'ws',
20+
},
21+
},
22+
channels: {
23+
UserSignedUp: {
24+
servers: [
25+
{ $ref: '#/servers/prod' },
26+
{ $ref: '#/servers/dev' },
27+
]
28+
}
29+
},
30+
},
31+
errors: [],
32+
},
33+
{
34+
name: 'valid case - channel with no servers field',
35+
document: {
36+
asyncapi: '3.0.0',
37+
info: {
38+
title: 'Account Service',
39+
version: '1.0.0'
40+
},
41+
channels: {
42+
UserSignedUp: {
43+
address: 'user/signedup'
44+
}
45+
},
46+
},
47+
errors: [],
48+
},
49+
{
50+
name: 'valid case - document with no channels',
51+
document: {
52+
asyncapi: '3.0.0',
53+
info: {
54+
title: 'Account Service',
55+
version: '1.0.0'
56+
},
57+
},
58+
errors: [],
59+
},
60+
{
61+
name: 'invalid case - root channel servers resolve to component servers',
62+
document: {
63+
asyncapi: '3.0.0',
64+
info: {
65+
title: 'Account Service',
66+
version: '1.0.0'
67+
},
68+
channels: {
69+
UserSignedUp: {
70+
servers: [
71+
{ $ref: '#/components/servers/prod' },
72+
{ $ref: '#/components/servers/dev' },
73+
]
74+
}
75+
},
76+
components: {
77+
servers: {
78+
prod: {
79+
host: 'my-api.com',
80+
protocol: 'ws',
81+
},
82+
dev: {
83+
host: 'localhost',
84+
protocol: 'ws',
85+
},
86+
}
87+
}
88+
},
89+
errors: [
90+
{
91+
message: 'Channel references a server that is not defined in the root "servers" object.',
92+
path: ['channels', 'UserSignedUp', 'servers', '0'],
93+
severity: DiagnosticSeverity.Error,
94+
},
95+
{
96+
message: 'Channel references a server that is not defined in the root "servers" object.',
97+
path: ['channels', 'UserSignedUp', 'servers', '1'],
98+
severity: DiagnosticSeverity.Error,
99+
}
100+
],
101+
},
102+
]);

0 commit comments

Comments
 (0)