Skip to content

Commit

Permalink
feat: enable AsyncAPI v3 validation (#825)
Browse files Browse the repository at this point in the history
Co-authored-by: Maciej Urbańczyk <[email protected]>
  • Loading branch information
smoya and magicmatatjahu authored Aug 9, 2023
1 parent 74ef1cf commit 3fa3290
Show file tree
Hide file tree
Showing 6 changed files with 197 additions and 43 deletions.
60 changes: 47 additions & 13 deletions src/ruleset/functions/documentStructure.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
/* eslint-disable sonarjs/no-duplicate-string */

import specs from '@asyncapi/specs';
import { createRulesetFunction } from '@stoplight/spectral-core';
import { schema as schemaFn } from '@stoplight/spectral-functions';

import { AsyncAPIFormats } from '../formats';
import { getSemver } from '../../utils';

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

type AsyncAPIVersions = keyof typeof specs.schemas;
type RawSchema = Record<string, unknown>;

function shouldIgnoreError(error: ErrorObject): boolean {
return (
Expand Down Expand Up @@ -45,24 +50,52 @@ function prepareResults(errors: ErrorObject[]): void {
}
}

function getCopyOfSchema(version: AsyncAPIVersions): Record<string, unknown> {
return JSON.parse(JSON.stringify(specs.schemas[version])) as Record<string, unknown>;
// this is needed because some v3 object fields are expected to be only `$ref` to other objects.
// In order to validate resolved references, we modify those schemas and instead allow the definition of the object
function prepareV3ResolvedSchema(copied: any): any {
// channel object
const channelObject = copied.definitions['http://asyncapi.com/definitions/3.0.0/channel.json'];
channelObject.properties.servers.items.$ref = 'http://asyncapi.com/definitions/3.0.0/server.json';

// operation object
const operationSchema = copied.definitions['http://asyncapi.com/definitions/3.0.0/operation.json'];
operationSchema.properties.channel.$ref = 'http://asyncapi.com/definitions/3.0.0/channel.json';
operationSchema.properties.messages.items.$ref = 'http://asyncapi.com/definitions/3.0.0/messageObject.json';

// operation reply object
const operationReplySchema = copied.definitions['http://asyncapi.com/definitions/3.0.0/operationReply.json'];
operationReplySchema.properties.channel.$ref = 'http://asyncapi.com/definitions/3.0.0/channel.json';
operationReplySchema.properties.messages.items.$ref = 'http://asyncapi.com/definitions/3.0.0/messageObject.json';

return copied;
}

function getCopyOfSchema(version: AsyncAPIVersions): RawSchema {
return JSON.parse(JSON.stringify(specs.schemas[version])) as RawSchema;
}

const serializedSchemas = new Map<AsyncAPIVersions, Record<string, unknown>>();
function getSerializedSchema(version: AsyncAPIVersions): Record<string, unknown> {
const schema = serializedSchemas.get(version);
const serializedSchemas = new Map<AsyncAPIVersions, RawSchema>();
function getSerializedSchema(version: AsyncAPIVersions, resolved: boolean): RawSchema {
const serializedSchemaKey = resolved ? `${version}-resolved` : `${version}-unresolved`;
const schema = serializedSchemas.get(serializedSchemaKey as AsyncAPIVersions);
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> };
let copied = getCopyOfSchema(version) as { '$id': string, definitions: RawSchema };
// 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'];
// Spectral caches the schemas using '$id' property
copied['$id'] = copied['$id'].replace('asyncapi.json', `asyncapi-${resolved ? 'resolved' : 'unresolved'}.json`);

const { major } = getSemver(version);
if (resolved && major === 3) {
copied = prepareV3ResolvedSchema(copied);
}

serializedSchemas.set(version, copied);
serializedSchemas.set(serializedSchemaKey as AsyncAPIVersions, copied);
return copied;
}

Expand All @@ -80,10 +113,10 @@ function filterRefErrors(errors: IFunctionResult[], resolved: boolean) {
});
}

export function getSchema(docFormats: Set<Format>): Record<string, any> | void {
export function getSchema(docFormats: Set<Format>, resolved: boolean): Record<string, any> | void {
for (const [version, format] of AsyncAPIFormats) {
if (docFormats.has(format)) {
return getSerializedSchema(version as AsyncAPIVersions);
return getSerializedSchema(version as AsyncAPIVersions, resolved);
}
}
}
Expand All @@ -107,16 +140,17 @@ export const documentStructure = createRulesetFunction<unknown, { resolved: bool
return;
}

const schema = getSchema(formats);
const resolved = options.resolved;
const schema = getSchema(formats, resolved);
if (!schema) {
return;
}

const errors = schemaFn(targetVal, { allErrors: true, schema, prepareResults: options.resolved ? prepareResults : undefined }, context);
const errors = schemaFn(targetVal, { allErrors: true, schema, prepareResults: resolved ? prepareResults : undefined }, context);
if (!Array.isArray(errors)) {
return;
}

return filterRefErrors(errors, options.resolved);
return filterRefErrors(errors, resolved);
},
);
7 changes: 3 additions & 4 deletions src/ruleset/ruleset.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@

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';
import { lastVersion } from '../constants';

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

