Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(rulesets): handle empty payload and headers in AsyncAPI message's examples validation #2284

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would probably add some more docs here and specifically stating that this is JSON Merge Patch.

https://www.rfc-editor.org/rfc/rfc7386

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, thanks! :)

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