Skip to content

Commit

Permalink
fix(rulesets): handle empty payload and headers in AsyncAPI message's…
Browse files Browse the repository at this point in the history
… examples validation (#2284)
  • Loading branch information
magicmatatjahu authored Oct 5, 2022
1 parent ba90a20 commit 4068221
Show file tree
Hide file tree
Showing 9 changed files with 256 additions and 16 deletions.
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { DiagnosticSeverity } from '@stoplight/types';
import { latestAsyncApiVersion } from '../functions/asyncApi2DocumentSchema';
import { latestVersion } from '../functions/utils/specs';
import testRule from './__helpers__/tester';

testRule('asyncapi-latest-version', [
{
name: 'valid case',
document: {
asyncapi: latestAsyncApiVersion,
asyncapi: latestVersion,
},
errors: [],
},
Expand All @@ -18,7 +18,7 @@ testRule('asyncapi-latest-version', [
},
errors: [
{
message: `The latest version is not used. You should update to the "${latestAsyncApiVersion}" version.`,
message: `The latest version is not used. You should update to the "${latestVersion}" version.`,
path: ['asyncapi'],
severity: DiagnosticSeverity.Information,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,121 @@ testRule('asyncapi-message-examples', [
errors: [],
},

{
name: 'valid case (with omitted payload)',
document: {
asyncapi: '2.0.0',
channels: {
someChannel: {
publish: {
message: {
headers: {
type: 'object',
},
examples: [
{
payload: 'foobar',
headers: {
someKey: 'someValue',
},
},
],
},
},
},
},
},
errors: [],
},

{
name: 'valid case (with omitted headers)',
document: {
asyncapi: '2.0.0',
channels: {
someChannel: {
publish: {
message: {
payload: {
type: 'string',
},
examples: [
{
payload: 'foobar',
headers: {
someKey: 'someValue',
},
},
],
},
},
},
},
},
errors: [],
},

{
name: 'valid case (with omitted paylaod and headers)',
document: {
asyncapi: '2.0.0',
channels: {
someChannel: {
publish: {
message: {
examples: [
{
payload: 'foobar',
headers: {
someKey: 'someValue',
},
},
],
},
},
},
},
},
errors: [],
},

{
name: 'valid case (with traits)',
document: {
asyncapi: '2.0.0',
channels: {
someChannel: {
publish: {
message: {
payload: {
type: 'string',
},
headers: {
type: 'object',
},
examples: [
{
payload: 2137,
headers: {
someKey: 'someValue',
},
},
],
traits: [
{
payload: {
type: 'number',
},
},
],
},
},
},
},
},
errors: [],
},

{
name: 'invalid case',
document: {
Expand Down Expand Up @@ -194,4 +309,47 @@ testRule('asyncapi-message-examples', [
},
],
},

{
name: 'invalid case (with traits)',
document: {
asyncapi: '2.0.0',
channels: {
someChannel: {
publish: {
message: {
payload: {
type: 'number',
},
headers: {
type: 'object',
},
examples: [
{
payload: 2137,
headers: {
someKey: 'someValue',
},
},
],
traits: [
{
payload: {
type: 'string',
},
},
],
},
},
},
},
},
errors: [
{
message: '"payload" property type must be string',
path: ['channels', 'someChannel', 'publish', 'message', 'examples', '0', 'payload'],
severity: DiagnosticSeverity.Error,
},
],
},
]);
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,6 @@ import type { ErrorObject } from 'ajv';
import type { IFunctionResult, Format } from '@stoplight/spectral-core';
import type { AsyncAPISpecVersion } from './utils/specs';

export const asyncApiSpecVersions = ['2.0.0', '2.1.0', '2.2.0', '2.3.0', '2.4.0'];
export const latestAsyncApiVersion = asyncApiSpecVersions[asyncApiSpecVersions.length - 1];

function shouldIgnoreError(error: ErrorObject): boolean {
return (
// oneOf is a fairly error as we have 2 options to choose from for most of the time.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { createRulesetFunction } from '@stoplight/spectral-core';
import { schema as schemaFn } from '@stoplight/spectral-functions';

import { mergeTraits } from './utils/mergeTraits';

import type { JsonPath } from '@stoplight/types';
import type { IFunctionResult, RulesetFunctionContext } from '@stoplight/spectral-core';
import type { JSONSchema7 } from 'json-schema';
Expand All @@ -15,18 +17,19 @@ interface MessageExample {
export interface MessageFragment {
payload: unknown;
headers: unknown;
traits?: any[];
examples?: MessageExample[];
}

function getMessageExamples(message: MessageFragment): Array<{ path: JsonPath; value: MessageExample }> {
function getMessageExamples(message: MessageFragment): Array<{ path: JsonPath; example: MessageExample }> {
if (!Array.isArray(message.examples)) {
return [];
}
return (
message.examples.map((example, index) => {
return {
path: ['examples', index],
value: example,
example,
};
}) ?? []
);
Expand Down Expand Up @@ -68,23 +71,26 @@ export default createRulesetFunction<MessageFragment, null>(
options: null,
},
function asyncApi2MessageExamplesValidation(targetVal, _, ctx) {
targetVal = mergeTraits(targetVal); // first merge all traits of message
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 (example.example.payload !== undefined) {
const payload = targetVal.payload ?? {}; // if payload is undefined we treat it as any schema
const errors = validate(example.example.payload, example.path, 'payload', 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 (example.example.headers !== undefined) {
const headers = targetVal.headers ?? {}; // if headers are undefined we treat them as any schema
const errors = validate(example.example.headers, example.path, 'headers', headers, ctx);
if (Array.isArray(errors)) {
results.push(...errors);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { mergeTraits } from '../mergeTraits';

describe('mergeTraits', () => {
test('should merge one trait', () => {
const result = mergeTraits({ payload: {}, traits: [{ payload: { someKey: 'someValue' } }] });
expect(result.payload).toEqual({ someKey: 'someValue' });
});

test('should merge two or more traits', () => {
const result = mergeTraits({
payload: {},
traits: [
{ payload: { someKey1: 'someValue1' } },
{ payload: { someKey2: 'someValue2' } },
{ payload: { someKey3: 'someValue3' } },
],
});
expect(result.payload).toEqual({ someKey1: 'someValue1', someKey2: 'someValue2', someKey3: 'someValue3' });
});

test('should override fields', () => {
const result = mergeTraits({
payload: { someKey: 'someValue' },
traits: [
{ payload: { someKey: 'someValue1' } },
{ payload: { someKey: 'someValue2' } },
{ payload: { someKey: 'someValue3' } },
],
});
expect(result.payload).toEqual({ someKey: 'someValue3' });
});
});
43 changes: 43 additions & 0 deletions packages/rulesets/src/asyncapi/functions/utils/mergeTraits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { isPlainObject } from '@stoplight/json';

type HaveTraits = { traits?: any[] } & Record<string, any>;

/**
* A function used to merge traits defined for the given object from the AsyncAPI document.
* It uses the [JSON Merge Patch](https://www.rfc-editor.org/rfc/rfc7386).
*
* @param data An object with the traits
* @returns Merged object
*/
export function mergeTraits<T extends HaveTraits>(data: T): T {
if (Array.isArray(data.traits)) {
data = { ...data }; // shallow copy
for (const trait of data.traits as T[]) {
for (const key in trait) {
data[key] = merge(data[key], trait[key]);
}
}
}
return data;
}

function merge<T>(origin: unknown, patch: unknown): T {
// If the patch is not an object, it replaces the origin.
if (!isPlainObject(patch)) {
return patch as T;
}

const result = !isPlainObject(origin)
? {} // Non objects are being replaced.
: Object.assign({}, origin); // Make sure we never modify the origin.

Object.keys(patch).forEach(key => {
const patchVal = patch[key];
if (patchVal === null) {
delete result[key];
} else {
result[key] = merge(result[key], patchVal);
}
});
return result as T;
}
3 changes: 3 additions & 0 deletions packages/rulesets/src/asyncapi/functions/utils/specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export const specs = {
'2.5.0': asyncAPI2_5_0Schema,
};

const versions = Object.keys(specs);
export const latestVersion = versions[versions.length - 1];

export function getCopyOfSchema(version: AsyncAPISpecVersion): Record<string, unknown> {
return JSON.parse(JSON.stringify(specs[version])) as Record<string, unknown>;
}
7 changes: 4 additions & 3 deletions packages/rulesets/src/asyncapi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {

import asyncApi2ChannelParameters from './functions/asyncApi2ChannelParameters';
import asyncApi2ChannelServers from './functions/asyncApi2ChannelServers';
import asyncApi2DocumentSchema, { latestAsyncApiVersion } from './functions/asyncApi2DocumentSchema';
import asyncApi2DocumentSchema from './functions/asyncApi2DocumentSchema';
import asyncApi2MessageExamplesValidation from './functions/asyncApi2MessageExamplesValidation';
import asyncApi2MessageIdUniqueness from './functions/asyncApi2MessageIdUniqueness';
import asyncApi2OperationIdUniqueness from './functions/asyncApi2OperationIdUniqueness';
Expand All @@ -19,6 +19,7 @@ import asyncApi2PayloadValidation from './functions/asyncApi2PayloadValidation';
import asyncApi2ServerVariables from './functions/asyncApi2ServerVariables';
import { uniquenessTags } from '../shared/functions';
import asyncApi2Security from './functions/asyncApi2Security';
import { latestVersion } from './functions/utils/specs';

export default {
documentationUrl: 'https://meta.stoplight.io/docs/spectral/docs/reference/asyncapi-rules.md',
Expand Down Expand Up @@ -174,7 +175,7 @@ export default {
},
'asyncapi-latest-version': {
description: 'Checking if the AsyncAPI document is using the latest version.',
message: `The latest version is not used. You should update to the "${latestAsyncApiVersion}" version.`,
message: `The latest version is not used. You should update to the "${latestVersion}" version.`,
recommended: true,
type: 'style',
severity: 'info',
Expand All @@ -183,7 +184,7 @@ export default {
function: schema,
functionOptions: {
schema: {
const: latestAsyncApiVersion,
const: latestVersion,
},
},
},
Expand Down
2 changes: 1 addition & 1 deletion test-harness/scenarios/asyncapi2-streetlights.scenario
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ module.exports = asyncapi;
====stdout====
{document}
1:1 warning asyncapi-tags AsyncAPI object must have non-empty "tags" array.
1:11 information asyncapi-latest-version The latest version is not used. You should update to the "2.4.0" version. asyncapi
1:11 information asyncapi-latest-version The latest version is not used. You should update to the "2.5.0" version. asyncapi
2:6 warning asyncapi-info-contact Info object must have "contact" object. info
45:13 warning asyncapi-operation-description Operation "description" must be present and non-empty string. channels.smartylighting/streetlights/1/0/event/{streetlightId}/lighting/measured.publish
57:15 warning asyncapi-operation-description Operation "description" must be present and non-empty string. channels.smartylighting/streetlights/1/0/action/{streetlightId}/turn/on.subscribe
Expand Down

0 comments on commit 4068221

Please sign in to comment.