export const recommendedRuleset = {
description: 'Recommended AsyncAPI x.x.x ruleset.',
formats: AsyncAPIFormats.filterByMajorVersions(['2']).formats(), // Validation for AsyncAPI v3 is still WIP.
formats: AsyncAPIFormats.filterByMajorVersions(['2']).formats(), // Recommended validation for AsyncAPI v3 is still WIP.
rules: {
/**
* Root Object rules
Expand Down
35 changes: 24 additions & 11 deletions test/custom-operations/apply-traits-v3.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,15 @@ describe('custom operations - apply traits v3', function() {
title: 'Valid AsyncApi document',
version: '1.0',
},
channels: {
channel1: {}
},
operations: {
someOperation1: {
action: 'send',
channel: {
$ref: '#/channels/channel1'
},
traits: [
{
description: 'some description'
Expand All @@ -25,6 +32,10 @@ describe('custom operations - apply traits v3', function() {
]
},
someOperation2: {
action: 'send',
channel: {
$ref: '#/channels/channel1'
},
description: 'root description',
traits: [
{
Expand All @@ -38,16 +49,18 @@ describe('custom operations - apply traits v3', function() {
}
};
const { document, diagnostics } = await parser.parse(documentRaw);
expect(diagnostics).toHaveLength(0);

const v3Document = document as AsyncAPIDocumentV3;
expect(v3Document).toBeInstanceOf(AsyncAPIDocumentV3);

const someOperation1 = v3Document?.json()?.operations?.someOperation1;
delete someOperation1?.traits;
expect(someOperation1).toEqual({ description: 'another description' });
expect(someOperation1).toEqual({ action: 'send', channel: {}, description: 'another description' });

const someOperation2 = v3Document?.json()?.operations?.someOperation2;
delete someOperation2?.traits;
expect(someOperation2).toEqual({ description: 'root description' });
expect(someOperation2).toEqual({ action: 'send', channel: {}, description: 'root description' });
});

it('should apply traits to messages (channels)', async function() {
Expand All @@ -59,8 +72,8 @@ describe('custom operations - apply traits v3', function() {
},
channels: {
someChannel1: {
messages: [
{
messages: {
someMessage: {
traits: [
{
messageId: 'traitMessageId',
Expand All @@ -71,11 +84,11 @@ describe('custom operations - apply traits v3', function() {
}
]
}
]
}
},
someChannel2: {
messages: [
{
messages: {
someMessage: {
messageId: 'rootMessageId',
description: 'root description',
traits: [
Expand All @@ -88,20 +101,20 @@ describe('custom operations - apply traits v3', function() {
}
]
}
]
}
}
}
};
const { document } = await parser.parse(documentRaw);
const { diagnostics, document } = await parser.parse(documentRaw);

const v3Document = document as AsyncAPIDocumentV3;
expect(v3Document).toBeInstanceOf(AsyncAPIDocumentV3);

const message1 = v3Document?.json()?.channels?.someChannel1?.messages?.[0];
const message1 = v3Document?.json()?.channels?.someChannel1?.messages?.someMessage;
delete (message1 as v3.MessageObject)?.traits;
expect(message1).toEqual({ messageId: 'traitMessageId', description: 'another description', 'x-parser-message-name': 'traitMessageId' });

const message2 = v3Document?.json()?.channels?.someChannel2?.messages?.[0];
const message2 = v3Document?.json()?.channels?.someChannel2?.messages?.someMessage;
delete (message2 as v3.MessageObject)?.traits;
expect(message2).toEqual({ messageId: 'rootMessageId', description: 'root description', 'x-parser-message-name': 'rootMessageId' });
});
Expand Down
4 changes: 2 additions & 2 deletions test/parse.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ describe('parse()', function() {
};
const { document, diagnostics } = await parser.parse(documentRaw);

expect(document).toBeInstanceOf(AsyncAPIDocumentV3);
expect(diagnostics.length === 0).toEqual(true); // Validation in v3 is still not enabled. This test will intentionally fail once that changes.
expect(document === undefined).toEqual(true);
expect(diagnostics.length > 0).toEqual(true);
});

it('should return extras', async function() {
Expand Down
89 changes: 87 additions & 2 deletions test/ruleset/rules/asyncapi-document-resolved.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ testRule('asyncapi-document-resolved', [
},

{
name: 'invalid case (channels property is missing)',
name: 'invalid case for 2.X.X (channels property is missing)',
document: {
asyncapi: '2.0.0',
info: {
Expand All @@ -32,7 +32,7 @@ testRule('asyncapi-document-resolved', [
},

{
name: 'valid case (case when other errors should also occur but we filter them out)',
name: 'valid case for 2.X.X (case validating $ref resolution works as expected)',
document: {
asyncapi: '2.0.0',
info: {
Expand Down Expand Up @@ -309,4 +309,89 @@ testRule('asyncapi-document-resolved', [
},
errors: [],
},

{
name: 'valid case (3.0.0 version)',
document: {
asyncapi: '3.0.0',
info: {
title: 'Signup service example (internal)',
version: '0.1.0',
},
channels: {
'user/signedup': {
address: 'user/signedup',
messages: {
'subscribe.message': {
payload: {}
}
}
}
},
operations: {
'user/signedup.subscribe': {
action: 'send',
channel: {
address: 'user/signedup',
messages: {
'subscribe.message': {
payload: {}
}
}
},
messages: [
{
payload: {}
}
]
}
},
},
errors: [],
},

{
name: 'invalid case for 3.X.X (info.version property is missing)',
document: {
asyncapi: '3.0.0',
info: {
title: 'Valid AsyncApi document',
},
},
errors: [
{
message: '"info" property must have required property "version"',
severity: DiagnosticSeverity.Error,
},
],
},

{
name: 'valid case for 3.X.X (case validating $ref resolution works as expected)',
document: {
asyncapi: '3.0.0',
info: {
title: 'Signup service example (internal)',
version: '0.1.0',
},
channels: {
userSignedup: {
address: 'user/signedup',
messages: {
'subscribe.message': {
$ref: '#/components/messages/testMessage'
}
}
}
},
components: {
messages: {
testMessage: {
payload: {}
}
}
}
},
errors: [],
},
]);
Loading

0 comments on commit 3fa3290

Please sign in to comment.