Skip to content

Commit 4068221

Browse files
fix(rulesets): handle empty payload and headers in AsyncAPI message's examples validation (#2284)
1 parent ba90a20 commit 4068221

File tree

9 files changed

+256
-16
lines changed

9 files changed

+256
-16
lines changed

packages/rulesets/src/asyncapi/__tests__/asyncapi-latest-version.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { DiagnosticSeverity } from '@stoplight/types';
2-
import { latestAsyncApiVersion } from '../functions/asyncApi2DocumentSchema';
2+
import { latestVersion } from '../functions/utils/specs';
33
import testRule from './__helpers__/tester';
44

55
testRule('asyncapi-latest-version', [
66
{
77
name: 'valid case',
88
document: {
9-
asyncapi: latestAsyncApiVersion,
9+
asyncapi: latestVersion,
1010
},
1111
errors: [],
1212
},
@@ -18,7 +18,7 @@ testRule('asyncapi-latest-version', [
1818
},
1919
errors: [
2020
{
21-
message: `The latest version is not used. You should update to the "${latestAsyncApiVersion}" version.`,
21+
message: `The latest version is not used. You should update to the "${latestVersion}" version.`,
2222
path: ['asyncapi'],
2323
severity: DiagnosticSeverity.Information,
2424
},

packages/rulesets/src/asyncapi/__tests__/asyncapi-message-examples.test.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,121 @@ testRule('asyncapi-message-examples', [
3232
errors: [],
3333
},
3434

35+
{
36+
name: 'valid case (with omitted payload)',
37+
document: {
38+
asyncapi: '2.0.0',
39+
channels: {
40+
someChannel: {
41+
publish: {
42+
message: {
43+
headers: {
44+
type: 'object',
45+
},
46+
examples: [
47+
{
48+
payload: 'foobar',
49+
headers: {
50+
someKey: 'someValue',
51+
},
52+
},
53+
],
54+
},
55+
},
56+
},
57+
},
58+
},
59+
errors: [],
60+
},
61+
62+
{
63+
name: 'valid case (with omitted headers)',
64+
document: {
65+
asyncapi: '2.0.0',
66+
channels: {
67+
someChannel: {
68+
publish: {
69+
message: {
70+
payload: {
71+
type: 'string',
72+
},
73+
examples: [
74+
{
75+
payload: 'foobar',
76+
headers: {
77+
someKey: 'someValue',
78+
},
79+
},
80+
],
81+
},
82+
},
83+
},
84+
},
85+
},
86+
errors: [],
87+
},
88+
89+
{
90+
name: 'valid case (with omitted paylaod and headers)',
91+
document: {
92+
asyncapi: '2.0.0',
93+
channels: {
94+
someChannel: {
95+
publish: {
96+
message: {
97+
examples: [
98+
{
99+
payload: 'foobar',
100+
headers: {
101+
someKey: 'someValue',
102+
},
103+
},
104+
],
105+
},
106+
},
107+
},
108+
},
109+
},
110+
errors: [],
111+
},
112+
113+
{
114+
name: 'valid case (with traits)',
115+
document: {
116+
asyncapi: '2.0.0',
117+
channels: {
118+
someChannel: {
119+
publish: {
120+
message: {
121+
payload: {
122+
type: 'string',
123+
},
124+
headers: {
125+
type: 'object',
126+
},
127+
examples: [
128+
{
129+
payload: 2137,
130+
headers: {
131+
someKey: 'someValue',
132+
},
133+
},
134+
],
135+
traits: [
136+
{
137+
payload: {
138+
type: 'number',
139+
},
140+
},
141+
],
142+
},
143+
},
144+
},
145+
},
146+
},
147+
errors: [],
148+
},
149+
35150
{
36151
name: 'invalid case',
37152
document: {
@@ -194,4 +309,47 @@ testRule('asyncapi-message-examples', [
194309
},
195310
],
196311
},
312+
313+
{
314+
name: 'invalid case (with traits)',
315+
document: {
316+
asyncapi: '2.0.0',
317+
channels: {
318+
someChannel: {
319+
publish: {
320+
message: {
321+
payload: {
322+
type: 'number',
323+
},
324+
headers: {
325+
type: 'object',
326+
},
327+
examples: [
328+
{
329+
payload: 2137,
330+
headers: {
331+
someKey: 'someValue',
332+
},
333+
},
334+
],
335+
traits: [
336+
{
337+
payload: {
338+
type: 'string',
339+
},
340+
},
341+
],
342+
},
343+
},
344+
},
345+
},
346+
},
347+
errors: [
348+
{
349+
message: '"payload" property type must be string',
350+
path: ['channels', 'someChannel', 'publish', 'message', 'examples', '0', 'payload'],
351+
severity: DiagnosticSeverity.Error,
352+
},
353+
],
354+
},
197355
]);

packages/rulesets/src/asyncapi/functions/asyncApi2DocumentSchema.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,6 @@ import type { ErrorObject } from 'ajv';
88
import type { IFunctionResult, Format } from '@stoplight/spectral-core';
99
import type { AsyncAPISpecVersion } from './utils/specs';
1010

11-
export const asyncApiSpecVersions = ['2.0.0', '2.1.0', '2.2.0', '2.3.0', '2.4.0'];
12-
export const latestAsyncApiVersion = asyncApiSpecVersions[asyncApiSpecVersions.length - 1];
13-
1411
function shouldIgnoreError(error: ErrorObject): boolean {
1512
return (
1613
// oneOf is a fairly error as we have 2 options to choose from for most of the time.

packages/rulesets/src/asyncapi/functions/asyncApi2MessageExamplesValidation.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { createRulesetFunction } from '@stoplight/spectral-core';
22
import { schema as schemaFn } from '@stoplight/spectral-functions';
33

4+
import { mergeTraits } from './utils/mergeTraits';
5+
46
import type { JsonPath } from '@stoplight/types';
57
import type { IFunctionResult, RulesetFunctionContext } from '@stoplight/spectral-core';
68
import type { JSONSchema7 } from 'json-schema';
@@ -15,18 +17,19 @@ interface MessageExample {
1517
export interface MessageFragment {
1618
payload: unknown;
1719
headers: unknown;
20+
traits?: any[];
1821
examples?: MessageExample[];
1922
}
2023

21-
function getMessageExamples(message: MessageFragment): Array<{ path: JsonPath; value: MessageExample }> {
24+
function getMessageExamples(message: MessageFragment): Array<{ path: JsonPath; example: MessageExample }> {
2225
if (!Array.isArray(message.examples)) {
2326
return [];
2427
}
2528
return (
2629
message.examples.map((example, index) => {
2730
return {
2831
path: ['examples', index],
29-
value: example,
32+
example,
3033
};
3134
}) ?? []
3235
);
@@ -68,23 +71,26 @@ export default createRulesetFunction<MessageFragment, null>(
6871
options: null,
6972
},
7073
function asyncApi2MessageExamplesValidation(targetVal, _, ctx) {
74+
targetVal = mergeTraits(targetVal); // first merge all traits of message
7175
if (!targetVal.examples) return;
7276
const examples = getMessageExamples(targetVal);
7377

7478
const results: IFunctionResult[] = [];
7579

7680
for (const example of examples) {
7781
// validate payload
78-
if (example.value.payload !== undefined) {
79-
const errors = validate(example.value.payload, example.path, 'payload', targetVal.payload, ctx);
82+
if (example.example.payload !== undefined) {
83+
const payload = targetVal.payload ?? {}; // if payload is undefined we treat it as any schema
84+
const errors = validate(example.example.payload, example.path, 'payload', payload, ctx);
8085
if (Array.isArray(errors)) {
8186
results.push(...errors);
8287
}
8388
}
8489

8590
// validate headers
86-
if (example.value.headers !== undefined) {
87-
const errors = validate(example.value.headers, example.path, 'headers', targetVal.headers, ctx);
91+
if (example.example.headers !== undefined) {
92+
const headers = targetVal.headers ?? {}; // if headers are undefined we treat them as any schema
93+
const errors = validate(example.example.headers, example.path, 'headers', headers, ctx);
8894
if (Array.isArray(errors)) {
8995
results.push(...errors);
9096
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { mergeTraits } from '../mergeTraits';
2+
3+
describe('mergeTraits', () => {
4+
test('should merge one trait', () => {
5+
const result = mergeTraits({ payload: {}, traits: [{ payload: { someKey: 'someValue' } }] });
6+
expect(result.payload).toEqual({ someKey: 'someValue' });
7+
});
8+
9+
test('should merge two or more traits', () => {
10+
const result = mergeTraits({
11+
payload: {},
12+
traits: [
13+
{ payload: { someKey1: 'someValue1' } },
14+
{ payload: { someKey2: 'someValue2' } },
15+
{ payload: { someKey3: 'someValue3' } },
16+
],
17+
});
18+
expect(result.payload).toEqual({ someKey1: 'someValue1', someKey2: 'someValue2', someKey3: 'someValue3' });
19+
});
20+
21+
test('should override fields', () => {
22+
const result = mergeTraits({
23+
payload: { someKey: 'someValue' },
24+
traits: [
25+
{ payload: { someKey: 'someValue1' } },
26+
{ payload: { someKey: 'someValue2' } },
27+
{ payload: { someKey: 'someValue3' } },
28+
],
29+
});
30+
expect(result.payload).toEqual({ someKey: 'someValue3' });
31+
});
32+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { isPlainObject } from '@stoplight/json';
2+
3+
type HaveTraits = { traits?: any[] } & Record<string, any>;
4+
5+
/**
6+
* A function used to merge traits defined for the given object from the AsyncAPI document.
7+
* It uses the [JSON Merge Patch](https://www.rfc-editor.org/rfc/rfc7386).
8+
*
9+
* @param data An object with the traits
10+
* @returns Merged object
11+
*/
12+
export function mergeTraits<T extends HaveTraits>(data: T): T {
13+
if (Array.isArray(data.traits)) {
14+
data = { ...data }; // shallow copy
15+
for (const trait of data.traits as T[]) {
16+
for (const key in trait) {
17+
data[key] = merge(data[key], trait[key]);
18+
}
19+
}
20+
}
21+
return data;
22+
}
23+
24+
function merge<T>(origin: unknown, patch: unknown): T {
25+
// If the patch is not an object, it replaces the origin.
26+
if (!isPlainObject(patch)) {
27+
return patch as T;
28+
}
29+
30+
const result = !isPlainObject(origin)
31+
? {} // Non objects are being replaced.
32+
: Object.assign({}, origin); // Make sure we never modify the origin.
33+
34+
Object.keys(patch).forEach(key => {
35+
const patchVal = patch[key];
36+
if (patchVal === null) {
37+
delete result[key];
38+
} else {
39+
result[key] = merge(result[key], patchVal);
40+
}
41+
});
42+
return result as T;
43+
}

packages/rulesets/src/asyncapi/functions/utils/specs.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ export const specs = {
1717
'2.5.0': asyncAPI2_5_0Schema,
1818
};
1919

20+
const versions = Object.keys(specs);
21+
export const latestVersion = versions[versions.length - 1];
22+
2023
export function getCopyOfSchema(version: AsyncAPISpecVersion): Record<string, unknown> {
2124
return JSON.parse(JSON.stringify(specs[version])) as Record<string, unknown>;
2225
}

packages/rulesets/src/asyncapi/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010

1111
import asyncApi2ChannelParameters from './functions/asyncApi2ChannelParameters';
1212
import asyncApi2ChannelServers from './functions/asyncApi2ChannelServers';
13-
import asyncApi2DocumentSchema, { latestAsyncApiVersion } from './functions/asyncApi2DocumentSchema';
13+
import asyncApi2DocumentSchema from './functions/asyncApi2DocumentSchema';
1414
import asyncApi2MessageExamplesValidation from './functions/asyncApi2MessageExamplesValidation';
1515
import asyncApi2MessageIdUniqueness from './functions/asyncApi2MessageIdUniqueness';
1616
import asyncApi2OperationIdUniqueness from './functions/asyncApi2OperationIdUniqueness';
@@ -19,6 +19,7 @@ import asyncApi2PayloadValidation from './functions/asyncApi2PayloadValidation';
1919
import asyncApi2ServerVariables from './functions/asyncApi2ServerVariables';
2020
import { uniquenessTags } from '../shared/functions';
2121
import asyncApi2Security from './functions/asyncApi2Security';
22+
import { latestVersion } from './functions/utils/specs';
2223

2324
export default {
2425
documentationUrl: 'https://meta.stoplight.io/docs/spectral/docs/reference/asyncapi-rules.md',
@@ -174,7 +175,7 @@ export default {
174175
},
175176
'asyncapi-latest-version': {
176177
description: 'Checking if the AsyncAPI document is using the latest version.',
177-
message: `The latest version is not used. You should update to the "${latestAsyncApiVersion}" version.`,
178+
message: `The latest version is not used. You should update to the "${latestVersion}" version.`,
178179
recommended: true,
179180
type: 'style',
180181
severity: 'info',
@@ -183,7 +184,7 @@ export default {
183184
function: schema,
184185
functionOptions: {
185186
schema: {
186-
const: latestAsyncApiVersion,
187+
const: latestVersion,
187188
},
188189
},
189190
},

test-harness/scenarios/asyncapi2-streetlights.scenario

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ module.exports = asyncapi;
218218
====stdout====
219219
{document}
220220
1:1 warning asyncapi-tags AsyncAPI object must have non-empty "tags" array.
221-
1:11 information asyncapi-latest-version The latest version is not used. You should update to the "2.4.0" version. asyncapi
221+
1:11 information asyncapi-latest-version The latest version is not used. You should update to the "2.5.0" version. asyncapi
222222
2:6 warning asyncapi-info-contact Info object must have "contact" object. info
223223
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
224224
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

0 commit comments

Comments
 (0)