Repository: stoplightio/spectral (ts)

Issue:
[AsyncAPI] Enhance parameters and variables linting
In #974, it's been identified that new rules could really be helpful. 

Especially:
- add a rule to ensure all variables used in server url are defined
- add a rule to ensure all defined server variables are used
- add a rule to ensure all parameters used in channel path are defined
- add a rule to ensure all defined channel parameters are used

The oas `path-params` custom function may be a great source of inspiration to fulfill that.

/cc @derberg

Pull request description:
feat(rulesets): add rules for validation of server variables and channel parameters
Fixes #1102

**Checklist**

- [X] Tests added / updated
- [X] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [X] No

**Context**

Two new rules add the ability to validate variables in server address and parameters in channel address. All variables/parameters must be defined and there can be no redundant variables/parameters.

> NOTE: workflows responsible for linting code and markdowns show errors, but in files which I have not changed at all... 🤷🏼 

Patch:
diff --git a/docs/reference/asyncapi-rules.md b/docs/reference/asyncapi-rules.md
index 4e488bbe..768b9c4a 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/functions/asyncApi2ChannelParameters.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2ChannelParameters.ts
new file mode 100644
index 00000000..9c214e6f
--- /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<string, unknown> }, 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 00000000..3fda72d8
--- /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<string, unknown> }, 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/getMissingProps.ts b/packages/rulesets/src/asyncapi/functions/utils/getMissingProps.ts
new file mode 100644
index 00000000..72d5784e
--- /dev/null
+++ b/packages/rulesets/src/asyncapi/functions/utils/getMissingProps.ts
@@ -0,0 +1,6 @@
+export function getMissingProps(arr: string[] = [], obj: Record<string, unknown> = {}): 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 00000000..d7a9fdea
--- /dev/null
+++ b/packages/rulesets/src/asyncapi/functions/utils/getRedundantProps.ts
@@ -0,0 +1,6 @@
+export function getRedundantProps(arr: string[] = [], obj: Record<string, unknown> = {}): 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 00000000..071c3e38
--- /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 c36d3ed1..06114f22 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,


Test patch:
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 00000000..e4715798
--- /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 00000000..b529611a
--- /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/utils/__tests__/getMissingProps.test.ts b/packages/rulesets/src/asyncapi/functions/utils/__tests__/getMissingProps.test.ts
new file mode 100644
index 00000000..928d06ae
--- /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 00000000..bad9660f
--- /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 00000000..ada09935
--- /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([]);
+  });
+});