Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/getting-started/5-asyncapi.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# AsyncAPI Support

Spectral has a built-in AsyncAPI [v2](https://v2.asyncapi.com/docs/reference/specification/v2.6.0) and [v3](https://www.asyncapi.com/docs/reference/specification/v3.0.0) ruleset that you can use to validate your AsyncAPI files.
Spectral has a built-in AsyncAPI [v2](https://v2.asyncapi.com/docs/reference/specification/v2.6.0) and [v3](https://www.asyncapi.com/docs/reference/specification/v3.1.0) ruleset that you can use to validate your AsyncAPI files.

Add `extends: "spectral:asyncapi"` to your ruleset file to apply rules for AsyncAPI v2 and v3.

Expand Down
1 change: 1 addition & 0 deletions docs/guides/4-custom-rulesets.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Formats are an optional way to specify which API description formats a rule, or
- `aas2_6` (AsyncAPI v2.6.0)
- `aas3` (AsyncAPI v3.x)
- `aas3_0` (AsyncAPI v3.0.0)
- `aas3_1` (AsyncAPI v3.1.x)
- `oas2` (OpenAPI v2.0)
- `oas3` (OpenAPI v3.x)
- `oas3_0` (OpenAPI v3.0.x)
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/asyncapi-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,7 @@ Channel servers must be defined in the `servers` object.
**Bad Example**

```yaml
asyncapi: "3.0.0"
asyncapi: "3.1.0"
info:
title: Awesome API
description: A very well-defined API
Expand All @@ -693,7 +693,7 @@ channels:
**Good Example**

```yaml
asyncapi: "3.0.0"
asyncapi: "3.1.0"
info:
title: Awesome API
description: A very well-defined API
Expand Down
44 changes: 25 additions & 19 deletions 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, aas2_5, aas2_6, aas3, aas3_0 } from '../asyncapi';
import { aas2, aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5, aas2_6, aas3, aas3_0, aas3_1 } from '../asyncapi';

describe('AsyncAPI format', () => {
describe('AsyncAPI 2.x', () => {
Expand Down Expand Up @@ -126,29 +126,35 @@ describe('AsyncAPI format', () => {
});
});
describe('AsyncAPI 3.0', () => {
it.each(['3.0.0'])('recognizes %s version correctly', version => {
it.each(['3.0.0', '3.0.3'])('recognizes %s version correctly', version => {
expect(aas3({ asyncapi: version }, null)).toBe(true);
});
it.each(['3.0.0'])('recognizes %s version correctly', version => {
it.each(['3.0.0', '3.0.3'])('recognizes %s version correctly', version => {
expect(aas3_0({ 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.5.0',
'2.5.4',
'2.7.0',
'2.7.4',
])('does not recognize %s version', version => {
expect(aas3({ asyncapi: version }, null)).toBe(false);
describe('AsyncAPI 3.1', () => {
it.each(['3.1.0', '3.1.3'])('recognizes %s version correctly', version => {
expect(aas3({ asyncapi: version }, null)).toBe(true);
expect(aas3_1({ asyncapi: version }, null)).toBe(true);
});

it.each(['3.0.0', '3.0.3', '3.2.0', '3', '3.1', '3.1.', '3.1.01'])('does not recognize %s version', version => {
expect(aas3_1({ asyncapi: version }, null)).toBe(false);
});
});

describe('AsyncAPI 3.x', () => {
it.each(['2', '2.3', '2.0.0', '2.7.4', '3', '3.0', '3.1', '3.1.01', '4.0.0'])(
'does not recognize %s version',
version => {
expect(aas3({ asyncapi: version }, null)).toBe(false);
},
);

it('does not recognize an AsyncAPI 3.1 document as AsyncAPI 3.0', () => {
expect(aas3_0({ asyncapi: '3.1.0' }, 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 @@ -14,6 +14,7 @@ const aas2_4Regex = /^2\.4(?:\.[0-9]*)?$/;
const aas2_5Regex = /^2\.5(?:\.[0-9]*)?$/;
const aas2_6Regex = /^2\.6(?:\.[0-9]*)?$/;
const aas3_0Regex = /^3\.0(?:\.[0-9]*)?$/;
const aas3_1Regex = /^3\.1(?:\.[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 @@ -61,3 +62,7 @@ aas2_6.displayName = 'AsyncAPI 2.6.x';
export const aas3_0: Format = (document: unknown): boolean =>
isAas3(document) && aas3_0Regex.test(String((document as MaybeAAS3).asyncapi));
aas3_0.displayName = 'AsyncAPI 3.0.x';

export const aas3_1: Format = (document: unknown): boolean =>
isAas3(document) && aas3_1Regex.test(String((document as MaybeAAS3).asyncapi));
aas3_1.displayName = 'AsyncAPI 3.1.x';
2 changes: 1 addition & 1 deletion packages/rulesets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"url": "https://github.com/stoplightio/spectral.git"
},
"dependencies": {
"@asyncapi/specs": "^6.8.0",
"@asyncapi/specs": "^6.11.1",
"@scarf/scarf": "^1.4.0",
"@stoplight/better-ajv-errors": "1.0.3",
"@stoplight/json": "^3.17.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,91 @@ testRule('asyncapi-3-document-resolved', [
},
errors: [],
},
{
name: 'valid AsyncAPI 3.1 document with resolved references and ROS 2 bindings',
document: {
asyncapi: '3.1.0',
info: {
title: 'Turtle telemetry',
version: '1.0.0',
},
servers: {
development: {
host: 'localhost',
protocol: 'ros2',
bindings: {
ros2: {
domainId: 0,
rmwImplementation: 'rmw_fastrtps_cpp',
},
},
},
},
channels: {
turtlePose: {
address: 'turtle/pose',
messages: {
TurtlePose: {
payload: {
type: 'object',
},
},
},
},
},
operations: {
publishPose: {
action: 'send',
channel: {
$ref: '#/channels/turtlePose',
},
messages: [{ $ref: '#/channels/turtlePose/messages/TurtlePose' }],
bindings: {
ros2: {
node: '/turtlesim',
role: 'publisher',
},
},
},
},
},
errors: [],
},
{
name: 'AsyncAPI 3.0 rejects ROS 2 bindings that are introduced in 3.1',
document: {
asyncapi: '3.0.0',
info: {
title: 'Turtle telemetry',
version: '1.0.0',
},
servers: {
development: {
host: 'localhost',
protocol: 'ros2',
bindings: {
ros2: {
domainId: 0,
},
},
},
},
},
errors: [
{
message: 'Property "ros2" is not expected to be here',
path: ['servers', 'development', 'bindings', 'ros2'],
severity: DiagnosticSeverity.Error,
},
],
},
{
name: 'invalid AsyncAPI 3.1 info property is missing',
document: {
asyncapi: '3.1.0',
},
errors: [{ message: 'Object must have required property "info"', severity: DiagnosticSeverity.Error }],
},
{
name: 'valid case resolved case message',
document: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,27 @@ testRule('asyncapi-3-document-unresolved', [
},
errors: [],
},
{
name: 'invalid AsyncAPI 3.1 reference for info object is not allowed',
document: {
asyncapi: '3.1.0',
info: {
$ref: '#/components/x-titles/someTitle',
},
components: {
'x-titles': {
someTitle: 'some-title',
},
},
},
errors: [
{
message: 'Referencing in this place is not allowed',
path: ['info'],
severity: DiagnosticSeverity.Error,
},
],
},
{
name: 'valid case unresolved case message',
document: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,18 @@ testRule('asyncapi-3-tags', [
},
],
},
{
name: 'info tags property is missing in AsyncAPI 3.1',
document: {
asyncapi: '3.1.0',
info: {},
},
errors: [
{
message: 'AsyncAPI document must have non-empty "tags" array.',
path: ['info'],
severity: DiagnosticSeverity.Warning,
},
],
},
]);
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { DiagnosticSeverity } from '@stoplight/types';
import { latestVersion } from '../functions/utils/specs';
import testRule from './__helpers__/tester';

testRule('asyncapi-latest-version', [
{
name: 'valid case',
document: {
asyncapi: latestVersion,
asyncapi: '3.1.0',
},
errors: [],
},
Expand All @@ -18,7 +17,7 @@ testRule('asyncapi-latest-version', [
},
errors: [
{
message: `The latest version is not used. You should update to the "${latestVersion}" version.`,
message: 'The latest version is not used. You should update to the "3.1.0" version.',
path: ['asyncapi'],
severity: DiagnosticSeverity.Information,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { aas2_0 } from '@stoplight/spectral-formats';
import { aas2_0, aas3_1 } from '@stoplight/spectral-formats';
import asyncApiPayloadValidation from '../asyncApiPayloadValidation';

function runPayloadValidation(targetVal: any) {
function runPayloadValidation(targetVal: any, format = aas2_0) {
return asyncApiPayloadValidation(targetVal, null, {
path: ['components', 'messages', 'aMessage'],
document: { formats: new Set([aas2_0]) },
document: { formats: new Set([format]) },
} as any);
}

Expand All @@ -24,4 +24,21 @@ describe('asyncApiPayloadValidation', () => {
},
]);
});

test('validates payloads against the AsyncAPI 3.1 schema object definition', () => {
const results = runPayloadValidation(
{
type: 'object',
deprecated: 14,
},
aas3_1,
);

expect(results).toEqual([
{
message: '"deprecated" property type must be boolean',
path: ['components', 'messages', 'aMessage', 'deprecated'],
},
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { createRulesetFunction, IFunctionResult, Format } from '@stoplight/spect
import { schema as schemaFn } from '@stoplight/spectral-functions';
import type { ErrorObject } from 'ajv';
import { getCopyOfSchema } from './utils/specs';
import { aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5, aas2_6, aas3_0 } from '@stoplight/spectral-formats';
import { aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5, aas2_6, aas3_0, aas3_1 } from '@stoplight/spectral-formats';

type AsyncAPIVersions = keyof typeof specs.schemas;
type RawSchema = Record<string, unknown>;
Expand Down Expand Up @@ -51,20 +51,22 @@ export function prepareResults(errors: ErrorObject[]): void {

// 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 {
function prepareV3ResolvedSchema(copied: any, version: '3.0.0' | '3.1.0'): any {
const definition = (name: string): string => `http://asyncapi.com/definitions/${version}/${name}.json`;

// 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';
const channelObject = copied.definitions[definition('channel')];
channelObject.properties.servers.items.$ref = definition('server');

// 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';
const operationSchema = copied.definitions[definition('operation')];
operationSchema.properties.channel.$ref = definition('channel');
operationSchema.properties.messages.items.$ref = definition('messageObject');

// 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';
const operationReplySchema = copied.definitions[definition('operationReply')];
operationReplySchema.properties.channel.$ref = definition('channel');
operationReplySchema.properties.messages.items.$ref = definition('messageObject');

return copied;
}
Expand All @@ -85,8 +87,8 @@ function getSerializedSchema(version: AsyncAPIVersions, resolved: boolean): RawS
// Spectral caches the schemas using '$id' property
copied['$id'] = copied['$id'].replace('asyncapi.json', `asyncapi-${resolved ? 'resolved' : 'unresolved'}.json`);

if (resolved && version === '3.0.0') {
copied = prepareV3ResolvedSchema(copied);
if (resolved && (version === '3.0.0' || version === '3.1.0')) {
copied = prepareV3ResolvedSchema(copied, version);
}

serializedSchemas.set(serializedSchemaKey as AsyncAPIVersions, copied);
Expand All @@ -109,6 +111,8 @@ function filterRefErrors(errors: IFunctionResult[], resolved: boolean) {

export function getSchema(formats: Set<Format>, resolved: boolean): Record<string, any> | void {
switch (true) {
case formats.has(aas3_1):
return getSerializedSchema('3.1.0', resolved);
case formats.has(aas3_0):
return getSerializedSchema('3.0.0', resolved);
case formats.has(aas2_6):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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, aas2_6, aas3_0 } from '@stoplight/spectral-formats';
import { aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5, aas2_6, aas3_0, aas3_1 } from '@stoplight/spectral-formats';
import betterAjvErrors from '@stoplight/better-ajv-errors';

import { getCopyOfSchema } from './utils/specs';
Expand Down Expand Up @@ -52,6 +52,8 @@ function getValidator(version: AsyncAPISpecVersion): ValidateFunction {

function getSchemaValidator(formats: Set<Format>): ValidateFunction | void {
switch (true) {
case formats.has(aas3_1):
return getValidator('3.1.0');
case formats.has(aas3_0):
return getValidator('3.0.0');
case formats.has(aas2_6):
Expand Down
Loading