Repository: stoplightio/spectral (ts)

Issue:
Support for OAS Server Variables
### **Requirements**

- [ ] Implement a Spectral rule to ensure URLs are valid format even when variables are used

- [ ] Implement a Spectral rule to check that if allowed values (enums) are defined for server variables, the default value should be a part of those allowed values.
    - For instance, if allowed values for `version` are `v1`, `v2`, and `v3`, and the default value is `v4`, then this should throw an error.

- [ ] Implement a rule to ensure that all server variables used in the server URL are defined in the server variables section.
    - For example, if `version` is used in a server URL like `https://api.example.com/{version}`, ensure that `version` is defined as a server variable.

- [ ] Implement a rule to ensure all defined server variables are used in the server URL.
    - If a server variable like `stage` is defined but not used in any server URL, this should trigger a warning.

Pull request:
feat(rulesets): add oas3-server-variables rule
Closes #2524

**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No




Patch:
diff --git a/docs/reference/openapi-rules.md b/docs/reference/openapi-rules.md
index d295b15c..d55e4377 100644
--- a/docs/reference/openapi-rules.md
+++ b/docs/reference/openapi-rules.md
@@ -846,3 +846,79 @@ schemas:
       - name
       - petType
 ```
+
+### oas3-server-variables
+
+This rule ensures that server variables defined in OpenAPI Specification 3 (OAS3) and 3.1 are valid, not unused, and result in a valid URL. Properly defining and using server variables is crucial for the accurate representation of API endpoints and preventing potential misconfigurations or security issues.
+
+**Recommended**: Yes
+
+**Bad Examples**
+
+1. **Missing definition for a URL variable**:
+
+```yaml
+servers:
+  - url: "https://api.{region}.example.com/v1"
+    variables:
+      version:
+        default: "v1"
+```
+
+In this example, the variable **`{region}`** in the URL is not defined within the **`variables`** object.
+
+2. **Unused URL variable:**
+
+```yaml
+servers:
+  - url: "https://api.example.com/v1"
+    variables:
+      region:
+        default: "us-west"
+```
+
+Here, the variable **`region`** is defined but not used in the server URL.
+
+3. **Invalid default value for an allowed value variable**:
+
+```yaml
+servers:
+  - url: "https://api.{region}.example.com/v1"
+    variables:
+      region:
+        default: "us-south"
+        enum:
+          - "us-west"
+          - "us-east"
+```
+
+The default value 'us-south' isn't one of the allowed values in the **`enum`**.
+
+4. **Invalid resultant URL**:
+
+```yaml
+servers:
+  - url: "https://api.example.com:{port}/v1"
+    variables:
+      port:
+        default: "8o80"
+```
+
+Substituting the default value of **`{port}`** results in an invalid URL.
+
+**Good Example**
+
+```yaml
+servers:
+  - url: "https://api.{region}.example.com/{version}"
+    variables:
+      region:
+        default: "us-west"
+        enum:
+          - "us-west"
+          - "us-east"
+      version:
+        default: "v1"
+```
+
+In this example, both **`{region}`** and **`{version}`** variables are properly defined and used in the server URL. Also, the default value for **`region`** is within the allowed values.
diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2ChannelParameters.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2ChannelParameters.ts
index 9c214e6f..71a189fd 100644
--- a/packages/rulesets/src/asyncapi/functions/asyncApi2ChannelParameters.ts
+++ b/packages/rulesets/src/asyncapi/functions/asyncApi2ChannelParameters.ts
@@ -1,10 +1,9 @@
 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';
+import { parseUrlVariables } from '../../shared/functions/serverVariables/utils/parseUrlVariables';
+import { getMissingProps } from '../../shared/utils/getMissingProps';
+import { getRedundantProps } from '../../shared/utils/getRedundantProps';
 
 export default createRulesetFunction<{ parameters: Record<string, unknown> }, null>(
   {
@@ -26,7 +25,7 @@ export default createRulesetFunction<{ parameters: Record<string, unknown> }, nu
     const parameters = parseUrlVariables(path);
     if (parameters.length === 0) return;
 
-    const missingParameters = getMissingProps(parameters, targetVal.parameters);
+    const missingParameters = getMissingProps(parameters, Object.keys(targetVal.parameters));
     if (missingParameters.length) {
       results.push({
         message: `Not all channel's parameters are described with "parameters" object. Missed: ${missingParameters.join(
