From 0382c65079064d2e582c94f5077f0f3548dd9fa1 Mon Sep 17 00:00:00 2001 From: Sergio Moya <1083296+smoya@users.noreply.github.com> Date: Tue, 1 Aug 2023 12:11:39 +0200 Subject: [PATCH] refactor: match Spectral formats based on schemas found in @asyncapi/specs pkg (#822) --- src/ruleset/formats.ts | 98 ++++++------- src/ruleset/functions/documentStructure.ts | 25 +--- src/ruleset/functions/unusedComponent.ts | 7 +- src/ruleset/ruleset.ts | 8 +- src/ruleset/v2/ruleset.ts | 9 +- test/parse.spec.ts | 19 ++- test/ruleset/formats.spec.ts | 153 ++++++++------------- 7 files changed, 146 insertions(+), 173 deletions(-) diff --git a/src/ruleset/formats.ts b/src/ruleset/formats.ts index 7abda4180..a0d069323 100644 --- a/src/ruleset/formats.ts +++ b/src/ruleset/formats.ts @@ -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 => - 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 { + 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 { + 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]; diff --git a/src/ruleset/functions/documentStructure.ts b/src/ruleset/functions/documentStructure.ts index e6f6c08f4..a00268d25 100644 --- a/src/ruleset/functions/documentStructure.ts +++ b/src/ruleset/functions/documentStructure.ts @@ -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; @@ -80,24 +80,11 @@ function filterRefErrors(errors: IFunctionResult[], resolved: boolean) { }); } -function getSchema(formats: Set): Record | 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): Record | void { + for (const [version, format] of AsyncAPIFormats) { + if (docFormats.has(format)) { + return getSerializedSchema(version as AsyncAPIVersions); + } } } diff --git a/src/ruleset/functions/unusedComponent.ts b/src/ruleset/functions/unusedComponent.ts index 0be382d71..00402046f 100644 --- a/src/ruleset/functions/unusedComponent.ts +++ b/src/ruleset/functions/unusedComponent.ts @@ -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'; @@ -23,9 +22,9 @@ export const unusedComponent = createRulesetFunction<{ components: Record { - // 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; } diff --git a/src/ruleset/ruleset.ts b/src/ruleset/ruleset.ts index 0f87c3d15..c24ebfdfd 100644 --- a/src/ruleset/ruleset.ts +++ b/src/ruleset/ruleset.ts @@ -1,4 +1,4 @@ -import { aas2All as aas2AllFormats } from './formats'; + import { lastVersion } from '../constants'; import { truthy, schema } from '@stoplight/spectral-functions'; @@ -6,10 +6,11 @@ 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 @@ -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 @@ -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', diff --git a/src/ruleset/v2/ruleset.ts b/src/ruleset/v2/ruleset.ts index 419a6a030..a9c609692 100644 --- a/src/ruleset/v2/ruleset.ts +++ b/src/ruleset/v2/ruleset.ts @@ -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'; @@ -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 @@ -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': { @@ -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 @@ -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.*', diff --git a/test/parse.spec.ts b/test/parse.spec.ts index cdca94a43..265ffb362 100644 --- a/test/parse.spec.ts +++ b/test/parse.spec.ts @@ -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: { @@ -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('Version "3.0.0" is not supported'); + }); + it('should parse invalid document', async function() { const documentRaw = { asyncapi: '2.0.0', diff --git a/test/ruleset/formats.spec.ts b/test/ruleset/formats.spec.ts index 5de116897..b2639a0e4 100644 --- a/test/ruleset/formats.spec.ts +++ b/test/ruleset/formats.spec.ts @@ -1,117 +1,82 @@ -import { aas2, aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5, aas2_6 } from '../../src/ruleset/formats'; +import { schemas } from '@asyncapi/specs'; +import { AsyncAPIFormats, Formats } from '../../src/ruleset/formats'; +import { getSemver } from '../../src/utils'; -describe('AsyncAPI format', () => { - describe('AsyncAPI 2.x', () => { - it.each(['2.0.0', '2.1.0', '2.2.0', '2.3.0', '2.0.17', '2.1.37', '2.9.0', '2.9.3'])( - 'recognizes %s version correctly', - version => { - expect(aas2({ asyncapi: version }, null)).toBe(true); - }, - ); +import type { Format } from '@stoplight/spectral-core'; +describe('AsyncAPI format', () => { + describe('Recognizes versions', () => { const testCases = [ - { asyncapi: '3.0' }, - { asyncapi: '3.0.0' }, - { asyncapi: '2' }, - { asyncapi: '2.0' }, - { asyncapi: '2.0.' }, - { asyncapi: '2.0.01' }, - { asyncapi: '1.0' }, - { asyncapi: 2 }, - { asyncapi: null }, - { openapi: '4.0' }, - { openapi: '2.0' }, - { openapi: null }, - { swagger: null }, - { swagger: '3.0' }, - {}, - null, + { formatVersion: '2.0.0', document: {asyncapi: '2.0.0'}, existsFormat: true, result: true }, + { formatVersion: '2.0.0', document: {asyncapi: '2.1.8'}, existsFormat: true, result: false }, + { formatVersion: '2.1.8', document: {asyncapi: '2.0.0'}, existsFormat: true, result: false }, + { formatVersion: '2.1.3', document: {asyncapi: '2.1.3'}, existsFormat: true, result: true }, + { formatVersion: '2.1.3', document: {asyncapi: '2.0.0'}, existsFormat: true, result: false }, + { formatVersion: '2.0.0', document: {asyncapi: '2.1.3'}, existsFormat: true, result: false }, + { formatVersion: '2.2.9', document: {asyncapi: '2.2.9'}, existsFormat: true, result: true }, + { formatVersion: '2.2.9', document: {asyncapi: '2.0.0'}, existsFormat: true, result: false }, + { formatVersion: '2.0.0', document: {asyncapi: '2.2.9'}, existsFormat: true, result: false }, + { formatVersion: '2.6.5', document: {asyncapi: '2.6.5'}, existsFormat: true, result: true }, + { formatVersion: '2.6.5', document: {asyncapi: '2.0.0'}, existsFormat: true, result: false }, + { formatVersion: '2.0.0', document: {asyncapi: '2.6.5'}, existsFormat: true, result: false }, + { formatVersion: '3.0.10', document: {asyncapi: '3.0.10'}, existsFormat: false, result: false }, + { formatVersion: '3.0.0', document: {openapi: '3.0.0'}, existsFormat: false, result: false }, + { formatVersion: '3.0.0', document: null, existsFormat: false, result: false }, + { formatVersion: '999.999.0', document: {}, existsFormat: false, result: false }, + { formatVersion: '19923.1.0', document: {}, existsFormat: false, result: false }, + { formatVersion: '2.99.0', document: {}, existsFormat: false, result: false }, ]; - - it.each(testCases)('does not recognize invalid document %o', document => { - expect(aas2(document, null)).toBe(false); + + it.each(testCases)('format formatVersion recognizes version %p correctly', testCase => { + const format = AsyncAPIFormats.find(testCase.formatVersion); + expect(format !== undefined).toEqual(testCase.existsFormat); + if (format !== undefined) { + expect(format(testCase.document, null)).toEqual(testCase.result); + } }); }); +}); - describe('AsyncAPI 2.0', () => { - it.each(['2.0.0', '2.0.3'])('recognizes %s version correctly', version => { - expect(aas2_0({ asyncapi: version }, null)).toBe(true); - }); - - it.each(['2', '2.0', '2.1.0', '2.1.3'])('does not recognize %s version', version => { - expect(aas2_0({ asyncapi: version }, null)).toBe(false); - }); +describe('AsyncAPIFormats collection', () => { + it('Is a Formats collection', () => { + expect(AsyncAPIFormats).toBeInstanceOf(Formats); }); - describe('AsyncAPI 2.1', () => { - it.each(['2.1.0', '2.1.37'])('recognizes %s version correctly', version => { - expect(aas2_1({ asyncapi: version }, null)).toBe(true); - }); - - it.each(['2', '2.1', '2.0.0', '2.2.0', '2.2.3'])('does not recognize %s version', version => { - expect(aas2_1({ asyncapi: version }, null)).toBe(false); - }); + it('Returns all formats as array', () => { + const formats = AsyncAPIFormats.formats(); + expect(formats).toHaveLength(Object.keys(schemas).length); }); - describe('AsyncAPI 2.2', () => { - it.each(['2.2.0', '2.2.3'])('recognizes %s version correctly', version => { - expect(aas2_2({ asyncapi: version }, null)).toBe(true); - }); - - it.each(['2', '2.2', '2.0.0', '2.1.0', '2.1.37', '2.3.0', '2.3.3'])('does not recognize %s version', version => { - expect(aas2_2({ asyncapi: version }, null)).toBe(false); - }); + it('Finds existing version', () => { + expect(AsyncAPIFormats.find('2.0.0') !== undefined).toBeTruthy(); }); - describe('AsyncAPI 2.3', () => { - it.each(['2.3.0', '2.3.3'])('recognizes %s version correctly', version => { - expect(aas2_3({ asyncapi: version }, null)).toBe(true); - }); - - it.each(['2', '2.3', '2.0.0', '2.1.0', '2.1.37', '2.2.0', '2.4.0', '2.4.3'])( - 'does not recognize %s version', - version => { - expect(aas2_3({ asyncapi: version }, null)).toBe(false); - }, - ); + it('Finds non-existing version', () => { + expect(AsyncAPIFormats.find('9999.9999.99999-rc') === undefined).toBeTruthy(); }); - describe('AsyncAPI 2.4', () => { - it.each(['2.4.0', '2.4.3'])('recognizes %s version correctly', version => { - expect(aas2_4({ asyncapi: version }, null)).toBe(true); - }); + it('Filters by major version', () => { + const formats = AsyncAPIFormats; + formats.set('999.0.0', (_: unknown): boolean => true); - it.each(['2', '2.3', '2.0.0', '2.1.0', '2.1.37', '2.2.0', '2.3.0', '2.5.0', '2.5.3'])( - 'does not recognize %s version', - version => { - expect(aas2_4({ asyncapi: version }, null)).toBe(false); - }, - ); - }); + const filteredMajorVersion = '2'; + const previousLenght = AsyncAPIFormats.formats().length; + const filteredFormats = AsyncAPIFormats.filterByMajorVersions([filteredMajorVersion]); - 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); + expect(filteredFormats.size).toBeLessThan(previousLenght); + filteredFormats.forEach((_, version) => { + expect(String(getSemver(version).major)).toEqual(filteredMajorVersion); }); - - 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); - }, - ); }); - describe('AsyncAPI 2.6', () => { - it.each(['2.6.0', '2.6.2'])('recognizes %s version correctly', version => { - expect(aas2_6({ asyncapi: version }, null)).toBe(true); - }); + it('Excludes by version', () => { + const excludedVersions = ['2.0.0', '2.1.0', '2.6.0']; + const previousLenght = AsyncAPIFormats.formats().length; + const filteredFormats = AsyncAPIFormats.excludeByVersions(excludedVersions); - 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.5.0', '2.5.3', '2.7.0', '2.7.4'])( - 'does not recognize %s version', - version => { - expect(aas2_6({ asyncapi: version }, null)).toBe(false); - }, - ); + expect(filteredFormats.size).toEqual(previousLenght - excludedVersions.length); + excludedVersions.forEach((version) => { + expect(filteredFormats.find(version)).toBeFalsy(); + }); }); });