Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(rulesets): add support for 2.5.0 AsyncAPI #2292

Merged
merged 11 commits into from
Oct 3, 2022
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",
magicmatatjahu marked this conversation as resolved.
Show resolved Hide resolved
"@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'",
},
magicmatatjahu marked this conversation as resolved.
Show resolved Hide resolved
{
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',
},
magicmatatjahu marked this conversation as resolved.
Show resolved Hide resolved
{
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
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--;
magicmatatjahu marked this conversation as resolved.
Show resolved Hide resolved
}
}

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