@@ -36,7 +35,7 @@ export default createRulesetFunction<{ parameters: Record<string, unknown> }, nu
       });
     }
 
-    const redundantParameters = getRedundantProps(parameters, targetVal.parameters);
+    const redundantParameters = getRedundantProps(parameters, Object.keys(targetVal.parameters));
     if (redundantParameters.length) {
       redundantParameters.forEach(param => {
         results.push({
diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2ServerVariables.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2ServerVariables.ts
deleted file mode 100644
index 3fda72d8..00000000
--- a/packages/rulesets/src/asyncapi/functions/asyncApi2ServerVariables.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-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
deleted file mode 100644
index 72d5784e..00000000
--- a/packages/rulesets/src/asyncapi/functions/utils/getMissingProps.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-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
deleted file mode 100644
index d7a9fdea..00000000
--- a/packages/rulesets/src/asyncapi/functions/utils/getRedundantProps.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-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/index.ts b/packages/rulesets/src/asyncapi/index.ts
index eb20fa1f..680cda75 100644
--- a/packages/rulesets/src/asyncapi/index.ts
+++ b/packages/rulesets/src/asyncapi/index.ts
@@ -16,7 +16,7 @@ import asyncApi2MessageIdUniqueness from './functions/asyncApi2MessageIdUniquene
 import asyncApi2OperationIdUniqueness from './functions/asyncApi2OperationIdUniqueness';
 import asyncApi2SchemaValidation from './functions/asyncApi2SchemaValidation';
 import asyncApi2PayloadValidation from './functions/asyncApi2PayloadValidation';
-import asyncApi2ServerVariables from './functions/asyncApi2ServerVariables';
+import serverVariables from '../shared/functions/serverVariables';
 import { uniquenessTags } from '../shared/functions';
 import asyncApi2Security from './functions/asyncApi2Security';
 import { latestVersion } from './functions/utils/specs';
@@ -370,7 +370,7 @@ export default {
       recommended: true,
       given: ['$.servers.*', '$.components.servers.*'],
       then: {
-        function: asyncApi2ServerVariables,
+        function: serverVariables,
       },
     },
     'asyncapi-server-no-empty-variable': {
diff --git a/packages/rulesets/src/oas/index.ts b/packages/rulesets/src/oas/index.ts
index 120b3995..98094f2e 100644
--- a/packages/rulesets/src/oas/index.ts
+++ b/packages/rulesets/src/oas/index.ts
@@ -26,6 +26,7 @@ import {
   oasDiscriminator,
 } from './functions';
 import { uniquenessTags } from '../shared/functions';
+import serverVariables from '../shared/functions/serverVariables';
 
 export { ruleset as default };
 
@@ -35,6 +36,26 @@ const ruleset = {
   aliases: {
     PathItem: ['$.paths[*]'],
     OperationObject: ['#PathItem[get,put,post,delete,options,head,patch,trace]'],
+    ResponseObject: {
+      targets: [
+        {
+          formats: [oas2],
+          given: ['#OperationObject.responses[*]', '$.responses[*]'],
+        },
+        {
+          formats: [oas3],
+          given: ['#OperationObject.responses[*]', '$.components.responses[*]'],
+        },
+      ],
+    },
+    LinkObject: {
+      targets: [
+        {
+          formats: [oas3],
+          given: ['$.components.links[*]', '#ResponseObject.links[*]'],
+        },
+      ],
+    },
   },
   rules: {
     'operation-success-response': {
@@ -671,5 +692,18 @@ const ruleset = {
         function: oasUnusedComponent,
       },
     },
+    'oas3-server-variables': {
+      description: 'Server variables must be defined and valid and there must be no unused variables.',
+      message: '{{error}}',
+      severity: 0,
+      recommended: true,
+      given: ['$.servers[*]', '#PathItem.servers[*]', '#OperationObject.servers[*]', '#LinkObject.server'],
+      then: {
+        function: serverVariables,
+        functionOptions: {
+          checkSubstitutions: true,
+        },
+      },
+    },
   },
 };
diff --git a/packages/rulesets/src/shared/functions/serverVariables/index.ts b/packages/rulesets/src/shared/functions/serverVariables/index.ts
new file mode 100644
index 00000000..de2abe5e
--- /dev/null
+++ b/packages/rulesets/src/shared/functions/serverVariables/index.ts
@@ -0,0 +1,168 @@
+import { createRulesetFunction } from '@stoplight/spectral-core';
+import type { IFunctionResult } from '@stoplight/spectral-core';
+
+import { parseUrlVariables } from './utils/parseUrlVariables';
+import { getMissingProps } from '../../utils/getMissingProps';
+import { getRedundantProps } from '../../utils/getRedundantProps';
+import { applyUrlVariables } from './utils/applyUrlVariables';
+import { JsonPath } from '@stoplight/types';
+
+type Input = {
+  url: string;
+  variables?: Record<
+    string,
+    {
+      enum: string[] | never;
+      default: string | never;
+      description: string | never;
+      examples: string | never;
+      [key: string]: unknown; // ^x-
+    }
+  >;
+};
+
+type Options = {
+  checkSubstitutions?: boolean;
+} | null;
+
+export default createRulesetFunction<Input, Options>(
+  {
+    input: {
+      errorMessage: 'Invalid Server Object',
+      type: 'object',
+      properties: {
+        url: {
+          type: 'string',
+        },
+        variables: {
+          type: 'object',
+          additionalProperties: {
+            type: 'object',
+            properties: {
+              enum: {
+                type: 'array',
+                items: {
+                  type: 'string',
+                },
+              },
+              default: {
+                type: 'string',
+              },
+              description: {
+                type: 'string',
+              },
+              examples: {
+                type: 'string',
+              },
+            },
+            patternProperties: {
+              '^x-': true,
+            },
+            additionalProperties: false,
+          },
+        },
+      },
+      required: ['url'],
+    },
+    errorOnInvalidInput: true,
+    options: {
+      type: ['object', 'null'],
+      properties: {
+        checkSubstitutions: {
+          type: 'boolean',
+          default: 'false',
+        },
+      },
+      additionalProperties: false,
+    },
+  },
+  function serverVariables({ url, variables }, opts, ctx) {
+    if (variables === void 0) return;
+
+    const results: IFunctionResult[] = [];
+
+    const foundVariables = parseUrlVariables(url);
+    const definedVariablesKeys = Object.keys(variables);
+
+    const redundantVariables = getRedundantProps(foundVariables, definedVariablesKeys);
+    for (const variable of redundantVariables) {
+      results.push({
+        message: `Server's "variables" object has unused defined "${variable}" url variable.`,
+        path: [...ctx.path, 'variables', variable],
+      });
+    }
+
+    if (foundVariables.length === 0) return results;
+
+    const missingVariables = getMissingProps(foundVariables, definedVariablesKeys);
+    if (missingVariables.length > 0) {
+      results.push({
+        message: `Not all server's variables are described with "variables" object. Missed: ${missingVariables.join(
+          ', ',
+        )}.`,
+        path: [...ctx.path, 'variables'],
+      });
+    }
+
+    const variablePairs: [key: string, values: string[]][] = [];
+
+    for (const key of definedVariablesKeys) {
+      if (redundantVariables.includes(key)) continue;
+
+      const values = variables[key];
+
+      if ('enum' in values) {
+        variablePairs.push([key, values.enum]);
+
+        if ('default' in values && !values.enum.includes(values.default)) {
+          results.push({
+            message: `Server Variable "${key}" has a default not listed in the enum`,
+            path: [...ctx.path, 'variables', key, 'default'],
+          });
+        }
+      } else {
+        variablePairs.push([key, [values.default ?? '']]);
+      }
+    }
+
+    if (opts?.checkSubstitutions === true && variablePairs.length > 0) {
+      checkSubstitutions(results, ctx.path, url, variablePairs);
+    }
+
+    return results;
+  },
+);
+
+function checkSubstitutions(
+  results: IFunctionResult[],
+  path: JsonPath,
+  url: string,
+  variables: [key: string, values: string[]][],
+): void {
+  const invalidUrls: string[] = [];
+
+  for (const substitutedUrl of applyUrlVariables(url, variables)) {
+    try {
+      new URL(substitutedUrl);
+    } catch {
+      invalidUrls.push(substitutedUrl);
+      if (invalidUrls.length === 5) {
+        break;
+      }
+    }
+  }
+
+  if (invalidUrls.length === 5) {
+    results.push({
+      message: `At least 5 substitutions of server variables resulted in invalid URLs: ${invalidUrls.join(
+        ', ',
+      )} and more`,
+      path: [...path, 'variables'],
+    });
+  } else if (invalidUrls.length > 0) {
+    results.push({
+      message: `A few substitutions of server variables resulted in invalid URLs: ${invalidUrls.join(', ')}`,
+      path: [...path, 'variables'],
+    });
+  }
+}
diff --git a/packages/rulesets/src/asyncapi/functions/utils/__tests__/parseUrlVariables.test.ts b/packages/rulesets/src/shared/functions/serverVariables/utils/__tests__/parseUrlVariables.test.ts
similarity index 100%
rename from packages/rulesets/src/asyncapi/functions/utils/__tests__/parseUrlVariables.test.ts
rename to packages/rulesets/src/shared/functions/serverVariables/utils/__tests__/parseUrlVariables.test.ts
diff --git a/packages/rulesets/src/shared/functions/serverVariables/utils/applyUrlVariables.ts b/packages/rulesets/src/shared/functions/serverVariables/utils/applyUrlVariables.ts
new file mode 100644
index 00000000..7643c2cc
--- /dev/null
+++ b/packages/rulesets/src/shared/functions/serverVariables/utils/applyUrlVariables.ts
@@ -0,0 +1,36 @@
+type Variable = readonly [name: string, values: readonly string[]];
+type ApplicableVariable = readonly [name: RegExp, encodedValues: readonly string[]];
+
+export function* applyUrlVariables(url: string, variables: readonly Variable[]): Iterable<string> {
+  yield* _applyUrlVariables(url, 0, variables.map(toApplicableVariable));
+}
+
+// this loosely follows https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.2
+function* _applyUrlVariables(url: string, i: number, variables: readonly ApplicableVariable[]): Iterable<string> {
+  const [name, values] = variables[i];
+  let x = 0;
+  while (x < values.length) {
+    const substitutedValue = url.replace(name, values[x]);
+
+    if (i === variables.length - 1) {
+      yield substitutedValue;
+    } else {
+      yield* _applyUrlVariables(substitutedValue, i + 1, variables);
+    }
+
+    x++;
+  }
+}
+
+function toApplicableVariable([name, values]: Variable): ApplicableVariable {
+  return [toReplaceRegExp(name), values.map(encodeURI)];
+}
+
+function toReplaceRegExp(name: string): RegExp {
+  return RegExp(escapeRegexp(`{${name}}`), 'g');
+}
+
+// https://github.com/tc39/proposal-regex-escaping/blob/main/polyfill.js
+function escapeRegexp(value: string): string {
+  return value.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&');
+}
diff --git a/packages/rulesets/src/shared/utils/getMissingProps.ts b/packages/rulesets/src/shared/utils/getMissingProps.ts
new file mode 100644
index 00000000..d5dd68c5
--- /dev/null
+++ b/packages/rulesets/src/shared/utils/getMissingProps.ts
@@ -0,0 +1,5 @@
+export function getMissingProps(arr: string[], props: string[]): string[] {
+  return arr.filter(val => {
+    return !props.includes(val);
+  });
+}
diff --git a/packages/rulesets/src/shared/utils/getRedundantProps.ts b/packages/rulesets/src/shared/utils/getRedundantProps.ts
new file mode 100644
index 00000000..20f4d8c2
--- /dev/null
+++ b/packages/rulesets/src/shared/utils/getRedundantProps.ts
@@ -0,0 +1,5 @@
+export function getRedundantProps(arr: string[], keys: string[]): string[] {
+  return keys.filter(val => {
+    return !arr.includes(val);
+  });
+}


Test patch:
diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-server-variables.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-server-variables.test.ts
index b529611a..8f46235b 100644
--- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-server-variables.test.ts
+++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-server-variables.test.ts
@@ -92,7 +92,7 @@ testRule('asyncapi-server-variables', [
   },
 
   {
-    name: 'server has redundant url variables',
+    name: 'server has unused url variables',
     document: {
       asyncapi: '2.0.0',
       servers: {
@@ -109,12 +109,12 @@ testRule('asyncapi-server-variables', [
     },
     errors: [
       {
-        message: 'Server\'s "variables" object has redundant defined "anotherParam1" url variable.',
+        message: 'Server\'s "variables" object has unused defined "anotherParam1" url variable.',
         path: ['servers', 'production', 'variables', 'anotherParam1'],
         severity: DiagnosticSeverity.Error,
       },
       {
-        message: 'Server\'s "variables" object has redundant defined "anotherParam2" url variable.',
+        message: 'Server\'s "variables" object has unused defined "anotherParam2" url variable.',
         path: ['servers', 'production', 'variables', 'anotherParam2'],
         severity: DiagnosticSeverity.Error,
       },
@@ -122,7 +122,7 @@ testRule('asyncapi-server-variables', [
   },
 
   {
-    name: 'server has redundant url variables (in the components.servers)',
+    name: 'server has unused url variables (in the components.servers)',
     document: {
       asyncapi: '2.3.0',
       components: {
@@ -141,12 +141,12 @@ testRule('asyncapi-server-variables', [
     },
     errors: [
       {
-        message: 'Server\'s "variables" object has redundant defined "anotherParam1" url variable.',
+        message: 'Server\'s "variables" object has unused defined "anotherParam1" url variable.',
         path: ['components', 'servers', 'production', 'variables', 'anotherParam1'],
         severity: DiagnosticSeverity.Error,
       },
       {
-        message: 'Server\'s "variables" object has redundant defined "anotherParam2" url variable.',
+        message: 'Server\'s "variables" object has unused defined "anotherParam2" url variable.',
         path: ['components', 'servers', 'production', 'variables', 'anotherParam2'],
         severity: DiagnosticSeverity.Error,
       },
diff --git a/packages/rulesets/src/oas/__tests__/oas3-server-variables.test.ts b/packages/rulesets/src/oas/__tests__/oas3-server-variables.test.ts
new file mode 100644
index 00000000..487eb1f3
--- /dev/null
+++ b/packages/rulesets/src/oas/__tests__/oas3-server-variables.test.ts
@@ -0,0 +1,308 @@
+import { DiagnosticSeverity } from '@stoplight/types';
+import testRule from './__helpers__/tester';
+
+testRule('oas3-server-variables', [
+  {
+    name: 'valid case',
+    document: {
+      openapi: '3.1.0',
+      servers: [
+        {
+          url: '{protocol}://{env}.stoplight.io:{port}',
+          variables: {
+            env: {
+              default: 'v2',
+            },
+            protocol: {
+              enum: ['http', 'https'],
+              default: 'https',
+            },
+            port: {
+              enum: ['80', '443'],
+              default: '443',
+            },
+          },
+        },
+      ],
+      paths: {
+        '/': {
+          servers: [],
+          responses: {
+            '2xx': {
+              links: {
+                user: {
+                  $ref: '#/components/links/User',
+                },
+              },
+            },
+          },
+        },
+        '/user': {
+          get: {
+            operationId: 'getUser',
+          },
+        },
+      },
+      components: {
+        links: {
+          User: {
+            operationId: 'getUser',
+            parameters: [],
+            server: {
+              url: 'https://{env}.stoplight.io',
+              variables: {
+                env: {
+                  default: 'v2',
+                },
+              },
+            },
+          },
+        },
+      },
+    },
+    errors: [],
+  },
+
+  {
+    name: 'server has not defined definition for one of the url variables',
+    document: {
+      openapi: '3.1.0',
+      servers: [
+        {
+          url: '{protocol}://stoplight.io:{port}',
+          variables: {},
+        },
+      ],
+      paths: {
+        '/': {
+          servers: [
+            {
+              url: '{protocol}://stoplight.io',
+              variables: {},
+            },
+          ],
+          get: {
+            servers: [
+              {
+                url: 'https://{env}.stoplight.io',
+                variables: {},
+              },
+            ],
+          },
+        },
+      },
+    },
+    errors: [
+      {
+        message: 'Not all server\'s variables are described with "variables" object. Missed: protocol, port.',
+        path: ['servers', '0', 'variables'],
+        severity: DiagnosticSeverity.Error,
+      },
+      {
+        message: 'Not all server\'s variables are described with "variables" object. Missed: protocol.',
+        path: ['paths', '/', 'servers', '0', 'variables'],
+        severity: DiagnosticSeverity.Error,
+      },
+      {
+        message: 'Not all server\'s variables are described with "variables" object. Missed: env.',
+        path: ['paths', '/', 'get', 'servers', '0', 'variables'],
+        severity: DiagnosticSeverity.Error,
+      },
+    ],
+  },
+
+  {
+    name: 'server has unused url variables',
+    document: {
+      openapi: '3.1.0',
+      servers: [
+        {
+          url: 'https://stoplight.io:{port}',
+          variables: {
+            port: {
+              default: '443',
+            },
+            env: {
+              enum: ['staging', 'integration'],
+            },
+          },
+        },
+      ],
+      paths: {
+        '/': {
+          servers: [
+            {
+              url: 'https://{env}.stoplight.io',
+              variables: {
+                port: {
+                  default: '443',
+                },
+                env: {
+                  enum: ['staging', 'integration'],
+                },
+              },
+            },
+          ],
+          get: {
+            servers: [
+              {
+                url: 'https://stoplight.io',
+                variables: {
+                  port: {},
+                  env: {},
+                },
+              },
+            ],
+          },
+        },
+      },
+    },
+    errors: [
+      {
+        message: 'Server\'s "variables" object has unused defined "env" url variable.',
+        path: ['servers', '0', 'variables', 'env'],
+        severity: DiagnosticSeverity.Error,
+      },
+      {
+        message: 'Server\'s "variables" object has unused defined "port" url variable.',
+        path: ['paths', '/', 'servers', '0', 'variables', 'port'],
+        severity: DiagnosticSeverity.Error,
+      },
+      {
+        message: 'Server\'s "variables" object has unused defined "port" url variable.',
+        path: ['paths', '/', 'get', 'servers', '0', 'variables', 'port'],
+        severity: DiagnosticSeverity.Error,
+      },
+      {
+        message: 'Server\'s "variables" object has unused defined "env" url variable.',
+        path: ['paths', '/', 'get', 'servers', '0', 'variables', 'env'],
+        severity: DiagnosticSeverity.Error,
+      },
+    ],
+  },
+
+  {
+    name: 'server has an unlisted default',
+    document: {
+      openapi: '3.1.0',
+      servers: [
+        {
+          url: 'https://stoplight.io:{port}',
+          variables: {
+            port: {
+              enum: ['80'],
+              default: '443',
+            },
+          },
+        },
+      ],
+      paths: {
+        '/': {
+          operationId: 'test',
+          servers: [
+            {
+              url: 'https://{env}.stoplight.io',
+              variables: {
+                env: {
+                  enum: [],
+                  default: 'staging',
+                },
+              },
+            },
+          ],
+        },
+      },
+      components: {
+        links: {
+          Address: {
+            operationId: 'test',
+            server: {
+              url: 'https://{env}.stoplight.io',
+              variables: {
+                env: {
+                  enum: [],
+                  default: 'staging',
+                },
+              },
+            },
+          },
+        },
+      },
+    },
+    errors: [
+      {
+        message: 'Server Variable "port" has a default not listed in the enum',
+        path: ['servers', '0', 'variables', 'port', 'default'],
+        severity: DiagnosticSeverity.Error,
+      },
+      {
+        message: 'Server Variable "env" has a default not listed in the enum',
+        path: ['paths', '/', 'servers', '0', 'variables', 'env', 'default'],
+        severity: DiagnosticSeverity.Error,
+      },
+      {
+        message: 'Server Variable "env" has a default not listed in the enum',
+        path: ['components', 'links', 'Address', 'server', 'variables', 'env', 'default'],
+        severity: DiagnosticSeverity.Error,
+      },
+    ],
+  },
+
+  {
+    name: 'server has a server variable resulting in broken URL',
+    document: {
+      openapi: '3.1.0',
+      servers: [
+        {
+          url: 'https://stoplight.io:{port}',
+          variables: {
+            port: {
+              enum: ['invalid port', 'another-one', '443'],
+            },
+          },
+        },
+        {
+          url: '{username}',
+          variables: {
+            username: {
+              enum: ['stoplight', 'io'],
+            },
+          },
+        },
+      ],
+      paths: {
+        '/': {
+          servers: [
+            {
+              url: '{base}.test',
+              variables: {
+                base: {
+                  enum: ['http', 'https', 'ftp', 'ftps', 'ssh', 'smtp'],
+                },
+              },
+            },
+          ],
+        },
+      },
+    },
+    errors: [
+      {
+        message:
+          'A few substitutions of server variables resulted in invalid URLs: https://stoplight.io:invalid%20port, https://stoplight.io:another-one',
+        path: ['servers', '0', 'variables'],
+        severity: DiagnosticSeverity.Error,
+      },
+      {
+        message: 'A few substitutions of server variables resulted in invalid URLs: stoplight, io',
+        path: ['servers', '1', 'variables'],
+        severity: DiagnosticSeverity.Error,
+      },
+      {
+        message:
+          'At least 5 substitutions of server variables resulted in invalid URLs: http.test, https.test, ftp.test, ftps.test, ssh.test and more',
+        path: ['paths', '/', 'servers', '0', 'variables'],
+        severity: DiagnosticSeverity.Error,
+      },
+    ],
+  },
+]);
diff --git a/packages/rulesets/src/shared/functions/serverVariables/utils/__tests__/applyUrlVariables.spec.ts b/packages/rulesets/src/shared/functions/serverVariables/utils/__tests__/applyUrlVariables.spec.ts
new file mode 100644
index 00000000..2fd7db30
--- /dev/null
+++ b/packages/rulesets/src/shared/functions/serverVariables/utils/__tests__/applyUrlVariables.spec.ts
@@ -0,0 +1,22 @@
+import { applyUrlVariables } from '../applyUrlVariables';
+
+describe('applyUrlVariables', () => {
+  test('should return all possible combinations', () => {
+    const result = [
+      ...applyUrlVariables('{protocol}://{env}.stoplight.io:{port}', [
+        ['protocol', ['https']],
+        ['env', ['integration', 'staging', 'prod']],
+        ['port', ['8080', '443']],
+      ]),
+    ];
+
+    expect(result).toStrictEqual([
+      'https://integration.stoplight.io:8080',
+      'https://integration.stoplight.io:443',
+      'https://staging.stoplight.io:8080',
+      'https://staging.stoplight.io:443',
+      'https://prod.stoplight.io:8080',
+      'https://prod.stoplight.io:443',
+    ]);
+  });
+});
diff --git a/packages/rulesets/src/asyncapi/functions/utils/parseUrlVariables.ts b/packages/rulesets/src/shared/functions/serverVariables/utils/parseUrlVariables.ts
similarity index 100%
rename from packages/rulesets/src/asyncapi/functions/utils/parseUrlVariables.ts
rename to packages/rulesets/src/shared/functions/serverVariables/utils/parseUrlVariables.ts
diff --git a/packages/rulesets/src/asyncapi/functions/utils/__tests__/getMissingProps.test.ts b/packages/rulesets/src/shared/utils/__tests__/getMissingProps.test.ts
similarity index 63%
rename from packages/rulesets/src/asyncapi/functions/utils/__tests__/getMissingProps.test.ts
rename to packages/rulesets/src/shared/utils/__tests__/getMissingProps.test.ts
index 928d06ae..8edff3f1 100644
--- a/packages/rulesets/src/asyncapi/functions/utils/__tests__/getMissingProps.test.ts
+++ b/packages/rulesets/src/shared/utils/__tests__/getMissingProps.test.ts
@@ -2,17 +2,17 @@ import { getMissingProps } from '../getMissingProps';
 
 describe('getMissingProps', () => {
   test('should return all props when object is empty', () => {
-    const result = getMissingProps(['one', 'two', 'three'], {});
+    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: {} });
+    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: {} });
+    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/shared/utils/__tests__/getRedundantProps.test.ts
similarity index 62%
rename from packages/rulesets/src/asyncapi/functions/utils/__tests__/getRedundantProps.test.ts
rename to packages/rulesets/src/shared/utils/__tests__/getRedundantProps.test.ts
index bad9660f..cf28e7d7 100644
--- a/packages/rulesets/src/asyncapi/functions/utils/__tests__/getRedundantProps.test.ts
+++ b/packages/rulesets/src/shared/utils/__tests__/getRedundantProps.test.ts
@@ -2,17 +2,17 @@ import { getRedundantProps } from '../getRedundantProps';
 
 describe('getRedundantProps', () => {
   test('should return all redundant props when array is empty', () => {
-    const result = getRedundantProps([], { one: {}, two: {}, three: {} });
+    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: {} });
+    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: {} });
+    const result = getRedundantProps(['one', 'two', 'three'], ['one', 'two', 'three']);
     expect(result).toEqual([]);
   });
 });