diff --git a/docs/reference/asyncapi-rules.md b/docs/reference/asyncapi-rules.md index 186399604..92f460ae2 100644 --- a/docs/reference/asyncapi-rules.md +++ b/docs/reference/asyncapi-rules.md @@ -132,6 +132,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..e6c89978f --- /dev/null +++ b/packages/rulesets/src/asyncapi/functions/asyncApi2MessageExamplesValidation.ts @@ -0,0 +1,96 @@ +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 }> { + if (!Array.isArray(message.examples)) { + return []; + } + 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: { + type: 'object', + properties: { + name: { + type: 'string', + }, + summary: { + type: 'string', + }, + }, + }, + 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 930a92aba..f0bbe118c 100644 --- a/packages/rulesets/src/asyncapi/index.ts +++ b/packages/rulesets/src/asyncapi/index.ts @@ -10,6 +10,7 @@ import { import asyncApi2ChannelParameters from './functions/asyncApi2ChannelParameters'; import asyncApi2DocumentSchema from './functions/asyncApi2DocumentSchema'; +import asyncApi2MessageExamplesValidation from './functions/asyncApi2MessageExamplesValidation'; import asyncApi2OperationIdUniqueness from './functions/asyncApi2OperationIdUniqueness'; import asyncApi2SchemaValidation from './functions/asyncApi2SchemaValidation'; import asyncApi2PayloadValidation from './functions/asyncApi2PayloadValidation'; @@ -157,6 +158,31 @@ export default { function: truthy, }, }, + 'asyncapi-message-examples': { + description: 'Examples of message object should follow by "payload" and "headers" schemas.', + 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,