From d7dc2117b01193cf4fc0aaf743a441af5e0e0383 Mon Sep 17 00:00:00 2001 From: Matatjahu Date: Tue, 12 Apr 2022 22:54:55 +0200 Subject: [PATCH] feat(rulesets): add rule to validate message's examples --- docs/reference/asyncapi-rules.md | 45 ++++ .../asyncapi-message-examples.test.ts | 197 ++++++++++++++++++ .../asyncApi2MessageExamplesValidation.ts | 83 ++++++++ packages/rulesets/src/asyncapi/index.ts | 26 +++ 4 files changed, 351 insertions(+) create mode 100644 packages/rulesets/src/asyncapi/__tests__/asyncapi-message-examples.test.ts create mode 100644 packages/rulesets/src/asyncapi/functions/asyncApi2MessageExamplesValidation.ts diff --git a/docs/reference/asyncapi-rules.md b/docs/reference/asyncapi-rules.md index 4e488bbe2..e36d6ae40 100644 --- a/docs/reference/asyncapi-rules.md +++ b/docs/reference/asyncapi-rules.md @@ -126,6 +126,51 @@ info: name: MIT ``` +### asyncapi-message-examples + +All `examples` in message object should follow by `payload` and `headers` schemas. + +**Bad Example** + +```yaml +asyncapi: "2.0.0" +info: + title: Bad API + version: "1.0.0" +components: + messages: + someMessage: + payload: + type: string + headers: + type: object + examples: + - payload: 2137 + headers: someHeader +``` + +**Good Example** + +```yaml +asyncapi: "2.0.0" +info: + title: Good API + version: "1.0.0" +components: + messages: + someMessage: + payload: + type: string + headers: + type: object + examples: + - payload: foobar + headers: + someHeader: someValue +``` + +**Recommended:** Yes + ### asyncapi-operation-description Operation objects should have a description. diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-message-examples.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-message-examples.test.ts new file mode 100644 index 000000000..b007b7f34 --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-message-examples.test.ts @@ -0,0 +1,197 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-message-examples', [ + { + name: 'valid case', + document: { + asyncapi: '2.0.0', + channels: { + someChannel: { + publish: { + message: { + payload: { + type: 'string', + }, + headers: { + type: 'object', + }, + examples: [ + { + payload: 'foobar', + headers: { + someKey: 'someValue', + }, + }, + ], + }, + }, + }, + }, + }, + errors: [], + }, + + { + name: 'invalid case', + document: { + asyncapi: '2.0.0', + channels: { + someChannel: { + publish: { + message: { + payload: { + type: 'string', + }, + headers: { + type: 'object', + }, + examples: [ + { + payload: 2137, + headers: { + someKey: 'someValue', + }, + }, + ], + }, + }, + }, + }, + }, + errors: [ + { + message: '"payload" property type must be string', + path: ['channels', 'someChannel', 'publish', 'message', 'examples', '0', 'payload'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'invalid case (oneOf case)', + document: { + asyncapi: '2.0.0', + channels: { + someChannel: { + publish: { + message: { + oneOf: [ + { + payload: { + type: 'string', + }, + headers: { + type: 'object', + }, + examples: [ + { + payload: 2137, + headers: { + someKey: 'someValue', + }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + errors: [ + { + message: '"payload" property type must be string', + path: ['channels', 'someChannel', 'publish', 'message', 'oneOf', '0', 'examples', '0', 'payload'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'invalid case (inside components.messages)', + document: { + asyncapi: '2.0.0', + components: { + messages: { + someMessage: { + payload: { + type: 'string', + }, + headers: { + type: 'object', + }, + examples: [ + { + payload: 2137, + headers: { + someKey: 'someValue', + }, + }, + ], + }, + }, + }, + }, + errors: [ + { + message: '"payload" property type must be string', + path: ['components', 'messages', 'someMessage', 'examples', '0', 'payload'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'invalid case (with multiple errors)', + document: { + asyncapi: '2.0.0', + components: { + messages: { + someMessage: { + payload: { + type: 'object', + required: ['key1', 'key2'], + properties: { + key1: { + type: 'string', + }, + key2: { + type: 'string', + }, + }, + }, + headers: { + type: 'object', + }, + examples: [ + { + payload: { + key1: 2137, + }, + headers: 'someValue', + }, + ], + }, + }, + }, + }, + errors: [ + { + message: '"payload" property must have required property "key2"', + path: ['components', 'messages', 'someMessage', 'examples', '0', 'payload'], + severity: DiagnosticSeverity.Error, + }, + { + message: '"key1" property type must be string', + path: ['components', 'messages', 'someMessage', 'examples', '0', 'payload', 'key1'], + severity: DiagnosticSeverity.Error, + }, + { + message: '"headers" property type must be object', + path: ['components', 'messages', 'someMessage', 'examples', '0', 'headers'], + severity: DiagnosticSeverity.Error, + }, + ], + }, +]); diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2MessageExamplesValidation.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2MessageExamplesValidation.ts new file mode 100644 index 000000000..053e47acd --- /dev/null +++ b/packages/rulesets/src/asyncapi/functions/asyncApi2MessageExamplesValidation.ts @@ -0,0 +1,83 @@ +import { createRulesetFunction } from '@stoplight/spectral-core'; +import { schema as schemaFn } from '@stoplight/spectral-functions'; + +import type { JsonPath } from '@stoplight/types'; +import type { IFunctionResult, RulesetFunctionContext } from '@stoplight/spectral-core'; +import type { JSONSchema7 } from 'json-schema'; + +interface MessageExample { + name?: string; + summary?: string; + payload?: unknown; + headers?: unknown; +} + +export interface MessageFragment { + payload: unknown; + headers: unknown; + examples?: MessageExample[]; +} + +function getMessageExamples(message: MessageFragment): Array<{ path: JsonPath; value: MessageExample }> { + return ( + message.examples?.map((example, index) => { + return { + path: ['examples', index], + value: example, + }; + }) ?? [] + ); +} + +function validate( + value: unknown, + path: JsonPath, + type: 'payload' | 'headers', + schema: unknown, + ctx: RulesetFunctionContext, +): ReturnType { + return schemaFn( + value, + { + allErrors: true, + schema: schema as JSONSchema7, + }, + { + ...ctx, + path: [...ctx.path, ...path, type], + }, + ); +} + +export default createRulesetFunction( + { + input: null, + options: null, + }, + function asyncApi2MessageExamplesValidation(targetVal, _, ctx) { + if (!targetVal.examples) return; + const examples = getMessageExamples(targetVal); + + const results: IFunctionResult[] = []; + + for (const example of examples) { + // validate payload + if (example.value.payload !== undefined) { + const errors = validate(example.value.payload, example.path, 'payload', targetVal.payload, ctx); + if (Array.isArray(errors)) { + results.push(...errors); + } + } + + // validate headers + if (example.value.headers !== undefined) { + const errors = validate(example.value.headers, example.path, 'headers', targetVal.headers, ctx); + if (Array.isArray(errors)) { + results.push(...errors); + } + } + } + + return results; + }, +); diff --git a/packages/rulesets/src/asyncapi/index.ts b/packages/rulesets/src/asyncapi/index.ts index c36d3ed13..8e11c9559 100644 --- a/packages/rulesets/src/asyncapi/index.ts +++ b/packages/rulesets/src/asyncapi/index.ts @@ -9,6 +9,7 @@ import { } from '@stoplight/spectral-functions'; import asyncApi2DocumentSchema from './functions/asyncApi2DocumentSchema'; +import asyncApi2MessageExamplesValidation from './functions/asyncApi2MessageExamplesValidation'; import asyncApi2SchemaValidation from './functions/asyncApi2SchemaValidation'; import asyncApi2PayloadValidation from './functions/asyncApi2PayloadValidation'; @@ -142,6 +143,31 @@ export default { function: truthy, }, }, + 'asyncapi-message-examples': { + description: 'Message...', + message: '{{error}}', + severity: 'error', + type: 'validation', + recommended: true, + given: [ + // messages + '$.channels.*.[publish,subscribe].message', + '$.channels.*.[publish,subscribe].message.oneOf.*', + '$.components.channels.*.[publish,subscribe].message', + '$.components.channels.*.[publish,subscribe].message.oneOf.*', + '$.components.messages.*', + // message traits + '$.channels.*.[publish,subscribe].message.traits.*', + '$.channels.*.[publish,subscribe].message.oneOf.*.traits.*', + '$.components.channels.*.[publish,subscribe].message.traits.*', + '$.components.channels.*.[publish,subscribe].message.oneOf.*.traits.*', + '$.components.messages.*.traits.*', + '$.components.messageTraits.*', + ], + then: { + function: asyncApi2MessageExamplesValidation, + }, + }, 'asyncapi-operation-description': { description: 'Operation "description" must be present and non-empty string.', recommended: true,