From 3d69be7058e8f25f0697b69fd8317a2aefe313c1 Mon Sep 17 00:00:00 2001 From: Jonas Lagoni Date: Mon, 14 Oct 2024 17:59:25 +0200 Subject: [PATCH] feat(rulesets): add AsyncAPI v3 support (#2697) * feat(rulesets): add asyncapi v3 support * chore(rulesets): remove unused file * chore(rulesets): remove unused file * chore(rulesets): update dependencies * chore(rulesets): update dependencies * chore(rulesets): update test harness --------- Co-authored-by: Nauman --- docs/getting-started/1-concepts.md | 2 +- docs/getting-started/3-rulesets.md | 4 +- docs/getting-started/5-asyncapi.md | 2 +- docs/guides/4-custom-rulesets.md | 2 + docs/reference/asyncapi-rules.md | 504 +++++++++++++----- .../formats/src/__tests__/asyncapi.test.ts | 28 +- packages/formats/src/asyncapi.ts | 12 + .../__fixtures__/formats-variant-2/output.cjs | 4 +- .../__fixtures__/formats-variant-2/output.mjs | 4 +- .../formats-variant-2/ruleset.yaml | 1 + .../src/transformers/formats.ts | 1 + packages/rulesets/package.json | 2 +- ...ncapi-3-channel-no-empty-parameter.test.ts | 32 ++ ...pi-3-channel-no-query-nor-fragment.test.ts | 50 ++ .../asyncapi-3-channel-servers.test.ts | 117 ++++ .../asyncapi-3-document-resolved.test.ts | 157 ++++++ .../asyncapi-3-document-unresolved.test.ts | 89 ++++ ...ncapi-3-headers-schema-type-object.test.ts | 168 ++++++ .../asyncapi-3-operation-description.test.ts | 33 ++ ...3-payload-unsupported-schemaFormat.test.ts | 54 ++ ...syncapi-3-server-no-empty-variable.test.ts | 37 ++ ...syncapi-3-server-no-trailing-slash.test.ts | 38 ++ .../asyncapi-3-server-not-example-com.test.ts | 38 ++ .../asyncapi-3-tag-description.test.ts | 41 ++ .../asyncapi-3-tags-alphabetical.test.ts | 32 ++ .../asyncapi-3-tags-uniqueness.test.ts | 164 ++++++ .../__tests__/asyncapi-3-tags.test.ts | 29 + .../asyncapi-channel-parameters.test.ts | 41 +- .../asyncapi-info-contact-properties.test.ts | 29 +- .../__tests__/asyncapi-info-contact.test.ts | 29 +- .../asyncapi-info-description.test.ts | 24 + .../asyncapi-info-license-url.test.ts | 29 +- .../__tests__/asyncapi-info-license.test.ts | 27 +- .../asyncapi-parameter-description.test.ts | 53 +- .../__tests__/asyncapi-schema.test.ts | 2 +- .../__tests__/asyncapi-servers.test.ts | 40 ++ .../asyncapi-unused-components-schema.test.ts | 51 ++ .../asyncapi-unused-components-server.test.ts | 33 ++ ...test.ts => asyncApiDocumentSchema.test.ts} | 42 +- ...t.ts => asyncApiPayloadValidation.test.ts} | 6 +- ...st.ts => asyncApiSchemaValidation.test.ts} | 6 +- .../functions/asyncApi2DocumentSchema.ts | 134 ----- ...meters.ts => asyncApiChannelParameters.ts} | 12 +- .../functions/asyncApiDocumentSchema.ts | 169 ++++++ ...dation.ts => asyncApiPayloadValidation.ts} | 8 +- ...idation.ts => asyncApiSchemaValidation.ts} | 2 +- ...yncApi2Security.ts => asyncApiSecurity.ts} | 2 +- .../src/asyncapi/functions/utils/specs.ts | 6 +- packages/rulesets/src/asyncapi/index.ts | 334 +++++++++++- .../scenarios/asyncapi2-streetlights.scenario | 2 +- yarn.lock | 10 +- 51 files changed, 2410 insertions(+), 326 deletions(-) create mode 100644 packages/rulesets/src/asyncapi/__tests__/asyncapi-3-channel-no-empty-parameter.test.ts create mode 100644 packages/rulesets/src/asyncapi/__tests__/asyncapi-3-channel-no-query-nor-fragment.test.ts create mode 100644 packages/rulesets/src/asyncapi/__tests__/asyncapi-3-channel-servers.test.ts create mode 100644 packages/rulesets/src/asyncapi/__tests__/asyncapi-3-document-resolved.test.ts create mode 100644 packages/rulesets/src/asyncapi/__tests__/asyncapi-3-document-unresolved.test.ts create mode 100644 packages/rulesets/src/asyncapi/__tests__/asyncapi-3-headers-schema-type-object.test.ts create mode 100644 packages/rulesets/src/asyncapi/__tests__/asyncapi-3-operation-description.test.ts create mode 100644 packages/rulesets/src/asyncapi/__tests__/asyncapi-3-payload-unsupported-schemaFormat.test.ts create mode 100644 packages/rulesets/src/asyncapi/__tests__/asyncapi-3-server-no-empty-variable.test.ts create mode 100644 packages/rulesets/src/asyncapi/__tests__/asyncapi-3-server-no-trailing-slash.test.ts create mode 100644 packages/rulesets/src/asyncapi/__tests__/asyncapi-3-server-not-example-com.test.ts create mode 100644 packages/rulesets/src/asyncapi/__tests__/asyncapi-3-tag-description.test.ts create mode 100644 packages/rulesets/src/asyncapi/__tests__/asyncapi-3-tags-alphabetical.test.ts create mode 100644 packages/rulesets/src/asyncapi/__tests__/asyncapi-3-tags-uniqueness.test.ts create mode 100644 packages/rulesets/src/asyncapi/__tests__/asyncapi-3-tags.test.ts rename packages/rulesets/src/asyncapi/functions/__tests__/{asyncApi2DocumentSchema.test.ts => asyncApiDocumentSchema.test.ts} (91%) rename packages/rulesets/src/asyncapi/functions/__tests__/{asyncApi2PayloadValidation.test.ts => asyncApiPayloadValidation.test.ts} (78%) rename packages/rulesets/src/asyncapi/functions/__tests__/{asyncApi2SchemaValidation.test.ts => asyncApiSchemaValidation.test.ts} (80%) delete mode 100644 packages/rulesets/src/asyncapi/functions/asyncApi2DocumentSchema.ts rename packages/rulesets/src/asyncapi/functions/{asyncApi2ChannelParameters.ts => asyncApiChannelParameters.ts} (76%) create mode 100644 packages/rulesets/src/asyncapi/functions/asyncApiDocumentSchema.ts rename packages/rulesets/src/asyncapi/functions/{asyncApi2PayloadValidation.ts => asyncApiPayloadValidation.ts} (90%) rename packages/rulesets/src/asyncapi/functions/{asyncApi2SchemaValidation.ts => asyncApiSchemaValidation.ts} (96%) rename packages/rulesets/src/asyncapi/functions/{asyncApi2Security.ts => asyncApiSecurity.ts} (97%) diff --git a/docs/getting-started/1-concepts.md b/docs/getting-started/1-concepts.md index b510022a2..c4aba8385 100644 --- a/docs/getting-started/1-concepts.md +++ b/docs/getting-started/1-concepts.md @@ -8,7 +8,7 @@ To achieve this, Spectral has three key concepts: - **Functions** accept a value and return issues if the value is incorrect. - **Rulesets** act as a container for rules and functions. -Spectral comes bundled with a [set of core functions](../reference/functions.md) and rulesets for working with [OpenAPI v2 and v3](./4-openapi.md), [AsyncAPI v2](./5-asyncapi.md), and [Arazzo v1](./6-arazzo.md) that you can chose to use or extend, but Spectral is about far more than just checking your OpenAPI/AsyncAPI/Arazzo documents are valid. +Spectral comes bundled with a [set of core functions](../reference/functions.md) and rulesets for working with [OpenAPI v2 and v3](./4-openapi.md), [AsyncAPI v2 and v3](./5-asyncapi.md), and [Arazzo v1](./6-arazzo.md) that you can chose to use or extend, but Spectral is about far more than just checking your OpenAPI/AsyncAPI/Arazzo documents are valid. By far the most popular use-case of Spectral is automating [API Style Guides](https://stoplight.io/api-style-guides-guidelines-and-best-practices?utm_source=github&utm_medium=spectral&utm_campaign=docs), implementing rules that your Architecture, DevOps, API Governance, "Center for Enablement", or "Center of Excellence" teams have decided upon. Companies generally write these style guides as wiki pages, and several can be found on [API Stylebook](http://apistylebook.com/), but most of these rules could be automated with Spectral. For example: diff --git a/docs/getting-started/3-rulesets.md b/docs/getting-started/3-rulesets.md index 735a8f930..79c8d32ba 100644 --- a/docs/getting-started/3-rulesets.md +++ b/docs/getting-started/3-rulesets.md @@ -13,7 +13,7 @@ The fastest way to create a ruleset is to use the `extends` property to leverage Spectral comes with three built-in rulesets: - `spectral:oas` - [OpenAPI v2/v3 rules](./4-openapi.md) -- `spectral:asyncapi` - [AsyncAPI v2 rules](./5-asyncapi.md) +- `spectral:asyncapi` - [AsyncAPI v2/v3 rules](./5-asyncapi.md) - `spectral:arazzo` - [Arazzo v1 rules](./6-arazzo.md) To create a ruleset that extends both rulesets, open your terminal and run: @@ -22,7 +22,7 @@ To create a ruleset that extends both rulesets, open your terminal and run: echo 'extends: ["spectral:oas", "spectral:asyncapi", "spectral:arazzo"]' > .spectral.yaml ``` -The newly created ruleset file can then be used to lint any OpenAPI v2/v3 or AsyncAPI descriptions using the `spectral lint` command: +The newly created ruleset file can then be used to lint any OpenAPI v2/v3 or AsyncAPI v2/v3 descriptions using the `spectral lint` command: ```bash spectral lint myapifile.yaml diff --git a/docs/getting-started/5-asyncapi.md b/docs/getting-started/5-asyncapi.md index 7ab32585b..d22605c7e 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://www.asyncapi.com/docs/specifications/v2.0.0) ruleset that you can use to validate your AsyncAPI files. +Spectral has a built-in AsyncAPI [v2](https://www.asyncapi.com/docs/specifications/v2.0.0) and [v3](https://www.asyncapi.com/docs/reference/specification/v3.0.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. diff --git a/docs/guides/4-custom-rulesets.md b/docs/guides/4-custom-rulesets.md index 14b42b707..58d73e878 100644 --- a/docs/guides/4-custom-rulesets.md +++ b/docs/guides/4-custom-rulesets.md @@ -31,6 +31,8 @@ Formats are an optional way to specify which API description formats a rule, or - `aas2_4` (AsyncAPI v2.4.0) - `aas2_5` (AsyncAPI v2.5.0) - `aas2_6` (AsyncAPI v2.6.0) +- `aas3` (AsyncAPI v3.x) +- `aas3_0` (AsyncAPI v3.0.0) - `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 a8056fc66..d32be7b36 100644 --- a/docs/reference/asyncapi-rules.md +++ b/docs/reference/asyncapi-rules.md @@ -1,28 +1,14 @@ # AsyncAPI Rules -Spectral has a built-in "asyncapi" ruleset for the [AsyncAPI Specification](https://v2.asyncapi.com/docs/reference). +Spectral has a built-in "asyncapi" ruleset for the [AsyncAPI Specification](https://asyncapi.com). In your ruleset file you can add `extends: "spectral:asyncapi"` and you'll get all of the following rules applied. -These rules will only apply to AsyncAPI v2 documents. +For simplicity, the rules are split up into v2 and v3 compliant rules to make it easier to know which apply when. -### asyncapi-channel-no-empty-parameter - -Channel parameter declarations cannot be empty, ex.`/given/{}` is invalid. - -**Recommended:** Yes - -### asyncapi-channel-no-query-nor-fragment +## AsyncAPI v2 and v3 -Query parameters and fragments shouldn't be used in channel names. Instead, use bindings to define them. - -**Recommended:** Yes - -### asyncapi-channel-no-trailing-slash - -Keep trailing slashes off of channel names, as it can cause some confusion. Most messaging protocols will treat `example/foo` and `example/foo/` as different things. Keep in mind that tooling may replace slashes (`/`) with protocol-specific notation (e.g.: `.` for AMQP), therefore, a trailing slash may result in an invalid channel name in some protocols. - -**Recommended:** Yes +The following rules apply to both AsyncAPI v2 and v3 documents. ### asyncapi-channel-parameters @@ -30,54 +16,6 @@ All channel parameters should be defined in the `parameters` object of the chann **Recommended:** Yes -### asyncapi-channel-servers - -Channel servers must be defined in the `servers` object. - -**Bad Example** - -```yaml -asyncapi: "2.0.0" -info: - title: Awesome API - description: A very well-defined API - version: "1.0" -servers: - production: - url: "stoplight.io" - protocol: "https" -channels: - hello: - servers: - - development -``` - -**Good Example** - -```yaml -asyncapi: "2.0.0" -info: - title: Awesome API - description: A very well-defined API - version: "1.0" -servers: - production: - url: "stoplight.io" - protocol: "https" -channels: - hello: - servers: - - production -``` - -**Recommended:** Yes - -### asyncapi-headers-schema-type-object - -The schema definition of the application headers must be of type “object”. - -**Recommended:** Yes - ### asyncapi-info-contact-properties The [asyncapi-info-contact](#asyncapi-info-contact) rule will ask you to put in a contact object, and this rule will make sure it's full of the most useful properties: `name`, `url`, and `email`. @@ -180,6 +118,152 @@ Checking if the AsyncAPI document is using the latest version. **Recommended:** Yes +### asyncapi-parameter-description + +Parameter objects should have a `description`. + +**Recommended:** No + +### asyncapi-servers + +A non-empty `servers` object is expected to be located at the root of the document. + +**Recommended:** Yes + +### asyncapi-unused-components-schema + +Potential unused reusable `schema` entry has been detected. + + + +_Warning:_ This rule may identify false positives when linting a specification +that acts as a library (a container storing reusable objects, leveraged by other +specifications that reference those objects). + +**Recommended:** Yes + +### asyncapi-unused-components-server + +Potential unused reusable `server` entry has been detected. + + + +_Warning:_ This rule may identify false positives when linting a specification +that acts as a library (a container storing reusable objects, leveraged by other +specifications that reference those objects). + +**Recommended:** Yes + +## AsyncAPI v2 + +The following rules ONLY apply to AsyncAPI v2 documents. + +### asyncapi-schema + +Validate structure of AsyncAPI specification. + +**Recommended:** Yes + +### asyncapi-server-security + +Server `security` values must match a scheme defined in the `components.securitySchemes` object. It also checks if there are `oauth2` scopes that have been defined for the given security. + +**Recommended:** Yes + +**Good Example** + +```yaml +asyncapi: 2.0.0 +servers: + production: + url: test.mosquitto.org + security: + - petstore_auth: [] +components: + securitySchemes: + petstore_auth: ... +``` + +**Bad Example** + +```yaml +asyncapi: 2.0.0 +servers: + production: + url: test.mosquitto.org + security: + - not_defined: [] +components: + securitySchemes: + petstore_auth: ... +``` + +### asyncapi-channel-no-empty-parameter + +Channel parameter declarations cannot be empty, ex.`/given/{}` is invalid. + +**Recommended:** Yes + +### asyncapi-channel-no-query-nor-fragment + +Query parameters and fragments shouldn't be used in channel names. Instead, use bindings to define them. + +**Recommended:** Yes + +### asyncapi-channel-no-trailing-slash + +Keep trailing slashes off of channel names, as it can cause some confusion. Most messaging protocols will treat `example/foo` and `example/foo/` as different things. Keep in mind that tooling may replace slashes (`/`) with protocol-specific notation (e.g.: `.` for AMQP), therefore, a trailing slash may result in an invalid channel name in some protocols. + +**Recommended:** Yes + +### asyncapi-channel-servers + +Channel servers must be defined in the `servers` object. + +**Bad Example** + +```yaml +asyncapi: "2.0.0" +info: + title: Awesome API + description: A very well-defined API + version: "1.0" +servers: + production: + url: "stoplight.io" + protocol: "https" +channels: + hello: + servers: + - development +``` + +**Good Example** + +```yaml +asyncapi: "2.0.0" +info: + title: Awesome API + description: A very well-defined API + version: "1.0" +servers: + production: + url: "stoplight.io" + protocol: "https" +channels: + hello: + servers: + - production +``` + +**Recommended:** Yes + +### asyncapi-headers-schema-type-object + +The schema definition of the application headers must be of type “object”. + +**Recommended:** Yes + ### asyncapi-message-examples All `examples` in message object should follow `payload` and `headers` schemas. @@ -333,12 +417,6 @@ components: petstore_auth: ... ``` -### asyncapi-parameter-description - -Parameter objects should have a `description`. - -**Recommended:** No - ### asyncapi-payload-default `default` objects should be valid against the `payload` they decorate. @@ -425,7 +503,7 @@ Other formats such as OpenAPI Schema Object, JSON Schema Draft 07, and Avro will ### asyncapi-payload -When `schemaFormat` is undefined, the `payload` object should be valid against the AsyncAPI 2 Schema Object definition. +When `schemaFormat` is undefined, the `payload` object should be valid against the AsyncAPI v2 Schema Object definition. **Recommended:** Yes @@ -441,12 +519,6 @@ Values of the `examples` array should be valid against the `schema` they decorat **Recommended:** Yes -### asyncapi-schema - -Validate structure of AsyncAPI v2 specification. - -**Recommended:** Yes - ### asyncapi-server-no-empty-variable Server URL variable declarations cannot be empty, ex.`gigantic-server.com/{}` is invalid. @@ -483,50 +555,12 @@ Server URL should not point to example.com. **Recommended:** No -### asyncapi-server-security - -Server `security` values must match a scheme defined in the `components.securitySchemes` object. It also checks if there are `oauth2` scopes that have been defined for the given security. - -**Recommended:** Yes - -**Good Example** - -```yaml -servers: - production: - url: test.mosquitto.org - security: - - petstore_auth: [] -components: - securitySchemes: - petstore_auth: ... -``` - -**Bad Example** - -```yaml -servers: - production: - url: test.mosquitto.org - security: - - not_defined: [] -components: - securitySchemes: - petstore_auth: ... -``` - ### asyncapi-server-variables All server URL variables should be defined in the `variables` object of the server. They should also not contain redundant variables that do not exist in the server address. **Recommended:** Yes -### asyncapi-servers - -A non-empty `servers` object is expected to be located at the root of the document. - -**Recommended:** Yes - ### asyncapi-tag-description Tags alone are not very descriptive. Give folks a bit more information to work with. @@ -598,41 +632,245 @@ tags: ### asyncapi-tags -AsyncAPI object should have non-empty `tags` array. +AsyncAPI root object should have non-empty `tags` array. Why? Well, you _can_ reference tags arbitrarily in operations, and definition is optional... ```yaml /invoices/{id}/items: - get: - tags: - - Invoice Items + tags: + - Invoice Items ``` Defining tags allows you to add more information like a `description`. For more information see [asyncapi-tag-description](#asyncapi-tag-description). **Recommended:** Yes -### asyncapi-unused-components-schema +## AsyncAPI v3 -Potential unused reusable `schema` entry has been detected. +The following rules ONLY apply to AsyncAPI v3 documents. - +### asyncapi-3-channel-no-empty-parameter -_Warning:_ This rule may identify false positives when linting a specification -that acts as a library (a container storing reusable objects, leveraged by other -specifications that reference those objects). +Channel address parameter declarations cannot be empty, ex.`/given/{}` is invalid. **Recommended:** Yes -### asyncapi-unused-components-server +### asyncapi-3-channel-no-query-nor-fragment -Potential unused reusable `server` entry has been detected. +Query parameters and fragments shouldn't be used in channel address. Instead, use bindings to define them, ex.`/given?test` is invalid. - +**Recommended:** Yes -_Warning:_ This rule may identify false positives when linting a specification -that acts as a library (a container storing reusable objects, leveraged by other -specifications that reference those objects). +### asyncapi-3-channel-no-trailing-slash + +Keep trailing slashes off of channel address, as it can cause some confusion. Most messaging protocols will treat `example/foo` and `example/foo/` as different things. Keep in mind that tooling may replace slashes (`/`) with protocol-specific notation (e.g.: `.` for AMQP), therefore, a trailing slash may result in an invalid channel address in some protocols. + +**Recommended:** Yes + +### asyncapi-3-channel-servers + +Channel servers must be defined in the `servers` object. + +**Bad Example** + +```yaml +asyncapi: "3.0.0" +info: + title: Awesome API + description: A very well-defined API + version: "1.0" +servers: + production: + url: "stoplight.io" + protocol: "https" +channels: + hello: + servers: + - $ref: #/servers/development +``` + +**Good Example** + +```yaml +asyncapi: "3.0.0" +info: + title: Awesome API + description: A very well-defined API + version: "1.0" +servers: + production: + url: "stoplight.io" + protocol: "https" +channels: + hello: + servers: + - $ref: #/servers/production +``` + +**Recommended:** Yes + +### asyncapi-3-headers-schema-type-object + +The schema definition of the application headers must be of type “object”. + +**Recommended:** Yes + +### asyncapi-3-operation-description + +Operation objects should have a description. + +**Recommended:** Yes + +### asyncapi-3-payload-unsupported-schemaFormat + +AsyncAPI can support various `schemaFormat` values. When unspecified, one of the following will be assumed: + +application/vnd.aai.asyncapi;version=2.0.0 +application/vnd.aai.asyncapi+json;version=2.0.0 +application/vnd.aai.asyncapi+yaml;version=2.0.0 + +At this point, explicitly setting `schemaFormat` is not supported by Spectral, so if you use it this rule will emit an info message and skip validating the payload. + +Other formats such as OpenAPI Schema Object, JSON Schema Draft 07, and Avro will be added in various upcoming versions. + +**Recommended:** Yes + +### asyncapi-3-server-no-empty-variable + +Server host and pathname variable declarations cannot be empty, ex.`gigantic-server.com{}` is invalid. + +**Recommended:** Yes + +### asyncapi-3-server-no-trailing-slash + +Server host should not have a trailing slash. + +Some tooling forgets to strip trailing slashes off when it's joining the `servers.host` with `channels`, and you can get awkward URLs like `mqtt://example.com//pets`. Best to just strip them off yourself. + +**Recommended:** Yes + +**Good Example** + +```yaml +servers: + - host: mqtt://example.com +``` + +**Bad Example** + +```yaml +servers: + - host: mqtt://example.com/ + - host: mqtt://example.com/broker/ +``` + +### asyncapi-3-server-not-example-com + +Server host should not point to example.com. + +**Recommended:** No + +### asyncapi-3-tag-description + +Tags alone are not very descriptive. Give folks a bit more information to work with. + +```yaml +info: + tags: + - name: "Aardvark" + description: Funny-nosed pig-head raccoon. + - name: "Badger" + description: Angry short-legged omnivores. +``` + +If your tags are business objects then you can use the term to explain them a bit. An 'Account' could be a user account, company information, bank account, potential sales lead, or anything. What is clear to the folks writing the document is probably not as clear to others. + +```yaml +info: + tags: + - name: Invoice Items + description: |+ + Giant long explanation about what this business concept is, because other people _might_ not have a clue! +``` + +**Recommended:** No + +### asyncapi-3-tags-alphabetical + +AsyncAPI object should have alphabetical `tags`. This will be sorted by the `name` property. + +**Recommended:** No + +**Bad Example** + +```yaml +info: + tags: + - name: "Badger" + - name: "Aardvark" +``` + +**Good Example** + +```yaml +info: + tags: + - name: "Aardvark" + - name: "Badger" +``` + +**Recommended:** No + +### asyncapi-3-tags-uniqueness + +Tags must not have duplicate names (identifiers). + +**Recommended:** Yes + +**Bad Example** + +```yaml +info: + tags: + - name: "Badger" + - name: "Badger" +``` + +**Good Example** + +```yaml +info: + tags: + - name: "Aardvark" + - name: "Badger" +``` + +### asyncapi-3-tags + +AsyncAPI object should have non-empty `tags` array. + +Why? Well, you _can_ reference tags arbitrarily in operations, and definition is optional... + +```yaml +invoicedItems: + address: /invoices/{id}/items + tags: + - Invoice Items +``` + +Defining tags allows you to add more information like a `description`. For more information see [asyncapi-3-tag-description](#asyncapi-3-tag-description). + +**Recommended:** Yes + +### asyncapi-3-document-resolved + +Validate structure of AsyncAPI specification when references have been resolved. + +**Recommended:** Yes + +### asyncapi-3-document-unresolved + +Validate structure of AsyncAPI specification before references have been resolved. **Recommended:** Yes diff --git a/packages/formats/src/__tests__/asyncapi.test.ts b/packages/formats/src/__tests__/asyncapi.test.ts index 1b350cf9b..d75546a6d 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 } from '../asyncapi'; +import { aas2, aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5, aas2_6, aas3, aas3_0 } from '../asyncapi'; describe('AsyncAPI format', () => { describe('AsyncAPI 2.x', () => { @@ -125,4 +125,30 @@ describe('AsyncAPI format', () => { expect(aas2_6({ asyncapi: version }, null)).toBe(false); }); }); + describe('AsyncAPI 3.0', () => { + it.each(['3.0.0'])('recognizes %s version correctly', version => { + expect(aas3({ asyncapi: version }, null)).toBe(true); + }); + it.each(['3.0.0'])('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); + }); + }); }); diff --git a/packages/formats/src/asyncapi.ts b/packages/formats/src/asyncapi.ts index a47b629a4..a04826eb0 100644 --- a/packages/formats/src/asyncapi.ts +++ b/packages/formats/src/asyncapi.ts @@ -2,8 +2,10 @@ import type { Format } from '@stoplight/spectral-core'; import { isPlainObject } from '@stoplight/json'; type MaybeAAS2 = { asyncapi: unknown } & Record; +type MaybeAAS3 = { asyncapi: unknown } & Record; const aas2Regex = /^2\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/; +const aas3Regex = /^3\.(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]*)?$/; @@ -11,12 +13,18 @@ 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 aas3_0Regex = /^3\.0(?:\.[0-9]*)?$/; const isAas2 = (document: unknown): document is { asyncapi: string } & Record => isPlainObject(document) && 'asyncapi' in document && aas2Regex.test(String((document as MaybeAAS2).asyncapi)); +const isAas3 = (document: unknown): document is { asyncapi: string } & Record => + isPlainObject(document) && 'asyncapi' in document && aas3Regex.test(String((document as MaybeAAS3).asyncapi)); + export const aas2: Format = isAas2; aas2.displayName = 'AsyncAPI 2.x'; +export const aas3: Format = isAas3; +aas3.displayName = 'AsyncAPI 3.x'; // for backward compatibility export const asyncApi2 = aas2; @@ -49,3 +57,7 @@ aas2_5.displayName = 'AsyncAPI 2.5.x'; export const aas2_6: Format = (document: unknown): boolean => isAas2(document) && aas2_6Regex.test(String((document as MaybeAAS2).asyncapi)); 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'; diff --git a/packages/ruleset-migrator/src/__tests__/__fixtures__/formats-variant-2/output.cjs b/packages/ruleset-migrator/src/__tests__/__fixtures__/formats-variant-2/output.cjs index d673840d8..209c5501e 100644 --- a/packages/ruleset-migrator/src/__tests__/__fixtures__/formats-variant-2/output.cjs +++ b/packages/ruleset-migrator/src/__tests__/__fixtures__/formats-variant-2/output.cjs @@ -1,11 +1,11 @@ -const { asyncapi2, jsonSchemaLoose, oas2, oas3, oas3_0, oas3_1 } = require('@stoplight/spectral-formats'); +const { asyncapi2, asyncapi3, jsonSchemaLoose, oas2, oas3, oas3_0, oas3_1 } = require('@stoplight/spectral-formats'); const { truthy } = require('@stoplight/spectral-functions'); module.exports = { formats: [oas2, oas3_1, oas3_0, jsonSchemaLoose], rules: { test: { given: '$', - formats: [asyncapi2, oas3, oas3_0, oas3_1], + formats: [asyncapi2, asyncapi3, oas3, oas3_0, oas3_1], then: { function: truthy, }, diff --git a/packages/ruleset-migrator/src/__tests__/__fixtures__/formats-variant-2/output.mjs b/packages/ruleset-migrator/src/__tests__/__fixtures__/formats-variant-2/output.mjs index d593171a2..e357e7dc6 100644 --- a/packages/ruleset-migrator/src/__tests__/__fixtures__/formats-variant-2/output.mjs +++ b/packages/ruleset-migrator/src/__tests__/__fixtures__/formats-variant-2/output.mjs @@ -1,11 +1,11 @@ -import { asyncapi2, jsonSchemaLoose, oas2, oas3, oas3_0, oas3_1 } from '@stoplight/spectral-formats'; +import { asyncapi2, asyncapi3, jsonSchemaLoose, oas2, oas3, oas3_0, oas3_1 } from '@stoplight/spectral-formats'; import { truthy } from '@stoplight/spectral-functions'; export default { formats: [oas2, oas3_1, oas3_0, jsonSchemaLoose], rules: { test: { given: '$', - formats: [asyncapi2, oas3, oas3_0, oas3_1], + formats: [asyncapi2, asyncapi3, oas3, oas3_0, oas3_1], then: { function: truthy, }, diff --git a/packages/ruleset-migrator/src/__tests__/__fixtures__/formats-variant-2/ruleset.yaml b/packages/ruleset-migrator/src/__tests__/__fixtures__/formats-variant-2/ruleset.yaml index 71b13f5ef..7a537082a 100644 --- a/packages/ruleset-migrator/src/__tests__/__fixtures__/formats-variant-2/ruleset.yaml +++ b/packages/ruleset-migrator/src/__tests__/__fixtures__/formats-variant-2/ruleset.yaml @@ -9,6 +9,7 @@ rules: given: $ formats: - asyncapi2 + - asyncapi3 - oas3 - oas3.0 - oas3.1 diff --git a/packages/ruleset-migrator/src/transformers/formats.ts b/packages/ruleset-migrator/src/transformers/formats.ts index 96fc7e3bc..ff6191411 100644 --- a/packages/ruleset-migrator/src/transformers/formats.ts +++ b/packages/ruleset-migrator/src/transformers/formats.ts @@ -15,6 +15,7 @@ const FORMATS = [ 'arazzo1', 'arazzo1.0', 'asyncapi2', + 'asyncapi3', 'json-schema', 'json-schema-loose', 'json-schema-draft4', diff --git a/packages/rulesets/package.json b/packages/rulesets/package.json index 0fc2aa927..adf25b1a6 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": "^4.1.0", + "@asyncapi/specs": "^6.8.0", "@stoplight/better-ajv-errors": "1.0.3", "@stoplight/json": "^3.17.0", "@stoplight/spectral-core": "^1.8.1", diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-channel-no-empty-parameter.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-channel-no-empty-parameter.test.ts new file mode 100644 index 000000000..46ed1f5d1 --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-channel-no-empty-parameter.test.ts @@ -0,0 +1,32 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-3-channel-no-empty-parameter', [ + { + name: 'valid case', + document: { + asyncapi: '3.0.0', + channels: { + SomeChannel: { address: 'users/{userId}/signedUp' }, + }, + }, + errors: [], + }, + { + name: 'channels.{channel} contains empty parameter substitution pattern', + document: { + asyncapi: '3.0.0', + channels: { + SomeChannel: { address: 'users/{userId}/signedUp' }, + SomeChannel1: { address: 'users/{}/signedOut' }, + }, + }, + errors: [ + { + message: 'Channel address must not have empty parameter substitution pattern.', + path: ['channels', 'SomeChannel1', 'address'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, +]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-channel-no-query-nor-fragment.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-channel-no-query-nor-fragment.test.ts new file mode 100644 index 000000000..3b50c6655 --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-channel-no-query-nor-fragment.test.ts @@ -0,0 +1,50 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-3-channel-no-query-nor-fragment', [ + { + name: 'valid case', + document: { + asyncapi: '3.0.0', + channels: { + SomeChannel: { address: 'users/{userId}/signedUp' }, + }, + }, + errors: [], + }, + + { + name: 'channels.{channel} contains a query delimiter', + document: { + asyncapi: '3.0.0', + channels: { + SomeChannel: { address: 'users/{userId}/signedUp' }, + SomeChannel1: { address: 'users/{userId}/signedOut?byMistake={didFatFingerTheSignOutButton}' }, + }, + }, + errors: [ + { + message: 'Channel address must not include query ("?") or fragment ("#") delimiter.', + path: ['channels', 'SomeChannel1', 'address'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'channels.{channel} contains a fragment delimiter', + document: { + asyncapi: '3.0.0', + channels: { + SomeChannel: { address: 'users/{userId}/signedUp' }, + SomeChannel1: { address: 'users/{userId}/signedOut#onPurpose' }, + }, + }, + errors: [ + { + message: 'Channel address must not include query ("?") or fragment ("#") delimiter.', + path: ['channels', 'SomeChannel1', 'address'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, +]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-channel-servers.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-channel-servers.test.ts new file mode 100644 index 000000000..f300b0049 --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-channel-servers.test.ts @@ -0,0 +1,117 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-3-channel-servers', [ + { + name: 'valid case', + document: { + asyncapi: '3.0.0', + servers: { + development: {}, + production: {}, + }, + channels: { + channel: { + servers: [{ $ref: '#/servers/development' }], + }, + }, + }, + errors: [], + }, + + { + name: 'valid case - without defined servers', + document: { + asyncapi: '3.0.0', + servers: { + development: {}, + production: {}, + }, + channels: { + channel: {}, + }, + }, + errors: [], + }, + + { + name: 'valid case - without defined servers in the root', + document: { + asyncapi: '3.0.0', + channels: { + channel: {}, + }, + }, + errors: [], + }, + + { + name: 'valid case - without defined channels in the root', + document: { + asyncapi: '3.0.0', + servers: { + development: {}, + production: {}, + }, + }, + errors: [], + }, + + { + name: 'valid case - with empty array', + document: { + asyncapi: '3.0.0', + servers: { + development: {}, + production: {}, + }, + channels: { + channel: { + servers: [], + }, + }, + }, + errors: [], + }, + + { + name: 'invalid case - without defined servers', + document: { + asyncapi: '3.0.0', + channels: { + channel: { + servers: [{ $ref: '#/another-server' }], + }, + }, + }, + errors: [ + { + message: 'Channel servers must be defined in the "servers" object.', + path: ['channels', 'channel', 'servers', '0', '$ref'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid case - with defined servers but incorrect reference', + document: { + asyncapi: '3.0.0', + servers: { + development: {}, + production: {}, + }, + channels: { + channel: { + servers: [{ $ref: '#/development' }], + }, + }, + }, + errors: [ + { + message: 'Channel servers must be defined in the "servers" object.', + path: ['channels', 'channel', 'servers', '0', '$ref'], + severity: DiagnosticSeverity.Error, + }, + ], + }, +]); 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 new file mode 100644 index 000000000..5d5a73734 --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-document-resolved.test.ts @@ -0,0 +1,157 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-3-document-resolved', [ + { + name: 'valid case AsyncAPI 3', + document: { + asyncapi: '3.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + }, + errors: [], + }, + { + name: 'valid case resolved case message', + document: { + asyncapi: '3.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: { + SomeChannel: { + address: 'users/{userId}/signedUp', + messages: { + SomeMessage: { $ref: '#/components/messages/SomeMessage' }, + }, + }, + }, + components: { + messages: { + SomeMessage: { payload: { type: 'string' } }, + }, + }, + }, + errors: [], + }, + { + name: 'invalid AsyncAPI 3 info property is missing', + document: { + asyncapi: '3.0.0', + }, + errors: [{ message: 'Object must have required property "info"', severity: DiagnosticSeverity.Error }], + }, + { + name: 'invalid AsyncAPI 3 resolved case message', + document: { + asyncapi: '3.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: { + SomeChannel: { + address: 'users/{userId}/signedUp', + messages: { + SomeMessage: { $ref: '#/x-SomeMessage' }, + }, + }, + }, + 'x-SomeMessage': { test: 'test' }, + }, + errors: [ + { + message: 'Property "test" is not expected to be here', + severity: DiagnosticSeverity.Error, + path: ['channels', 'SomeChannel', 'messages', 'SomeMessage', 'test'], + }, + ], + }, + { + 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.0.0 (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: [], + }, +]); 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 new file mode 100644 index 000000000..293f282d6 --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-document-unresolved.test.ts @@ -0,0 +1,89 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-3-document-unresolved', [ + { + name: 'valid case AsyncAPI 3', + document: { + asyncapi: '3.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + }, + errors: [], + }, + { + name: 'valid case unresolved case message', + document: { + asyncapi: '3.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: { + SomeChannel: { + address: 'users/{userId}/signedUp', + messages: { + SomeMessage: { $ref: '#/components/messages/SomeMessage' }, + }, + }, + }, + components: { + messages: { + SomeMessage: { payload: { type: 'string' } }, + }, + }, + }, + errors: [], + }, + { + name: 'valid AsyncAPI 3 unresolved case operations', + document: { + asyncapi: '3.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: { + SomeChannel: { + address: 'users/{userId}/signedUp', + messages: { + SomeMessage: { $ref: '#/x-SomeMessage' }, + }, + }, + }, + operations: { + SomeOperation: { + action: 'send', + channel: { + $ref: '#/channels/SomeChannel', + }, + messages: [{ $ref: '#/channels/SomeChannel' }], + }, + }, + }, + errors: [], + }, + { + name: 'invalid case for 3.0.0 (reference for info object is not allowed)', + document: { + asyncapi: '3.0.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, + }, + ], + }, +]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-headers-schema-type-object.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-headers-schema-type-object.test.ts new file mode 100644 index 000000000..89e8d9462 --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-headers-schema-type-object.test.ts @@ -0,0 +1,168 @@ +import { cloneDeep } from 'lodash'; +import produce from 'immer'; +import { DiagnosticSeverity } from '@stoplight/types'; + +import testRule from './__helpers__/tester'; + +const headersBearer = { + headers: { + type: 'object', + properties: { + 'some-header': { + type: 'string', + }, + }, + }, +}; + +const document = { + asyncapi: '3.0.0', + channels: { + SomeChannel: { + address: 'users/{userId}/signedUp', + messages: { + SomeMessage: cloneDeep(headersBearer), + }, + }, + SomeChannel2: { + address: 'users/{userId}/loggedIn', + messages: { + SomeMessage: { + traits: [cloneDeep(headersBearer)], + }, + }, + }, + }, + components: { + messageTraits: { + aTrait: cloneDeep(headersBearer), + }, + messages: { + aMessage: cloneDeep(headersBearer), + }, + }, +}; + +testRule('asyncapi-3-headers-schema-type-object', [ + { + name: 'valid case', + document, + errors: [], + }, + + { + name: 'components.messages.{message}.headers is not of type "object"', + document: produce(document, draft => { + draft.components.messages.aMessage.headers.type = 'integer'; + }), + errors: [ + { + message: + 'Headers schema type must be "object" ("type" property must be equal to one of the allowed values: "object". Did you mean "object"?).', + path: ['components', 'messages', 'aMessage', 'headers', 'type'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'components.messages.{message}.headers lacks "type" property', + document: produce(document, (draft: any) => { + draft.components.messages.aMessage.headers = { const: 'Hello World!' }; + }), + errors: [ + { + message: 'Headers schema type must be "object" ("headers" property must have required property "type").', + path: ['components', 'messages', 'aMessage', 'headers'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'components.messageTraits.{trait}.headers is not of type "object"', + document: produce(document, (draft: any) => { + draft.components.messageTraits.aTrait.headers = { type: 'integer' }; + }), + errors: [ + { + message: + 'Headers schema type must be "object" ("type" property must be equal to one of the allowed values: "object". Did you mean "object"?).', + path: ['components', 'messageTraits', 'aTrait', 'headers', 'type'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'components.messageTraits.{trait}.headers lacks "type" property', + document: produce(document, (draft: any) => { + draft.components.messageTraits.aTrait.headers = { const: 'Hello World!' }; + }), + errors: [ + { + message: 'Headers schema type must be "object" ("headers" property must have required property "type").', + path: ['components', 'messageTraits', 'aTrait', 'headers'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: `channels.SomeChannel.messages.SomeMessage.headers lacks "type" property`, + document: produce(document, (draft: any) => { + draft.channels.SomeChannel.messages.SomeMessage.headers = { const: 'Hello World!' }; + }), + errors: [ + { + message: 'Headers schema type must be "object" ("headers" property must have required property "type").', + path: ['channels', 'SomeChannel', 'messages', 'SomeMessage', 'headers'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: `channels.SomeChannel.messages.SomeMessage.headers is not of type "object"`, + document: produce(document, (draft: any) => { + draft.channels.SomeChannel.messages.SomeMessage.headers = { type: 'integer' }; + }), + errors: [ + { + message: + 'Headers schema type must be "object" ("type" property must be equal to one of the allowed values: "object". Did you mean "object"?).', + path: ['channels', 'SomeChannel', 'messages', 'SomeMessage', 'headers', 'type'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: `channels.SomeChannel2.messages.SomeMessage.traits.[*].headers lacks "type" property`, + document: produce(document, (draft: any) => { + draft.channels.SomeChannel2.messages.SomeMessage.traits[0].headers = { const: 'Hello World!' }; + }), + errors: [ + { + message: 'Headers schema type must be "object" ("headers" property must have required property "type").', + path: ['channels', 'SomeChannel2', 'messages', 'SomeMessage', 'traits', '0', 'headers'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: `channels.SomeChannel2.messages.SomeMessage.traits.[*].headers is not of type "object"`, + document: produce(document, (draft: any) => { + draft.channels.SomeChannel2.messages.SomeMessage.traits[0].headers = { type: 'integer' }; + }), + errors: [ + { + message: + 'Headers schema type must be "object" ("type" property must be equal to one of the allowed values: "object". Did you mean "object"?).', + path: ['channels', 'SomeChannel2', 'messages', 'SomeMessage', 'traits', '0', 'headers', 'type'], + severity: DiagnosticSeverity.Error, + }, + ], + }, +]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-operation-description.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-operation-description.test.ts new file mode 100644 index 000000000..e23c04d01 --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-operation-description.test.ts @@ -0,0 +1,33 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-3-operation-description', [ + { + name: 'valid case', + document: { + asyncapi: '3.0.0', + operations: { + SomeOperation: { + description: 'I do this.', + }, + }, + }, + errors: [], + }, + { + name: `operations.SomeOperation.description property is missing`, + document: { + asyncapi: '3.0.0', + operations: { + SomeOperation: {}, + }, + }, + errors: [ + { + message: 'Operation "description" must be present and non-empty string.', + path: ['operations', 'SomeOperation'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, +]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-payload-unsupported-schemaFormat.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-payload-unsupported-schemaFormat.test.ts new file mode 100644 index 000000000..4bac528fa --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-payload-unsupported-schemaFormat.test.ts @@ -0,0 +1,54 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import produce from 'immer'; +import testRule from './__helpers__/tester'; + +const document = { + asyncapi: '3.0.0', + channels: { + SomeChannel: { + address: 'users/{userId}/signedUp', + messages: { + SomeMessage: { payload: {} }, + }, + }, + }, + components: { + messages: { + aMessage: { payload: {} }, + }, + }, +}; + +testRule('asyncapi-3-payload-unsupported-schemaFormat', [ + { + name: 'valid case', + document, + errors: [], + }, + { + name: 'components.messages.{message}.schemaFormat is set to a non supported value', + document: produce(document, (draft: any) => { + draft.components.messages.aMessage.payload.schemaFormat = 'application/nope'; + }), + errors: [ + { + message: 'Message schema validation is only supported with default unspecified "schemaFormat".', + path: ['components', 'messages', 'aMessage', 'payload', 'schemaFormat'], + severity: DiagnosticSeverity.Information, + }, + ], + }, + { + name: `channels.SomeChannel.messages.SomeMessage.schemaFormat is set to a non supported value`, + document: produce(document, (draft: any) => { + draft.channels.SomeChannel.messages.SomeMessage.payload.schemaFormat = 'application/nope'; + }), + errors: [ + { + message: 'Message schema validation is only supported with default unspecified "schemaFormat".', + path: ['channels', 'SomeChannel', 'messages', 'SomeMessage', 'payload', 'schemaFormat'], + severity: DiagnosticSeverity.Information, + }, + ], + }, +]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-server-no-empty-variable.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-server-no-empty-variable.test.ts new file mode 100644 index 000000000..90950c891 --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-server-no-empty-variable.test.ts @@ -0,0 +1,37 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-3-server-no-empty-variable', [ + { + name: 'valid case', + document: { + asyncapi: '3.0.0', + servers: { + production: { + host: '{sub}.stoplight.io', + protocol: 'https', + }, + }, + }, + errors: [], + }, + { + name: '{server}.url property contains empty variable substitution pattern', + document: { + asyncapi: '3.0.0', + servers: { + production: { + host: '{}.stoplight.io', + protocol: 'https', + }, + }, + }, + errors: [ + { + message: 'Server host and pathname must not have empty variable substitution pattern.', + path: ['servers', 'production', 'host'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, +]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-server-no-trailing-slash.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-server-no-trailing-slash.test.ts new file mode 100644 index 000000000..b3b4779cb --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-server-no-trailing-slash.test.ts @@ -0,0 +1,38 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-3-server-no-trailing-slash', [ + { + name: 'valid', + document: { + asyncapi: '3.0.0', + servers: { + production: { + host: 'stoplight.io', + protocol: 'https', + }, + }, + }, + errors: [], + }, + + { + name: '{server}.host property ends with a trailing slash', + document: { + asyncapi: '3.0.0', + servers: { + production: { + host: 'stoplight.io/', + protocol: 'https', + }, + }, + }, + errors: [ + { + message: 'Server host must not end with slash.', + path: ['servers', 'production', 'host'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, +]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-server-not-example-com.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-server-not-example-com.test.ts new file mode 100644 index 000000000..b2244e622 --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-server-not-example-com.test.ts @@ -0,0 +1,38 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-3-server-not-example-com', [ + { + name: 'valid case', + document: { + asyncapi: '3.0.0', + servers: { + production: { + host: 'stoplight.io', + protocol: 'https', + }, + }, + }, + errors: [], + }, + + { + name: '{server}.host property is set to `example.com`', + document: { + asyncapi: '3.0.0', + servers: { + production: { + host: 'example.com', + protocol: 'https', + }, + }, + }, + errors: [ + { + message: 'Server host must not point at example.com.', + path: ['servers', 'production', 'host'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, +]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-tag-description.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-tag-description.test.ts new file mode 100644 index 000000000..50d25771e --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-tag-description.test.ts @@ -0,0 +1,41 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-3-tag-description', [ + { + name: 'valid case', + document: { + asyncapi: '3.0.0', + info: { + tags: [ + { + name: 'a tag', + description: "I'm a tag.", + }, + ], + }, + }, + errors: [], + }, + + { + name: 'description property is missing', + document: { + asyncapi: '3.0.0', + info: { + tags: [ + { + name: 'a tag', + }, + ], + }, + }, + errors: [ + { + message: 'Tag object must have "description".', + path: ['info', 'tags', '0'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, +]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-tags-alphabetical.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-tags-alphabetical.test.ts new file mode 100644 index 000000000..5a4568335 --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-tags-alphabetical.test.ts @@ -0,0 +1,32 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-3-tags-alphabetical', [ + { + name: 'valid case', + document: { + asyncapi: '3.0.0', + info: { + tags: [{ name: 'a tag' }, { name: 'another tag' }], + }, + }, + errors: [], + }, + + { + name: 'tags are not sorted', + document: { + asyncapi: '3.0.0', + info: { + tags: [{ name: 'wrongly ordered' }, { name: 'a tag' }, { name: 'another tag' }], + }, + }, + errors: [ + { + message: 'AsyncAPI object must have alphabetical "tags".', + path: ['info', 'tags'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, +]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-tags-uniqueness.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-tags-uniqueness.test.ts new file mode 100644 index 000000000..6ac8b8c87 --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-tags-uniqueness.test.ts @@ -0,0 +1,164 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-3-tags-uniqueness', [ + { + name: 'valid case', + document: { + asyncapi: '3.0.0', + info: { + tags: [{ name: 'one' }, { name: 'two' }], + }, + }, + errors: [], + }, + + { + name: 'tags has duplicated names (root)', + document: { + asyncapi: '3.0.0', + info: { + tags: [{ name: 'one' }, { name: 'one' }], + }, + }, + errors: [ + { + message: '"tags" object contains duplicate tag name "one".', + path: ['info', 'tags', '1', 'name'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'tags has duplicated names (server)', + document: { + asyncapi: '3.0.0', + servers: { + someServer: { + tags: [{ name: 'one' }, { name: 'one' }], + }, + anotherServer: { + tags: [{ name: 'one' }, { name: 'two' }], + }, + }, + }, + errors: [ + { + message: '"tags" object contains duplicate tag name "one".', + path: ['servers', 'someServer', 'tags', '1', 'name'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'tags has duplicated names (operation)', + document: { + asyncapi: '3.0.0', + operations: { + SomeOperation: { + tags: [{ name: 'one' }, { name: 'one' }], + }, + }, + }, + errors: [ + { + message: '"tags" object contains duplicate tag name "one".', + path: ['operations', 'SomeOperation', 'tags', '1', 'name'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'tags has duplicated names (operation trait)', + document: { + asyncapi: '3.0.0', + operations: { + SomeOperation: { + traits: [ + { + tags: [{ name: 'one' }, { name: 'one' }], + }, + ], + }, + }, + }, + errors: [ + { + message: '"tags" object contains duplicate tag name "one".', + path: ['operations', 'SomeOperation', 'traits', '0', 'tags', '1', 'name'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'tags has duplicated names (message)', + document: { + asyncapi: '3.0.0', + components: { + messages: { + someMessage: { + tags: [{ name: 'one' }, { name: 'one' }], + }, + }, + }, + }, + errors: [ + { + message: '"tags" object contains duplicate tag name "one".', + path: ['components', 'messages', 'someMessage', 'tags', '1', 'name'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'tags has duplicated names (message trait)', + document: { + asyncapi: '3.0.0', + components: { + messages: { + someMessage: { + traits: [ + { + tags: [{ name: 'one' }, { name: 'one' }], + }, + ], + }, + }, + }, + }, + errors: [ + { + message: '"tags" object contains duplicate tag name "one".', + path: ['components', 'messages', 'someMessage', 'traits', '0', 'tags', '1', 'name'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'tags has duplicated more that two times this same name', + document: { + asyncapi: '3.0.0', + info: { + tags: [{ name: 'one' }, { name: 'one' }, { name: 'two' }, { name: 'one' }], + }, + }, + errors: [ + { + message: '"tags" object contains duplicate tag name "one".', + path: ['info', 'tags', '1', 'name'], + severity: DiagnosticSeverity.Error, + }, + { + message: '"tags" object contains duplicate tag name "one".', + path: ['info', 'tags', '3', 'name'], + severity: DiagnosticSeverity.Error, + }, + ], + }, +]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-tags.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-tags.test.ts new file mode 100644 index 000000000..ebab1e153 --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-3-tags.test.ts @@ -0,0 +1,29 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-3-tags', [ + { + name: 'valid case', + document: { + asyncapi: '3.0.0', + info: { + tags: [{ name: 'one' }, { name: 'another' }], + }, + }, + errors: [], + }, + { + name: 'info tags property is missing', + document: { + asyncapi: '3.0.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-channel-parameters.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-channel-parameters.test.ts index e4715798a..6eb57e9e4 100644 --- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-channel-parameters.test.ts +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-channel-parameters.test.ts @@ -16,7 +16,42 @@ testRule('asyncapi-channel-parameters', [ }, errors: [], }, - + { + name: 'valid v3 case', + document: { + asyncapi: '3.0.0', + channels: { + SomeChannel: { + address: 'users/{userId}/signedUp', + parameters: { + userId: {}, + }, + }, + }, + }, + errors: [], + }, + { + name: 'channel for v3 has not defined definition for one of the parameters', + document: { + asyncapi: '2.0.0', + channels: { + SomeChannel: { + address: 'users/{userId}/{anotherParam}/signedUp', + parameters: { + userId: {}, + }, + }, + }, + }, + errors: [ + { + message: 'Not all channel\'s parameters are described with "parameters" object. Missed: anotherParam.', + path: ['channels', 'SomeChannel', 'parameters'], + severity: DiagnosticSeverity.Error, + }, + ], + }, { name: 'channel has not defined definition for one of the parameters', document: { @@ -84,7 +119,7 @@ testRule('asyncapi-channel-parameters', [ }, { - name: 'channel has redundant paramaters', + name: 'channel has redundant parameters', document: { asyncapi: '2.0.0', channels: { @@ -112,7 +147,7 @@ testRule('asyncapi-channel-parameters', [ }, { - name: 'channel has redundant paramaters (in the components.channels)', + name: 'channel has redundant parameters (in the components.channels)', document: { asyncapi: '2.3.0', components: { diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-info-contact-properties.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-info-contact-properties.test.ts index 1f6192e72..806b439af 100644 --- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-info-contact-properties.test.ts +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-info-contact-properties.test.ts @@ -12,6 +12,16 @@ const document = { }, }, }; +const document_v3 = { + asyncapi: '3.0.0', + info: { + contact: { + name: 'stoplight', + url: 'stoplight.io', + email: 'support@stoplight.io', + }, + }, +}; testRule('asyncapi-info-contact-properties', [ { @@ -19,7 +29,24 @@ testRule('asyncapi-info-contact-properties', [ document, errors: [], }, - + { + name: 'valid v3 case', + document: document_v3, + errors: [], + }, + ...['name', 'url', 'email'].map(property => ({ + name: `for v3 contact.${property} property is missing`, + document: produce(document_v3, draft => { + delete draft.info.contact[property]; + }), + errors: [ + { + message: 'Contact object must have "name", "url" and "email".', + path: ['info', 'contact'], + severity: DiagnosticSeverity.Warning, + }, + ], + })), ...['name', 'url', 'email'].map(property => ({ name: `contact.${property} property is missing`, document: produce(document, draft => { diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-info-contact.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-info-contact.test.ts index edd04fb25..70214b7f5 100644 --- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-info-contact.test.ts +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-info-contact.test.ts @@ -16,7 +16,20 @@ testRule('asyncapi-info-contact', [ }, errors: [], }, - + { + name: 'valid v3 case', + document: { + asyncapi: '3.0.0', + info: { + contact: { + name: 'stoplight', + url: 'stoplight.io', + email: 'support@stoplight.io', + }, + }, + }, + errors: [], + }, { name: 'contact property is missing', document: { @@ -31,4 +44,18 @@ testRule('asyncapi-info-contact', [ }, ], }, + { + name: 'v3 contact property is missing', + document: { + asyncapi: '3.0.0', + info: {}, + }, + errors: [ + { + message: 'Info object must have "contact" object.', + path: ['info'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, ]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-info-description.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-info-description.test.ts index 437c8cac5..627af6a51 100644 --- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-info-description.test.ts +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-info-description.test.ts @@ -12,6 +12,16 @@ testRule('asyncapi-info-description', [ }, errors: [], }, + { + name: 'valid v3 case', + document: { + asyncapi: '3.0.0', + info: { + description: 'Very descriptive list of self explaining consecutive characters.', + }, + }, + errors: [], + }, { name: 'description property is missing', document: { @@ -26,4 +36,18 @@ testRule('asyncapi-info-description', [ }, ], }, + { + name: 'v3 description property is missing', + document: { + asyncapi: '3.0.0', + info: {}, + }, + errors: [ + { + message: 'Info "description" must be present and non-empty string.', + path: ['info'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, ]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-info-license-url.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-info-license-url.test.ts index bbef37d38..2be8f2c87 100644 --- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-info-license-url.test.ts +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-info-license-url.test.ts @@ -14,7 +14,18 @@ testRule('asyncapi-info-license-url', [ }, errors: [], }, - + { + name: 'valid v3 case', + document: { + asyncapi: '3.0.0', + info: { + license: { + url: 'https://github.com/stoplightio/spectral/blob/develop/LICENSE', + }, + }, + }, + errors: [], + }, { name: 'url property is missing', document: { @@ -31,4 +42,20 @@ testRule('asyncapi-info-license-url', [ }, ], }, + { + name: 'v3 url property is missing', + document: { + asyncapi: '3.0.0', + info: { + license: {}, + }, + }, + errors: [ + { + message: 'License object must include "url".', + path: ['info', 'license'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, ]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-info-license.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-info-license.test.ts index c0ad7802f..9a7219e68 100644 --- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-info-license.test.ts +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-info-license.test.ts @@ -14,7 +14,18 @@ testRule('asyncapi-info-license', [ }, errors: [], }, - + { + name: 'valid v3 case', + document: { + asyncapi: '3.0.0', + info: { + license: { + name: 'MIT', + }, + }, + }, + errors: [], + }, { name: 'license property is missing', document: { @@ -29,4 +40,18 @@ testRule('asyncapi-info-license', [ }, ], }, + { + name: 'v3 license property is missing', + document: { + asyncapi: '3.0.0', + info: {}, + }, + errors: [ + { + message: 'Info object must have "license" object.', + path: ['info'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, ]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-parameter-description.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-parameter-description.test.ts index a338f960f..106305295 100644 --- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-parameter-description.test.ts +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-parameter-description.test.ts @@ -22,13 +22,37 @@ const document = { }, }; +const document_v3 = { + asyncapi: '3.0.0', + channels: { + SomeChannel: { + address: 'users/{userId}/signedUp', + parameters: { + userId: { + description: 'The identifier of the user being tracked.', + }, + }, + }, + }, + components: { + parameters: { + orphanParameter: { + description: 'A defined, but orphaned, parameter.', + }, + }, + }, +}; testRule('asyncapi-parameter-description', [ { name: 'valid case', document, errors: [], }, - + { + name: 'valid v3 case', + document: document_v3, + errors: [], + }, { name: 'channels.{channel}.parameters.{parameter} lack a description', document: produce(document, (draft: any) => { @@ -42,6 +66,19 @@ testRule('asyncapi-parameter-description', [ }, ], }, + { + name: 'v3 channels.{channel}.parameters.{parameter} lack a description', + document: produce(document_v3, (draft: any) => { + delete draft.channels.SomeChannel.parameters.userId.description; + }), + errors: [ + { + message: 'Parameter objects must have "description".', + path: ['channels', 'SomeChannel', 'parameters', 'userId'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, { name: 'components.parameters.{parameter} lack a description', @@ -56,4 +93,18 @@ testRule('asyncapi-parameter-description', [ }, ], }, + + { + name: 'v3 components.parameters.{parameter} lack a description', + document: produce(document_v3, (draft: any) => { + delete draft.components.parameters.orphanParameter.description; + }), + errors: [ + { + message: 'Parameter objects must have "description".', + path: ['components', 'parameters', 'orphanParameter'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, ]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-schema.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-schema.test.ts index 0385bfb49..35f04b96a 100644 --- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-schema.test.ts +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-schema.test.ts @@ -15,7 +15,7 @@ testRule('asyncapi-schema', [ errors: [], }, { - name: 'channels property is missing', + name: 'invalid AsyncAPI 2 channels property is missing', document: { asyncapi: '2.0.0', info: { diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-servers.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-servers.test.ts index 6f4e9110d..e86f4f5e8 100644 --- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-servers.test.ts +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-servers.test.ts @@ -15,6 +15,32 @@ testRule('asyncapi-servers', [ }, errors: [], }, + { + name: 'valid v3 case', + document: { + asyncapi: '3.0.0', + servers: { + production: { + host: 'stoplight.io', + protocol: 'https', + }, + }, + }, + errors: [], + }, + { + name: 'v3 servers property is missing', + document: { + asyncapi: '3.0.0', + }, + errors: [ + { + message: 'AsyncAPI object must have non-empty "servers" object.', + path: [], + severity: DiagnosticSeverity.Warning, + }, + ], + }, { name: 'servers property is missing', @@ -30,6 +56,20 @@ testRule('asyncapi-servers', [ ], }, + { + name: 'v3 servers property is empty', + document: { + asyncapi: '3.0.0', + servers: {}, + }, + errors: [ + { + message: 'AsyncAPI object must have non-empty "servers" object.', + path: ['servers'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, { name: 'servers property is empty', document: { diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-unused-components-schema.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-unused-components-schema.test.ts index f133e119b..342f1f849 100644 --- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-unused-components-schema.test.ts +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-unused-components-schema.test.ts @@ -24,12 +24,63 @@ const document = { }, }; +const document_v3 = { + asyncapi: '3.0.0', + channels: { + SomeChannel: { + address: 'users/signedUp', + messages: [ + { + payload: { + $ref: '#/components/schemas/externallyDefinedUser', + }, + }, + ], + }, + }, + components: { + schemas: { + externallyDefinedUser: { + type: 'string', + }, + }, + }, +}; + testRule('asyncapi-unused-components-schema', [ { name: 'valid case', document, errors: [], }, + { + name: 'valid v3 case', + document: document_v3, + errors: [], + }, + { + name: 'v3 components.schemas contains unreferenced objects', + document: produce(document_v3, (draft: any) => { + delete draft.channels['SomeChannel']; + + draft.channels['SomeChannel'] = { + messages: [ + { + payload: { + type: 'string', + }, + }, + ], + }; + }), + errors: [ + { + message: 'Potentially unused components schema has been detected.', + path: ['components', 'schemas', 'externallyDefinedUser'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, { name: 'components.schemas contains unreferenced objects', diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-unused-components-server.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-unused-components-server.test.ts index aec6edc07..404c7606b 100644 --- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-unused-components-server.test.ts +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-unused-components-server.test.ts @@ -17,13 +17,46 @@ const document = { }, }; +const document_v3 = { + asyncapi: '3.0.0', + servers: { + test: { + $ref: '#/components/servers/externallyDefinedServer', + }, + }, + channels: {}, + components: { + servers: { + externallyDefinedServer: {}, + }, + }, +}; + testRule('asyncapi-unused-components-server', [ { name: 'valid case', document, errors: [], }, + { + name: 'valid v3 case', + document: document_v3, + errors: [], + }, + { + name: 'v3 components.servers contains unreferenced objects', + document: produce(document_v3, (draft: any) => { + delete draft.servers['test']; + }), + errors: [ + { + message: 'Potentially unused components server has been detected.', + path: ['components', 'servers', 'externallyDefinedServer'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, { name: 'components.servers contains unreferenced objects', document: produce(document, (draft: any) => { diff --git a/packages/rulesets/src/asyncapi/functions/__tests__/asyncApi2DocumentSchema.test.ts b/packages/rulesets/src/asyncapi/functions/__tests__/asyncApiDocumentSchema.test.ts similarity index 91% rename from packages/rulesets/src/asyncapi/functions/__tests__/asyncApi2DocumentSchema.test.ts rename to packages/rulesets/src/asyncapi/functions/__tests__/asyncApiDocumentSchema.test.ts index d530b9930..a6b6aa554 100644 --- a/packages/rulesets/src/asyncapi/functions/__tests__/asyncApi2DocumentSchema.test.ts +++ b/packages/rulesets/src/asyncapi/functions/__tests__/asyncApiDocumentSchema.test.ts @@ -1,11 +1,11 @@ import { DiagnosticSeverity } from '@stoplight/types'; import { Spectral } from '@stoplight/spectral-core'; -import { prepareResults } from '../asyncApi2DocumentSchema'; +import { prepareResults } from '../asyncApiDocumentSchema'; import { ErrorObject } from 'ajv'; import { createWithRules } from '../../__tests__/__helpers__/tester'; -describe('asyncApi2DocumentSchema', () => { +describe('asyncApiDocumentSchema', () => { let s: Spectral; beforeEach(async () => { @@ -303,6 +303,44 @@ describe('asyncApi2DocumentSchema', () => { }); }); + describe('given AsyncAPI 3.0.0 document', () => { + test('validate valid spec', async () => { + expect( + await s.run({ + asyncapi: '3.0.0', + info: { + title: 'Account Service', + version: '1.0.0', + description: 'This service is in charge of processing user signups', + }, + channels: { + userSignedup: { + address: 'user/signedup', + messages: { + UserSignedUp: { + payload: { + type: 'object', + properties: { + displayName: { + type: 'string', + description: 'Name of the user', + }, + email: { + type: 'string', + format: 'email', + description: 'Email of the user', + }, + }, + }, + }, + }, + }, + }, + }), + ).toEqual([]); + }); + }); + describe('prepareResults', () => { test('given oneOf error one of which is required $ref property missing, picks only one error', () => { const errors: ErrorObject[] = [ diff --git a/packages/rulesets/src/asyncapi/functions/__tests__/asyncApi2PayloadValidation.test.ts b/packages/rulesets/src/asyncapi/functions/__tests__/asyncApiPayloadValidation.test.ts similarity index 78% rename from packages/rulesets/src/asyncapi/functions/__tests__/asyncApi2PayloadValidation.test.ts rename to packages/rulesets/src/asyncapi/functions/__tests__/asyncApiPayloadValidation.test.ts index b38b6b33e..3bf0d4a29 100644 --- a/packages/rulesets/src/asyncapi/functions/__tests__/asyncApi2PayloadValidation.test.ts +++ b/packages/rulesets/src/asyncapi/functions/__tests__/asyncApiPayloadValidation.test.ts @@ -1,14 +1,14 @@ import { aas2_0 } from '@stoplight/spectral-formats'; -import asyncApi2PayloadValidation from '../asyncApi2PayloadValidation'; +import asyncApiPayloadValidation from '../asyncApiPayloadValidation'; function runPayloadValidation(targetVal: any) { - return asyncApi2PayloadValidation(targetVal, null, { + return asyncApiPayloadValidation(targetVal, null, { path: ['components', 'messages', 'aMessage'], document: { formats: new Set([aas2_0]) }, } as any); } -describe('asyncApi2PayloadValidation', () => { +describe('asyncApiPayloadValidation', () => { test('Properly identify payload that do not fit the AsyncApi2 schema object definition', () => { const payload = { type: 'object', diff --git a/packages/rulesets/src/asyncapi/functions/__tests__/asyncApi2SchemaValidation.test.ts b/packages/rulesets/src/asyncapi/functions/__tests__/asyncApiSchemaValidation.test.ts similarity index 80% rename from packages/rulesets/src/asyncapi/functions/__tests__/asyncApi2SchemaValidation.test.ts rename to packages/rulesets/src/asyncapi/functions/__tests__/asyncApiSchemaValidation.test.ts index e4538dc5c..ffb6fe4e0 100644 --- a/packages/rulesets/src/asyncapi/functions/__tests__/asyncApi2SchemaValidation.test.ts +++ b/packages/rulesets/src/asyncapi/functions/__tests__/asyncApiSchemaValidation.test.ts @@ -1,10 +1,10 @@ -import asyncApi2SchemaValidation from '../asyncApi2SchemaValidation'; +import asyncApiSchemaValidation from '../asyncApiSchemaValidation'; function runPayloadValidation(targetVal: any, opts: { type: 'examples' | 'default' }) { - return asyncApi2SchemaValidation(targetVal, opts, { path: [], documentInventory: {} } as any); + return asyncApiSchemaValidation(targetVal, opts, { path: [], documentInventory: {} } as any); } -describe('asyncApi2SchemaValidation', () => { +describe('asyncApiSchemaValidation', () => { test('validates examples', () => { const payload = { type: 'object', diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2DocumentSchema.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2DocumentSchema.ts deleted file mode 100644 index 810c64d57..000000000 --- a/packages/rulesets/src/asyncapi/functions/asyncApi2DocumentSchema.ts +++ /dev/null @@ -1,134 +0,0 @@ -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 '@stoplight/spectral-formats'; - -import { getCopyOfSchema } from './utils/specs'; - -import type { ErrorObject } from 'ajv'; -import type { IFunctionResult, Format } from '@stoplight/spectral-core'; -import type { AsyncAPISpecVersion } from './utils/specs'; - -function shouldIgnoreError(error: ErrorObject): boolean { - return ( - // oneOf is a fairly error as we have 2 options to choose from for most of the time. - error.keyword === 'oneOf' || - // the required $ref is entirely useless, since aas-schema rules operate on resolved content, so there won't be any $refs in the document - (error.keyword === 'required' && error.params.missingProperty === '$ref') - ); -} - -// this is supposed to cover edge cases we need to cover manually, when it's impossible to detect the most appropriate error, i.e. oneOf consisting of more than 3 members, etc. -// note, more errors can be included if certain messages reported by AJV are not quite meaningful -const ERROR_MAP = [ - { - path: /^components\/securitySchemes\/[^/]+$/, - message: 'Invalid security scheme', - }, -]; - -// The function removes irrelevant (aka misleading, confusing, useless, whatever you call it) errors. -// There are a few exceptions, i.e. security components I covered manually, -// yet apart from them we usually deal with a relatively simple scenario that can be literally expressed as: "either proper value of $ref property". -// The $ref part is never going to be interesting for us, because both aas-schema rules operate on resolved content, so we won't have any $refs left. -// As you can see, what we deal here wit is actually not really oneOf anymore - it's always the first member of oneOf we match against. -// That being said, we always strip both oneOf and $ref, since we are always interested in the first error. -export function prepareResults(errors: ErrorObject[]): void { - // Update additionalProperties errors to make them more precise and prevent them from being treated as duplicates - for (let i = 0; i < errors.length; i++) { - const error = errors[i]; - - if (error.keyword === 'additionalProperties') { - error.instancePath = `${error.instancePath}/${String(error.params['additionalProperty'])}`; - } else if (error.keyword === 'required' && error.params.missingProperty === '$ref') { - errors.splice(i, 1); - i--; - } - } - - for (let i = 0; i < errors.length; i++) { - const error = errors[i]; - - if (i + 1 < errors.length && errors[i + 1].instancePath === error.instancePath) { - errors.splice(i + 1, 1); - i--; - } else if (i > 0 && shouldIgnoreError(error) && errors[i - 1].instancePath.startsWith(error.instancePath)) { - errors.splice(i, 1); - i--; - } - } -} - -function applyManualReplacements(errors: IFunctionResult[]): void { - for (const error of errors) { - if (error.path === void 0) continue; - - const joinedPath = error.path.join('/'); - - for (const mappedError of ERROR_MAP) { - if (mappedError.path.test(joinedPath)) { - error.message = mappedError.message; - break; - } - } - } -} - -const serializedSchemas = new Map>(); -function getSerializedSchema(version: AsyncAPISpecVersion): Record { - const schema = serializedSchemas.get(version); - 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 }; - // 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']; - - serializedSchemas.set(version, copied); - return copied; -} - -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 default createRulesetFunction( - { - input: null, - options: null, - }, - function asyncApi2DocumentSchema(targetVal, _, context) { - const formats = context.document?.formats; - if (formats === null || formats === void 0) return; - - const schema = getSchema(formats); - if (schema === void 0) return; - - const errors = schemaFn(targetVal, { allErrors: true, schema, prepareResults }, context); - - if (Array.isArray(errors)) { - applyManualReplacements(errors); - } - - return errors; - }, -); diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2ChannelParameters.ts b/packages/rulesets/src/asyncapi/functions/asyncApiChannelParameters.ts similarity index 76% rename from packages/rulesets/src/asyncapi/functions/asyncApi2ChannelParameters.ts rename to packages/rulesets/src/asyncapi/functions/asyncApiChannelParameters.ts index 71a189fdc..31ba06b5f 100644 --- a/packages/rulesets/src/asyncapi/functions/asyncApi2ChannelParameters.ts +++ b/packages/rulesets/src/asyncapi/functions/asyncApiChannelParameters.ts @@ -5,7 +5,7 @@ import { parseUrlVariables } from '../../shared/functions/serverVariables/utils/ import { getMissingProps } from '../../shared/utils/getMissingProps'; import { getRedundantProps } from '../../shared/utils/getRedundantProps'; -export default createRulesetFunction<{ parameters: Record }, null>( +export default createRulesetFunction<{ address?: string; parameters: Record }, null>( { input: { type: 'object', @@ -18,10 +18,14 @@ export default createRulesetFunction<{ parameters: Record }, nu }, options: null, }, - function asyncApi2ChannelParameters(targetVal, _, ctx) { - const path = ctx.path[ctx.path.length - 1] as string; + function asyncApiChannelParameters(targetVal, _, ctx) { + let path = ctx.path[ctx.path.length - 1] as string; const results: IFunctionResult[] = []; - + // If v3 using address, use that. + if (targetVal.address !== null && targetVal.address !== undefined) { + path = targetVal.address; + } + // Ignore v3 reply channels with no address, id of v3 contain no variable substitutions. const parameters = parseUrlVariables(path); if (parameters.length === 0) return; diff --git a/packages/rulesets/src/asyncapi/functions/asyncApiDocumentSchema.ts b/packages/rulesets/src/asyncapi/functions/asyncApiDocumentSchema.ts new file mode 100644 index 000000000..2f64cbc6a --- /dev/null +++ b/packages/rulesets/src/asyncapi/functions/asyncApiDocumentSchema.ts @@ -0,0 +1,169 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +import specs from '@asyncapi/specs'; +import { createRulesetFunction, IFunctionResult, Format } from '@stoplight/spectral-core'; +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'; + +type AsyncAPIVersions = keyof typeof specs.schemas; +type RawSchema = Record; + +function shouldIgnoreError(error: ErrorObject): boolean { + return ( + // oneOf is a fairly error as we have 2 options to choose from for most of the time. + error.keyword === 'oneOf' || + // the required $ref is entirely useless, since aas-schema rules operate on resolved content, so there won't be any $refs in the document + (error.keyword === 'required' && error.params.missingProperty === '$ref') + ); +} + +// ajv throws a lot of errors that have no understandable context, e.g. errors related to the fact that a value doesn't meet the conditions of some sub-schema in `oneOf`, `anyOf` etc. +// for this reason, we filter these unnecessary errors and leave only the most important ones (usually the first occurring in the list of errors). +export function prepareResults(errors: ErrorObject[]): void { + // Update additionalProperties errors to make them more precise and prevent them from being treated as duplicates + for (let i = 0; i < errors.length; i++) { + const error = errors[i]; + + if (error.keyword === 'additionalProperties') { + error.instancePath = `${error.instancePath}/${String(error.params['additionalProperty'])}`; + } else if (error.keyword === 'required' && error.params.missingProperty === '$ref') { + errors.splice(i, 1); + i--; + } + } + + for (let i = 0; i < errors.length; i++) { + const error = errors[i]; + + if (i + 1 < errors.length && errors[i + 1].instancePath === error.instancePath) { + errors.splice(i + 1, 1); + i--; + } else if (i > 0 && shouldIgnoreError(error) && errors[i - 1].instancePath.startsWith(error.instancePath)) { + errors.splice(i, 1); + i--; + } + } +} + +// 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; +} + +const serializedSchemas = new Map(); +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. + 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`); + + if (resolved && version === '3.0.0') { + copied = prepareV3ResolvedSchema(copied); + } + + serializedSchemas.set(serializedSchemaKey as AsyncAPIVersions, copied); + return copied; +} + +const refErrorMessage = 'Property "$ref" is not expected to be here'; +function filterRefErrors(errors: IFunctionResult[], resolved: boolean) { + if (resolved) { + return errors.filter(err => err.message !== refErrorMessage); + } + + return errors + .filter(err => err.message === refErrorMessage) + .map(err => { + err.message = 'Referencing in this place is not allowed'; + return err; + }); +} + +export function getSchema(formats: Set, resolved: boolean): Record | void { + switch (true) { + case formats.has(aas3_0): + return getSerializedSchema('3.0.0', resolved); + case formats.has(aas2_6): + return getSerializedSchema('2.6.0', resolved); + case formats.has(aas2_5): + return getSerializedSchema('2.5.0', resolved); + case formats.has(aas2_4): + return getSerializedSchema('2.4.0', resolved); + case formats.has(aas2_3): + return getSerializedSchema('2.3.0', resolved); + case formats.has(aas2_2): + return getSerializedSchema('2.2.0', resolved); + case formats.has(aas2_1): + return getSerializedSchema('2.1.0', resolved); + case formats.has(aas2_0): + return getSerializedSchema('2.0.0', resolved); + default: + return; + } +} + +export const asyncApiDocumentSchema = createRulesetFunction( + { + input: null, + options: { + type: 'object', + properties: { + resolved: { + type: 'boolean', + }, + }, + required: ['resolved'], + }, + }, + (targetVal, options, context) => { + const formats = context.document?.formats; + if (!formats) { + return; + } + + const resolved = options.resolved; + const schema = getSchema(formats, resolved); + if (!schema) { + return; + } + + const errors = schemaFn( + targetVal, + { allErrors: true, schema, prepareResults: resolved ? prepareResults : undefined }, + context, + ); + if (!Array.isArray(errors)) { + return; + } + + return filterRefErrors(errors, resolved); + }, +); diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2PayloadValidation.ts b/packages/rulesets/src/asyncapi/functions/asyncApiPayloadValidation.ts similarity index 90% rename from packages/rulesets/src/asyncapi/functions/asyncApi2PayloadValidation.ts rename to packages/rulesets/src/asyncapi/functions/asyncApiPayloadValidation.ts index 496b795d8..ccc428688 100644 --- a/packages/rulesets/src/asyncapi/functions/asyncApi2PayloadValidation.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 } from '@stoplight/spectral-formats'; +import { aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5, aas2_6, aas3_0 } from '@stoplight/spectral-formats'; import betterAjvErrors from '@stoplight/better-ajv-errors'; import { getCopyOfSchema } from './utils/specs'; @@ -52,6 +52,10 @@ function getValidator(version: AsyncAPISpecVersion): ValidateFunction { function getSchemaValidator(formats: Set): ValidateFunction | void { switch (true) { + case formats.has(aas3_0): + return getValidator('3.0.0'); + case formats.has(aas2_6): + return getValidator('2.6.0'); case formats.has(aas2_5): return getValidator('2.5.0'); case formats.has(aas2_4): @@ -74,7 +78,7 @@ export default createRulesetFunction( input: null, options: null, }, - function asyncApi2PayloadValidation(targetVal, _, context) { + function asyncApiPayloadValidation(targetVal, _, context) { const formats = context.document?.formats; if (formats === null || formats === void 0) return; diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2SchemaValidation.ts b/packages/rulesets/src/asyncapi/functions/asyncApiSchemaValidation.ts similarity index 96% rename from packages/rulesets/src/asyncapi/functions/asyncApi2SchemaValidation.ts rename to packages/rulesets/src/asyncapi/functions/asyncApiSchemaValidation.ts index 3fa907b03..b79cd6896 100644 --- a/packages/rulesets/src/asyncapi/functions/asyncApi2SchemaValidation.ts +++ b/packages/rulesets/src/asyncapi/functions/asyncApiSchemaValidation.ts @@ -48,7 +48,7 @@ export default createRulesetFunction( required: ['type'], }, }, - function asyncApi2SchemaValidation(targetVal, opts, context) { + function asyncApiSchemaValidation(targetVal, opts, context) { const schemaObject = targetVal; const relevantItems = getRelevantItems(targetVal, opts.type); diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2Security.ts b/packages/rulesets/src/asyncapi/functions/asyncApiSecurity.ts similarity index 97% rename from packages/rulesets/src/asyncapi/functions/asyncApi2Security.ts rename to packages/rulesets/src/asyncapi/functions/asyncApiSecurity.ts index e24c938ab..a9793b605 100644 --- a/packages/rulesets/src/asyncapi/functions/asyncApi2Security.ts +++ b/packages/rulesets/src/asyncapi/functions/asyncApiSecurity.ts @@ -46,7 +46,7 @@ export default createRulesetFunction, { objectType: 'Se }, }, }, - function asyncApi2Security(targetVal = {}, { objectType }, ctx) { + function asyncApiSecurity(targetVal = {}, { objectType }, ctx) { const results: IFunctionResult[] = []; const spec = ctx.document.data as { components: { securitySchemes: Record }; diff --git a/packages/rulesets/src/asyncapi/functions/utils/specs.ts b/packages/rulesets/src/asyncapi/functions/utils/specs.ts index 274a2481d..caa20bcdf 100644 --- a/packages/rulesets/src/asyncapi/functions/utils/specs.ts +++ b/packages/rulesets/src/asyncapi/functions/utils/specs.ts @@ -1,4 +1,8 @@ -import specs from '@asyncapi/specs'; +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import allSchemas from '@asyncapi/specs'; +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment +const specs = allSchemas.schemas; export type AsyncAPISpecVersion = keyof typeof specs; diff --git a/packages/rulesets/src/asyncapi/index.ts b/packages/rulesets/src/asyncapi/index.ts index 680cda751..da9abe6cd 100644 --- a/packages/rulesets/src/asyncapi/index.ts +++ b/packages/rulesets/src/asyncapi/index.ts @@ -1,4 +1,15 @@ -import { aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5, aas2_6 } from '@stoplight/spectral-formats'; +import { + aas2, + aas2_0, + aas2_1, + aas2_2, + aas2_3, + aas2_4, + aas2_5, + aas2_6, + aas3, + aas3_0, +} from '@stoplight/spectral-formats'; import { truthy, pattern, @@ -8,27 +19,28 @@ import { alphabetical, } from '@stoplight/spectral-functions'; -import asyncApi2ChannelParameters from './functions/asyncApi2ChannelParameters'; +import asyncApiChannelParameters from './functions/asyncApiChannelParameters'; import asyncApi2ChannelServers from './functions/asyncApi2ChannelServers'; -import asyncApi2DocumentSchema from './functions/asyncApi2DocumentSchema'; +import { asyncApiDocumentSchema } from './functions/asyncApiDocumentSchema'; import asyncApi2MessageExamplesValidation from './functions/asyncApi2MessageExamplesValidation'; import asyncApi2MessageIdUniqueness from './functions/asyncApi2MessageIdUniqueness'; import asyncApi2OperationIdUniqueness from './functions/asyncApi2OperationIdUniqueness'; -import asyncApi2SchemaValidation from './functions/asyncApi2SchemaValidation'; -import asyncApi2PayloadValidation from './functions/asyncApi2PayloadValidation'; +import asyncApiSchemaValidation from './functions/asyncApiSchemaValidation'; +import asyncApiPayloadValidation from './functions/asyncApiPayloadValidation'; import serverVariables from '../shared/functions/serverVariables'; import { uniquenessTags } from '../shared/functions'; -import asyncApi2Security from './functions/asyncApi2Security'; +import asyncApiSecurity from './functions/asyncApiSecurity'; 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], + formats: [aas2_0, aas2_1, aas2_2, aas2_3, aas2_4, aas2_5, aas2_6, aas3_0], rules: { 'asyncapi-channel-no-empty-parameter': { description: 'Channel path must not have empty parameter substitution pattern.', recommended: true, given: '$.channels', + formats: [aas2], then: { field: '@key', function: pattern, @@ -37,10 +49,24 @@ export default { }, }, }, + 'asyncapi-3-channel-no-empty-parameter': { + description: 'Channel address must not have empty parameter substitution pattern.', + recommended: true, + given: '$.channels.*', + formats: [aas3], + then: { + field: 'address', + function: pattern, + functionOptions: { + notMatch: '{}', + }, + }, + }, 'asyncapi-channel-no-query-nor-fragment': { description: 'Channel path must not include query ("?") or fragment ("#") delimiter.', recommended: true, given: '$.channels', + formats: [aas2], then: { field: '@key', function: pattern, @@ -49,10 +75,24 @@ export default { }, }, }, + 'asyncapi-3-channel-no-query-nor-fragment': { + description: 'Channel address must not include query ("?") or fragment ("#") delimiter.', + recommended: true, + given: '$.channels.*', + formats: [aas3], + then: { + field: 'address', + function: pattern, + functionOptions: { + notMatch: '[\\?#]', + }, + }, + }, 'asyncapi-channel-no-trailing-slash': { description: 'Channel path must not end with slash.', recommended: true, given: '$.channels', + formats: [aas2], then: { field: '@key', function: pattern, @@ -61,14 +101,28 @@ export default { }, }, }, + 'asyncapi-3-channel-no-trailing-slash': { + description: 'Channel address must not end with slash.', + recommended: true, + given: '$.channels.*', + formats: [aas3], + then: { + field: 'address', + function: pattern, + functionOptions: { + notMatch: '.+\\/$', + }, + }, + }, 'asyncapi-channel-parameters': { description: 'Channel parameters must be defined and there must be no redundant parameters.', message: '{{error}}', severity: 'error', recommended: true, + formats: [aas2, aas3], given: ['$.channels.*', '$.components.channels.*'], then: { - function: asyncApi2ChannelParameters, + function: asyncApiChannelParameters, }, }, 'asyncapi-channel-servers': { @@ -76,16 +130,33 @@ export default { message: '{{error}}', severity: 'error', recommended: true, + formats: [aas2], given: '$', then: { function: asyncApi2ChannelServers, }, }, + 'asyncapi-3-channel-servers': { + description: 'Channel servers must be defined in the "servers" object.', + severity: 'error', + recommended: true, + resolved: false, // We use the JSON pointer to match the channel. + given: '$.channels.*', + formats: [aas3], + then: { + field: '$.servers.*.$ref', + function: pattern, + functionOptions: { + match: '#\\/servers\\/', // If doesn't match, rule fails. + }, + }, + }, 'asyncapi-headers-schema-type-object': { description: 'Headers schema type must be "object".', message: 'Headers schema type must be "object" ({{error}}).', severity: 'error', recommended: true, + formats: [aas2], given: [ '$.components.messageTraits.*.headers', '$.components.messages.*.headers', @@ -108,10 +179,39 @@ export default { }, }, }, + 'asyncapi-3-headers-schema-type-object': { + description: 'Headers schema type must be "object".', + message: 'Headers schema type must be "object" ({{error}}).', + severity: 'error', + recommended: true, + formats: [aas3], + given: [ + '$.components.messageTraits.*.headers', + '$.components.messages.*.headers', + '$.channels.*.messages.*.headers', + '$.channels.*.messages.*.traits[*].headers', + ], + then: { + function: schema, + functionOptions: { + allErrors: true, + schema: { + type: 'object', + properties: { + type: { + enum: ['object'], + }, + }, + required: ['type'], + }, + }, + }, + }, 'asyncapi-info-contact-properties': { description: 'Contact object must have "name", "url" and "email".', recommended: true, given: '$.info.contact', + formats: [aas2, aas3], then: [ { field: 'name', @@ -130,6 +230,7 @@ export default { 'asyncapi-info-contact': { description: 'Info object must have "contact" object.', recommended: true, + formats: [aas2, aas3], given: '$', then: { field: 'info.contact', @@ -140,6 +241,7 @@ export default { description: 'Info "description" must be present and non-empty string.', recommended: true, given: '$', + formats: [aas2, aas3], then: { field: 'info.description', function: truthy, @@ -149,6 +251,7 @@ export default { description: 'License object must include "url".', recommended: false, given: '$', + formats: [aas2, aas3], then: { field: 'info.license.url', function: truthy, @@ -158,6 +261,7 @@ export default { description: 'Info object must have "license" object.', recommended: true, given: '$', + formats: [aas2, aas3], then: { field: 'info.license', function: truthy, @@ -169,6 +273,7 @@ export default { recommended: true, severity: 'info', given: '$.asyncapi', + formats: [aas2, aas3], then: { function: schema, functionOptions: { @@ -183,6 +288,7 @@ export default { message: '{{error}}', severity: 'error', recommended: true, + formats: [aas2], given: [ // messages '$.channels.*.[publish,subscribe].message', @@ -207,6 +313,7 @@ export default { severity: 'error', recommended: true, given: '$', + formats: [aas2], then: { function: asyncApi2MessageIdUniqueness, }, @@ -214,16 +321,28 @@ export default { 'asyncapi-operation-description': { description: 'Operation "description" must be present and non-empty string.', recommended: true, + formats: [aas2], given: '$.channels[*][publish,subscribe]', then: { field: 'description', function: truthy, }, }, + 'asyncapi-3-operation-description': { + description: 'Operation "description" must be present and non-empty string.', + recommended: true, + formats: [aas3], + given: '$.operations.*', + then: { + field: 'description', + function: truthy, + }, + }, 'asyncapi-operation-operationId-uniqueness': { description: '"operationId" must be unique across all the operations.', severity: 'error', recommended: true, + formats: [aas2], given: '$', then: { function: asyncApi2OperationIdUniqueness, @@ -233,6 +352,7 @@ export default { description: 'Operation must have "operationId".', severity: 'error', recommended: true, + formats: [aas2], given: '$.channels[*][publish,subscribe]', then: { field: 'operationId', @@ -244,9 +364,24 @@ export default { message: '{{error}}', severity: 'error', recommended: true, + formats: [aas2], given: '$.channels[*][publish,subscribe].security.*', then: { - function: asyncApi2Security, + function: asyncApiSecurity, + functionOptions: { + objectType: 'Operation', + }, + }, + }, + 'asyncapi-3-operation-security': { + description: 'Operation have to reference a defined security schemes.', + message: '{{error}}', + severity: 'error', + recommended: true, + formats: [aas3], + given: '$.operations.*.security.*', + then: { + function: asyncApiSecurity, functionOptions: { objectType: 'Operation', }, @@ -255,6 +390,7 @@ export default { 'asyncapi-parameter-description': { description: 'Parameter objects must have "description".', recommended: false, + formats: [aas2, aas3], given: ['$.components.parameters.*', '$.channels.*.parameters.*'], then: { field: 'description', @@ -266,13 +402,14 @@ export default { message: '{{error}}', severity: 'error', recommended: true, + formats: [aas2], given: [ '$.components.messageTraits[?(@.schemaFormat === void 0)].payload.default^', '$.components.messages[?(@.schemaFormat === void 0)].payload.default^', "$.channels[*][publish,subscribe][?(@property === 'message' && @.schemaFormat === void 0)].payload.default^", ], then: { - function: asyncApi2SchemaValidation, + function: asyncApiSchemaValidation, functionOptions: { type: 'default', }, @@ -283,13 +420,14 @@ export default { message: '{{error}}', severity: 'error', recommended: true, + formats: [aas2], given: [ '$.components.messageTraits[?(@.schemaFormat === void 0)].payload.examples^', '$.components.messages[?(@.schemaFormat === void 0)].payload.examples^', "$.channels[*][publish,subscribe][?(@property === 'message' && @.schemaFormat === void 0)].payload.examples^", ], then: { - function: asyncApi2SchemaValidation, + function: asyncApiSchemaValidation, functionOptions: { type: 'examples', }, @@ -299,24 +437,37 @@ export default { description: 'Message schema validation is only supported with default unspecified "schemaFormat".', severity: 'info', recommended: true, + formats: [aas2], given: ['$.components.messageTraits.*', '$.components.messages.*', '$.channels[*][publish,subscribe].message'], then: { field: 'schemaFormat', function: undefined, }, }, + 'asyncapi-3-payload-unsupported-schemaFormat': { + description: 'Message schema validation is only supported with default unspecified "schemaFormat".', + severity: 'info', + recommended: true, + formats: [aas3], + given: ['$.components.messages.*.payload', '$.channels.*.messages.*.payload'], + then: { + field: 'schemaFormat', + function: undefined, + }, + }, 'asyncapi-payload': { description: 'Payloads must be valid against AsyncAPI Schema object.', message: '{{error}}', severity: 'error', recommended: true, + formats: [aas2], given: [ '$.components.messageTraits[?(@.schemaFormat === void 0)].payload', '$.components.messages[?(@.schemaFormat === void 0)].payload', "$.channels[*][publish,subscribe][?(@property === 'message' && @.schemaFormat === void 0)].payload", ], then: { - function: asyncApi2PayloadValidation, + function: asyncApiPayloadValidation, }, }, 'asyncapi-schema-default': { @@ -324,13 +475,14 @@ export default { message: '{{error}}', severity: 'error', recommended: true, + formats: [aas2], given: [ '$.components.schemas.*.default^', '$.components.parameters.*.schema.default^', '$.channels.*.parameters.*.schema.default^', ], then: { - function: asyncApi2SchemaValidation, + function: asyncApiSchemaValidation, functionOptions: { type: 'default', }, @@ -341,26 +493,31 @@ export default { message: '{{error}}', severity: 'error', recommended: true, + formats: [aas2], given: [ '$.components.schemas.*.examples^', '$.components.parameters.*.schema.examples^', '$.channels.*.parameters.*.schema.examples^', ], then: { - function: asyncApi2SchemaValidation, + function: asyncApiSchemaValidation, functionOptions: { type: 'examples', }, }, }, 'asyncapi-schema': { - description: 'Validate structure of AsyncAPI v2 specification.', + description: 'Validate structure of AsyncAPI specification.', message: '{{error}}', severity: 'error', recommended: true, + formats: [aas2], given: '$', then: { - function: asyncApi2DocumentSchema, + function: asyncApiDocumentSchema, + functionOptions: { + resolved: true, + }, }, }, 'asyncapi-server-variables': { @@ -368,6 +525,7 @@ export default { message: '{{error}}', severity: 'error', recommended: true, + formats: [aas2], given: ['$.servers.*', '$.components.servers.*'], then: { function: serverVariables, @@ -376,6 +534,7 @@ export default { 'asyncapi-server-no-empty-variable': { description: 'Server URL must not have empty variable substitution pattern.', recommended: true, + formats: [aas2], given: '$.servers[*].url', then: { function: pattern, @@ -384,9 +543,22 @@ export default { }, }, }, + 'asyncapi-3-server-no-empty-variable': { + description: 'Server host and pathname must not have empty variable substitution pattern.', + recommended: true, + formats: [aas3], + given: ['$.servers[*].host', '$.servers[*].pathname'], + then: { + function: pattern, + functionOptions: { + notMatch: '{}', + }, + }, + }, 'asyncapi-server-no-trailing-slash': { description: 'Server URL must not end with slash.', recommended: true, + formats: [aas2], given: '$.servers[*].url', then: { function: pattern, @@ -395,10 +567,35 @@ export default { }, }, }, + 'asyncapi-3-server-no-trailing-slash': { + description: 'Server host must not end with slash.', + recommended: true, + formats: [aas3], + given: '$.servers.*.host', + then: { + function: pattern, + functionOptions: { + notMatch: '/$', + }, + }, + }, 'asyncapi-server-not-example-com': { description: 'Server URL must not point at example.com.', recommended: false, given: '$.servers[*].url', + formats: [aas2], + then: { + function: pattern, + functionOptions: { + notMatch: 'example\\.com', + }, + }, + }, + 'asyncapi-3-server-not-example-com': { + description: 'Server host must not point at example.com.', + recommended: false, + given: '$.servers.*.host', + formats: [aas3], then: { function: pattern, functionOptions: { @@ -411,9 +608,10 @@ export default { message: '{{error}}', severity: 'error', recommended: true, + formats: [aas2], given: '$.servers.*.security.*', then: { - function: asyncApi2Security, + function: asyncApiSecurity, functionOptions: { objectType: 'Server', }, @@ -422,6 +620,7 @@ export default { 'asyncapi-servers': { description: 'AsyncAPI object must have non-empty "servers" object.', recommended: true, + formats: [aas2, aas3], given: '$', then: { field: 'servers', @@ -438,16 +637,41 @@ export default { 'asyncapi-tag-description': { description: 'Tag object must have "description".', recommended: false, + formats: [aas2], given: '$.tags[*]', then: { field: 'description', function: truthy, }, }, + 'asyncapi-3-tag-description': { + description: 'Tag object must have "description".', + recommended: false, + formats: [aas3], + given: '$.info.tags[*]', + then: { + field: 'description', + function: truthy, + }, + }, 'asyncapi-tags-alphabetical': { description: 'AsyncAPI object must have alphabetical "tags".', recommended: false, given: '$', + formats: [aas2], + then: { + field: 'tags', + function: alphabetical, + functionOptions: { + keyedBy: 'name', + }, + }, + }, + 'asyncapi-3-tags-alphabetical': { + description: 'AsyncAPI object must have alphabetical "tags".', + recommended: false, + given: '$.info', + formats: [aas3], then: { field: 'tags', function: alphabetical, @@ -461,6 +685,7 @@ export default { message: '{{error}}', severity: 'error', recommended: true, + formats: [aas2], given: [ // root '$.tags', @@ -492,19 +717,64 @@ export default { function: uniquenessTags, }, }, + 'asyncapi-3-tags-uniqueness': { + description: 'Each tag must have a unique name.', + message: '{{error}}', + severity: 'error', + recommended: true, + formats: [aas3], + given: [ + // root + '$.info.tags', + // servers + '$.servers.*.tags', + '$.components.servers.*.tags', + // operations + '$.operations.*.tags', + '$.components.operations.*.tags', + // operation traits + '$.operations.*.traits.*.tags', + '$.components.operations.traits.*.tags', + '$.components.operationTraits.*.tags', + // messages + '$.channels.*.messages.*.tags', + '$.components.channels.*.messages.tags', + '$.components.messages.*.tags', + // message traits + '$.channels.*..messages.*.traits.*.tags', + '$.components.channels.*.messages.*.traits.*.tags', + '$.components.messages.*.traits.*.tags', + '$.components.messageTraits.*.tags', + ], + then: { + function: uniquenessTags, + }, + }, 'asyncapi-tags': { description: 'AsyncAPI object must have non-empty "tags" array.', recommended: true, + formats: [aas2], given: '$', then: { field: 'tags', function: truthy, }, }, + 'asyncapi-3-tags': { + description: 'AsyncAPI document must have non-empty "tags" array.', + recommended: true, + formats: [aas3], + given: ['$.info'], + then: { + field: 'tags', + function: truthy, + }, + }, 'asyncapi-unused-components-schema': { description: 'Potentially unused components schema has been detected.', recommended: true, resolved: false, + formats: [aas2, aas3], given: '$.components.schemas', then: { function: unreferencedReusableObject, @@ -517,6 +787,7 @@ export default { description: 'Potentially unused components server has been detected.', recommended: true, resolved: false, + formats: [aas2, aas3], given: '$.components.servers', then: { function: unreferencedReusableObject, @@ -525,5 +796,34 @@ export default { }, }, }, + 'asyncapi-3-document-resolved': { + description: 'Checking if the AsyncAPI v3 document has valid structure after resolving references.', + message: '{{error}}', + severity: 'error', + recommended: true, + given: '$', + formats: [aas3], + then: { + function: asyncApiDocumentSchema, + functionOptions: { + resolved: true, + }, + }, + }, + 'asyncapi-3-document-unresolved': { + description: 'Checking if the AsyncAPI v3 document has valid structure before resolving references.', + message: '{{error}}', + severity: 'error', + recommended: true, + resolved: false, + given: '$', + formats: [aas3], + then: { + function: asyncApiDocumentSchema, + functionOptions: { + resolved: false, + }, + }, + }, }, }; diff --git a/test-harness/scenarios/asyncapi2-streetlights.scenario b/test-harness/scenarios/asyncapi2-streetlights.scenario index 1a3c075d4..8481e6a21 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 "2.6.0" version. asyncapi + 1:11 information asyncapi-latest-version The latest version is not used. You should update to the "3.0.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/yarn.lock b/yarn.lock index 2dc3bb6e3..c6f22587d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -37,12 +37,12 @@ __metadata: languageName: node linkType: hard -"@asyncapi/specs@npm:^4.1.0": - version: 4.1.0 - resolution: "@asyncapi/specs@npm:4.1.0" +"@asyncapi/specs@npm:^6.8.0": + version: 6.8.0 + resolution: "@asyncapi/specs@npm:6.8.0" dependencies: "@types/json-schema": ^7.0.11 - checksum: 6e95f3c1ef7267480cdfc69f5a015f63b9101874289e31843a629346a3ea07490e3043b296a089bf3458e877c664d83d4b4738dcb53d37d7e13b75c7bc08c879 + checksum: 8a968a9fb842fa0facf4b727cca4e17792cca26b08f8df4f3c5e32a015a9e30c8a2651f76faaabf0933ec2796b9e6318fc7a8abb64a7a95930637ab3af2bebb4 languageName: node linkType: hard @@ -3115,7 +3115,7 @@ __metadata: version: 0.0.0-use.local resolution: "@stoplight/spectral-rulesets@workspace:packages/rulesets" dependencies: - "@asyncapi/specs": ^4.1.0 + "@asyncapi/specs": ^6.8.0 "@stoplight/better-ajv-errors": 1.0.3 "@stoplight/json": ^3.17.0 "@stoplight/path": ^1.3.2