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 Oct 3, 2022
1 parent cfbd1d1 commit 9050785
Show file tree
Hide file tree
Showing 13 changed files with 191 additions and 54 deletions.
1 change: 1 addition & 0 deletions docs/getting-started/3-rulesets.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Formats are an optional way to specify which API description formats a rule, or
- `aas2_2` (AsyncAPI v2.2.0)
- `aas2_3` (AsyncAPI v2.3.0)
- `aas2_4` (AsyncAPI v2.4.0)
- `aas2_5` (AsyncAPI v2.5.0)
- `oas2` (OpenAPI v2.0)
- `oas3` (OpenAPI v3.x)
- `oas3_0` (OpenAPI v3.0.x)
Expand Down
15 changes: 14 additions & 1 deletion packages/formats/src/__tests__/asyncapi.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { aas2, aas2_0, aas2_1, aas2_2, aas2_3, aas2_4 } from '../asyncapi';
import { aas2, aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5 } from '../asyncapi';

describe('AsyncAPI format', () => {
describe('AsyncAPI 2.x', () => {
Expand Down Expand Up @@ -88,4 +88,17 @@ describe('AsyncAPI format', () => {
},
);
});

describe('AsyncAPI 2.5', () => {
it.each(['2.5.0', '2.5.2'])('recognizes %s version correctly', version => {
expect(aas2_5({ asyncapi: version }, null)).toBe(true);
});

it.each(['2', '2.3', '2.0.0', '2.1.0', '2.1.37', '2.2.0', '2.3.0', '2.4.0', '2.4.3', '2.6.0', '2.6.4'])(
'does not recognize %s version',
version => {
expect(aas2_5({ asyncapi: version }, null)).toBe(false);
},
);
});
});
5 changes: 5 additions & 0 deletions packages/formats/src/asyncapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const aas2_1Regex = /^2\.1(?:\.[0-9]*)?$/;
const aas2_2Regex = /^2\.2(?:\.[0-9]*)?$/;
const aas2_3Regex = /^2\.3(?:\.[0-9]*)?$/;
const aas2_4Regex = /^2\.4(?:\.[0-9]*)?$/;
const aas2_5Regex = /^2\.5(?:\.[0-9]*)?$/;

const isAas2 = (document: unknown): document is { asyncapi: string } & Record<string, unknown> =>
isPlainObject(document) && 'asyncapi' in document && aas2Regex.test(String((document as MaybeAAS2).asyncapi));
Expand Down Expand Up @@ -39,3 +40,7 @@ aas2_3.displayName = 'AsyncAPI 2.3.x';
export const aas2_4: Format = (document: unknown): boolean =>
isAas2(document) && aas2_4Regex.test(String((document as MaybeAAS2).asyncapi));
aas2_4.displayName = 'AsyncAPI 2.4.x';

export const aas2_5: Format = (document: unknown): boolean =>
isAas2(document) && aas2_5Regex.test(String((document as MaybeAAS2).asyncapi));
aas2_5.displayName = 'AsyncAPI 2.5.x';
2 changes: 1 addition & 1 deletion packages/rulesets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"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",
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
Loading

0 comments on commit 9050785

Please sign in to comment.