Skip to content

Commit

Permalink
feat: match Spectral formats based on schemas found in @asyncapi/spec…
Browse files Browse the repository at this point in the history
…s package
  • Loading branch information
smoya committed Aug 1, 2023
1 parent c4cb9f2 commit c0a4c19
Show file tree
Hide file tree
Showing 7 changed files with 142 additions and 174 deletions.
98 changes: 52 additions & 46 deletions src/ruleset/formats.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,57 @@
/* eslint-disable security/detect-unsafe-regex */

import { isObject } from '../utils';
import { getSemver, isObject } from '../utils';
import { schemas } from '@asyncapi/specs';

import type { Format } from '@stoplight/spectral-core';
import type { MaybeAsyncAPI } from '../types';

const aas2Regex = /^2\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
const aas2_0Regex = /^2\.0(?:\.[0-9]*)?$/;
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 aas2_6Regex = /^2\.6(?:\.[0-9]*)?$/;

const isAas2 = (document: unknown): document is { asyncapi: string } & Record<string, unknown> =>
isObject(document) && 'asyncapi' in document && aas2Regex.test(String((document as MaybeAsyncAPI).asyncapi));

export const aas2: Format = isAas2;
aas2.displayName = 'AsyncAPI 2.x';

export const aas2_0: Format = (document: unknown): boolean =>
isAas2(document) && aas2_0Regex.test(String((document as MaybeAsyncAPI).asyncapi));
aas2_0.displayName = 'AsyncAPI 2.0.x';

export const aas2_1: Format = (document: unknown): boolean =>
isAas2(document) && aas2_1Regex.test(String((document as MaybeAsyncAPI).asyncapi));
aas2_1.displayName = 'AsyncAPI 2.1.x';

export const aas2_2: Format = (document: unknown): boolean =>
isAas2(document) && aas2_2Regex.test(String((document as MaybeAsyncAPI).asyncapi));
aas2_2.displayName = 'AsyncAPI 2.2.x';

export const aas2_3: Format = (document: unknown): boolean =>
isAas2(document) && aas2_3Regex.test(String((document as MaybeAsyncAPI).asyncapi));
aas2_3.displayName = 'AsyncAPI 2.3.x';

export const aas2_4: Format = (document: unknown): boolean =>
isAas2(document) && aas2_4Regex.test(String((document as MaybeAsyncAPI).asyncapi));
aas2_4.displayName = 'AsyncAPI 2.4.x';

export const aas2_5: Format = (document: unknown): boolean =>
isAas2(document) && aas2_5Regex.test(String((document as MaybeAsyncAPI).asyncapi));
aas2_5.displayName = 'AsyncAPI 2.5.x';

export const aas2_6: Format = (document: unknown): boolean =>
isAas2(document) && aas2_6Regex.test(String((document as MaybeAsyncAPI).asyncapi));
aas2_6.displayName = 'AsyncAPI 2.6.x';
export class Formats extends Map<string, Format> {
filterByMajorVersions(majorsToInclude: string[]): Formats {
return new Formats([...this.entries()].filter(element => {return majorsToInclude.includes(element[0].split('.')[0]);}));
}

excludeByVersions(versionsToExclude: string[]): Formats {
return new Formats([...this.entries()].filter(element => {return !versionsToExclude.includes(element[0]);}));
}

find(version: string): Format | undefined {
return this.get(formatVersion(version));
}

formats(): Format[] {
return [...this.values()];
}
}

export const AsyncAPIFormats = new Formats(Object.entries(schemas).reverse().map(([version]) => [version, createFormat(version)])); // reverse is used for giving newer versions a higher priority when matching

function isAsyncAPIVersion(versionToMatch: string, document: unknown): document is { asyncapi: string } & Record<string, unknown> {
const asyncAPIDoc = document as MaybeAsyncAPI;
if (!asyncAPIDoc) return false;

const documentVersion = String(asyncAPIDoc.asyncapi);
return isObject(document) && 'asyncapi' in document
&& assertValidAsyncAPIVersion(documentVersion)
&& versionToMatch === formatVersion(documentVersion);
}

