diff --git a/docs/getting-started/5-asyncapi.md b/docs/getting-started/5-asyncapi.md index 0df415aba..da12aee76 100644 --- a/docs/getting-started/5-asyncapi.md +++ b/docs/getting-started/5-asyncapi.md @@ -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. diff --git a/docs/guides/4-custom-rulesets.md b/docs/guides/4-custom-rulesets.md index 58d73e878..2a0496b7b 100644 --- a/docs/guides/4-custom-rulesets.md +++ b/docs/guides/4-custom-rulesets.md @@ -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) diff --git a/docs/reference/asyncapi-rules.md b/docs/reference/asyncapi-rules.md index d32be7b36..e746a9943 100644 --- a/docs/reference/asyncapi-rules.md +++ b/docs/reference/asyncapi-rules.md @@ -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 @@ -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 diff --git a/packages/formats/src/__tests__/asyncapi.test.ts b/packages/formats/src/__tests__/asyncapi.test.ts index d75546a6d..7896ac759 100644 --- a/packages/formats/src/__tests__/asyncapi.test.ts +++ b/packages/formats/src/__tests__/asyncapi.test.ts @@ -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', () => { @@ -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); }); }); }); diff --git a/packages/formats/src/asyncapi.ts b/packages/formats/src/asyncapi.ts index a04826eb0..51569e9cc 100644 --- a/packages/formats/src/asyncapi.ts +++ b/packages/formats/src/asyncapi.ts @@ -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 => isPlainObject(document) && 'asyncapi' in document && aas2Regex.test(String((document as MaybeAAS2).asyncapi)); @@ -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'; diff --git a/packages/rulesets/package.json b/packages/rulesets/package.json index eae88c136..100022f1c 100644 --- a/packages/rulesets/package.json +++ b/packages/rulesets/package.json @@ -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", diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-document-resolved.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-document-resolved.test.ts index 5d5a73734..95da6da8b 100644 --- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-document-resolved.test.ts +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-document-resolved.test.ts @@ -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: { diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-document-unresolved.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-document-unresolved.test.ts index 293f282d6..0e50a4708 100644 --- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-document-unresolved.test.ts +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-document-unresolved.test.ts @@ -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: { diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-tags.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-tags.test.ts index ebab1e153..5fea1176f 100644 --- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-tags.test.ts +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-tags.test.ts @@ -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, + }, + ], + }, ]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-latest-version.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-latest-version.test.ts index f3277b322..78d620aec 100644 --- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-latest-version.test.ts +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-latest-version.test.ts @@ -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: [], }, @@ -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, }, diff --git a/packages/rulesets/src/asyncapi/functions/__tests__/asyncApiPayloadValidation.test.ts b/packages/rulesets/src/asyncapi/functions/__tests__/asyncApiPayloadValidation.test.ts index 3bf0d4a29..46960fea4 100644 --- a/packages/rulesets/src/asyncapi/functions/__tests__/asyncApiPayloadValidation.test.ts +++ b/packages/rulesets/src/asyncapi/functions/__tests__/asyncApiPayloadValidation.test.ts @@ -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); } @@ -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'], + }, + ]); + }); }); diff --git a/packages/rulesets/src/asyncapi/functions/asyncApiDocumentSchema.ts b/packages/rulesets/src/asyncapi/functions/asyncApiDocumentSchema.ts index 2f64cbc6a..43efa1389 100644 --- a/packages/rulesets/src/asyncapi/functions/asyncApiDocumentSchema.ts +++ b/packages/rulesets/src/asyncapi/functions/asyncApiDocumentSchema.ts @@ -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; @@ -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; } @@ -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); @@ -109,6 +111,8 @@ function filterRefErrors(errors: IFunctionResult[], resolved: boolean) { export function getSchema(formats: Set, resolved: boolean): Record | 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): diff --git a/packages/rulesets/src/asyncapi/functions/asyncApiPayloadValidation.ts b/packages/rulesets/src/asyncapi/functions/asyncApiPayloadValidation.ts index ccc428688..8a104f41c 100644 --- a/packages/rulesets/src/asyncapi/functions/asyncApiPayloadValidation.ts +++ b/packages/rulesets/src/asyncapi/functions/asyncApiPayloadValidation.ts @@ -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'; @@ -52,6 +52,8 @@ function getValidator(version: AsyncAPISpecVersion): ValidateFunction { function getSchemaValidator(formats: Set): 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): diff --git a/packages/rulesets/src/asyncapi/index.ts b/packages/rulesets/src/asyncapi/index.ts index da9abe6cd..bed16450d 100644 --- a/packages/rulesets/src/asyncapi/index.ts +++ b/packages/rulesets/src/asyncapi/index.ts @@ -9,6 +9,7 @@ import { aas2_6, aas3, aas3_0, + aas3_1, } from '@stoplight/spectral-formats'; import { truthy, @@ -34,7 +35,7 @@ import { latestVersion } from './functions/utils/specs'; export default { documentationUrl: 'https://meta.stoplight.io/docs/spectral/docs/reference/asyncapi-rules.md', - formats: [aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5, aas2_6, aas3_0], + formats: [aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5, aas2_6, aas3_0, aas3_1], rules: { 'asyncapi-channel-no-empty-parameter': { description: 'Channel path must not have empty parameter substitution pattern.', diff --git a/test-harness/scenarios/asyncapi2-streetlights.scenario b/test-harness/scenarios/asyncapi2-streetlights.scenario index 8481e6a21..bf7535fe4 100644 --- a/test-harness/scenarios/asyncapi2-streetlights.scenario +++ b/test-harness/scenarios/asyncapi2-streetlights.scenario @@ -218,7 +218,7 @@ module.exports = asyncapi; ====stdout==== {document} 1:1 warning asyncapi-tags AsyncAPI object must have non-empty "tags" array. - 1:11 information asyncapi-latest-version The latest version is not used. You should update to the "3.0.0" version. asyncapi + 1:11 information asyncapi-latest-version The latest version is not used. You should update to the "3.1.0" version. asyncapi 2:6 warning asyncapi-info-contact Info object must have "contact" object. info 45:13 warning asyncapi-operation-description Operation "description" must be present and non-empty string. channels.smartylighting/streetlights/1/0/event/{streetlightId}/lighting/measured.publish 57:15 warning asyncapi-operation-description Operation "description" must be present and non-empty string. channels.smartylighting/streetlights/1/0/action/{streetlightId}/turn/on.subscribe diff --git a/test-harness/scenarios/asyncapi3.1-ros2.scenario b/test-harness/scenarios/asyncapi3.1-ros2.scenario new file mode 100644 index 000000000..5f94e3327 --- /dev/null +++ b/test-harness/scenarios/asyncapi3.1-ros2.scenario @@ -0,0 +1,52 @@ +====test==== +Validate an AsyncAPI 3.1 document with ROS 2 bindings +====document==== +asyncapi: "3.1.0" +info: + title: Turtle telemetry + version: "1.0.0" + description: Publishes turtle telemetry over ROS 2. + contact: + name: API Support + url: https://example.org/support + email: support@example.org + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + tags: + - name: telemetry + description: Turtle telemetry operations. +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 + description: Publishes the current turtle pose. + channel: + $ref: "#/channels/turtlePose" + messages: + - $ref: "#/channels/turtlePose/messages/TurtlePose" + bindings: + ros2: + node: /turtlesim + role: publisher +====asset:ruleset==== +const { asyncapi } = require('@stoplight/spectral-rulesets'); +module.exports = asyncapi; +====command==== +{bin} lint {document} --ruleset "{asset:ruleset}" +====stdout==== +No results with a severity of 'error' found! diff --git a/yarn.lock b/yarn.lock index 9e9aa7a27..dfc08f3bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -73,12 +73,12 @@ __metadata: languageName: node linkType: hard -"@asyncapi/specs@npm:^6.8.0": - version: 6.8.0 - resolution: "@asyncapi/specs@npm:6.8.0" +"@asyncapi/specs@npm:^6.11.1": + version: 6.11.1 + resolution: "@asyncapi/specs@npm:6.11.1" dependencies: "@types/json-schema": ^7.0.11 - checksum: 8a968a9fb842fa0facf4b727cca4e17792cca26b08f8df4f3c5e32a015a9e30c8a2651f76faaabf0933ec2796b9e6318fc7a8abb64a7a95930637ab3af2bebb4 + checksum: 3492128c6cdb2437890c7b21d72e251052972d35dfa5fcc258a957bbe1346663c71c4e00cc5c5c3354b51d752ba2a20bc344ec8d2ea5de1ba1dca79243e8e97f languageName: node linkType: hard @@ -3439,7 +3439,7 @@ __metadata: version: 0.0.0-use.local resolution: "@stoplight/spectral-rulesets@workspace:packages/rulesets" 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