diff --git a/package-lock.json b/package-lock.json index 838334a4a..2286e2b8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "webapi-parser": "^0.5.0" }, "devDependencies": { + "@asyncapi/avro-schema-parser": "^3.0.1", "@jest/types": "^29.0.2", "@swc/core": "^1.2.248", "@swc/jest": "^0.2.22", @@ -78,6 +79,42 @@ "node": ">=6.0.0" } }, + "node_modules/@asyncapi/avro-schema-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@asyncapi/avro-schema-parser/-/avro-schema-parser-3.0.1.tgz", + "integrity": "sha512-jXovhjvkFQvcv9PfTldS7jRAWuExLkeQKn/2HQf8vAR9azCVw63GfqJQ3HBPlouWt0CoS2VtGu/SpN2MS8Li8Q==", + "dev": true, + "dependencies": { + "@asyncapi/parser": "^2.0.1", + "@types/json-schema": "^7.0.11", + "avsc": "^5.7.6" + } + }, + "node_modules/@asyncapi/parser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@asyncapi/parser/-/parser-2.0.1.tgz", + "integrity": "sha512-T6Z8PPD+U3XPL05sjT0yLHtDyoqA16pj1j7xNbvpt8SCFzdzw4QMsbvJFAKaXk1/bztzlMq+oWVmeiGazt5WGA==", + "dev": true, + "dependencies": { + "@asyncapi/specs": "^5.0.1", + "@openapi-contrib/openapi-schema-to-json-schema": "~3.2.0", + "@stoplight/json-ref-resolver": "^3.1.5", + "@stoplight/spectral-core": "^1.16.1", + "@stoplight/spectral-functions": "^1.7.2", + "@stoplight/spectral-parsers": "^1.0.2", + "@types/json-schema": "^7.0.11", + "@types/urijs": "^1.19.19", + "ajv": "^8.11.0", + "ajv-errors": "^3.0.0", + "ajv-formats": "^2.1.1", + "avsc": "^5.7.5", + "js-yaml": "^4.1.0", + "jsonpath-plus": "^7.2.0", + "node-fetch": "2.6.7", + "ramldt2jsonschema": "^1.2.3", + "webapi-parser": "^0.5.0" + } + }, "node_modules/@asyncapi/specs": { "version": "6.0.0-next-major-spec.6", "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-6.0.0-next-major-spec.6.tgz", @@ -11115,6 +11152,42 @@ "@jridgewell/trace-mapping": "^0.3.9" } }, + "@asyncapi/avro-schema-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@asyncapi/avro-schema-parser/-/avro-schema-parser-3.0.1.tgz", + "integrity": "sha512-jXovhjvkFQvcv9PfTldS7jRAWuExLkeQKn/2HQf8vAR9azCVw63GfqJQ3HBPlouWt0CoS2VtGu/SpN2MS8Li8Q==", + "dev": true, + "requires": { + "@asyncapi/parser": "^2.0.1", + "@types/json-schema": "^7.0.11", + "avsc": "^5.7.6" + } + }, + "@asyncapi/parser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@asyncapi/parser/-/parser-2.0.1.tgz", + "integrity": "sha512-T6Z8PPD+U3XPL05sjT0yLHtDyoqA16pj1j7xNbvpt8SCFzdzw4QMsbvJFAKaXk1/bztzlMq+oWVmeiGazt5WGA==", + "dev": true, + "requires": { + "@asyncapi/specs": "^5.0.1", + "@openapi-contrib/openapi-schema-to-json-schema": "~3.2.0", + "@stoplight/json-ref-resolver": "^3.1.5", + "@stoplight/spectral-core": "^1.16.1", + "@stoplight/spectral-functions": "^1.7.2", + "@stoplight/spectral-parsers": "^1.0.2", + "@types/json-schema": "^7.0.11", + "@types/urijs": "^1.19.19", + "ajv": "^8.11.0", + "ajv-errors": "^3.0.0", + "ajv-formats": "^2.1.1", + "avsc": "^5.7.5", + "js-yaml": "^4.1.0", + "jsonpath-plus": "^7.2.0", + "node-fetch": "2.6.7", + "ramldt2jsonschema": "^1.2.3", + "webapi-parser": "^0.5.0" + } + }, "@asyncapi/specs": { "version": "6.0.0-next-major-spec.6", "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-6.0.0-next-major-spec.6.tgz", diff --git a/package.json b/package.json index 3d86dde61..04866d8d6 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "webapi-parser": "^0.5.0" }, "devDependencies": { + "@asyncapi/avro-schema-parser": "^3.0.1", "@jest/types": "^29.0.2", "@swc/core": "^1.2.248", "@swc/jest": "^0.2.22", diff --git a/src/ruleset/v2/functions/messageExamples-spectral-rule-v2.ts b/src/ruleset/v2/functions/messageExamples-spectral-rule-v2.ts new file mode 100644 index 000000000..2d964762b --- /dev/null +++ b/src/ruleset/v2/functions/messageExamples-spectral-rule-v2.ts @@ -0,0 +1,127 @@ +import { RuleDefinition, createRulesetFunction } from '@stoplight/spectral-core'; + +import type { JsonPath } from '@stoplight/types'; +import type { IFunctionResult } from '@stoplight/spectral-core'; +import type { v2 } from 'spec-types'; +import type { Parser } from 'parser'; +import type { DetailedAsyncAPI } from 'types'; +import { ParseSchemaInput, getDefaultSchemaFormat, getSchemaFormat, parseSchema } from '../../../schema-parser'; +import { createDetailedAsyncAPI } from '../../../utils'; +import { getMessageExamples, validate } from './messageExamples'; + +export function asyncApi2MessageExamplesParserRule(parser: Parser): RuleDefinition { + return { + description: 'Examples of message object should validate against a payload with an explicit schemaFormat.', + message: '{{error}}', + severity: 'error', + recommended: true, + given: [ + // messages + '$.channels.*.[publish,subscribe][?(@property === \'message\' && @.schemaFormat !== void 0)]', + '$.channels.*.[publish,subscribe].message.oneOf[?(!@null && @.schemaFormat !== void 0)]', + '$.components.channels.*.[publish,subscribe].message[?(@property === \'message\' && @.schemaFormat !== void 0)]', + '$.components.channels.*.[publish,subscribe].message.oneOf[?(!@null && @.schemaFormat !== void 0)]', + '$.components.messages[?(!@null && @.schemaFormat !== void 0)]', + // message traits + '$.channels.*.[publish,subscribe].message.traits[?(!@null && @.schemaFormat !== void 0)]', + '$.channels.*.[publish,subscribe].message.oneOf.*.traits[?(!@null && @.schemaFormat !== void 0)]', + '$.components.channels.*.[publish,subscribe].message.traits[?(!@null && @.schemaFormat !== void 0)]', + '$.components.channels.*.[publish,subscribe].message.oneOf.*.traits[?(!@null && @.schemaFormat !== void 0)]', + '$.components.messages.*.traits[?(!@null && @.schemaFormat !== void 0)]', + '$.components.messageTraits[?(!@null && @.schemaFormat !== void 0)]', + ], + then: { + function: rulesetFunction(parser), + }, + }; +} + +function rulesetFunction(parser: Parser) { + return createRulesetFunction( + { + input: { + type: 'object', + properties: { + name: { + type: 'string', + }, + summary: { + type: 'string', + }, + }, + }, + options: null, + }, + async (targetVal, _, ctx) => { + if (!targetVal.examples) return; + if (!targetVal.payload) return; + + const document = ctx.document; + const parsedSpec = document.data as v2.AsyncAPIObject; + const schemaFormat = getSchemaFormat(targetVal.schemaFormat, parsedSpec.asyncapi); + const defaultSchemaFormat = getDefaultSchemaFormat(parsedSpec.asyncapi); + const asyncapi = createDetailedAsyncAPI(parsedSpec, (document as any).__parserInput, document.source || undefined); + const input: ParseExampleSchemaInput = { + asyncapi, + rootPath: ctx.path, + schemaFormat, + defaultSchemaFormat + }; + + const results: IFunctionResult[] = []; + const payloadSchemaResults = await parseExampleSchema(parser, targetVal.payload, input); + const payloadSchema = payloadSchemaResults.schema; + results.push(...payloadSchemaResults.errors); + + for (const example of getMessageExamples(targetVal)) { + const { path, value } = example; + // validate payload + if (value.payload !== undefined && payloadSchema !== undefined) { + const errors = validate(value.payload, path, 'payload', payloadSchema, ctx); + if (Array.isArray(errors)) { + results.push(...errors); + } + } + } + return results; + } + ); +} + +interface ParseExampleSchemaInput { + asyncapi: DetailedAsyncAPI; + rootPath: JsonPath; + schemaFormat: string; + defaultSchemaFormat: string; +} + +interface ParseExampleSchemaResult { + path: Array; + schema: v2.AsyncAPISchemaObject | undefined; + errors: IFunctionResult[] +} + +async function parseExampleSchema(parser: Parser, schema: unknown, input: ParseExampleSchemaInput): Promise { + const path = [...input.rootPath, 'payload']; + if (schema === undefined) { + return {path, schema: undefined, errors: []}; + } + try { + const parseSchemaInput: ParseSchemaInput = { + asyncapi: input.asyncapi, + data: schema, + meta: {}, + path, + schemaFormat: input.schemaFormat, + defaultSchemaFormat: input.defaultSchemaFormat, + }; + const parsedSchema = await parseSchema(parser, parseSchemaInput); + return {path, schema: parsedSchema, errors: []}; + } catch (err: any) { + const error: IFunctionResult = { + message: `Error thrown during schema validation. Name: ${err.name}, message: ${err.message}, stack: ${err.stack}`, + path + }; + return {path, schema: undefined, errors: [error]}; + } +} diff --git a/src/ruleset/v2/functions/messageExamples.ts b/src/ruleset/v2/functions/messageExamples.ts index dbc5e2d28..2a6c0d2f5 100644 --- a/src/ruleset/v2/functions/messageExamples.ts +++ b/src/ruleset/v2/functions/messageExamples.ts @@ -23,7 +23,7 @@ function serializeSchema(schema: unknown, type: 'payload' | 'headers'): any { return schema; } -function getMessageExamples(message: v2.MessageObject): Array<{ path: JsonPath; value: v2.MessageExampleObject }> { +export function getMessageExamples(message: v2.MessageObject): Array<{ path: JsonPath; value: v2.MessageExampleObject }> { if (!Array.isArray(message.examples)) { return []; } @@ -37,7 +37,7 @@ function getMessageExamples(message: v2.MessageObject): Array<{ path: JsonPath; ); } -function validate( +export function validate( value: unknown, path: JsonPath, type: 'payload' | 'headers', diff --git a/src/ruleset/v2/ruleset.ts b/src/ruleset/v2/ruleset.ts index a9c609692..bbd3a65af 100644 --- a/src/ruleset/v2/ruleset.ts +++ b/src/ruleset/v2/ruleset.ts @@ -7,6 +7,7 @@ import { channelParameters } from './functions/channelParameters'; import { channelServers } from './functions/channelServers'; import { checkId } from './functions/checkId'; import { messageExamples } from './functions/messageExamples'; +import { asyncApi2MessageExamplesParserRule } from './functions/messageExamples-spectral-rule-v2'; import { messageIdUniqueness } from './functions/messageIdUniqueness'; import { operationIdUniqueness } from './functions/operationIdUniqueness'; import { schemaValidation } from './functions/schemaValidation'; @@ -122,9 +123,9 @@ export const v2CoreRuleset = { recommended: true, given: [ // messages - '$.channels.*.[publish,subscribe].[?(@property === \'message\' && @.schemaFormat === void 0)]', + '$.channels.*.[publish,subscribe][?(@property === \'message\' && @.schemaFormat === void 0)]', '$.channels.*.[publish,subscribe].message.oneOf[?(!@null && @.schemaFormat === void 0)]', - '$.components.channels.*.[publish,subscribe].[?(@property === \'message\' && @.schemaFormat === void 0)]', + '$.components.channels.*.[publish,subscribe][?(@property === \'message\' && @.schemaFormat === void 0)]', '$.components.channels.*.[publish,subscribe].message.oneOf[?(!@null && @.schemaFormat === void 0)]', '$.components.messages[?(!@null && @.schemaFormat === void 0)]', // message traits @@ -237,6 +238,7 @@ export const v2SchemasRuleset = (parser: Parser) => { }, }, }, + 'asyncapi2-message-examples-custom-format': asyncApi2MessageExamplesParserRule(parser), } }; }; diff --git a/test/ruleset/rules/v2/asyncapi2-message-examples-spectral-v2.spec.ts b/test/ruleset/rules/v2/asyncapi2-message-examples-spectral-v2.spec.ts new file mode 100644 index 000000000..cb6886aa6 --- /dev/null +++ b/test/ruleset/rules/v2/asyncapi2-message-examples-spectral-v2.spec.ts @@ -0,0 +1,207 @@ +import { testRule, DiagnosticSeverity } from '../../tester'; + +testRule('asyncapi2-message-examples-custom-format', [ + + { + 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', + }, + ], + }, + }, + }, + }, + // no errors as this rule is just checking examples where schemaFormat is set + errors: [], + }, + + { + name: 'valid avro spec case', + document: { + asyncapi: '2.0.0', + channels: { + someChannel: { + publish: { + message: { + schemaFormat: 'application/vnd.apache.avro;version=1.9.0', + payload: { + type: 'record', + name: 'Test', + fields: [ + {name: 'direction', type: { type: 'enum', name: 'directionEnum', symbols: ['North', 'East', 'South', 'West']}}, + {name: 'speed', type: 'string'} + ] + }, + examples: [ + { + payload: { + direction: 'North', + speed: '18' + }, + }, + ], + }, + }, + }, + }, + }, + errors: [], + }, + + { + name: 'invalid example from avro spec case', + document: { + asyncapi: '2.0.0', + channels: { + someChannel: { + publish: { + message: { + schemaFormat: 'application/vnd.apache.avro;version=1.9.0', + payload: { + type: 'record', + name: 'Test', + fields: [ + {name: 'direction', type: { type: 'enum', name: 'directionEnum', symbols: ['North', 'East', 'South', 'West']}}, + {name: 'speed', type: 'string'} + ] + }, + examples: [ + { + payload: { + direction: 'South-West', + speed: '18' + }, + }, + ], + }, + }, + }, + }, + }, + errors: [ + { + message: '"direction" property must be equal to one of the allowed values: "North", "East", "South", "West"', + path: ['channels', 'someChannel', 'publish', 'message', 'examples', '0', 'payload', 'direction'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'avro can contain null values', + document: { + asyncapi: '2.6.0', + channels: { + someChannel: { + publish: { + message: { + schemaFormat: 'application/vnd.apache.avro;version=1.9.0', + payload: { + type: 'record', + name: 'Command', + fields: [{ + name: 'foo', + default: null, + type: ['null', 'string'], + }], + }, + examples: [ + { + payload: {} + }, + ], + }, + }, + }, + }, + }, + errors: [], + }, + + { + name: 'handles oneOf processing', + document: { + asyncapi: '2.6.0', + channels: { + someChannel: { + publish: { + message: { + oneOf: [ + { + schemaFormat: 'application/vnd.apache.avro;version=1.9.0', + payload: { + type: 'record', + name: 'Command', + fields: [{ + name: 'foo', + default: null, + type: ['null', 'string'], + }], + }, + examples: [ + { + payload: {foo: 1} + }, + ], + }, + { + payload: { + type: 'string' + }, + examples: [ + { + // no error for this as this rule is just checking examples where schemaFormat is set + payload: 1 + }, + ], + }, + ], + }, + }, + }, + }, + }, + errors: [ + { + message: '"foo" property type must be string', + path: ['channels', 'someChannel', 'publish', 'message', 'oneOf', '0', 'examples', '0', 'payload', 'foo'], + severity: DiagnosticSeverity.Error, + }, + { + message: '"foo" property type must be null', + path: ['channels', 'someChannel', 'publish', 'message', 'oneOf', '0', 'examples', '0', 'payload', 'foo'], + severity: DiagnosticSeverity.Error, + }, + { + message: '"foo" property must match exactly one schema in oneOf', + path: ['channels', 'someChannel', 'publish', 'message', 'oneOf', '0', 'examples', '0', 'payload', 'foo'], + severity: DiagnosticSeverity.Error, + } + ], + }, +]); diff --git a/test/ruleset/rules/v2/asyncapi2-message-examples.spec.ts b/test/ruleset/rules/v2/asyncapi2-message-examples.spec.ts index d8d2ff0db..f6229b6a1 100644 --- a/test/ruleset/rules/v2/asyncapi2-message-examples.spec.ts +++ b/test/ruleset/rules/v2/asyncapi2-message-examples.spec.ts @@ -350,7 +350,7 @@ testRule('asyncapi2-message-examples', [ payload: { direction: 'North', speed: '18' - } + }, }, ], }, @@ -362,7 +362,7 @@ testRule('asyncapi2-message-examples', [ }, { - name: 'invalid avro spec case', + name: 'invalid example from avro spec case', document: { asyncapi: '2.0.0', channels: { @@ -383,7 +383,7 @@ testRule('asyncapi2-message-examples', [ payload: { direction: 'South-West', speed: '18' - } + }, }, ], }, @@ -391,6 +391,7 @@ testRule('asyncapi2-message-examples', [ }, }, }, + // no errors as this rule is just checking examples where schemaFormat is set errors: [], }, @@ -447,7 +448,8 @@ testRule('asyncapi2-message-examples', [ }, examples: [ { - payload: {} + // no error for this as this rule is just checking examples where schemaFormat is not set + payload: {foo: 1} }, ], }, diff --git a/test/ruleset/tester.ts b/test/ruleset/tester.ts index 42f384f50..df08292aa 100644 --- a/test/ruleset/tester.ts +++ b/test/ruleset/tester.ts @@ -1,4 +1,5 @@ import { Parser } from '../../src/parser'; +import { AvroSchemaParser } from '@asyncapi/avro-schema-parser'; // allows testing non default schema parsers // rulesets import { coreRuleset, recommendedRuleset } from '../../src/ruleset/ruleset'; @@ -28,6 +29,7 @@ export function testRule(ruleName: RuleNames, tests: Scenario,): void { for (const testCase of tests) { it(testCase.name, async () => { const parser = createParser([ruleName]); + parser.registerSchemaParser(AvroSchemaParser()); const doc = JSON.stringify(testCase.document); const errors = await parser.validate(doc);