function assertValidAsyncAPIVersion(documentVersion: string): boolean {
const semver = getSemver(documentVersion);
const regexp = new RegExp(`^(${semver.major})\\.(${semver.minor})\\.(0|[1-9][0-9]*)$`); // eslint-disable-line security/detect-non-literal-regexp
return regexp.test(documentVersion);
}

function createFormat(version: string): Format {
const format: Format = (document: unknown): boolean =>
isAsyncAPIVersion(version, document);

const semver = getSemver(version);
format.displayName = `AsyncAPI ${semver.major}.${semver.minor}.x`;

return format;
}

const formatVersion = function (version: string): string {
const versionSemver = getSemver(version);
return `${versionSemver.major}.${versionSemver.minor}.0`;
};

export const aas2All = [aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5, aas2_6];
25 changes: 6 additions & 19 deletions src/ruleset/functions/documentStructure.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import specs from '@asyncapi/specs';
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, aas2_5, aas2_6 } from '../formats';

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

type AsyncAPIVersions = keyof typeof specs.schemas;

Expand Down Expand Up @@ -80,24 +80,11 @@ function filterRefErrors(errors: IFunctionResult[], resolved: boolean) {
});
}

function getSchema(formats: Set<Format>): Record<string, any> | void {
switch (true) {
case formats.has(aas2_6):
return getSerializedSchema('2.6.0');
case formats.has(aas2_5):
return getSerializedSchema('2.5.0');
case formats.has(aas2_4):
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;
export function getSchema(docFormats: Set<Format>): Record<string, any> | void {
for (const [version, format] of AsyncAPIFormats) {
if (docFormats.has(format)) {
return getSerializedSchema(version as AsyncAPIVersions);
}
}
}

Expand Down
7 changes: 3 additions & 4 deletions src/ruleset/functions/unusedComponent.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { unreferencedReusableObject } from '@stoplight/spectral-functions';
import { createRulesetFunction } from '@stoplight/spectral-core';
import { aas2 } from '../formats';
import { isObject } from '../../utils';

import type { IFunctionResult } from '@stoplight/spectral-core';
Expand All @@ -23,9 +22,9 @@ export const unusedComponent = createRulesetFunction<{ components: Record<string

const results: IFunctionResult[] = [];
Object.keys(components).forEach(componentType => {
// if component type is `securitySchemes` and we operate on AsyncAPI 2.x.x skip validation
// security schemes in 2.x.x are referenced by keys, not by object ref - for this case we have a separate `asyncapi2-unused-securityScheme` rule
if (componentType === 'securitySchemes' && aas2(targetVal, null)) {
// if component type is `securitySchemes` we skip the validation
// security schemes in >=2.x.x are referenced by keys, not by object ref - for this case we have a separate `asyncapi2-unused-securityScheme` rule
if (componentType === 'securitySchemes') {
return;
}

Expand Down
8 changes: 5 additions & 3 deletions src/ruleset/ruleset.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { aas2All as aas2AllFormats } from './formats';

import { lastVersion } from '../constants';
import { truthy, schema } from '@stoplight/spectral-functions';

import { documentStructure } from './functions/documentStructure';
import { internal } from './functions/internal';
import { isAsyncAPIDocument } from './functions/isAsyncAPIDocument';
import { unusedComponent } from './functions/unusedComponent';
import { AsyncAPIFormats } from './formats';

export const coreRuleset = {
description: 'Core AsyncAPI x.x.x ruleset.',
formats: [...aas2AllFormats],
formats: AsyncAPIFormats.filterByMajorVersions(['2']).formats(), // Validation for AsyncAPI v3 is still WIP.
rules: {
/**
* Root Object rules
Expand Down Expand Up @@ -80,7 +81,7 @@ export const coreRuleset = {

export const recommendedRuleset = {
description: 'Recommended AsyncAPI x.x.x ruleset.',
formats: [...aas2AllFormats],
formats: AsyncAPIFormats.filterByMajorVersions(['2']).formats(), // Validation for AsyncAPI v3 is still WIP.
rules: {
/**
* Root Object rules
Expand Down Expand Up @@ -188,6 +189,7 @@ export const recommendedRuleset = {
*/
'asyncapi-unused-component': {
description: 'Potentially unused component has been detected in AsyncAPI document.',
formats: AsyncAPIFormats.filterByMajorVersions(['2']).formats(), // Validation for AsyncAPI v3 is still WIP.
recommended: true,
resolved: false,
severity: 'info',
Expand Down
9 changes: 4 additions & 5 deletions src/ruleset/v2/ruleset.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable sonarjs/no-duplicate-string */

import { aas2All as aas2AllFormats } from '../formats';
import { AsyncAPIFormats } from '../formats';
import { truthy, pattern } from '@stoplight/spectral-functions';

import { channelParameters } from './functions/channelParameters';
Expand All @@ -20,7 +20,7 @@ import type { Parser } from '../../parser';

export const v2CoreRuleset = {
description: 'Core AsyncAPI 2.x.x ruleset.',
formats: [...aas2AllFormats],
formats: AsyncAPIFormats.filterByMajorVersions(['2']).formats(),
rules: {
/**
* Server Object rules
Expand Down Expand Up @@ -191,7 +191,6 @@ export const v2CoreRuleset = {
export const v2SchemasRuleset = (parser: Parser) => {
return {
description: 'Schemas AsyncAPI 2.x.x ruleset.',
formats: [...aas2AllFormats],
rules: {
'asyncapi2-schemas': asyncApi2SchemaParserRule(parser),
'asyncapi2-schema-default': {
Expand Down Expand Up @@ -244,7 +243,7 @@ export const v2SchemasRuleset = (parser: Parser) => {

export const v2RecommendedRuleset = {
description: 'Recommended AsyncAPI 2.x.x ruleset.',
formats: [...aas2AllFormats],
formats: AsyncAPIFormats.filterByMajorVersions(['2']).formats(),
rules: {
/**
* Root Object rules
Expand Down Expand Up @@ -334,7 +333,7 @@ export const v2RecommendedRuleset = {
'asyncapi2-message-messageId': {
description: 'Message should have a "messageId" field defined.',
recommended: true,
formats: aas2AllFormats.slice(4), // from 2.4.0
formats: AsyncAPIFormats.filterByMajorVersions(['2']).excludeByVersions(['2.0.0', '2.1.0', '2.2.0', '2.3.0']).formats(), // message.messageId is available starting from v2.4.
given: [
'$.channels.*.[publish,subscribe][?(@property === "message" && @.oneOf == void 0)]',
'$.channels.*.[publish,subscribe].message.oneOf.*',
Expand Down
19 changes: 17 additions & 2 deletions test/parse.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { Document } from '@stoplight/spectral-core';

import { AsyncAPIDocumentV2 } from '../src/models';
import { AsyncAPIDocumentV2, AsyncAPIDocumentV3 } from '../src/models';
import { Parser } from '../src/parser';
import { xParserApiVersion } from '../src/constants';

describe('parse()', function() {
const parser = new Parser();

it('should parse valid document', async function() {
it('should parse valid document', async function() {
const documentRaw = {
asyncapi: '2.0.0',
info: {
Expand All @@ -22,6 +22,21 @@ describe('parse()', function() {
expect(diagnostics.length > 0).toEqual(true);
});

it('should not parse valid v3 document', async function() {
const documentRaw = {
asyncapi: '3.0.0',
info: {
title: 'Valid AsyncApi document',
version: '1.0',
},
channels: {}
};
const { document, diagnostics } = await parser.parse(documentRaw);
expect(document).toEqual(undefined);
expect(diagnostics.length > 0).toEqual(true);
expect(diagnostics[0].message).toContain('Unsupported AsyncAPI version: 3.0.0');
});

it('should parse invalid document', async function() {
const documentRaw = {
asyncapi: '2.0.0',
Expand Down
Loading

0 comments on commit c0a4c19

Please sign in to comment.