diff --git a/README.md b/README.md index 6077c0589..e0186b94b 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ If you have a bug or feature request, please [create an issue](https://github.co ### How is this different to Ajv -[Ajv](https://www.npmjs.com/package/ajv) is a JSON Schema validator, and Spectral is a JSON/YAML linter. Instead of just validating against JSON Schema, it can be used to write rules for any sort of JSON/YAML object, which could be JSON Schema, or OpenAPI, or anything similar. Spectral does expose a [`schema` function](https://meta.stoplight.io/docs/spectral/docs/reference/functions.md) that you can use in your rules to validate all or part of the target object with JSON Schema (we even use Ajv used under the hood for this), but that's just one of many functions. +[Ajv](https://www.npmjs.com/package/ajv) is a JSON Schema validator, and Spectral is a JSON/YAML linter. Instead of just validating against JSON Schema, it can be used to write rules for any sort of JSON/YAML object, which could be JSON Schema, or OpenAPI, or anything similar. Spectral does expose a [`schema` function](https://meta.stoplight.io/docs/spectral/docs/reference/functions.md) that you can use in your rules to validate all or part of the target object with JSON Schema (we even use Ajv under the hood for this), but that's just one of many functions. ### I want to lint my OpenAPI documents but don't want to implement Spectral right now. diff --git a/docs/reference/asyncapi-rules.md b/docs/reference/asyncapi-rules.md index fa11723df..c88945b09 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 660c1bb38..a683fb80b 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'; import { uniquenessTags } from '../shared/functions'; export default { @@ -56,6 +58,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}}).', @@ -283,6 +296,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,