From 263e2b0f230c5585a0ed980ad798a50de6062703 Mon Sep 17 00:00:00 2001 From: Sorin Sbarnea Date: Thu, 26 May 2022 14:28:29 +0100 Subject: [PATCH 1/2] docs(repo): minor grammar fix in readme (#2168) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From 9acc63375309bd7babe8b0130bd64fbbdbc56738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Urba=C5=84czyk?= Date: Mon, 30 May 2022 13:59:42 +0200 Subject: [PATCH 2/2] feat(rulesets): add rules for validation of server variables and channel parameters (#2101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jakub Rożek --- docs/reference/asyncapi-rules.md | 12 ++ .../asyncapi-channel-parameters.test.ts | 143 ++++++++++++++++ .../asyncapi-server-variables.test.ts | 155 ++++++++++++++++++ .../functions/asyncApi2ChannelParameters.ts | 51 ++++++ .../functions/asyncApi2ServerVariables.ts | 53 ++++++ .../utils/__tests__/getMissingProps.test.ts | 18 ++ .../utils/__tests__/getRedundantProps.test.ts | 18 ++ .../utils/__tests__/parseUrlVariables.test.ts | 13 ++ .../functions/utils/getMissingProps.ts | 6 + .../functions/utils/getRedundantProps.ts | 6 + .../functions/utils/parseUrlVariables.ts | 6 + packages/rulesets/src/asyncapi/index.ts | 24 +++ 12 files changed, 505 insertions(+) create mode 100644 packages/rulesets/src/asyncapi/__tests__/asyncapi-channel-parameters.test.ts create mode 100644 packages/rulesets/src/asyncapi/__tests__/asyncapi-server-variables.test.ts create mode 100644 packages/rulesets/src/asyncapi/functions/asyncApi2ChannelParameters.ts create mode 100644 packages/rulesets/src/asyncapi/functions/asyncApi2ServerVariables.ts create mode 100644 packages/rulesets/src/asyncapi/functions/utils/__tests__/getMissingProps.test.ts create mode 100644 packages/rulesets/src/asyncapi/functions/utils/__tests__/getRedundantProps.test.ts create mode 100644 packages/rulesets/src/asyncapi/functions/utils/__tests__/parseUrlVariables.test.ts create mode 100644 packages/rulesets/src/asyncapi/functions/utils/getMissingProps.ts create mode 100644 packages/rulesets/src/asyncapi/functions/utils/getRedundantProps.ts create mode 100644 packages/rulesets/src/asyncapi/functions/utils/parseUrlVariables.ts 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,