Skip to content

Commit

Permalink
feat: validate message object examples with spectral schemas (#782)
Browse files Browse the repository at this point in the history
Co-authored-by: Chris Patmore <[email protected]>
  • Loading branch information
chrispatmore and Chris Patmore authored Aug 3, 2023
1 parent 0382c65 commit 298b289
Show file tree
Hide file tree
Showing 8 changed files with 422 additions and 8 deletions.
73 changes: 73 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
127 changes: 127 additions & 0 deletions src/ruleset/v2/functions/messageExamples-spectral-rule-v2.ts
Original file line number Diff line number Diff line change
@@ -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<v2.MessageObject, null>(
{
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<string | number>;
schema: v2.AsyncAPISchemaObject | undefined;
errors: IFunctionResult[]
}

async function parseExampleSchema(parser: Parser, schema: unknown, input: ParseExampleSchemaInput): Promise<ParseExampleSchemaResult> {
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]};
}
}
4 changes: 2 additions & 2 deletions src/ruleset/v2/functions/messageExamples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
}
Expand All @@ -37,7 +37,7 @@ function getMessageExamples(message: v2.MessageObject): Array<{ path: JsonPath;
);
}

function validate(
export function validate(
value: unknown,
path: JsonPath,
type: 'payload' | 'headers',
Expand Down
6 changes: 4 additions & 2 deletions src/ruleset/v2/ruleset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -237,6 +238,7 @@ export const v2SchemasRuleset = (parser: Parser) => {
},
},
},
'asyncapi2-message-examples-custom-format': asyncApi2MessageExamplesParserRule(parser),
}
};
};
Expand Down
Loading

0 comments on commit 298b289

Please sign in to comment.