diff --git a/docs/reference/asyncapi-rules.md b/docs/reference/asyncapi-rules.md index 4e488bbe2..768b9c4ae 100644 --- a/docs/reference/asyncapi-rules.md +++ b/docs/reference/asyncapi-rules.md @@ -24,6 +24,12 @@ Keep trailing slashes off of channel names, as it can cause some confusion. Most **Recommended:** Yes +### asyncapi-channel-parameters + +All channel parameters should be defined in the `parameters` object of the channel. They should also not contain redundant parameters that do not exist in the channel address. + +**Recommended:** Yes + ### asyncapi-headers-schema-type-object The schema definition of the application headers must be of type “object”. @@ -288,6 +294,12 @@ Server URL should not point at example.com. **Recommended:** No +### asyncapi-server-variables + +All server URL variables should be defined in the `variables` object of the server. They should also not contain redundant variables that do not exist in the server address. + +**Recommended:** Yes + ### asyncapi-servers A non empty `servers` object is expected to be located at the root of the document. diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-channel-parameters.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-channel-parameters.test.ts new file mode 100644 index 000000000..e4715798a --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-channel-parameters.test.ts @@ -0,0 +1,143 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-channel-parameters', [ + { + name: 'valid case', + document: { + asyncapi: '2.0.0', + channels: { + 'users/{userId}/signedUp': { + parameters: { + userId: {}, + }, + }, + }, + }, + errors: [], + }, + + { + name: 'channel has not defined definition for one of the parameters', + document: { + asyncapi: '2.0.0', + channels: { + 'users/{userId}/{anotherParam}/signedUp': { + parameters: { + userId: {}, + }, + }, + }, + }, + errors: [ + { + message: 'Not all channel\'s parameters are described with "parameters" object. Missed: anotherParam.', + path: ['channels', 'users/{userId}/{anotherParam}/signedUp', 'parameters'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'channel has not defined definition for two+ of the parameters', + document: { + asyncapi: '2.0.0', + channels: { + 'users/{userId}/{anotherParam1}/{anotherParam2}/signedUp': { + parameters: { + userId: {}, + }, + }, + }, + }, + errors: [ + { + message: + 'Not all channel\'s parameters are described with "parameters" object. Missed: anotherParam1, anotherParam2.', + path: ['channels', 'users/{userId}/{anotherParam1}/{anotherParam2}/signedUp', 'parameters'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'channel has not defined definition for one of the parameters (in the components.channels)', + document: { + asyncapi: '2.3.0', + components: { + channels: { + 'users/{userId}/{anotherParam}/signedUp': { + parameters: { + userId: {}, + }, + }, + }, + }, + }, + errors: [ + { + message: 'Not all channel\'s parameters are described with "parameters" object. Missed: anotherParam.', + path: ['components', 'channels', 'users/{userId}/{anotherParam}/signedUp', 'parameters'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'channel has redundant paramaters', + document: { + asyncapi: '2.0.0', + channels: { + 'users/{userId}/signedUp': { + parameters: { + userId: {}, + anotherParam1: {}, + anotherParam2: {}, + }, + }, + }, + }, + errors: [ + { + message: 'Channel\'s "parameters" object has redundant defined "anotherParam1" parameter.', + path: ['channels', 'users/{userId}/signedUp', 'parameters', 'anotherParam1'], + severity: DiagnosticSeverity.Error, + }, + { + message: 'Channel\'s "parameters" object has redundant defined "anotherParam2" parameter.', + path: ['channels', 'users/{userId}/signedUp', 'parameters', 'anotherParam2'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'channel has redundant paramaters (in the components.channels)', + document: { + asyncapi: '2.3.0', + components: { + channels: { + 'users/{userId}/signedUp': { + parameters: { + userId: {}, + anotherParam1: {}, + anotherParam2: {}, + }, + }, + }, + }, + }, + errors: [ + { + message: 'Channel\'s "parameters" object has redundant defined "anotherParam1" parameter.', + path: ['components', 'channels', 'users/{userId}/signedUp', 'parameters', 'anotherParam1'], + severity: DiagnosticSeverity.Error, + }, + { + message: 'Channel\'s "parameters" object has redundant defined "anotherParam2" parameter.', + path: ['components', 'channels', 'users/{userId}/signedUp', 'parameters', 'anotherParam2'], + severity: DiagnosticSeverity.Error, + }, + ], + }, +]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-server-variables.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-server-variables.test.ts new file mode 100644 index 000000000..b529611aa --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-server-variables.test.ts @@ -0,0 +1,155 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-server-variables', [ + { + name: 'valid case', + document: { + asyncapi: '2.0.0', + servers: { + production: { + url: '{sub}.stoplight.io', + protocol: 'https', + variables: { + sub: {}, + }, + }, + }, + }, + errors: [], + }, + + { + name: 'server has not defined definition for one of the url variables', + document: { + asyncapi: '2.0.0', + servers: { + production: { + url: '{sub}.{anotherParam}.stoplight.io', + protocol: 'https', + variables: { + sub: {}, + }, + }, + }, + }, + errors: [ + { + message: 'Not all server\'s variables are described with "variables" object. Missed: anotherParam.', + path: ['servers', 'production', 'variables'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'server has not defined definition for two of the url variables', + document: { + asyncapi: '2.0.0', + servers: { + production: { + url: '{sub}.{anotherParam1}.{anotherParam2}.stoplight.io', + protocol: 'https', + variables: { + sub: {}, + }, + }, + }, + }, + errors: [ + { + message: + 'Not all server\'s variables are described with "variables" object. Missed: anotherParam1, anotherParam2.', + path: ['servers', 'production', 'variables'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'server has not defined definition for one of the url variables (in the components.servers)', + document: { + asyncapi: '2.3.0', + components: { + servers: { + production: { + url: '{sub}.{anotherParam}.stoplight.io', + protocol: 'https', + variables: { + sub: {}, + }, + }, + }, + }, + }, + errors: [ + { + message: 'Not all server\'s variables are described with "variables" object. Missed: anotherParam.', + path: ['components', 'servers', 'production', 'variables'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'server has redundant url variables', + document: { + asyncapi: '2.0.0', + servers: { + production: { + url: '{sub}.stoplight.io', + protocol: 'https', + variables: { + sub: {}, + anotherParam1: {}, + anotherParam2: {}, + }, + }, + }, + }, + errors: [ + { + message: 'Server\'s "variables" object has redundant defined "anotherParam1" url variable.', + path: ['servers', 'production', 'variables', 'anotherParam1'], + severity: DiagnosticSeverity.Error, + }, + { + message: 'Server\'s "variables" object has redundant defined "anotherParam2" url variable.', + path: ['servers', 'production', 'variables', 'anotherParam2'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'server has redundant url variables (in the components.servers)', + document: { + asyncapi: '2.3.0', + components: { + servers: { + production: { + url: '{sub}.stoplight.io', + protocol: 'https', + variables: { + sub: {}, + anotherParam1: {}, + anotherParam2: {}, + }, + }, + }, + }, + }, + errors: [ + { + message: 'Server\'s "variables" object has redundant defined "anotherParam1" url variable.', + path: ['components', 'servers', 'production', 'variables', 'anotherParam1'], + severity: DiagnosticSeverity.Error, + }, + { + message: 'Server\'s "variables" object has redundant defined "anotherParam2" url variable.', + path: ['components', 'servers', 'production', 'variables', 'anotherParam2'], + severity: DiagnosticSeverity.Error, + }, + ], + }, +]); diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2ChannelParameters.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2ChannelParameters.ts new file mode 100644 index 000000000..9c214e6fb --- /dev/null +++ b/packages/rulesets/src/asyncapi/functions/asyncApi2ChannelParameters.ts @@ -0,0 +1,51 @@ +import { createRulesetFunction } from '@stoplight/spectral-core'; + +import { parseUrlVariables } from './utils/parseUrlVariables'; +import { getMissingProps } from './utils/getMissingProps'; +import { getRedundantProps } from './utils/getRedundantProps'; + +import type { IFunctionResult } from '@stoplight/spectral-core'; + +export default createRulesetFunction<{ parameters: Record }, null>( + { + input: { + type: 'object', + properties: { + parameters: { + type: 'object', + }, + }, + required: ['parameters'], + }, + options: null, + }, + function asyncApi2ChannelParameters(targetVal, _, ctx) { + const path = ctx.path[ctx.path.length - 1] as string; + const results: IFunctionResult[] = []; + + const parameters = parseUrlVariables(path); + if (parameters.length === 0) return; + + const missingParameters = getMissingProps(parameters, targetVal.parameters); + if (missingParameters.length) { + results.push({ + message: `Not all channel's parameters are described with "parameters" object. Missed: ${missingParameters.join( + ', ', + )}.`, + path: [...ctx.path, 'parameters'], + }); + } + + const redundantParameters = getRedundantProps(parameters, targetVal.parameters); + if (redundantParameters.length) { + redundantParameters.forEach(param => { + results.push({ + message: `Channel's "parameters" object has redundant defined "${param}" parameter.`, + path: [...ctx.path, 'parameters', param], + }); + }); + } + + return results; + }, +); diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2ServerVariables.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2ServerVariables.ts new file mode 100644 index 000000000..3fda72d8e --- /dev/null +++ b/packages/rulesets/src/asyncapi/functions/asyncApi2ServerVariables.ts @@ -0,0 +1,53 @@ +import { createRulesetFunction } from '@stoplight/spectral-core'; + +import { parseUrlVariables } from './utils/parseUrlVariables'; +import { getMissingProps } from './utils/getMissingProps'; +import { getRedundantProps } from './utils/getRedundantProps'; + +import type { IFunctionResult } from '@stoplight/spectral-core'; + +export default createRulesetFunction<{ url: string; variables: Record }, null>( + { + input: { + type: 'object', + properties: { + url: { + type: 'string', + }, + variables: { + type: 'object', + }, + }, + required: ['url', 'variables'], + }, + options: null, + }, + function asyncApi2ServerVariables(targetVal, _, ctx) { + const results: IFunctionResult[] = []; + + const variables = parseUrlVariables(targetVal.url); + if (variables.length === 0) return results; + + const missingVariables = getMissingProps(variables, targetVal.variables); + if (missingVariables.length) { + results.push({ + message: `Not all server's variables are described with "variables" object. Missed: ${missingVariables.join( + ', ', + )}.`, + path: [...ctx.path, 'variables'], + }); + } + + const redundantVariables = getRedundantProps(variables, targetVal.variables); + if (redundantVariables.length) { + redundantVariables.forEach(variable => { + results.push({ + message: `Server's "variables" object has redundant defined "${variable}" url variable.`, + path: [...ctx.path, 'variables', variable], + }); + }); + } + + return results; + }, +); diff --git a/packages/rulesets/src/asyncapi/functions/utils/__tests__/getMissingProps.test.ts b/packages/rulesets/src/asyncapi/functions/utils/__tests__/getMissingProps.test.ts new file mode 100644 index 000000000..928d06ae0 --- /dev/null +++ b/packages/rulesets/src/asyncapi/functions/utils/__tests__/getMissingProps.test.ts @@ -0,0 +1,18 @@ +import { getMissingProps } from '../getMissingProps'; + +describe('getMissingProps', () => { + test('should return all props when object is empty', () => { + const result = getMissingProps(['one', 'two', 'three'], {}); + expect(result).toEqual(['one', 'two', 'three']); + }); + + test('should return only missed props', () => { + const result = getMissingProps(['one', 'two', 'three'], { one: {}, three: {} }); + expect(result).toEqual(['two']); + }); + + test('should return empty array when all props are defined', () => { + const result = getMissingProps(['one', 'two', 'three'], { one: {}, two: {}, three: {} }); + expect(result).toEqual([]); + }); +}); diff --git a/packages/rulesets/src/asyncapi/functions/utils/__tests__/getRedundantProps.test.ts b/packages/rulesets/src/asyncapi/functions/utils/__tests__/getRedundantProps.test.ts new file mode 100644 index 000000000..bad9660fa --- /dev/null +++ b/packages/rulesets/src/asyncapi/functions/utils/__tests__/getRedundantProps.test.ts @@ -0,0 +1,18 @@ +import { getRedundantProps } from '../getRedundantProps'; + +describe('getRedundantProps', () => { + test('should return all redundant props when array is empty', () => { + const result = getRedundantProps([], { one: {}, two: {}, three: {} }); + expect(result).toEqual(['one', 'two', 'three']); + }); + + test('should return only redundant props', () => { + const result = getRedundantProps(['one', 'three'], { one: {}, two: {}, three: {} }); + expect(result).toEqual(['two']); + }); + + test('should return empty array when all props are defined', () => { + const result = getRedundantProps(['one', 'two', 'three'], { one: {}, two: {}, three: {} }); + expect(result).toEqual([]); + }); +}); diff --git a/packages/rulesets/src/asyncapi/functions/utils/__tests__/parseUrlVariables.test.ts b/packages/rulesets/src/asyncapi/functions/utils/__tests__/parseUrlVariables.test.ts new file mode 100644 index 000000000..ada099353 --- /dev/null +++ b/packages/rulesets/src/asyncapi/functions/utils/__tests__/parseUrlVariables.test.ts @@ -0,0 +1,13 @@ +import { parseUrlVariables } from '../parseUrlVariables'; + +describe('parseUrlVariables', () => { + test('should return all variables from string', () => { + const result = parseUrlVariables('{stage}.some.{channel}'); + expect(result).toEqual(['stage', 'channel']); + }); + + test('should return empty array if no variable is defined', () => { + const result = parseUrlVariables('stage.some.channel'); + expect(result).toEqual([]); + }); +}); diff --git a/packages/rulesets/src/asyncapi/functions/utils/getMissingProps.ts b/packages/rulesets/src/asyncapi/functions/utils/getMissingProps.ts new file mode 100644 index 000000000..72d5784ed --- /dev/null +++ b/packages/rulesets/src/asyncapi/functions/utils/getMissingProps.ts @@ -0,0 +1,6 @@ +export function getMissingProps(arr: string[] = [], obj: Record = {}): string[] { + if (!Object.keys(obj).length) return arr; + return arr.filter(val => { + return !Object.prototype.hasOwnProperty.call(obj, val); + }); +} diff --git a/packages/rulesets/src/asyncapi/functions/utils/getRedundantProps.ts b/packages/rulesets/src/asyncapi/functions/utils/getRedundantProps.ts new file mode 100644 index 000000000..d7a9fdeae --- /dev/null +++ b/packages/rulesets/src/asyncapi/functions/utils/getRedundantProps.ts @@ -0,0 +1,6 @@ +export function getRedundantProps(arr: string[] = [], obj: Record = {}): string[] { + if (!arr.length) return Object.keys(obj); + return Object.keys(obj).filter(val => { + return !arr.includes(val); + }); +} diff --git a/packages/rulesets/src/asyncapi/functions/utils/parseUrlVariables.ts b/packages/rulesets/src/asyncapi/functions/utils/parseUrlVariables.ts new file mode 100644 index 000000000..071c3e388 --- /dev/null +++ b/packages/rulesets/src/asyncapi/functions/utils/parseUrlVariables.ts @@ -0,0 +1,6 @@ +export function parseUrlVariables(str: string): string[] { + if (typeof str !== 'string') return []; + const variables = str.match(/{(.+?)}/g); + if (!variables || variables.length === 0) return []; + return variables.map(v => v.slice(1, -1)); +} diff --git a/packages/rulesets/src/asyncapi/index.ts b/packages/rulesets/src/asyncapi/index.ts index c36d3ed13..06114f22b 100644 --- a/packages/rulesets/src/asyncapi/index.ts +++ b/packages/rulesets/src/asyncapi/index.ts @@ -8,9 +8,11 @@ import { alphabetical, } from '@stoplight/spectral-functions'; +import asyncApi2ChannelParameters from './functions/asyncApi2ChannelParameters'; import asyncApi2DocumentSchema from './functions/asyncApi2DocumentSchema'; import asyncApi2SchemaValidation from './functions/asyncApi2SchemaValidation'; import asyncApi2PayloadValidation from './functions/asyncApi2PayloadValidation'; +import asyncApi2ServerVariables from './functions/asyncApi2ServerVariables'; export default { documentationUrl: 'https://meta.stoplight.io/docs/spectral/docs/reference/asyncapi-rules.md', @@ -55,6 +57,17 @@ export default { }, }, }, + 'asyncapi-channel-parameters': { + description: 'Channel parameters must be defined and there must be no redundant parameters.', + message: '{{error}}', + severity: 'error', + type: 'validation', + recommended: true, + given: ['$.channels.*', '$.components.channels.*'], + then: { + function: asyncApi2ChannelParameters, + }, + }, 'asyncapi-headers-schema-type-object': { description: 'Headers schema type must be "object".', message: 'Headers schema type must be "object" ({{error}}).', @@ -282,6 +295,17 @@ export default { function: asyncApi2DocumentSchema, }, }, + 'asyncapi-server-variables': { + description: 'Server variables must be defined and there must be no redundant variables.', + message: '{{error}}', + severity: 'error', + type: 'validation', + recommended: true, + given: ['$.servers.*', '$.components.servers.*'], + then: { + function: asyncApi2ServerVariables, + }, + }, 'asyncapi-server-no-empty-variable': { description: 'Server URL must not have empty variable substitution pattern.', recommended: true,