Repository: stoplightio/spectral (ts)

Issue:
Add a rule to validate message's examples
**User story.**
As a user I want to know if the examples defined in the message object are valid.

**Is your feature request related to a problem?**
The following AsyncAPI document is invalid and Spectral should throw errors:

```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 # should be string
          headers: someHeader # should have object shape
```

**Additional context**
Part of #2100 


Pull request:
feat(rulesets): add rule to validate message's examples
Fixes #2125

**Checklist**

- [X] Tests added / updated
- [X] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [X] No

**Additional context**

This PR adds rule to validate message's examples: validate `payload` and `headers` field in each example according to the schema in payload and headers.

Part of #2100 


Patch:
diff --git a/docs/reference/asyncapi-rules.md b/docs/reference/asyncapi-rules.md
index 18639960..92f460ae 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/functions/asyncApi2MessageExamplesValidation.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2MessageExamplesValidation.ts
new file mode 100644
index 00000000..e6c89978
--- /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<typeof schemaFn> {
+  return schemaFn(
+    value,
+    {
+      allErrors: true,
+      schema: schema as JSONSchema7,
+    },
+    {
+      ...ctx,
+      path: [...ctx.path, ...path, type],
+    },
+  );
+}
+
+export default createRulesetFunction<MessageFragment, null>(
+  {
+    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 930a92ab..f0bbe118 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,


Test patch:
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 00000000..b007b7f3
--- /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,
+      },
+    ],
+  },
+]);