Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { createRulesetFunction } from '@stoplight/spectral-core';
import type { IFunctionResult } from '@stoplight/spectral-core';

type ServerObject = Record<string, unknown>;
type ChannelObject = {
servers?: ServerObject[];
[key: string]: unknown;
};
type AsyncAPIDocument = {
servers?: Record<string, ServerObject>;
channels?: Record<string, ChannelObject>;
[key: string]: unknown;
};

/**
* This function validates that channels under the root "channels" object
* reference servers that are defined in the root "servers" object.
*
* This validation runs on the RESOLVED document, meaning all $refs have been
* dereferenced. This is necessary to catch cases where an external file's
* channel is referenced, and that channel has servers pointing to components
* instead of root servers.
*/
export const requiredChannelServersUnambiguity = createRulesetFunction<AsyncAPIDocument, null>(
{
input: {
type: 'object',
properties: {
servers: {
type: 'object',
},
channels: {
type: 'object',
},
},
},
options: null,
},
(targetVal) => {
const results: IFunctionResult[] = [];

if (!targetVal.channels) {
return results;
}

const rootServers = targetVal.servers ?? {};
const rootServerValues = Object.values(rootServers);

Object.entries(targetVal.channels).forEach(([channelName, channel]) => {
if (!channel.servers || !Array.isArray(channel.servers)) {
return;
}

channel.servers.forEach((server, index) => {
// After resolution, each server in the array should be the actual server object
// We need to check if this resolved server object is one of the root servers
// by comparing object references (after resolution, they should be the same object)
const isRootServer = rootServerValues.some(
(rootServer) => rootServer === server
);

if (!isRootServer) {
results.push({
message: 'Channel references a server that is not defined in the root "servers" object.',
path: ['channels', channelName, 'servers', index],
});
}
});
});

return results;
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { createRulesetFunction } from '@stoplight/spectral-core';
import type { IFunctionResult } from '@stoplight/spectral-core';

type ChannelObject = Record<string, unknown>;
type OperationObject = {
channel?: ChannelObject;
[key: string]: unknown;
};
type AsyncAPIDocument = {
channels?: Record<string, ChannelObject>;
operations?: Record<string, OperationObject>;
[key: string]: unknown;
};

/**
* This function validates that operations under the root "operations" object
* reference channels that are defined in the root "channels" object.
*
* This validation runs on the RESOLVED document, meaning all $refs have been
* dereferenced. This is necessary to catch cases where an external file's
* channel is referenced, and that channel points to components instead of root channels.
*/
export const requiredOperationChannelUnambiguity = createRulesetFunction<AsyncAPIDocument, null>(
{
input: {
type: 'object',
properties: {
channels: {
type: 'object',
},
operations: {
type: 'object',
},
},
},
options: null,
},
(targetVal) => {
const results: IFunctionResult[] = [];

if (!targetVal.operations) {
return results;
}

const rootChannels = targetVal.channels ?? {};
const rootChannelValues = Object.values(rootChannels);

Object.entries(targetVal.operations).forEach(([operationName, operation]) => {
if (!operation.channel) {
return;
}

// After resolution, operation.channel should be the actual channel object
// We need to check if this resolved channel object is one of the root channels
const resolvedChannel = operation.channel;

// Check if the resolved channel is actually one of the root channels
// by comparing object references (after resolution, they should be the same object)
const isRootChannel = rootChannelValues.some(
(rootChannel) => rootChannel === resolvedChannel
);

if (!isRootChannel) {
results.push({
message: 'Operation references a channel that is not defined in the root "channels" object.',
path: ['operations', operationName, 'channel'],
});
}
});

return results;
},
);
34 changes: 34 additions & 0 deletions packages/parser/src/ruleset/v3/ruleset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

import { AsyncAPIFormats } from '../formats';
import { operationMessagesUnambiguity } from './functions/operationMessagesUnambiguity';
import { requiredOperationChannelUnambiguity } from './functions/requiredOperationChannelUnambiguity';
import { requiredChannelServersUnambiguity } from './functions/requiredChannelServersUnambiguity';
import { pattern } from '@stoplight/spectral-functions';
import { channelServers } from '../functions/channelServers';

Expand Down Expand Up @@ -41,6 +43,22 @@ export const v3CoreRuleset = {
},
},
},
/**
* This rule runs on the RESOLVED document to catch cases where external file
* references resolve to channels that are not in the root channels object.
* See: https://github.com/asyncapi/parser-js/issues/924
*/
'asyncapi3-required-operation-channel-unambiguity-resolved': {
description: 'The "channel" field of an operation under the root "operations" object must resolve to a channel defined in the root "channels" object.',
message: '{{error}}',
severity: 'error',
recommended: true,
resolved: true, // Run on resolved document to catch external file references
given: '$',
then: {
function: requiredOperationChannelUnambiguity,
},
},

/**
* Channel Object rules
Expand All @@ -59,6 +77,22 @@ export const v3CoreRuleset = {
},
},
},
/**
* This rule runs on the RESOLVED document to catch cases where external file
* references resolve to servers that are not in the root servers object.
* See: https://github.com/asyncapi/parser-js/issues/924
*/
'asyncapi3-required-channel-servers-unambiguity-resolved': {
description: 'The "servers" field of a channel under the root "channels" object must resolve to servers defined in the root "servers" object.',
message: '{{error}}',
severity: 'error',
recommended: true,
resolved: true, // Run on resolved document to catch external file references
given: '$',
then: {
function: requiredChannelServersUnambiguity,
},
},
'asyncapi3-channel-servers': {
description: 'Channel servers must be defined in the "servers" object.',
message: '{{error}}',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { testRule, DiagnosticSeverity } from '../../tester';

testRule('asyncapi3-required-channel-servers-unambiguity-resolved', [
{
name: 'valid case - root channel servers resolve to root servers',
document: {
asyncapi: '3.0.0',
info: {
title: 'Account Service',
version: '1.0.0'
},
servers: {
prod: {
host: 'my-api.com',
protocol: 'ws',
},
dev: {
host: 'localhost',
protocol: 'ws',
},
},
channels: {
UserSignedUp: {
servers: [
{ $ref: '#/servers/prod' },
{ $ref: '#/servers/dev' },
]
}
},
},
errors: [],
},
{
name: 'valid case - channel with no servers field',
document: {
asyncapi: '3.0.0',
info: {
title: 'Account Service',
version: '1.0.0'
},
channels: {
UserSignedUp: {
address: 'user/signedup'
}
},
},
errors: [],
},
{
name: 'valid case - document with no channels',
document: {
asyncapi: '3.0.0',
info: {
title: 'Account Service',
version: '1.0.0'
},
},
errors: [],
},
{
name: 'invalid case - root channel servers resolve to component servers',
document: {
asyncapi: '3.0.0',
info: {
title: 'Account Service',
version: '1.0.0'
},
channels: {
UserSignedUp: {
servers: [
{ $ref: '#/components/servers/prod' },
{ $ref: '#/components/servers/dev' },
]
}
},
components: {
servers: {
prod: {
host: 'my-api.com',
protocol: 'ws',
},
dev: {
host: 'localhost',
protocol: 'ws',
},
}
}
},
errors: [
{
message: 'Channel references a server that is not defined in the root "servers" object.',
path: ['channels', 'UserSignedUp', 'servers', '0'],
severity: DiagnosticSeverity.Error,
},
{
message: 'Channel references a server that is not defined in the root "servers" object.',
path: ['channels', 'UserSignedUp', 'servers', '1'],
severity: DiagnosticSeverity.Error,
}
],
},
]);
Loading