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

refactor: match Spectral formats based on schemas found in @asyncapi/specs pkg #822

Merged
merged 2 commits into from
Aug 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not the biggest fan of using reverse instead of a sorting function, cause what if the spec library changes the order? It implies that the sorted list is a feature and the knowledge of it 😄


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') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For v3, we need to remember to let securitySchemes through, and not use asyncapi2-unused-securityScheme.

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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AsyncAPIFormats.filterByMajorVersions(['2']).fromVersion(['2.4.0']).formats(), would make it easier to read and use 😄

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah but then I will need to deal with sorting and filtering making it a bit more complicated and I don't want to go in that direction yet if this solves the issue.

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('Version "3.0.0" is not supported');
});

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