Skip to content

Commit

Permalink
feat(rulesets): add rule to validate AsyncAPI message's examples (#2126)
Browse files Browse the repository at this point in the history
  • Loading branch information
magicmatatjahu authored Jun 6, 2022
1 parent 1f1fd92 commit 87ef046
Show file tree
Hide file tree
Showing 4 changed files with 364 additions and 0 deletions.
45 changes: 45 additions & 0 deletions docs/reference/asyncapi-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
},
],
},
]);
Original file line number Diff line number Diff line change
@@ -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;
},
);
26 changes: 26 additions & 0 deletions packages/rulesets/src/asyncapi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit 87ef046

Please sign in to comment.