Skip to content

Commit

Permalink
feat(rulesets): add support for 2.5.0 AsyncAPI (#2292)
Browse files Browse the repository at this point in the history
  • Loading branch information
magicmatatjahu authored and P0lip committed Oct 3, 2022
1 parent 7a67c81 commit 0cb2e85
Show file tree
Hide file tree
Showing 10 changed files with 174 additions and 56 deletions.
4 changes: 2 additions & 2 deletions packages/rulesets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@
"release": "semantic-release -e semantic-release-monorepo"
},
"dependencies": {
"@asyncapi/specs": "^2.14.0",
"@asyncapi/specs": "^3.2.0",
"@stoplight/better-ajv-errors": "1.0.3",
"@stoplight/json": "^3.17.0",
"@stoplight/spectral-core": "^1.8.1",
"@stoplight/spectral-formats": "^1.2.0",
"@stoplight/spectral-formats": "^1.4.0",
"@stoplight/spectral-functions": "^1.5.1",
"@stoplight/spectral-runtime": "^1.1.1",
"@stoplight/types": "^13.6.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ testRule('asyncapi-payload', [
errors: [],
},

{
name: 'valid case (2.5.0 version)',
document: produce(document, (draft: any) => {
draft.asyncapi = '2.5.0';
}),
errors: [],
},

