From 57cd0597c9a7c616ed30222f5f46ea20c400b89d Mon Sep 17 00:00:00 2001 From: Matatjahu Date: Mon, 19 Sep 2022 17:52:31 +0200 Subject: [PATCH 1/2] fix(rulesets): handle empty payload and headers in AsyncAPI message's examples validation --- .../asyncapi-message-examples.test.ts | 158 ++++++++++++++++++ .../asyncApi2MessageExamplesValidation.ts | 9 +- .../utils/__tests__/mergeTraits.test.ts | 32 ++++ .../asyncapi/functions/utils/mergeTraits.ts | 36 ++++ 4 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 packages/rulesets/src/asyncapi/functions/utils/__tests__/mergeTraits.test.ts create mode 100644 packages/rulesets/src/asyncapi/functions/utils/mergeTraits.ts diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-message-examples.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-message-examples.test.ts index b007b7f34..527975174 100644 --- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-message-examples.test.ts +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-message-examples.test.ts @@ -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: { @@ -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, + }, + ], + }, ]); diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2MessageExamplesValidation.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2MessageExamplesValidation.ts index e6c89978f..a1c0258fa 100644 --- a/packages/rulesets/src/asyncapi/functions/asyncApi2MessageExamplesValidation.ts +++ b/packages/rulesets/src/asyncapi/functions/asyncApi2MessageExamplesValidation.ts @@ -4,6 +4,7 @@ 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'; +import { mergeTraits } from './utils/mergeTraits'; interface MessageExample { name?: string; @@ -15,6 +16,7 @@ interface MessageExample { export interface MessageFragment { payload: unknown; headers: unknown; + traits?: any[]; examples?: MessageExample[]; } @@ -68,6 +70,7 @@ export default createRulesetFunction( options: null, }, function asyncApi2MessageExamplesValidation(targetVal, _, ctx) { + targetVal = mergeTraits(targetVal); // first merge all traits of message if (!targetVal.examples) return; const examples = getMessageExamples(targetVal); @@ -76,7 +79,8 @@ export default createRulesetFunction( for (const example of examples) { // validate payload if (example.value.payload !== undefined) { - const errors = validate(example.value.payload, example.path, 'payload', targetVal.payload, ctx); + const payload = targetVal.payload ?? {}; // if payload is undefined we treat it as any schema + const errors = validate(example.value.payload, example.path, 'payload', payload, ctx); if (Array.isArray(errors)) { results.push(...errors); } @@ -84,7 +88,8 @@ export default createRulesetFunction( // validate headers if (example.value.headers !== undefined) { - const errors = validate(example.value.headers, example.path, 'headers', targetVal.headers, ctx); + const headers = targetVal.headers ?? {}; // if headers are undefined we treat them as any schema + const errors = validate(example.value.headers, example.path, 'headers', headers, ctx); if (Array.isArray(errors)) { results.push(...errors); } diff --git a/packages/rulesets/src/asyncapi/functions/utils/__tests__/mergeTraits.test.ts b/packages/rulesets/src/asyncapi/functions/utils/__tests__/mergeTraits.test.ts new file mode 100644 index 000000000..949bfda65 --- /dev/null +++ b/packages/rulesets/src/asyncapi/functions/utils/__tests__/mergeTraits.test.ts @@ -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' }); + }); +}); diff --git a/packages/rulesets/src/asyncapi/functions/utils/mergeTraits.ts b/packages/rulesets/src/asyncapi/functions/utils/mergeTraits.ts new file mode 100644 index 000000000..ab19eb357 --- /dev/null +++ b/packages/rulesets/src/asyncapi/functions/utils/mergeTraits.ts @@ -0,0 +1,36 @@ +import { isPlainObject } from '@stoplight/json'; + +type HaveTraits = { traits?: any[] } & Record; + +export function mergeTraits(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(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; +} From d2bfabe5be5b99559bfb2cdf9280f94e574f68ef Mon Sep 17 00:00:00 2001 From: Matatjahu Date: Wed, 5 Oct 2022 17:20:40 +0200 Subject: [PATCH 2/2] fix(rulesets): handle latest version of AsyncAPI --- .../__tests__/asyncapi-latest-version.test.ts | 6 +++--- .../asyncapi/functions/asyncApi2DocumentSchema.ts | 3 --- .../asyncApi2MessageExamplesValidation.ts | 15 ++++++++------- .../src/asyncapi/functions/utils/mergeTraits.ts | 7 +++++++ .../src/asyncapi/functions/utils/specs.ts | 3 +++ packages/rulesets/src/asyncapi/index.ts | 7 ++++--- .../scenarios/asyncapi2-streetlights.scenario | 2 +- 7 files changed, 26 insertions(+), 17 deletions(-) diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-latest-version.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-latest-version.test.ts index 8d4815204..f3277b322 100644 --- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-latest-version.test.ts +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-latest-version.test.ts @@ -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: [], }, @@ -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, }, diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2DocumentSchema.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2DocumentSchema.ts index 539527536..7ab41fba0 100644 --- a/packages/rulesets/src/asyncapi/functions/asyncApi2DocumentSchema.ts +++ b/packages/rulesets/src/asyncapi/functions/asyncApi2DocumentSchema.ts @@ -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. diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2MessageExamplesValidation.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2MessageExamplesValidation.ts index a1c0258fa..056838f47 100644 --- a/packages/rulesets/src/asyncapi/functions/asyncApi2MessageExamplesValidation.ts +++ b/packages/rulesets/src/asyncapi/functions/asyncApi2MessageExamplesValidation.ts @@ -1,10 +1,11 @@ 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'; -import { mergeTraits } from './utils/mergeTraits'; interface MessageExample { name?: string; @@ -20,7 +21,7 @@ export interface MessageFragment { 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 []; } @@ -28,7 +29,7 @@ function getMessageExamples(message: MessageFragment): Array<{ path: JsonPath; v message.examples.map((example, index) => { return { path: ['examples', index], - value: example, + example, }; }) ?? [] ); @@ -78,18 +79,18 @@ export default createRulesetFunction( for (const example of examples) { // validate payload - if (example.value.payload !== undefined) { + if (example.example.payload !== undefined) { const payload = targetVal.payload ?? {}; // if payload is undefined we treat it as any schema - const errors = validate(example.value.payload, example.path, 'payload', payload, ctx); + 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) { + if (example.example.headers !== undefined) { const headers = targetVal.headers ?? {}; // if headers are undefined we treat them as any schema - const errors = validate(example.value.headers, example.path, 'headers', headers, ctx); + const errors = validate(example.example.headers, example.path, 'headers', headers, ctx); if (Array.isArray(errors)) { results.push(...errors); } diff --git a/packages/rulesets/src/asyncapi/functions/utils/mergeTraits.ts b/packages/rulesets/src/asyncapi/functions/utils/mergeTraits.ts index ab19eb357..7d398e98b 100644 --- a/packages/rulesets/src/asyncapi/functions/utils/mergeTraits.ts +++ b/packages/rulesets/src/asyncapi/functions/utils/mergeTraits.ts @@ -2,6 +2,13 @@ import { isPlainObject } from '@stoplight/json'; type HaveTraits = { traits?: any[] } & Record; +/** + * 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(data: T): T { if (Array.isArray(data.traits)) { data = { ...data }; // shallow copy diff --git a/packages/rulesets/src/asyncapi/functions/utils/specs.ts b/packages/rulesets/src/asyncapi/functions/utils/specs.ts index 7110f682e..0142f79e4 100644 --- a/packages/rulesets/src/asyncapi/functions/utils/specs.ts +++ b/packages/rulesets/src/asyncapi/functions/utils/specs.ts @@ -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 { return JSON.parse(JSON.stringify(specs[version])) as Record; } diff --git a/packages/rulesets/src/asyncapi/index.ts b/packages/rulesets/src/asyncapi/index.ts index bd9410e7d..20b4d3016 100644 --- a/packages/rulesets/src/asyncapi/index.ts +++ b/packages/rulesets/src/asyncapi/index.ts @@ -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'; @@ -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', @@ -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', @@ -183,7 +184,7 @@ export default { function: schema, functionOptions: { schema: { - const: latestAsyncApiVersion, + const: latestVersion, }, }, }, diff --git a/test-harness/scenarios/asyncapi2-streetlights.scenario b/test-harness/scenarios/asyncapi2-streetlights.scenario index 460c6d1a2..d9b93ee58 100644 --- a/test-harness/scenarios/asyncapi2-streetlights.scenario +++ b/test-harness/scenarios/asyncapi2-streetlights.scenario @@ -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