{
name: 'components.messages.{message}.payload is not valid against the AsyncApi2 schema object',
document: produce(document, (draft: any) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,28 @@ testRule('asyncapi-tags-uniqueness', [
],
},

{
name: 'tags has duplicated names (server)',
document: {
asyncapi: '2.5.0',
servers: {
someServer: {
tags: [{ name: 'one' }, { name: 'one' }],
},
anotherServer: {
tags: [{ name: 'one' }, { name: 'two' }],
},
},
},
errors: [
{
message: '"tags" object contains duplicate tag name "one".',
path: ['servers', 'someServer', 'tags', '1', 'name'],
severity: DiagnosticSeverity.Error,
},
],
},

{
name: 'tags has duplicated names (operation)',
document: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,13 +203,6 @@ describe('asyncApi2DocumentSchema', () => {
params: { type: 'string' },
message: 'must be string',
},
{
keyword: 'required',
instancePath: '/paths/test/post/parameters/0/schema',
schemaPath: '#/definitions/Reference/required',
params: { missingProperty: '$ref' },
message: "must have required property '$ref'",
},
{
keyword: 'oneOf',
instancePath: '/paths/test/post/parameters/0/schema',
Expand Down Expand Up @@ -325,15 +318,6 @@ describe('asyncApi2DocumentSchema', () => {
},
schemaPath: '#/properties/type/type',
},
{
instancePath: '/paths/foo/post/parameters/0/schema',
keyword: 'required',
message: "must have required property '$ref'",
params: {
missingProperty: '$ref',
},
schemaPath: '#/definitions/Reference/required',
},
{
instancePath: '/paths/baz/post/parameters/0/schema',
keyword: 'oneOf',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { aas2_0 } from '@stoplight/spectral-formats';
import asyncApi2PayloadValidation from '../asyncApi2PayloadValidation';

function runPayloadValidation(targetVal: any) {
return asyncApi2PayloadValidation(targetVal, null, { path: ['components', 'messages', 'aMessage'] } as any);
return asyncApi2PayloadValidation(targetVal, null, {
path: ['components', 'messages', 'aMessage'],
document: { formats: new Set([aas2_0]) },
} as any);
}

describe('asyncApi2PayloadValidation', () => {
Expand Down
60 changes: 40 additions & 20 deletions packages/rulesets/src/asyncapi/functions/asyncApi2DocumentSchema.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
import { createRulesetFunction } from '@stoplight/spectral-core';
import { schema as schemaFn } from '@stoplight/spectral-functions';
import { aas2_0, aas2_1, aas2_2, aas2_3, aas2_4 } from '@stoplight/spectral-formats';
import { aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5 } from '@stoplight/spectral-formats';

import { getCopyOfSchema } from './utils/specs';

import type { ErrorObject } from 'ajv';
import type { IFunctionResult, Format } from '@stoplight/spectral-core';

// import only 2.X.X AsyncAPI JSON Schemas for better treeshaking
import * as asyncAPI2_0_0Schema from '@asyncapi/specs/schemas/2.0.0.json';
import * as asyncAPI2_1_0Schema from '@asyncapi/specs/schemas/2.1.0.json';
import * as asyncAPI2_2_0Schema from '@asyncapi/specs/schemas/2.2.0.json';
import * as asyncAPI2_3_0Schema from '@asyncapi/specs/schemas/2.3.0.json';
import * as asyncAPI2_4_0Schema from '@asyncapi/specs/schemas/2.4.0.json';
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];
Expand Down Expand Up @@ -41,9 +37,14 @@ const ERROR_MAP = [
// That being said, we always strip both oneOf and $ref, since we are always interested in the first error.
export function prepareResults(errors: ErrorObject[]): void {
// Update additionalProperties errors to make them more precise and prevent them from being treated as duplicates
for (const error of errors) {
for (let i = 0; i < errors.length; i++) {
const error = errors[i];

if (error.keyword === 'additionalProperties') {
error.instancePath = `${error.instancePath}/${String(error.params['additionalProperty'])}`;
} else if (error.keyword === 'required' && error.params.missingProperty === '$ref') {
errors.splice(i, 1);
i--;
}
}

Expand Down Expand Up @@ -75,18 +76,37 @@ function applyManualReplacements(errors: IFunctionResult[]): void {
}
}

function getSchema(formats: Set<Format>): Record<string, unknown> | void {
const serializedSchemas = new Map<AsyncAPISpecVersion, Record<string, unknown>>();
function getSerializedSchema(version: AsyncAPISpecVersion): Record<string, unknown> {
const schema = serializedSchemas.get(version);
if (schema) {
return schema;
}

// Copy to not operate on the original json schema - between imports (in different modules) we operate on this same schema.
const copied = getCopyOfSchema(version) as { definitions: Record<string, unknown> };
// Remove the meta schemas because they are already present within Ajv, and it's not possible to add duplicated schemas.
delete copied.definitions['http://json-schema.org/draft-07/schema'];
delete copied.definitions['http://json-schema.org/draft-04/schema'];

serializedSchemas.set(version, copied);
return copied;
}

function getSchema(formats: Set<Format>): Record<string, any> | void {
switch (true) {
case formats.has(aas2_0):
return asyncAPI2_0_0Schema;
case formats.has(aas2_1):
return asyncAPI2_1_0Schema;
case formats.has(aas2_2):
return asyncAPI2_2_0Schema;
case formats.has(aas2_3):
return asyncAPI2_3_0Schema;
case formats.has(aas2_5):
return getSerializedSchema('2.5.0');
case formats.has(aas2_4):
return asyncAPI2_4_0Schema;
return getSerializedSchema('2.4.0');
case formats.has(aas2_3):
return getSerializedSchema('2.3.0');
case formats.has(aas2_2):
return getSerializedSchema('2.2.0');
case formats.has(aas2_1):
return getSerializedSchema('2.1.0');
case formats.has(aas2_0):
return getSerializedSchema('2.0.0');
default:
return;
}
Expand All @@ -98,7 +118,7 @@ export default createRulesetFunction<unknown, null>(
options: null,
},
function asyncApi2DocumentSchema(targetVal, _, context) {
const formats = context.document.formats;
const formats = context.document?.formats;
if (formats === null || formats === void 0) return;

const schema = getSchema(formats);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,88 @@
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import { createRulesetFunction } from '@stoplight/spectral-core';
import { aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5 } from '@stoplight/spectral-formats';
import betterAjvErrors from '@stoplight/better-ajv-errors';

// use latest AsyncAPI JSON Schema because there are no differences of Schema Object definitions between the 2.X.X.
import * as asyncApi2Schema from '@asyncapi/specs/schemas/2.3.0.json';
import { getCopyOfSchema } from './utils/specs';

import type { ValidateFunction } from 'ajv';
import type { Format } from '@stoplight/spectral-core';
import type { AsyncAPISpecVersion } from './utils/specs';

const asyncApi2SchemaObject = { $ref: 'asyncapi2#/definitions/schema' };

const ajv = new Ajv({
allErrors: true,
strict: false,
logger: false,
});

addFormats(ajv);

ajv.addSchema(asyncApi2Schema, 'asyncapi2');
/**
* To validate the schema of the payload we just need a small portion of official AsyncAPI spec JSON Schema, the Schema Object in particular. The definition of Schema Object must be
* included in the returned JSON Schema.
*/
function preparePayloadSchema(version: AsyncAPISpecVersion): Record<string, unknown> {
// Copy to not operate on the original json schema - between imports (in different modules) we operate on this same schema.
const copied = getCopyOfSchema(version) as { definitions: Record<string, unknown> };
// Remove the meta schemas because they are already present within Ajv, and it's not possible to add duplicated schemas.
delete copied.definitions['http://json-schema.org/draft-07/schema'];
delete copied.definitions['http://json-schema.org/draft-04/schema'];

const payloadSchema = `http://asyncapi.com/definitions/${version}/schema.json`;

return {
$ref: payloadSchema,
definitions: copied.definitions,
};
}

const ajvValidationFn = ajv.compile(asyncApi2SchemaObject);
function getValidator(version: AsyncAPISpecVersion): ValidateFunction {
let validator = ajv.getSchema(version);
if (!validator) {
const schema = preparePayloadSchema(version);

ajv.addSchema(schema, version);
validator = ajv.getSchema(version);
}

return validator as ValidateFunction;
}

function getSchemaValidator(formats: Set<Format>): ValidateFunction | void {
switch (true) {
case formats.has(aas2_5):
return getValidator('2.5.0');
case formats.has(aas2_4):
return getValidator('2.4.0');
case formats.has(aas2_3):
return getValidator('2.3.0');
case formats.has(aas2_2):
return getValidator('2.2.0');
case formats.has(aas2_1):
return getValidator('2.1.0');
case formats.has(aas2_0):
return getValidator('2.0.0');
default:
return;
}
}

export default createRulesetFunction<unknown, null>(
{
input: null,
options: null,
},
function asyncApi2PayloadValidation(targetVal, _opts, context) {
ajvValidationFn(targetVal);
function asyncApi2PayloadValidation(targetVal, _, context) {
const formats = context.document?.formats;
if (formats === null || formats === void 0) return;

const validator = getSchemaValidator(formats);
if (validator === void 0) return;

return betterAjvErrors(asyncApi2SchemaObject, ajvValidationFn.errors, {
validator(targetVal);
return betterAjvErrors(asyncApi2SchemaObject, validator.errors, {
propertyPath: context.path,
targetValue: targetVal,
}).map(({ suggestion, error, path: errorPath }) => ({
Expand Down
22 changes: 22 additions & 0 deletions packages/rulesets/src/asyncapi/functions/utils/specs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// import only 2.X.X AsyncAPI JSON Schemas for better treeshaking
import * as asyncAPI2_0_0Schema from '@asyncapi/specs/schemas/2.0.0.json';
import * as asyncAPI2_1_0Schema from '@asyncapi/specs/schemas/2.1.0.json';
import * as asyncAPI2_2_0Schema from '@asyncapi/specs/schemas/2.2.0.json';
import * as asyncAPI2_3_0Schema from '@asyncapi/specs/schemas/2.3.0.json';
import * as asyncAPI2_4_0Schema from '@asyncapi/specs/schemas/2.4.0.json';
import * as asyncAPI2_5_0Schema from '@asyncapi/specs/schemas/2.5.0.json';

export type AsyncAPISpecVersion = keyof typeof specs;

export const specs = {
'2.0.0': asyncAPI2_0_0Schema,
'2.1.0': asyncAPI2_1_0Schema,
'2.2.0': asyncAPI2_2_0Schema,
'2.3.0': asyncAPI2_3_0Schema,
'2.4.0': asyncAPI2_4_0Schema,
'2.5.0': asyncAPI2_5_0Schema,
};

export function getCopyOfSchema(version: AsyncAPISpecVersion): Record<string, unknown> {
return JSON.parse(JSON.stringify(specs[version])) as Record<string, unknown>;
}
7 changes: 5 additions & 2 deletions packages/rulesets/src/asyncapi/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { aas2_0, aas2_1, aas2_2, aas2_3, aas2_4 } from '@stoplight/spectral-formats';
import { aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5 } from '@stoplight/spectral-formats';
import {
truthy,
pattern,
Expand All @@ -22,7 +22,7 @@ import asyncApi2Security from './functions/asyncApi2Security';

export default {
documentationUrl: 'https://meta.stoplight.io/docs/spectral/docs/reference/asyncapi-rules.md',
formats: [aas2_0, aas2_1, aas2_2, aas2_3, aas2_4],
formats: [aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5],
rules: {
'asyncapi-channel-no-empty-parameter': {
description: 'Channel path must not have empty parameter substitution pattern.',
Expand Down Expand Up @@ -497,6 +497,9 @@ export default {
given: [
// root
'$.tags',
// servers
'$.servers.*.tags',
'$.components.servers.*.tags',
// operations
'$.channels.*.[publish,subscribe].tags',
'$.components.channels.*.[publish,subscribe].tags',
Expand Down
14 changes: 7 additions & 7 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ __metadata:
languageName: node
linkType: hard

"@asyncapi/specs@npm:^2.14.0":
version: 2.14.0
resolution: "@asyncapi/specs@npm:2.14.0"
checksum: 066c23c493df54c44c319433bdcf8482a3acd584e32c0073e6a9f5b167d61bde23a252621be2b28bbaf1466636f6cafaab570795de403f0c671358784d4b12ed
"@asyncapi/specs@npm:^3.2.0":
version: 3.2.0
resolution: "@asyncapi/specs@npm:3.2.0"
checksum: 09971262aefc8844ab3e7c0c3652711862ac562dd5d614f23b496185690430a81df8e50eddba657f4141e0fd9548ef622fe6c20f4e3dec8054be23f774798335
languageName: node
linkType: hard

Expand Down Expand Up @@ -2592,7 +2592,7 @@ __metadata:
languageName: unknown
linkType: soft

"@stoplight/spectral-formats@*, @stoplight/spectral-formats@>=1, @stoplight/spectral-formats@^1.0.0, @stoplight/spectral-formats@^1.2.0, @stoplight/spectral-formats@workspace:packages/formats":
"@stoplight/spectral-formats@*, @stoplight/spectral-formats@>=1, @stoplight/spectral-formats@^1.0.0, @stoplight/spectral-formats@^1.4.0, @stoplight/spectral-formats@workspace:packages/formats":
version: 0.0.0-use.local
resolution: "@stoplight/spectral-formats@workspace:packages/formats"
dependencies:
Expand Down Expand Up @@ -2703,12 +2703,12 @@ __metadata:
version: 0.0.0-use.local
resolution: "@stoplight/spectral-rulesets@workspace:packages/rulesets"
dependencies:
"@asyncapi/specs": ^2.14.0
"@asyncapi/specs": ^3.2.0
"@stoplight/better-ajv-errors": 1.0.3
"@stoplight/json": ^3.17.0
"@stoplight/path": ^1.3.2
"@stoplight/spectral-core": ^1.8.1
"@stoplight/spectral-formats": ^1.2.0
"@stoplight/spectral-formats": ^1.4.0
"@stoplight/spectral-functions": ^1.5.1
"@stoplight/spectral-parsers": "*"
"@stoplight/spectral-ref-resolver": "*"
Expand Down

0 comments on commit 0cb2e85

Please sign in to comment.