From 4dd3b467d8da0577b0aced634b16f4055d8f9fe0 Mon Sep 17 00:00:00 2001 From: nulltoken Date: Wed, 19 Feb 2020 07:51:43 +0100 Subject: [PATCH] feat(ruleset): add asyncapi2 ruleset --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- README.md | 6 +- docs/getting-started/asyncapi.md | 5 + docs/getting-started/concepts.md | 2 +- docs/getting-started/openapi.md | 4 +- docs/getting-started/rulesets.md | 8 +- docs/guides/cli.md | 4 +- docs/reference/asyncapi-rules.md | 371 ++++ docs/reference/functions.md | 2 + package.json | 5 +- rollup.config.js | 4 +- scripts/generate-assets.ts | 4 +- scripts/generate-karma-fixtures.js | 2 +- setupKarma.ts | 14 + setupTests.ts | 39 + .../__fixtures__/streetlights.asyncapi2.json | 297 +++ src/__tests__/generate-assets.jest.test.ts | 7 + src/__tests__/linter.test.ts | 1 + src/__tests__/spectral.test.ts | 20 +- src/assets.ts | 1 + src/cli/services/linter/linter.ts | 2 + src/cli/services/linter/utils/getRuleset.ts | 2 +- src/formats/__tests__/asyncapi.test.ts | 31 + src/formats/asyncapi.ts | 19 + src/formats/index.ts | 1 + src/functions/__tests__/schema-path.test.ts | 29 + src/functions/schema-path.ts | 2 + .../__tests__/offline.schemas.jest.test.ts | 14 +- .../asyncapi-channel-no-empty-parameter.ts | 45 + .../asyncapi-channel-no-query-nor-fragment.ts | 62 + .../asyncapi-channel-no-trailing-slash.ts | 45 + .../asyncapi-headers-schema-type-object.ts | 165 ++ .../asyncapi-info-contact-properties.ts | 49 + .../__tests__/asyncapi-info-contact.ts | 49 + .../__tests__/asyncapi-info-description.ts | 45 + .../__tests__/asyncapi-info-license-url.ts | 47 + .../__tests__/asyncapi-info-license.ts | 47 + .../asyncapi-operation-description.ts | 55 + .../asyncapi-operation-operationId.ts | 55 + .../asyncapi-parameter-description.ts | 75 + .../__tests__/asyncapi-payload-default.ts | 116 ++ .../__tests__/asyncapi-payload-examples.ts | 116 ++ ...yncapi-payload-unsupported-schemaFormat.ts | 97 + .../asyncapi/__tests__/asyncapi-payload.ts | 115 ++ .../__tests__/asyncapi-schema-default.ts | 100 ++ .../__tests__/asyncapi-schema-examples.ts | 118 ++ .../asyncapi/__tests__/asyncapi-schema.ts | 47 + .../asyncapi-server-no-empty-variable.ts | 48 + .../asyncapi-server-no-trailing-slash.ts | 48 + .../asyncapi-server-not-example-com.ts | 48 + .../asyncapi/__tests__/asyncapi-servers.ts | 66 + .../__tests__/asyncapi-tag-description.ts | 48 + .../__tests__/asyncapi-tags-alphabetical.ts | 43 + .../asyncapi/__tests__/asyncapi-tags.ts | 43 + .../asyncapi-unused-components-schema.ts | 70 + .../asyncApi2PayloadValidation.test.ts | 30 + .../functions/asyncApi2PayloadValidation.ts | 58 + src/rulesets/asyncapi/index.json | 401 +++++ .../asyncapi/schemas/schema.asyncapi2.json | 1599 +++++++++++++++++ src/rulesets/reader.ts | 7 +- .../scenarios/asyncapi2-streetlights.scenario | 226 +++ .../rules-matching-multiple-places.scenario | 2 +- .../scenarios/unrecognized-format.scenario | 2 +- yarn.lock | 16 + 64 files changed, 5071 insertions(+), 30 deletions(-) create mode 100644 docs/getting-started/asyncapi.md create mode 100644 docs/reference/asyncapi-rules.md create mode 100644 src/__tests__/__fixtures__/streetlights.asyncapi2.json create mode 100644 src/formats/__tests__/asyncapi.test.ts create mode 100644 src/formats/asyncapi.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-channel-no-empty-parameter.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-channel-no-query-nor-fragment.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-channel-no-trailing-slash.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-headers-schema-type-object.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-info-contact-properties.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-info-contact.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-info-description.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-info-license-url.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-info-license.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-operation-description.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-operation-operationId.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-parameter-description.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-payload-default.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-payload-examples.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-payload-unsupported-schemaFormat.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-payload.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-schema-default.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-schema-examples.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-schema.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-server-no-empty-variable.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-server-no-trailing-slash.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-server-not-example-com.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-servers.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-tag-description.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-tags-alphabetical.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-tags.ts create mode 100644 src/rulesets/asyncapi/__tests__/asyncapi-unused-components-schema.ts create mode 100644 src/rulesets/asyncapi/functions/__tests__/asyncApi2PayloadValidation.test.ts create mode 100644 src/rulesets/asyncapi/functions/asyncApi2PayloadValidation.ts create mode 100644 src/rulesets/asyncapi/index.json create mode 100644 src/rulesets/asyncapi/schemas/schema.asyncapi2.json create mode 100644 test-harness/scenarios/asyncapi2-streetlights.scenario diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index ba61e40f9..3628383f3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -13,7 +13,7 @@ A clear and concise description of what the bug is. **To Reproduce** -1. Given this OpenAPI document '...' +1. Given this OpenAPI/AsyncAPI document '...' 2. Run this CLI command '....' 3. See error diff --git a/README.md b/README.md index ab3936da2..4f26aa599 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,16 @@ [![NPM Downloads](https://img.shields.io/npm/dw/@stoplight/spectral?color=blue)](https://www.npmjs.com/package/@stoplight/spectral) [![Treeware (Trees)](https://img.shields.io/treeware/trees/stoplightio/spectral)](https://plant.treeware.earth/stoplightio/spectral) -A flexible JSON/YAML linter, with out of the box support for OpenAPI v2 and v3. +A flexible JSON/YAML linter, with out of the box support for OpenAPI v2/v3 and AsyncAPI v2. ![Demo of Spectral linting an OpenAPI document from the CLI](./docs/img/demo.svg) ## Spectral Features - Create custom rules to lint JSON or YAML objects -- Ready to use rules to validate and lint OpenAPI v2 _and_ v3 documents +- Ready-to-use rules to validate and lint: + - OpenAPI v2 _and_ v3 documents + - AsyncAPI v2 documents - Use JSON path to apply rules to specific parts of your objects - Built-in set of functions to help [create custom rules](https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/getting-started/rulesets.md#adding-a-rule). Functions include pattern checks, parameter checks, alphabetical ordering, a specified number of characters, provided keys are present in an object, etc. - Create custom functions for advanced use cases diff --git a/docs/getting-started/asyncapi.md b/docs/getting-started/asyncapi.md new file mode 100644 index 000000000..eff7735d9 --- /dev/null +++ b/docs/getting-started/asyncapi.md @@ -0,0 +1,5 @@ +# AsyncAPI Support + +Spectral is a generic linter, but a lot of effort has been put in to making sure [AsyncAPI v2](https://www.asyncapi.com/docs/specifications/2.0.0/) is well supported. + +Run Spectral against a document without specifying a ruleset will trigger an auto-detect, where Spectral will look to see if `asyncapi: 2.0.0` is in the root of the document. If it finds it, it will load `spectral:asyncapi`, which is documented in our [Reference > AsyncAPI Rules](../reference/asyncapi-rules.md). diff --git a/docs/getting-started/concepts.md b/docs/getting-started/concepts.md index af7bcf2a2..c65500f06 100644 --- a/docs/getting-started/concepts.md +++ b/docs/getting-started/concepts.md @@ -18,6 +18,6 @@ Rules can be comprised of one of more functions, to facilitate any style guide. - Tags must be plural - Tags must be singular -Spectral comes bundled with a [bunch of functions](../reference/functions.md) and a default style guide for [OpenAPI v2 and v3](./openapi.md), which you can extend, cherry-pick, or disable entirely. +Spectral comes bundled with a [bunch of functions](../reference/functions.md) and default style guides for [OpenAPI v2 and v3](./openapi.md) and [AsyncAPI v2](./asyncapi.md), which you can extend, cherry-pick, or disable entirely. Learn more about [rulesets](./rulesets.md). diff --git a/docs/getting-started/openapi.md b/docs/getting-started/openapi.md index 184c9d288..62281806f 100644 --- a/docs/getting-started/openapi.md +++ b/docs/getting-started/openapi.md @@ -2,7 +2,7 @@ Spectral is a generic linter, but a lot of effort has been put in to making sure OpenAPI is well supported. -Run Spectral against a document without specifying a ruleset will trigger an auto-detect, where Spectral will look to see if `swagger: 2.0` or `openapi: 3.0.x` is at the top of the file. If it finds either of those it will load `spectral:oas`, which is documented in our [Reference > OpenAPI Rules](../reference/openapi-rules.md). +Run Spectral against a document without specifying a ruleset will trigger an auto-detect, where Spectral will look to see if `swagger: 2.0` or `openapi: 3.0.x` are in the root of the document. If it finds either of those it will load `spectral:oas`, which is documented in our [Reference > OpenAPI Rules](../reference/openapi-rules.md). -> If you would like support for other API description formats like [RAML](https://raml.org/), message formats like [JSON:API](https://jsonapi.org/), event-driven API descriptions like [AsyncAPI](https://asyncapi.io/), etc., we recommend you start building custom but generic rulesets which can be shared with others. We've started putting together some over here on [OpenAPI Contrib](https://github.com/openapi-contrib/style-guides/). +> If you would like support for other API description formats like [RAML](https://raml.org/), message formats like [JSON:API](https://jsonapi.org/), etc., we recommend you start building custom but generic rulesets which can be shared with others. We've started putting together some over here on [OpenAPI Contrib](https://github.com/openapi-contrib/style-guides/). diff --git a/docs/getting-started/rulesets.md b/docs/getting-started/rulesets.md index c1c6613dd..be181ee74 100644 --- a/docs/getting-started/rulesets.md +++ b/docs/getting-started/rulesets.md @@ -117,6 +117,7 @@ Formats are an optional way to specify which API description formats a rule, or - `json-schema-draft6` (this is JSON Schema Draft 6, detection based on the value of $schema property) - `json-schema-draft7` (this is JSON Schema Draft 7, detection based on the value of $schema property) - `json-schema-2019-09` (this is JSON Schema 2019-09, detection based on the value of $schema property) +- `asyncapi2` (this is AsyncAPI v2.0.0) Specifying the format is optional, so you can completely ignore this if all the rules you are writing apply to any document you lint, or if you have specific rulesets for different formats. If you'd like to use one ruleset for multiple formats, the formats key is here to help. @@ -167,6 +168,10 @@ Custom formats can be registered via the [JS API](../guides/javascript.md), but Rulesets can extend other rulesets using the `extends` property. This can be used to build upon or customize other rulesets. +_Note:_ Spectral core rulesets are + - `spectral:oas`: OpenAPI v2/v3 rules + - `spectral:asyncapi`: AsyncAPI v2 rules + ```yaml extends: spectral:oas rules: @@ -178,7 +183,7 @@ rules: function: truthy ``` -The example above will apply the core rules from the built in OpenAPI v2 ruleset AND apply the custom `my-rule-name` rule. +The example above will apply the core rules from the built in OpenAPI v2/v3 ruleset AND apply the custom `my-rule-name` rule. Extends can be a single string or an array of strings, and can contain either local file paths or URLs. @@ -248,6 +253,7 @@ rules: The example above will run all of the rules defined in the `spectral:oas` ruleset (rather than the default behavior that runs only the recommended ones), with one exceptions - we turned `operation-operationId-unique` off. - [Rules relevant to OpenAPI v2 and v3](../reference/openapi-rules.md) +- [Rules relevant to AsyncAPI v2](../reference/asyncapi-rules.md) ## Selectively silencing some results diff --git a/docs/guides/cli.md b/docs/guides/cli.md index 84b815aac..77b211d13 100644 --- a/docs/guides/cli.md +++ b/docs/guides/cli.md @@ -43,7 +43,9 @@ The Spectral CLI supports loading documents as YAML or JSON, and validation of O You can also provide your own ruleset file. By default, the Spectral CLI will look for a ruleset file called `.spectral.yml` or `.spectral.json` in the current working directory. You can tell spectral to use a different file by using the `--ruleset` CLI option. -Here you can build a [custom ruleset](../getting-started/rulesets.md), or extend and modify our [core OpenAPI ruleset](https://stoplight.io/p/docs/gh/stoplightio/spectral/docs/reference/openapi-rules.md). +Here you can build a [custom ruleset](../getting-started/rulesets.md), or extend and modify our core rulesets: +- [OpenAPI ruleset](../reference/openapi-rules.md) +- [AsyncAPI ruleset](../reference/asyncapi-rules.md) ## Error Results diff --git a/docs/reference/asyncapi-rules.md b/docs/reference/asyncapi-rules.md new file mode 100644 index 000000000..9cf9f8a97 --- /dev/null +++ b/docs/reference/asyncapi-rules.md @@ -0,0 +1,371 @@ +# AsyncAPI Rules + +Spectral has a built-in "asyncapi" ruleset for the [AsyncAPI Specification](https://www.asyncapi.com/docs/specifications/2.0.0/). + +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. + +### 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-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`. + +Putting in the name of the developer/team/department/company responsible for the API, along with the support email and help-desk/GitHub Issues/whatever URL means people know where to go for help. This can mean more money in the bank, instead of developers just wandering off or complaining online. + +**Recommended:** Yes + +**Good Example** + +```yaml +asyncapi: "2.0.0" +info: + title: Awesome API + description: A very well defined API + version: "1.0" + contact: + name: A-Team + email: a-team@goarmy.com + url: https://goarmy.com/apis/support +``` + +### asyncapi-info-contact + +Info object should contain `contact` object. + +Hopefully your API description document is so good that nobody ever needs to contact you with questions, but that is rarely the case. The contact object has a few different options for contact details. + +**Recommended:** Yes + +**Good Example** + +```yaml +asyncapi: "2.0.0" +info: + title: Awesome API + version: "1.0" + contact: + name: A-Team + email: a-team@goarmy.com +``` + +### asyncapi-info-description + +AsyncAPI object info `description` must be present and non-empty string. + +Examples can contain Markdown so you can really go to town with them, implementing getting started information like where to find authentication keys, and how to use them. + +**Recommended:** Yes + +**Good Example** + +```yaml +asyncapi: "2.0.0" +info: + version: "1.0.0" + title: Descriptive API + description: >+ + Some description about the general point of this API, and why it exists when another similar but different API also exists. + +``` + +### asyncapi-info-license-url + +Mentioning a license is only useful if people know what the license means, so add a link to the full text for those who need it. + +**Recommended:** No + +**Good Example** + +```yaml +asyncapi: "2.0.0" +info: + license: + name: MIT + url: https://www.tldrlegal.com/l/mit +``` + +### asyncapi-info-license + +The `info` object should have a `license` key. + +It can be hard to pick a license, so if you don't have a lawyer around you can use [TLDRLegal](https://tldrlegal.com/) and [Choose a License](https://choosealicense.com/) to help give you an idea. + +How useful this is in court is not entirely known, but having a license is better than not having a license. + +**Recommended:** Yes + +**Good Example** + +```yaml +asyncapi: "2.0.0" +info: + license: + name: MIT +``` + +### asyncapi-operation-description + +Operation objects should have a description. + +**Recommended:** Yes + +### asyncapi-operation-operationId + +This operation ID is essentially a reference for the operation. Tools may use it for defining function names, class method names, and even URL hashes in documentation systems. + +**Recommended:** Yes + +### asyncapi-parameter-description + +Parameter objects should have a `description`. + +**Recommended:** No + +### asyncapi-payload-default + +`default` objects should be valid against the `payload` they decorate. + +**Recommended:** Yes + +**Good Example** + +``` yaml +payload: + type: object + properties: + value: + type: integer + required: + - value + default: + value: 17 +``` + +**Bad Example** + +``` yaml +payload: + type: object + properties: + value: + type: integer + required: + - value + default: + value: nope! +``` + +### asyncapi-payload-examples + +Values of the `examples` array should be valid against the `payload` they decorate. + +**Recommended:** Yes + +**Good Example** + +``` yaml +payload: + type: object + properties: + value: + type: integer + required: + - value + examples: + - value: 13 + - value: 17 +``` + +**Bad Example** + +``` yaml +payload: + type: object + properties: + value: + type: integer + required: + - value + examples: + - value: nope! # Wrong type + - notGoodEither: 17 # Missing required property +``` + +### asyncapi-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-payload + +When `schemaFormat` is undefined, the `payload` object should be valid against the AsyncAPI 2 Schema Object definition. + +**Recommended:** Yes + +### asyncapi-schema-default + +`default` objects should be valid against the `schema` they decorate. + +**Recommended:** Yes + +### asyncapi-schema-examples + +Values of the `examples` array should be valid against the `schema` they decorate. + +**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. + +**Recommended:** Yes + +### asyncapi-server-no-trailing-slash + +Server URL should not have a trailing slash. + +Some tooling forgets to strip trailing slashes off when it's joining the `servers.url` with `channels`, and you can get awkward URLs like `mqtt://example.com/broker//pets`. Best to just strip them off yourself. + +**Recommended:** Yes + +**Good Example** + +``` yaml +servers: + - url: mqtt://example.com + - url: mqtt://example.com/broker +``` + +**Bad Example** + +``` yaml +servers: + - url: mqtt://example.com/ + - url: mqtt://example.com/broker/ +``` + +### asyncapi-server-not-example-com + +Server URL should not point at example.com. + +**Recommended:** No + +### 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. + +```yaml +tags: + - name: 'Aardvark' + description: Funny nosed pig-head racoon. + - 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, anything. What is clear to the folks writing the document is probably not as clear to others. + +```yaml +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-tags-alphabetical + +AsyncAPI object should have alphabetical `tags`. This will be sorted by the `name` property. + +**Recommended:** No + +**Bad Example** + +```yaml +tags: + - name: 'Badger' + - name: 'Aardvark' +``` + +**Good Example** + +```yaml +tags: + - name: 'Aardvark' + - name: 'Badger' +``` + +**Recommended:** No + +### asyncapi-tags + +AsyncAPI 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 +``` + +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 + +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 diff --git a/docs/reference/functions.md b/docs/reference/functions.md index b562eab1d..4498b0ba3 100644 --- a/docs/reference/functions.md +++ b/docs/reference/functions.md @@ -153,6 +153,7 @@ Use JSON Schema (draft 4, 6 or 7) to treat the contents of the $given JSON Path name | description | required? ---------|----------|--------- schema | a valid JSON Schema document | yes +allErrors | returns all errors when `true`; otherwise only returns the first error | no @@ -183,6 +184,7 @@ name | description | required? ---------|----------|--------- field | the field to check | yes schemaPath | a json path pointing to the json schema to use | yes +allErrors | returns all errors when `true`; otherwise only returns the first error | no diff --git a/package.json b/package.json index 281801518..3f03c06e8 100644 --- a/package.json +++ b/package.json @@ -44,9 +44,9 @@ "lint.fix": "yarn lint --fix", "lint": "tsc --noEmit && tslint 'src/**/*.ts'", "copy.html-templates": "copyfiles -u 1 \"./src/formatters/html/*.html\" \"./dist/\"", - "postbuild.functions": "copyfiles -u 1 \"dist/rulesets/+(oas)/functions/*.js\" ./", + "postbuild.functions": "copyfiles -u 1 \"dist/rulesets/{oas,asyncapi}/functions/*.js\" ./", "postbuild": "yarn build.functions && yarn generate-assets", - "prebuild": "yarn build.clean && copyfiles -u 1 \"src/rulesets/+(oas)/**/*.json\" dist && copyfiles -u 1 \"src/rulesets/+(oas)/**/*.json\" ./ && yarn copy.html-templates", + "prebuild": "yarn build.clean && copyfiles -u 1 \"src/rulesets/{oas,asyncapi}/**/*.json\" dist && copyfiles -u 1 \"src/rulesets/{oas,asyncapi}/**/*.json\" ./ && yarn copy.html-templates", "prebuild.binary": "yarn build", "pretest.karma": "node ./scripts/generate-karma-fixtures.js && yarn pretest", "pretest": "yarn generate-assets", @@ -87,6 +87,7 @@ "devDependencies": { "@commitlint/config-conventional": "^8.3.4", "@rollup/plugin-commonjs": "^11.1.0", + "@rollup/plugin-json": "^4.0.2", "@rollup/plugin-node-resolve": "^7.1.3", "@types/chalk": "^2.2.0", "@types/fetch-mock": "^7.3.1", diff --git a/rollup.config.js b/rollup.config.js index 8eb6323e2..9132a6c34 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -3,13 +3,14 @@ import * as path from 'path'; import * as fs from 'fs'; import resolve from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json' import { terser } from 'rollup-plugin-terser'; const BASE_PATH = process.cwd(); const functions = []; -const builtIns = ['oas'] +const builtIns = ['oas', 'asyncapi'] for (const rulesetName of builtIns) { const targetDir = path.join(BASE_PATH, `dist/rulesets/${rulesetName}/functions/`); @@ -43,6 +44,7 @@ module.exports = functions.map(fn => ({ 'node_modules/@stoplight/types/dist/index.js': ['DiagnosticSeverity'], }, }), + json(), terser(), ], output: { diff --git a/scripts/generate-assets.ts b/scripts/generate-assets.ts index 21fae28cc..0e2368ac1 100644 --- a/scripts/generate-assets.ts +++ b/scripts/generate-assets.ts @@ -30,7 +30,7 @@ const assetsPath = path.join(baseDir, `assets.json`); const generatedAssets = {}; (async () => { - for (const kind of ['oas']) { + for (const kind of ['oas', 'asyncapi']) { await processDirectory(generatedAssets, path.join(__dirname, `../rulesets/${kind}`)); await writeFileAsync(assetsPath, JSON.stringify(generatedAssets, null, 2)); } @@ -39,7 +39,7 @@ const generatedAssets = {}; async function processDirectory(assets: Record, dir: string) { await Promise.all( (await readdirAsync(dir)).map(async (name: string) => { - if (name === 'schemas') return; + if (['schemas', '__tests__'].includes(name)) return; const target = path.join(dir, name); const stats = await statAsync(target); if (stats.isDirectory()) { diff --git a/scripts/generate-karma-fixtures.js b/scripts/generate-karma-fixtures.js index 9503fc2a1..29bca9688 100755 --- a/scripts/generate-karma-fixtures.js +++ b/scripts/generate-karma-fixtures.js @@ -8,7 +8,7 @@ if (!fs.existsSync(baseDir)) { fs.mkdirSync(baseDir); } -for (const rulesetName of ['oas']) { +for (const rulesetName of ['oas', 'asyncapi']) { const target = path.join(baseDir, `${rulesetName}-functions.json`); const fnsPath = path.join(__dirname, `../rulesets/${rulesetName}/functions`); const bundledFns = {}; diff --git a/setupKarma.ts b/setupKarma.ts index 6c0ae5f7b..22ac18d82 100644 --- a/setupKarma.ts +++ b/setupKarma.ts @@ -5,6 +5,9 @@ const oasRuleset = JSON.parse(JSON.stringify(require('./rulesets/oas/index.json' const oasFunctions = JSON.parse(JSON.stringify(require('./__karma__/__fixtures__/oas-functions.json'))); const oas2Schema = JSON.parse(JSON.stringify(require('./rulesets/oas/schemas/schema.oas2.json'))); const oas3Schema = JSON.parse(JSON.stringify(require('./rulesets/oas/schemas/schema.oas3.json'))); +const asyncApiRuleset = JSON.parse(JSON.stringify(require('./rulesets/asyncapi/index.json'))); +const asyncApiFunctions = JSON.parse(JSON.stringify(require('./__karma__/__fixtures__/asyncapi-functions.json'))); +const asyncApi2Schema = JSON.parse(JSON.stringify(require('./rulesets/asyncapi/schemas/schema.asyncapi2.json'))); const { fetch } = window; let fetchMock: FetchMockSandbox; @@ -33,8 +36,19 @@ beforeEach(() => { body: JSON.parse(JSON.stringify(oas3Schema)), }); + fetchMock.get('https://unpkg.com/@stoplight/spectral/rulesets/asyncapi/index.json', { + status: 200, + body: JSON.parse(JSON.stringify(asyncApiRuleset)), + }); + + fetchMock.get('https://unpkg.com/@stoplight/spectral/rulesets/asyncapi/schemas/schema.asyncapi2.json', { + status: 200, + body: JSON.parse(JSON.stringify(asyncApi2Schema)), + }); + [ ['oas', oasFunctions], + ['asyncapi', asyncApiFunctions], ].forEach(([rulesetName, funcs]) => { for (const [name, fn] of Object.entries(funcs)) { fetchMock.get(`https://unpkg.com/@stoplight/spectral/rulesets/${rulesetName}/functions/${name}`, { diff --git a/setupTests.ts b/setupTests.ts index c7c271db0..d480e703f 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -1,5 +1,9 @@ import { RulesetExceptionCollection } from './src/types/ruleset'; +import { Dictionary } from '@stoplight/types'; +import { IRunRule, isAsyncApiv2, Rule, Spectral } from './src'; +import { rules as asyncApiRules } from './src/rulesets/asyncapi/index.json'; + export const buildRulesetExceptionCollectionFrom = ( loc: string, rules: string[] = ['a'], @@ -8,3 +12,38 @@ export const buildRulesetExceptionCollectionFrom = ( source[loc] = rules; return source; }; + +const removeAllRulesBut = (spectral: Spectral, ruleName: string) => { + expect(Object.keys(spectral.rules)).toContain(ruleName); + + const rule1 = spectral.rules[ruleName]; + + const rawRule = asyncApiRules[ruleName]; + + const patchedRule: Rule = Object.assign(rule1, { + recommended: true, + severity: rawRule.severity, + }); + + const rules: Dictionary = {}; + rules[ruleName] = patchedRule; + spectral.setRules(rules); +}; + +export const buildTestSpectralWithAsyncApiRule = async (ruleName: string): Promise<[Spectral, IRunRule]> => { + const s = new Spectral(); + s.registerFormat('asyncapi2', isAsyncApiv2); + await s.loadRuleset('spectral:asyncapi'); + + removeAllRulesBut(s, ruleName); + expect(Object.keys(s.rules)).toEqual([ruleName]); + + const rule = s.rules[ruleName]; + expect(rule.recommended).not.toBe(false); + expect(rule.severity).not.toBeUndefined(); + expect(rule.severity).not.toEqual(-1); + expect(rule.formats).not.toBeUndefined(); + expect(rule.formats).toContain('asyncapi2'); + + return [s, rule]; +}; diff --git a/src/__tests__/__fixtures__/streetlights.asyncapi2.json b/src/__tests__/__fixtures__/streetlights.asyncapi2.json new file mode 100644 index 000000000..f792f3c4f --- /dev/null +++ b/src/__tests__/__fixtures__/streetlights.asyncapi2.json @@ -0,0 +1,297 @@ +{ + "asyncapi": "2.0.0", + "info": { + "title": "Streetlights API", + "version": "1.0.0", + "description": "The Smartylighting Streetlights API allows you to remotely manage the city lights.\n\n### Check out its awesome features:\n\n* Turn a specific streetlight on/off 🌃\n* Dim a specific streetlight 😎\n* Receive real-time information about environmental lighting conditions 📈\n", + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0" + } + }, + "servers": { + "production": { + "url": "test.mosquitto.org:{port}", + "protocol": "mqtt", + "description": "Test broker", + "variables": { + "port": { + "description": "Secure connection (TLS) is available through port 8883.", + "default": "1883", + "enum": [ + "1883", + "8883" + ] + } + }, + "security": [ + { + "apiKey": [] + }, + { + "supportedOauthFlows": [ + "streetlights:on", + "streetlights:off", + "streetlights:dim" + ] + }, + { + "openIdConnectWellKnown": [] + } + ] + } + }, + "defaultContentType": "application/json", + "channels": { + "smartylighting/streetlights/1/0/event/{streetlightId}/lighting/measured": { + "description": "The topic on which measured values may be produced and consumed.", + "parameters": { + "streetlightId": { + "$ref": "#/components/parameters/streetlightId" + } + }, + "publish": { + "summary": "Inform about environmental lighting conditions of a particular streetlight.", + "operationId": "receiveLightMeasurement", + "traits": [ + { + "$ref": "#/components/operationTraits/kafka" + } + ], + "message": { + "$ref": "#/components/messages/lightMeasured" + } + } + }, + "smartylighting/streetlights/1/0/action/{streetlightId}/turn/on": { + "parameters": { + "streetlightId": { + "$ref": "#/components/parameters/streetlightId" + } + }, + "subscribe": { + "operationId": "turnOn", + "traits": [ + { + "$ref": "#/components/operationTraits/kafka" + } + ], + "message": { + "$ref": "#/components/messages/turnOnOff" + } + } + }, + "smartylighting/streetlights/1/0/action/{streetlightId}/turn/off": { + "parameters": { + "streetlightId": { + "$ref": "#/components/parameters/streetlightId" + } + }, + "subscribe": { + "operationId": "turnOff", + "traits": [ + { + "$ref": "#/components/operationTraits/kafka" + } + ], + "message": { + "$ref": "#/components/messages/turnOnOff" + } + } + }, + "smartylighting/streetlights/1/0/action/{streetlightId}/dim": { + "parameters": { + "streetlightId": { + "$ref": "#/components/parameters/streetlightId" + } + }, + "subscribe": { + "operationId": "dimLight", + "traits": [ + { + "$ref": "#/components/operationTraits/kafka" + } + ], + "message": { + "$ref": "#/components/messages/dimLight" + } + } + } + }, + "components": { + "messages": { + "lightMeasured": { + "name": "lightMeasured", + "title": "Light measured", + "summary": "Inform about environmental lighting conditions of a particular streetlight.", + "contentType": "application/json", + "traits": [ + { + "$ref": "#/components/messageTraits/commonHeaders" + } + ], + "payload": { + "$ref": "#/components/schemas/lightMeasuredPayload" + } + }, + "turnOnOff": { + "name": "turnOnOff", + "title": "Turn on/off", + "summary": "Command a particular streetlight to turn the lights on or off.", + "traits": [ + { + "$ref": "#/components/messageTraits/commonHeaders" + } + ], + "payload": { + "$ref": "#/components/schemas/turnOnOffPayload" + } + }, + "dimLight": { + "name": "dimLight", + "title": "Dim light", + "summary": "Command a particular streetlight to dim the lights.", + "traits": [ + { + "$ref": "#/components/messageTraits/commonHeaders" + } + ], + "payload": { + "$ref": "#/components/schemas/dimLightPayload" + } + } + }, + "schemas": { + "lightMeasuredPayload": { + "type": "object", + "properties": { + "lumens": { + "type": "integer", + "minimum": 0, + "description": "Light intensity measured in lumens." + }, + "sentAt": { + "$ref": "#/components/schemas/sentAt" + } + } + }, + "turnOnOffPayload": { + "type": "object", + "properties": { + "command": { + "type": "string", + "enum": [ + "on", + "off" + ], + "description": "Whether to turn on or off the light." + }, + "sentAt": { + "$ref": "#/components/schemas/sentAt" + } + } + }, + "dimLightPayload": { + "type": "object", + "properties": { + "percentage": { + "type": "integer", + "description": "Percentage to which the light should be dimmed to.", + "minimum": 0, + "maximum": 100 + }, + "sentAt": { + "$ref": "#/components/schemas/sentAt" + } + } + }, + "sentAt": { + "type": "string", + "format": "date-time", + "description": "Date and time when the message was sent." + } + }, + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "user", + "description": "Provide your API key as the user and leave the password empty." + }, + "supportedOauthFlows": { + "type": "oauth2", + "description": "Flows to support OAuth 2.0", + "flows": { + "implicit": { + "authorizationUrl": "https://authserver.example/auth", + "scopes": { + "streetlights:on": "Ability to switch lights on", + "streetlights:off": "Ability to switch lights off", + "streetlights:dim": "Ability to dim the lights" + } + }, + "password": { + "tokenUrl": "https://authserver.example/token", + "scopes": { + "streetlights:on": "Ability to switch lights on", + "streetlights:off": "Ability to switch lights off", + "streetlights:dim": "Ability to dim the lights" + } + }, + "clientCredentials": { + "tokenUrl": "https://authserver.example/token", + "scopes": { + "streetlights:on": "Ability to switch lights on", + "streetlights:off": "Ability to switch lights off", + "streetlights:dim": "Ability to dim the lights" + } + }, + "authorizationCode": { + "authorizationUrl": "https://authserver.example/auth", + "tokenUrl": "https://authserver.example/token", + "refreshUrl": "https://authserver.example/refresh", + "scopes": { + "streetlights:on": "Ability to switch lights on", + "streetlights:off": "Ability to switch lights off", + "streetlights:dim": "Ability to dim the lights" + } + } + } + }, + "openIdConnectWellKnown": { + "type": "openIdConnect", + "openIdConnectUrl": "https://authserver.example/.well-known" + } + }, + "parameters": { + "streetlightId": { + "description": "The ID of the streetlight.", + "schema": { + "type": "string" + } + } + }, + "messageTraits": { + "commonHeaders": { + "headers": { + "type": "object", + "properties": { + "my-app-header": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + } + } + } + }, + "operationTraits": { + "kafka": { + "bindings": { + "kafka": { + "clientId": "my-app-id" + } + } + } + } + } +} \ No newline at end of file diff --git a/src/__tests__/generate-assets.jest.test.ts b/src/__tests__/generate-assets.jest.test.ts index f9609b16e..aafd64449 100644 --- a/src/__tests__/generate-assets.jest.test.ts +++ b/src/__tests__/generate-assets.jest.test.ts @@ -19,6 +19,7 @@ describe('generate-assets', () => { const testCases = [ ['oas', 'oas2-schema', 'title', 'A JSON Schema for Swagger 2.0 API.'], ['oas', 'oas3-schema', 'description', 'Validation schema for OpenAPI Specification 3.0.X.'], + ['asyncapi', 'asyncapi-schema', 'title', 'AsyncAPI 2.0.0 schema.'], ]; it.each(testCases)( @@ -35,4 +36,10 @@ describe('generate-assets', () => { }, ); }); + + it('Does not contain test files', () => { + Object.keys(assets).forEach(key => { + expect(key).not.toMatch('__tests__'); + }); + }); }); diff --git a/src/__tests__/linter.test.ts b/src/__tests__/linter.test.ts index a7b3b3562..0e2153382 100644 --- a/src/__tests__/linter.test.ts +++ b/src/__tests__/linter.test.ts @@ -479,6 +479,7 @@ describe('linter', () => { ]); }); + // TODO: Find a way to cover formats more extensively test('given a string input, should warn about unmatched formats', async () => { const result = await spectral.run('test'); diff --git a/src/__tests__/spectral.test.ts b/src/__tests__/spectral.test.ts index f5e5e1a0f..fbf0cb865 100644 --- a/src/__tests__/spectral.test.ts +++ b/src/__tests__/spectral.test.ts @@ -12,17 +12,22 @@ import { RulesetExceptionCollection } from '../types/ruleset'; import { buildRulesetExceptionCollectionFrom } from '../../setupTests'; const oasRuleset = JSON.parse(JSON.stringify(require('../rulesets/oas/index.json'))); +const asyncApiRuleset = JSON.parse(JSON.stringify(require('../rulesets/asyncapi/index.json'))); const oasRulesetRules: Dictionary = oasRuleset.rules; +const asyncApiRulesetRules: Dictionary = asyncApiRuleset.rules; describe('spectral', () => { describe('loadRuleset', () => { - test('should support loading built-in rulesets', async () => { + test.each([ + ['spectral:oas', oasRulesetRules], + ['spectral:asyncapi', asyncApiRulesetRules], + ])('should support loading "%s" built-in ruleset', async (rulesetName, rules) => { const s = new Spectral(); - await s.loadRuleset('spectral:oas'); + await s.loadRuleset(rulesetName); expect(s.rules).toEqual( expect.objectContaining( - Object.entries(oasRulesetRules).reduce>((oasRules, [name, rule]) => { + Object.entries(rules).reduce>((oasRules, [name, rule]) => { oasRules[name] = { name, ...rule, @@ -38,12 +43,15 @@ describe('spectral', () => { ); }); - test('should support loading multiple times the built-in ruleset', async () => { + test.each([ + ['spectral:oas', oasRulesetRules], + ['spectral:asyncapi', asyncApiRulesetRules], + ])('should support loading multiple times the built-in ruleset "%s"', async (rulesetName, expectedRules) => { const s = new Spectral(); - await s.loadRuleset(['spectral:oas', 'spectral:oas']); + await s.loadRuleset([rulesetName, rulesetName]); expect(s.rules).toEqual( - Object.entries(oasRulesetRules).reduce>((oasRules, [name, rule]) => { + Object.entries(expectedRules).reduce>((oasRules, [name, rule]) => { oasRules[name] = { name, ...rule, diff --git a/src/assets.ts b/src/assets.ts index 7e9d8543f..f8841fd3f 100644 --- a/src/assets.ts +++ b/src/assets.ts @@ -6,6 +6,7 @@ function resolveSpectralRuleset(ruleset: string) { export const RESOLVE_ALIASES: Dictionary = { 'spectral:oas': resolveSpectralRuleset('oas'), + 'spectral:asyncapi': resolveSpectralRuleset('asyncapi'), }; export const STATIC_ASSETS: Dictionary = {}; diff --git a/src/cli/services/linter/linter.ts b/src/cli/services/linter/linter.ts index 4c155c00f..e0473d3ab 100644 --- a/src/cli/services/linter/linter.ts +++ b/src/cli/services/linter/linter.ts @@ -1,5 +1,6 @@ import { Document, STDIN } from '../../../document'; import { + isAsyncApiv2, isJSONSchema, isJSONSchemaDraft2019_09, isJSONSchemaDraft4, @@ -21,6 +22,7 @@ import { getResolver } from './utils/getResolver'; const KNOWN_FORMATS: Array<[string, FormatLookup, string]> = [ ['oas2', isOpenApiv2, 'OpenAPI 2.0 (Swagger) detected'], ['oas3', isOpenApiv3, 'OpenAPI 3.x detected'], + ['asyncapi2', isAsyncApiv2, 'AsyncAPI 2.x detected'], ['json-schema', isJSONSchema, 'JSON Schema detected'], ['json-schema-loose', isJSONSchemaLoose, 'JSON Schema (loose) detected'], ['json-schema-draft4', isJSONSchemaDraft4, 'JSON Schema Draft 4 detected'], diff --git a/src/cli/services/linter/utils/getRuleset.ts b/src/cli/services/linter/utils/getRuleset.ts index cdee8b322..4b8a998d3 100644 --- a/src/cli/services/linter/utils/getRuleset.ts +++ b/src/cli/services/linter/utils/getRuleset.ts @@ -21,5 +21,5 @@ export async function getRuleset(rulesetFile: Optional) { return await (rulesetFiles ? loadRulesets(process.cwd(), Array.isArray(rulesetFiles) ? rulesetFiles : [rulesetFiles]) - : readRuleset('spectral:oas')); + : readRuleset(['spectral:oas', 'spectral:asyncapi'])); } diff --git a/src/formats/__tests__/asyncapi.test.ts b/src/formats/__tests__/asyncapi.test.ts new file mode 100644 index 000000000..81b883e0a --- /dev/null +++ b/src/formats/__tests__/asyncapi.test.ts @@ -0,0 +1,31 @@ +import { isAsyncApiv2 } from '../asyncapi'; + +describe('AsyncApi format', () => { + describe('AsyncApi 2.{minor}.{patch}', () => { + it.each([['2.0.17'], ['2.9.0'], ['2.9.3']])('recognizes %s version correctly', (version: string) => { + expect(isAsyncApiv2({ asyncapi: version })).toBe(true); + }); + + const testCases = [ + { asyncapi: '3.0' }, + { asyncapi: '3.0.0' }, + { asyncapi: '2' }, + { asyncapi: '2.0' }, + { asyncapi: '2.0.' }, + { asyncapi: '2.0.01' }, + { asyncapi: '1.0' }, + { asyncapi: 2 }, + { openapi: '4.0' }, + { openapi: '2.0' }, + { openapi: null }, + { swagger: null }, + { swagger: '3.0' }, + {}, + null, + ]; + + it.each(testCases)('does not recognize invalid document %o', document => { + expect(isAsyncApiv2(document)).toBe(false); + }); + }); +}); diff --git a/src/formats/asyncapi.ts b/src/formats/asyncapi.ts new file mode 100644 index 000000000..b9a8b9712 --- /dev/null +++ b/src/formats/asyncapi.ts @@ -0,0 +1,19 @@ +import { isObject } from 'lodash'; + +type MaybeAsyncApi2 = Partial<{ asyncapi: unknown }>; + +const bearsAStringPropertyNamed = (document: unknown, propertyName: string) => { + return isObject(document) && propertyName in document && typeof document[propertyName] === 'string'; +}; + +const version2Regex = /^2\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/; + +export const isAsyncApiv2 = (document: unknown) => { + if (!bearsAStringPropertyNamed(document, 'asyncapi')) { + return false; + } + + const version = String((document as MaybeAsyncApi2).asyncapi); + + return version2Regex.test(version); +}; diff --git a/src/formats/index.ts b/src/formats/index.ts index 54f1929eb..78a2a4f1c 100644 --- a/src/formats/index.ts +++ b/src/formats/index.ts @@ -1,2 +1,3 @@ export * from './openapi'; +export * from './asyncapi'; export * from './json-schema'; diff --git a/src/functions/__tests__/schema-path.test.ts b/src/functions/__tests__/schema-path.test.ts index 309250d47..0a2f5a9e7 100644 --- a/src/functions/__tests__/schema-path.test.ts +++ b/src/functions/__tests__/schema-path.test.ts @@ -119,6 +119,35 @@ describe('schema-path', () => { ]); }); + test('will pass with valid array input', () => { + const target = { + schema: { + type: 'string', + examples: ['one', 'another'], + }, + }; + expect(runSchemaPath(target, '$.schema.examples.*', path)).toHaveLength(0); + }); + + test('will error with invalid array input', () => { + const target = { + schema: { + type: 'string', + examples: [3, 'one', 17], + }, + }; + expect(runSchemaPath(target, '$.schema.examples.*', path)).toEqual([ + { + message: '{{property|gravis|append-property|optional-typeof|capitalize}}type should be string', + path: ['schema', 'examples', '0'], + }, + { + message: '{{property|gravis|append-property|optional-typeof|capitalize}}type should be string', + path: ['schema', 'examples', '2'], + }, + ]); + }); + test('will error formats', () => { const target = { schema: { diff --git a/src/functions/schema-path.ts b/src/functions/schema-path.ts index c439661d1..3ad080e39 100644 --- a/src/functions/schema-path.ts +++ b/src/functions/schema-path.ts @@ -21,6 +21,7 @@ export interface ISchemaPathOptions { field?: string; // The oasVersion, either 2 or 3 for OpenAPI Spec versions, could also be 3.1 or a larger number if there's a need for it, otherwise JSON Schema oasVersion?: Optional; + allErrors?: boolean; } export type SchemaPathRule = IRule; @@ -40,6 +41,7 @@ export const schemaPath: IFunction = (targetVal, opts, paths { schema: schemaObject, oasVersion: opts.oasVersion, + allErrors: opts.allErrors, }, { given: paths.given, diff --git a/src/rulesets/__tests__/offline.schemas.jest.test.ts b/src/rulesets/__tests__/offline.schemas.jest.test.ts index 227c086fe..1a2397955 100644 --- a/src/rulesets/__tests__/offline.schemas.jest.test.ts +++ b/src/rulesets/__tests__/offline.schemas.jest.test.ts @@ -3,7 +3,7 @@ import * as fs from 'fs'; import * as nock from 'nock'; import { Document } from '../../document'; -import { isOpenApiv2, isOpenApiv3 } from '../../formats'; +import { isAsyncApiv2, isOpenApiv2, isOpenApiv3 } from '../../formats'; import { readParsable } from '../../fs/reader'; import { Spectral } from '../../index'; import * as Parsers from '../../parsers'; @@ -32,6 +32,14 @@ const knownRulesets: ITestCases = { }, ], }, + 'spectral:asyncapi': { + fixtures: [ + { + fixture: '../../__tests__/__fixtures__/streetlights.asyncapi2.json', + format: { name: 'asyncapi2', lookupFn: isAsyncApiv2 }, + }, + ], + }, }; type FlattenedTestCases = [string, string, string, FormatLookup]; @@ -82,6 +90,8 @@ describe('Online vs Offline context', () => { }, ); + const ordinalSort = (arr: string[]) => arr.sort((a, b) => a.localeCompare(b)); + test('all rulesets are accounted for', async () => { const dir = path.join(__dirname, '../../../rulesets/'); @@ -105,6 +115,6 @@ describe('Online vs Offline context', () => { }); // Will fail when a ruleset has not been added to the `knownRulesets` variable - expect(discoveredRulesets).toEqual(Object.keys(knownRulesets)); + expect(ordinalSort(discoveredRulesets)).toEqual(ordinalSort(Object.keys(knownRulesets))); }); }); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-channel-no-empty-parameter.ts b/src/rulesets/asyncapi/__tests__/asyncapi-channel-no-empty-parameter.ts new file mode 100644 index 000000000..fda96a40a --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-channel-no-empty-parameter.ts @@ -0,0 +1,45 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-channel-no-empty-parameter'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + channels: { + 'users/{userId}/signedUp': {}, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if channels.{channel} contains empty parameter substitution pattern', async () => { + const clone = cloneDeep(doc); + + clone.channels['users/{}/signedOut'] = {}; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Channel path should not have empty parameter substitution pattern.', + path: ['channels', 'users/{}/signedOut'], + severity: rule.severity, + }), + ]); + }); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-channel-no-query-nor-fragment.ts b/src/rulesets/asyncapi/__tests__/asyncapi-channel-no-query-nor-fragment.ts new file mode 100644 index 000000000..a541eb979 --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-channel-no-query-nor-fragment.ts @@ -0,0 +1,62 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-channel-no-query-nor-fragment'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + channels: { + 'users/{userId}/signedUp': {}, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if channels.{channel} contains a query delimiter', async () => { + const clone = cloneDeep(doc); + + clone.channels['users/{userId}/signedOut?byMistake={didFatFingerTheSignOutButton}'] = {}; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Channel path should not include a query (`?`) or a fragment (`#`) delimiter.', + path: ['channels', 'users/{userId}/signedOut?byMistake={didFatFingerTheSignOutButton}'], + severity: rule.severity, + }), + ]); + }); + + test('return result if channels.{channel} contains a fragment delimiter', async () => { + const clone = cloneDeep(doc); + + clone.channels['users/{userId}/signedOut#onPurpose'] = {}; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Channel path should not include a query (`?`) or a fragment (`#`) delimiter.', + path: ['channels', 'users/{userId}/signedOut#onPurpose'], + severity: rule.severity, + }), + ]); + }); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-channel-no-trailing-slash.ts b/src/rulesets/asyncapi/__tests__/asyncapi-channel-no-trailing-slash.ts new file mode 100644 index 000000000..ad9440008 --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-channel-no-trailing-slash.ts @@ -0,0 +1,45 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-channel-no-trailing-slash'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + channels: { + 'users/{userId}/signedUp': {}, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if channels.{channel} ends with a trailing slash', async () => { + const clone = cloneDeep(doc); + + clone.channels['users/{userId}/signedOut/'] = {}; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Channel path should not end with a slash.', + path: ['channels', 'users/{userId}/signedOut/'], + severity: rule.severity, + }), + ]); + }); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-headers-schema-type-object.ts b/src/rulesets/asyncapi/__tests__/asyncapi-headers-schema-type-object.ts new file mode 100644 index 000000000..bde140ba0 --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-headers-schema-type-object.ts @@ -0,0 +1,165 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-headers-schema-type-object'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const headersBearer: any = { + headers: { + type: 'object', + properties: { + 'some-header': { + type: 'string', + }, + }, + }, + }; + + const doc: any = { + asyncapi: '2.0.0', + channels: { + 'users/{userId}/signedUp': { + publish: { + message: cloneDeep(headersBearer), + }, + subscribe: { + message: cloneDeep(headersBearer), + }, + }, + }, + components: { + messageTraits: { + aTrait: cloneDeep(headersBearer), + }, + messages: { + aMessage: cloneDeep(headersBearer), + }, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if components.messages.{message}.headers is not of type "object"', async () => { + const clone = cloneDeep(doc); + + clone.components.messages.aMessage.headers = { type: 'integer' }; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: + 'Headers schema type should be `object` (`type` property should be equal to one of the allowed values: object. Did you mean object?).', + path: ['components', 'messages', 'aMessage', 'headers', 'type'], + severity: rule.severity, + }), + ]); + }); + + test('return result if components.messages.{message}.headers lacks "type" property', async () => { + const clone = cloneDeep(doc); + + clone.components.messages.aMessage.headers = { const: 'Hello World!' }; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Headers schema type should be `object` (Object should have required property `type`).', + path: ['components', 'messages', 'aMessage', 'headers'], + severity: rule.severity, + }), + ]); + }); + + test('return result if components.messageTraits.{trait}.headers is not of type "object"', async () => { + const clone = cloneDeep(doc); + + clone.components.messageTraits.aTrait.headers = { type: 'integer' }; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: + 'Headers schema type should be `object` (`type` property should be equal to one of the allowed values: object. Did you mean object?).', + path: ['components', 'messageTraits', 'aTrait', 'headers', 'type'], + severity: rule.severity, + }), + ]); + }); + + test('return result if components.messageTraits.{trait}.headers lacks "type" property', async () => { + const clone = cloneDeep(doc); + + clone.components.messageTraits.aTrait.headers = { const: 'Hello World!' }; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Headers schema type should be `object` (Object should have required property `type`).', + path: ['components', 'messageTraits', 'aTrait', 'headers'], + severity: rule.severity, + }), + ]); + }); + + test.each(['publish', 'subscribe'])( + 'return result if channels.{channel}.%s.message.headers lacks "type" property', + async (property: string) => { + const clone = cloneDeep(doc); + + clone.channels['users/{userId}/signedUp'][property].message.headers = { const: 'Hello World!' }; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Headers schema type should be `object` (Object should have required property `type`).', + path: ['channels', 'users/{userId}/signedUp', property, 'message', 'headers'], + severity: rule.severity, + }), + ]); + }, + ); + + test.each(['publish', 'subscribe'])( + 'return result if channels.{channel}.%s.message.headers is not of type "object"', + async (property: string) => { + const clone = cloneDeep(doc); + + clone.channels['users/{userId}/signedUp'][property].message.headers = { type: 'integer' }; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: + 'Headers schema type should be `object` (`type` property should be equal to one of the allowed values: object. Did you mean object?).', + path: ['channels', 'users/{userId}/signedUp', property, 'message', 'headers', 'type'], + severity: rule.severity, + }), + ]); + }, + ); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-info-contact-properties.ts b/src/rulesets/asyncapi/__tests__/asyncapi-info-contact-properties.ts new file mode 100644 index 000000000..22b2f9646 --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-info-contact-properties.ts @@ -0,0 +1,49 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-info-contact-properties'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + info: { + contact: { + name: 'stoplight', + url: 'stoplight.io', + email: 'support@stoplight.io', + }, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test.each(['name', 'url', 'email'])('return result if contact.%s property is missing', async (property: string) => { + const clone = cloneDeep(doc); + + delete clone.info.contact[property]; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Contact object should have `name`, `url` and `email`.', + path: ['info', 'contact'], + severity: rule.severity, + }), + ]); + }); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-info-contact.ts b/src/rulesets/asyncapi/__tests__/asyncapi-info-contact.ts new file mode 100644 index 000000000..8b0dc6dbe --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-info-contact.ts @@ -0,0 +1,49 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-info-contact'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + info: { + contact: { + name: 'stoplight', + url: 'stoplight.io', + email: 'support@stoplight.io', + }, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if contact property is missing', async () => { + const clone = cloneDeep(doc); + + delete clone.info.contact; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Info object should contain `contact` object.', + path: ['info'], + severity: rule.severity, + }), + ]); + }); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-info-description.ts b/src/rulesets/asyncapi/__tests__/asyncapi-info-description.ts new file mode 100644 index 000000000..df2d9ce55 --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-info-description.ts @@ -0,0 +1,45 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-info-description'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + info: { + description: 'Very descriptive list of self explaining consecutive characters.', + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if description property is missing', async () => { + const clone = cloneDeep(doc); + + delete clone.info.description; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'AsyncAPI object info `description` must be present and non-empty string.', + path: ['info'], + severity: rule.severity, + }), + ]); + }); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-info-license-url.ts b/src/rulesets/asyncapi/__tests__/asyncapi-info-license-url.ts new file mode 100644 index 000000000..22f6f009e --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-info-license-url.ts @@ -0,0 +1,47 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-info-license-url'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + info: { + license: { + url: 'https://github.com/stoplightio/spectral/blob/develop/LICENSE', + }, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if url property is missing', async () => { + const clone = cloneDeep(doc); + + delete clone.info.license.url; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'License object should include `url`.', + path: ['info', 'license'], + severity: rule.severity, + }), + ]); + }); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-info-license.ts b/src/rulesets/asyncapi/__tests__/asyncapi-info-license.ts new file mode 100644 index 000000000..bfbc01d2c --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-info-license.ts @@ -0,0 +1,47 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-info-license'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + info: { + license: { + name: 'MIT', + }, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if license property is missing', async () => { + const clone = cloneDeep(doc); + + delete clone.info.license; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'AsyncAPI object should contain `license` object.', + path: ['info'], + severity: rule.severity, + }), + ]); + }); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-operation-description.ts b/src/rulesets/asyncapi/__tests__/asyncapi-operation-description.ts new file mode 100644 index 000000000..f329e40bd --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-operation-description.ts @@ -0,0 +1,55 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-operation-description'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + channels: { + one: { + publish: { + description: 'I do this.', + }, + subscribe: { + description: '...and that', + }, + }, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test.each(['publish', 'subscribe'])( + 'return result if channels.{channel}.%s.description property is missing', + async (property: string) => { + const clone = cloneDeep(doc); + + delete clone.channels.one[property].description; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Operation `description` must be present and non-empty string.', + path: ['channels', 'one', property], + severity: rule.severity, + }), + ]); + }, + ); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-operation-operationId.ts b/src/rulesets/asyncapi/__tests__/asyncapi-operation-operationId.ts new file mode 100644 index 000000000..29b48303e --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-operation-operationId.ts @@ -0,0 +1,55 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-operation-operationId'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + channels: { + one: { + publish: { + operationId: 'onePubId', + }, + subscribe: { + operationId: 'oneSubId', + }, + }, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test.each(['publish', 'subscribe'])( + 'return result if channels.{channel}.%s.operationId property is missing', + async (property: string) => { + const clone = cloneDeep(doc); + + delete clone.channels.one[property].operationId; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Operation should have an `operationId`.', + path: ['channels', 'one', property], + severity: rule.severity, + }), + ]); + }, + ); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-parameter-description.ts b/src/rulesets/asyncapi/__tests__/asyncapi-parameter-description.ts new file mode 100644 index 000000000..74ac28eb1 --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-parameter-description.ts @@ -0,0 +1,75 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-parameter-description'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + channels: { + 'users/{userId}/signedUp': { + parameters: { + userId: { + description: 'The identifier of the user being tracked.', + }, + }, + }, + }, + components: { + parameters: { + orphanParameter: { + description: 'A defined, but orphaned, parameter.', + }, + }, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if channels.{channel}.parameters.{parameter} lack a description', async () => { + const clone = cloneDeep(doc); + + delete clone.channels['users/{userId}/signedUp'].parameters.userId.description; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Parameter objects should have a `description`.', + path: ['channels', 'users/{userId}/signedUp', 'parameters', 'userId'], + severity: rule.severity, + }), + ]); + }); + + test('return result if components.parameters.{parameter} lack a description', async () => { + const clone = cloneDeep(doc); + + delete clone.components.parameters.orphanParameter.description; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Parameter objects should have a `description`.', + path: ['components', 'parameters', 'orphanParameter'], + severity: rule.severity, + }), + ]); + }); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-payload-default.ts b/src/rulesets/asyncapi/__tests__/asyncapi-payload-default.ts new file mode 100644 index 000000000..5240e15fa --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-payload-default.ts @@ -0,0 +1,116 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-payload-default'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const payload = { + type: 'object', + properties: { + value: { + type: 'integer', + }, + }, + required: ['value'], + default: { value: 17 }, + }; + + const doc: any = { + asyncapi: '2.0.0', + channels: { + 'users/{userId}/signedUp': { + publish: { + message: { + payload: cloneDeep(payload), + }, + }, + subscribe: { + message: { + payload: cloneDeep(payload), + }, + }, + }, + }, + components: { + messageTraits: { + aTrait: { + payload: cloneDeep(payload), + }, + }, + messages: { + aMessage: { + payload: cloneDeep(payload), + }, + }, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if components.messages.{message}.payload.default is not valid against the schema it decorates', async () => { + const clone = cloneDeep(doc); + + clone.components.messages.aMessage.payload.default = { seventeen: 17 }; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Object should have required property `value`', + path: ['components', 'messages', 'aMessage', 'payload', 'default'], + severity: rule.severity, + }), + ]); + }); + + test('return result if components.messageTraits.{trait}.payload.default is not valid against the schema it decorates', async () => { + const clone = cloneDeep(doc); + + clone.components.messageTraits.aTrait.payload.default = { seventeen: 17 }; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Object should have required property `value`', + path: ['components', 'messageTraits', 'aTrait', 'payload', 'default'], + severity: rule.severity, + }), + ]); + }); + + test.each(['publish', 'subscribe'])( + 'return result if channels.{channel}.%s.message.payload.default is not valid against the schema it decorates', + async (property: string) => { + const clone = cloneDeep(doc); + + clone.channels['users/{userId}/signedUp'][property].message.payload.default = { seventeen: 17 }; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Object should have required property `value`', + path: ['channels', 'users/{userId}/signedUp', property, 'message', 'payload', 'default'], + severity: rule.severity, + }), + ]); + }, + ); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-payload-examples.ts b/src/rulesets/asyncapi/__tests__/asyncapi-payload-examples.ts new file mode 100644 index 000000000..f5c89109a --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-payload-examples.ts @@ -0,0 +1,116 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-payload-examples'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const payload = { + type: 'object', + properties: { + value: { + type: 'integer', + }, + }, + required: ['value'], + examples: [{ value: 17 }, { value: 18 }], + }; + + const doc: any = { + asyncapi: '2.0.0', + channels: { + 'users/{userId}/signedUp': { + publish: { + message: { + payload: cloneDeep(payload), + }, + }, + subscribe: { + message: { + payload: cloneDeep(payload), + }, + }, + }, + }, + components: { + messageTraits: { + aTrait: { + payload: cloneDeep(payload), + }, + }, + messages: { + aMessage: { + payload: cloneDeep(payload), + }, + }, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if components.messages.{message}.payload.examples.{position} is not valid against the schema it decorates', async () => { + const clone = cloneDeep(doc); + + clone.components.messages.aMessage.payload.examples[1] = { seventeen: 17 }; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Object should have required property `value`', + path: ['components', 'messages', 'aMessage', 'payload', 'examples', '1'], + severity: rule.severity, + }), + ]); + }); + + test('return result if components.messageTraits.{trait}.payload.examples.{position} is not valid against the schema it decorates', async () => { + const clone = cloneDeep(doc); + + clone.components.messageTraits.aTrait.payload.examples[1] = { seventeen: 17 }; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Object should have required property `value`', + path: ['components', 'messageTraits', 'aTrait', 'payload', 'examples', '1'], + severity: rule.severity, + }), + ]); + }); + + test.each(['publish', 'subscribe'])( + 'return result if channels.{channel}.%s.message.payload.examples.{position} is not valid against the schema it decorates', + async (property: string) => { + const clone = cloneDeep(doc); + + clone.channels['users/{userId}/signedUp'][property].message.payload.examples[1] = { seventeen: 17 }; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Object should have required property `value`', + path: ['channels', 'users/{userId}/signedUp', property, 'message', 'payload', 'examples', '1'], + severity: rule.severity, + }), + ]); + }, + ); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-payload-unsupported-schemaFormat.ts b/src/rulesets/asyncapi/__tests__/asyncapi-payload-unsupported-schemaFormat.ts new file mode 100644 index 000000000..416bdf177 --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-payload-unsupported-schemaFormat.ts @@ -0,0 +1,97 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-payload-unsupported-schemaFormat'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + channels: { + 'users/{userId}/signedUp': { + publish: { + message: {}, + }, + subscribe: { + message: {}, + }, + }, + }, + components: { + messageTraits: { + aTrait: {}, + }, + messages: { + aMessage: {}, + }, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if components.messages.{message}.schemaFormat is set to a non supported value', async () => { + const clone = cloneDeep(doc); + + clone.components.messages.aMessage.schemaFormat = 'application/nope'; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Message schema validation is only supported with default unspecified `schemaFormat`.', + path: ['components', 'messages', 'aMessage', 'schemaFormat'], + severity: rule.severity, + }), + ]); + }); + + test('return result if components.messageTraits.{trait}.schemaFormat is set to a non supported value', async () => { + const clone = cloneDeep(doc); + + clone.components.messageTraits.aTrait.schemaFormat = 'application/nope'; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Message schema validation is only supported with default unspecified `schemaFormat`.', + path: ['components', 'messageTraits', 'aTrait', 'schemaFormat'], + severity: rule.severity, + }), + ]); + }); + + test.each(['publish', 'subscribe'])( + 'return result if channels.{channel}.%s.message.schemaFormat is set to a non supported value', + async (property: string) => { + const clone = cloneDeep(doc); + + clone.channels['users/{userId}/signedUp'][property].message.schemaFormat = 'application/nope'; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Message schema validation is only supported with default unspecified `schemaFormat`.', + path: ['channels', 'users/{userId}/signedUp', property, 'message', 'schemaFormat'], + severity: rule.severity, + }), + ]); + }, + ); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-payload.ts b/src/rulesets/asyncapi/__tests__/asyncapi-payload.ts new file mode 100644 index 000000000..13fab5cac --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-payload.ts @@ -0,0 +1,115 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-payload'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const payload = { + type: 'object', + properties: { + value: { + type: 'integer', + }, + }, + required: ['value'], + }; + + const doc: any = { + asyncapi: '2.0.0', + channels: { + 'users/{userId}/signedUp': { + publish: { + message: { + payload: cloneDeep(payload), + }, + }, + subscribe: { + message: { + payload: cloneDeep(payload), + }, + }, + }, + }, + components: { + messageTraits: { + aTrait: { + payload: cloneDeep(payload), + }, + }, + messages: { + aMessage: { + payload: cloneDeep(payload), + }, + }, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if components.messages.{message}.payload is not valid against the AsyncApi2 schema object', async () => { + const clone = cloneDeep(doc); + + clone.components.messages.aMessage.payload.deprecated = 17; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: '`deprecated` property type should be boolean', + path: ['components', 'messages', 'aMessage', 'payload', 'deprecated'], + severity: rule.severity, + }), + ]); + }); + + test('return result if components.messageTraits.{trait}.payload is not valid against the AsyncApi2 schema object', async () => { + const clone = cloneDeep(doc); + + clone.components.messageTraits.aTrait.payload.deprecated = 17; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: '`deprecated` property type should be boolean', + path: ['components', 'messageTraits', 'aTrait', 'payload', 'deprecated'], + severity: rule.severity, + }), + ]); + }); + + test.each(['publish', 'subscribe'])( + 'return result if channels.{channel}.%s.message.payload is not valid against the AsyncApi2 schema object', + async (property: string) => { + const clone = cloneDeep(doc); + + clone.channels['users/{userId}/signedUp'][property].message.payload.deprecated = 17; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: '`deprecated` property type should be boolean', + path: ['channels', 'users/{userId}/signedUp', property, 'message', 'payload', 'deprecated'], + severity: rule.severity, + }), + ]); + }, + ); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-schema-default.ts b/src/rulesets/asyncapi/__tests__/asyncapi-schema-default.ts new file mode 100644 index 000000000..4dec1912b --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-schema-default.ts @@ -0,0 +1,100 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-schema-default'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + channels: { + 'users/{userId}/signedUp': { + parameters: { + userId: { + schema: { + default: 17, + }, + }, + }, + }, + }, + components: { + parameters: { + orphanParameter: { + schema: { + default: 17, + }, + }, + }, + schemas: { + aSchema: { + default: 17, + }, + }, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if components.schemas.{schema}.default is not valid against the schema it decorates', async () => { + const clone = cloneDeep(doc); + + clone.components.schemas.aSchema.type = 'string'; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: '`default` property type should be string', + path: ['components', 'schemas', 'aSchema', 'default'], + severity: rule.severity, + }), + ]); + }); + + test('return result if components.parameters.{parameter}.schema.default is not valid against the schema it decorates', async () => { + const clone = cloneDeep(doc); + + clone.components.parameters.orphanParameter.schema.type = 'string'; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: '`default` property type should be string', + path: ['components', 'parameters', 'orphanParameter', 'schema', 'default'], + severity: rule.severity, + }), + ]); + }); + + test('return result if channels.{channel}.parameters.{parameter}.schema.default is not valid against the schema it decorates', async () => { + const clone = cloneDeep(doc); + + clone.channels['users/{userId}/signedUp'].parameters.userId.schema.type = 'string'; + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: '`default` property type should be string', + path: ['channels', 'users/{userId}/signedUp', 'parameters', 'userId', 'schema', 'default'], + severity: rule.severity, + }), + ]); + }); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-schema-examples.ts b/src/rulesets/asyncapi/__tests__/asyncapi-schema-examples.ts new file mode 100644 index 000000000..963e61b18 --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-schema-examples.ts @@ -0,0 +1,118 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-schema-examples'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + channels: { + 'users/{userId}/signedUp': { + parameters: { + userId: { + schema: { + examples: [17, 'one', 13], + }, + }, + }, + }, + }, + components: { + parameters: { + orphanParameter: { + schema: { + examples: [17, 'one', 13], + }, + }, + }, + schemas: { + aSchema: { + examples: [17, 'one', 13], + }, + }, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if components.schemas.{schema}.examples.{position} is not valid against the schema it decorates', async () => { + const clone = cloneDeep(doc); + + clone.components.schemas.aSchema.type = 'string'; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: '`0` property type should be string', + path: ['components', 'schemas', 'aSchema', 'examples', '0'], + severity: rule.severity, + }), + expect.objectContaining({ + code: ruleName, + message: '`2` property type should be string', + path: ['components', 'schemas', 'aSchema', 'examples', '2'], + severity: rule.severity, + }), + ]); + }); + + test('return result if components.parameters.{parameter}.schema.examples.{position} is not valid against the schema it decorates', async () => { + const clone = cloneDeep(doc); + + clone.components.parameters.orphanParameter.schema.type = 'string'; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: '`0` property type should be string', + path: ['components', 'parameters', 'orphanParameter', 'schema', 'examples', '0'], + severity: rule.severity, + }), + expect.objectContaining({ + code: ruleName, + message: '`2` property type should be string', + path: ['components', 'parameters', 'orphanParameter', 'schema', 'examples', '2'], + severity: rule.severity, + }), + ]); + }); + + test('return result if channels.{channel}.parameters.{parameter}.schema.examples.{position} is not valid against the schema it decorates', async () => { + const clone = cloneDeep(doc); + + clone.channels['users/{userId}/signedUp'].parameters.userId.schema.type = 'string'; + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: '`0` property type should be string', + path: ['channels', 'users/{userId}/signedUp', 'parameters', 'userId', 'schema', 'examples', '0'], + severity: rule.severity, + }), + expect.objectContaining({ + code: ruleName, + message: '`2` property type should be string', + path: ['channels', 'users/{userId}/signedUp', 'parameters', 'userId', 'schema', 'examples', '2'], + severity: rule.severity, + }), + ]); + }); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-schema.ts b/src/rulesets/asyncapi/__tests__/asyncapi-schema.ts new file mode 100644 index 000000000..43bcf1f6c --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-schema.ts @@ -0,0 +1,47 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-schema'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + info: { + title: 'Valid AsyncApi document', + version: '1.0', + }, + channels: {}, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if channels property is missing', async () => { + const clone = cloneDeep(doc); + + delete clone.channels; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Object should have required property `channels`', + path: [], + severity: rule.severity, + }), + ]); + }); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-server-no-empty-variable.ts b/src/rulesets/asyncapi/__tests__/asyncapi-server-no-empty-variable.ts new file mode 100644 index 000000000..d0c07054d --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-server-no-empty-variable.ts @@ -0,0 +1,48 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-server-no-empty-variable'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + servers: { + production: { + url: '{sub}.stoplight.io', + protocol: 'https', + }, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if {server}.url property contains empty variable substitution pattern', async () => { + const clone = cloneDeep(doc); + + clone.servers.production.url = '{}.stoplight.io'; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Server URL should not have empty variable substitution pattern.', + path: ['servers', 'production', 'url'], + severity: rule.severity, + }), + ]); + }); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-server-no-trailing-slash.ts b/src/rulesets/asyncapi/__tests__/asyncapi-server-no-trailing-slash.ts new file mode 100644 index 000000000..0684f92fb --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-server-no-trailing-slash.ts @@ -0,0 +1,48 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-server-no-trailing-slash'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + servers: { + production: { + url: 'stoplight.io', + protocol: 'https', + }, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if {server}.url property ends with a trailing slash', async () => { + const clone = cloneDeep(doc); + + clone.servers.production.url = 'stoplight.io/'; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Server URL should not end with a slash.', + path: ['servers', 'production', 'url'], + severity: rule.severity, + }), + ]); + }); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-server-not-example-com.ts b/src/rulesets/asyncapi/__tests__/asyncapi-server-not-example-com.ts new file mode 100644 index 000000000..c8eee20d5 --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-server-not-example-com.ts @@ -0,0 +1,48 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-server-not-example-com'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + servers: { + production: { + url: 'stoplight.io', + protocol: 'https', + }, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if {server}.url property is set to `example.com`', async () => { + const clone = cloneDeep(doc); + + clone.servers.production.url = 'example.com'; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Server URL should not point at example.com.', + path: ['servers', 'production', 'url'], + severity: rule.severity, + }), + ]); + }); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-servers.ts b/src/rulesets/asyncapi/__tests__/asyncapi-servers.ts new file mode 100644 index 000000000..f42e13cc8 --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-servers.ts @@ -0,0 +1,66 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-servers'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + servers: { + production: { + url: 'stoplight.io', + protocol: 'https', + }, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if servers property is missing', async () => { + const clone = cloneDeep(doc); + + delete clone.servers; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'AsyncAPI object should contain a non empty `servers` object.', + path: [], + severity: rule.severity, + }), + ]); + }); + + test('return result if servers property is empty', async () => { + const clone = cloneDeep(doc); + + delete clone.servers.production; + expect(clone.servers).toEqual({}); + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'AsyncAPI object should contain a non empty `servers` object.', + path: ['servers'], + severity: rule.severity, + }), + ]); + }); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-tag-description.ts b/src/rulesets/asyncapi/__tests__/asyncapi-tag-description.ts new file mode 100644 index 000000000..d65ad29f9 --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-tag-description.ts @@ -0,0 +1,48 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-tag-description'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + tags: [ + { + name: 'a tag', + description: "I'm a tag.", + }, + ], + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if description property is missing', async () => { + const clone = cloneDeep(doc); + + delete clone.tags[0].description; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Tag object should have a `description`.', + path: ['tags', '0'], + severity: rule.severity, + }), + ]); + }); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-tags-alphabetical.ts b/src/rulesets/asyncapi/__tests__/asyncapi-tags-alphabetical.ts new file mode 100644 index 000000000..129f8e1f2 --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-tags-alphabetical.ts @@ -0,0 +1,43 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-tags-alphabetical'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + tags: [{ name: 'a tag' }, { name: 'another tag' }], + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if tags are not sorted', async () => { + const clone = cloneDeep(doc); + + clone.tags = [{ name: 'wrongly ordered' }, ...clone.tags]; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'AsyncAPI object should have alphabetical `tags`.', + path: ['tags'], + severity: rule.severity, + }), + ]); + }); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-tags.ts b/src/rulesets/asyncapi/__tests__/asyncapi-tags.ts new file mode 100644 index 000000000..6fb880ce7 --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-tags.ts @@ -0,0 +1,43 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-tags'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + tags: [{ name: 'one' }, { name: 'another' }], + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if tags property is missing', async () => { + const clone = cloneDeep(doc); + + delete clone.tags; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'AsyncAPI object should have non-empty `tags` array.', + path: [], + severity: rule.severity, + }), + ]); + }); +}); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-unused-components-schema.ts b/src/rulesets/asyncapi/__tests__/asyncapi-unused-components-schema.ts new file mode 100644 index 000000000..80bbbf7ef --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-unused-components-schema.ts @@ -0,0 +1,70 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-unused-components-schema'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc: any = { + asyncapi: '2.0.0', + channels: { + 'users/signedUp': { + subscribe: { + message: { + payload: { + $ref: '#/components/schemas/externallyDefinedUser', + }, + }, + }, + }, + }, + components: { + schemas: { + externallyDefinedUser: { + type: 'string', + }, + }, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test('return result if components.schemas contains unreferenced objects', async () => { + const clone = cloneDeep(doc); + + delete clone.channels['users/signedUp']; + + clone.channels['users/signedOut'] = { + subscribe: { + message: { + payload: { + type: 'string', + }, + }, + }, + }; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Potentially unused components schema has been detected.', + path: ['components', 'schemas', 'externallyDefinedUser'], + severity: rule.severity, + }), + ]); + }); +}); diff --git a/src/rulesets/asyncapi/functions/__tests__/asyncApi2PayloadValidation.test.ts b/src/rulesets/asyncapi/functions/__tests__/asyncApi2PayloadValidation.test.ts new file mode 100644 index 000000000..97383744b --- /dev/null +++ b/src/rulesets/asyncapi/functions/__tests__/asyncApi2PayloadValidation.test.ts @@ -0,0 +1,30 @@ +import { functions } from '../../../../functions'; +import { asyncApi2PayloadValidation } from '../asyncApi2PayloadValidation'; + +function runPayloadValidation(targetVal: any, field: string) { + return asyncApi2PayloadValidation.call( + { functions }, + targetVal, + { field }, + { given: ['$', 'components', 'messages', 'aMessage'] }, + { given: null, original: null, documentInventory: {} as any }, + ); +} + +describe('asyncApi2PayloadValidation', () => { + test('Properly identify payload that do not fit the AsyncApi2 schema object definition', () => { + const payload = { + type: 'object', + deprecated: 14, + }; + + const results = runPayloadValidation(payload, '$'); + + expect(results).toEqual([ + { + message: '{{property|gravis|append-property|optional-typeof|capitalize}}type should be boolean', + path: ['$', 'components', 'messages', 'aMessage', 'deprecated'], + }, + ]); + }); +}); diff --git a/src/rulesets/asyncapi/functions/asyncApi2PayloadValidation.ts b/src/rulesets/asyncapi/functions/asyncApi2PayloadValidation.ts new file mode 100644 index 000000000..048856536 --- /dev/null +++ b/src/rulesets/asyncapi/functions/asyncApi2PayloadValidation.ts @@ -0,0 +1,58 @@ +import { ValidateFunction } from 'ajv'; + +import { ISchemaFunction } from '../../../functions/schema'; +import { IFunction, IFunctionContext } from '../../../types'; +import * as asyncApi2Schema from '../schemas/schema.asyncapi2.json'; + +const fakeSchemaObjectId = 'asyncapi2#schemaObject'; +const asyncApi2SchemaObject = { $ref: fakeSchemaObjectId }; + +let validator: ValidateFunction; + +const buildAsyncApi2SchemaObjectValidator = (schemaFn: ISchemaFunction): ValidateFunction => { + if (validator !== void 0) { + return validator; + } + + const ajv = schemaFn.createAJVInstance({ + meta: false, + jsonPointers: true, + allErrors: true, + }); + + ajv.addMetaSchema(schemaFn.specs.v7); + ajv.addSchema(asyncApi2Schema, asyncApi2Schema.$id); + + validator = ajv.compile(asyncApi2SchemaObject); + + return validator; +}; + +export const asyncApi2PayloadValidation: IFunction = function( + this: IFunctionContext, + targetVal, + _opts, + paths, + otherValues, +) { + const ajvValidationFn = buildAsyncApi2SchemaObjectValidator(this.functions.schema); + + const results = this.functions.schema( + targetVal, + { + schema: asyncApi2SchemaObject, + ajv: ajvValidationFn, + allErrors: true, + }, + paths, + otherValues, + ); + + if (!Array.isArray(results)) { + return []; + } + + return results; +}; + +export default asyncApi2PayloadValidation; diff --git a/src/rulesets/asyncapi/index.json b/src/rulesets/asyncapi/index.json new file mode 100644 index 000000000..54e478637 --- /dev/null +++ b/src/rulesets/asyncapi/index.json @@ -0,0 +1,401 @@ +{ + "formats": [ + "asyncapi2" + ], + "functions" : [ + "asyncApi2PayloadValidation" + ], + "rules": { + "asyncapi-channel-no-empty-parameter": { + "description": "Channel path should not have empty parameter substitution pattern.", + "recommended": true, + "type": "style", + "given": "$.channels.*~", + "then": { + "function": "pattern", + "functionOptions": { + "notMatch": "{}" + } + } + }, + "asyncapi-channel-no-query-nor-fragment": { + "description": "Channel path should not include a query (`?`) or a fragment (`#`) delimiter.", + "recommended": true, + "type": "style", + "given": "$.channels.*~", + "then": { + "function": "pattern", + "functionOptions": { + "notMatch": "[\\?#]" + } + } + }, + "asyncapi-channel-no-trailing-slash": { + "description": "Channel path should not end with a slash.", + "recommended": true, + "type": "style", + "given": "$.channels.*~", + "then": { + "function": "pattern", + "functionOptions": { + "notMatch": ".+\\/$" + } + } + }, + "asyncapi-headers-schema-type-object": { + "description": "Headers schema type should be `object`.", + "message": "Headers schema type should be `object` ({{error}}).", + "severity": "error", + "recommended": true, + "type": "validation", + "given": [ + "$.components.messageTraits.*.headers", + "$.components.messages.*.headers", + "$.channels.*.[publish,subscribe].message.headers" + ], + "then": { + "function": "schema", + "functionOptions": { + "schema": { + "type": "object", + "properties": { + "type": { + "enum": [ + "object" + ] + } + }, + "required": [ + "type" + ] + } + }, + "allErrors": true + } + }, + "asyncapi-info-contact-properties": { + "description": "Contact object should have `name`, `url` and `email`.", + "recommended": true, + "type": "style", + "given": "$.info.contact", + "then": [ + { + "field": "name", + "function": "truthy" + }, + { + "field": "url", + "function": "truthy" + }, + { + "field": "email", + "function": "truthy" + } + ] + }, + "asyncapi-info-contact": { + "description": "Info object should contain `contact` object.", + "recommended": true, + "type": "style", + "given": "$", + "then": { + "field": "info.contact", + "function": "truthy" + } + }, + "asyncapi-info-description": { + "description": "AsyncAPI object info `description` must be present and non-empty string.", + "recommended": true, + "type": "style", + "given": "$", + "then": { + "field": "info.description", + "function": "truthy" + } + }, + "asyncapi-info-license-url": { + "description": "License object should include `url`.", + "recommended": false, + "type": "style", + "given": "$", + "then": { + "field": "info.license.url", + "function": "truthy" + } + }, + "asyncapi-info-license": { + "description": "AsyncAPI object should contain `license` object.", + "recommended": true, + "type": "style", + "given": "$", + "then": { + "field": "info.license", + "function": "truthy" + } + }, + "asyncapi-operation-description": { + "description": "Operation `description` must be present and non-empty string.", + "recommended": true, + "type": "style", + "given": [ + "$.channels.*.[publish,subscribe]" + ], + "then": { + "field": "description", + "function": "truthy" + } + }, + "asyncapi-operation-operationId": { + "description": "Operation should have an `operationId`.", + "severity": "error", + "recommended": true, + "type": "validation", + "given": [ + "$.channels.*.[publish,subscribe]" + ], + "then": { + "field": "operationId", + "function": "truthy" + } + }, + "asyncapi-parameter-description": { + "description": "Parameter objects should have a `description`.", + "recommended": false, + "type": "style", + "given": [ + "$.components.parameters.*", + "$.channels.*.parameters.*" + ], + "then": { + "field": "description", + "function": "truthy" + } + }, + "asyncapi-payload-default": { + "description": "Default must be valid against its defined schema.", + "message": "{{error}}", + "severity": "error", + "recommended": true, + "type": "validation", + "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": "schemaPath", + "functionOptions": { + "field": "default", + "schemaPath": "$", + "allErrors": true + } + } + }, + "asyncapi-payload-examples": { + "description": "Examples must be valid against their defined schema.", + "message": "{{error}}", + "severity": "error", + "recommended": true, + "type": "validation", + "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": "schemaPath", + "functionOptions": { + "field": "$.examples.*", + "schemaPath": "$", + "allErrors": true + } + } + }, + "asyncapi-payload-unsupported-schemaFormat": { + "description": "Message schema validation is only supported with default unspecified `schemaFormat`.", + "severity": "info", + "recommended": true, + "type": "validation", + "given": [ + "$.components.messageTraits.*", + "$.components.messages.*", + "$.channels.*.[publish,subscribe].message" + ], + "then": { + "field": "schemaFormat", + "function": "undefined" + } + }, + "asyncapi-payload": { + "description": "Payloads must be valid against AsyncAPI Schema object.", + "message": "{{error}}", + "severity": "error", + "recommended": true, + "type": "validation", + "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" + } + }, + "asyncapi-schema-default": { + "description": "Default must be valid against its defined schema.", + "message": "{{error}}", + "severity": "error", + "recommended": true, + "type": "validation", + "given": [ + "$.components.schemas.*.default^", + "$.components.parameters.*.schema.default^", + "$.channels.*.parameters.*.schema.default^" + ], + "then": { + "function": "schemaPath", + "functionOptions": { + "field": "default", + "schemaPath": "$", + "allErrors": true + } + } + }, + "asyncapi-schema-examples": { + "description": "Examples must be valid against their defined schema.", + "message": "{{error}}", + "severity": "error", + "recommended": true, + "type": "validation", + "given": [ + "$.components.schemas.*.examples^", + "$.components.parameters.*.schema.examples^", + "$.channels.*.parameters.*.schema.examples^" + ], + "then": { + "function": "schemaPath", + "functionOptions": { + "field": "$.examples.*", + "schemaPath": "$", + "allErrors": true + } + } + }, + "asyncapi-schema": { + "description": "Validate structure of AsyncAPI v2.0.0 Specification.", + "message": "{{error}}", + "severity": "error", + "recommended": true, + "type": "validation", + "given": "$", + "then": { + "function": "schema", + "functionOptions": { + "schema": { + "$ref": "./schemas/schema.asyncapi2.json" + }, + "allErrors": true + } + } + }, + "asyncapi-server-no-empty-variable": { + "description": "Server URL should not have empty variable substitution pattern.", + "recommended": true, + "type": "style", + "given": "$.servers[*].url", + "then": { + "function": "pattern", + "functionOptions": { + "notMatch": "{}" + } + } + }, + "asyncapi-server-no-trailing-slash": { + "description": "Server URL should not end with a slash.", + "recommended": true, + "type": "style", + "given": "$.servers[*].url", + "then": { + "function": "pattern", + "functionOptions": { + "notMatch": "/$" + } + } + }, + "asyncapi-server-not-example-com": { + "description": "Server URL should not point at example.com.", + "recommended": false, + "type": "style", + "given": "$.servers[*].url", + "then": { + "function": "pattern", + "functionOptions": { + "notMatch": "example\\.com" + } + } + }, + "asyncapi-servers": { + "description": "AsyncAPI object should contain a non empty `servers` object.", + "recommended": true, + "type": "validation", + "given": "$", + "then": { + "field": "servers", + "function": "schema", + "functionOptions": { + "schema": { + "type": "object", + "minProperties": 1 + }, + "allErrors": true + } + } + }, + "asyncapi-tag-description": { + "description": "Tag object should have a `description`.", + "recommended": false, + "type": "style", + "given": "$.tags[*]", + "then": { + "field": "description", + "function": "truthy" + } + }, + "asyncapi-tags-alphabetical": { + "description": "AsyncAPI object should have alphabetical `tags`.", + "recommended": false, + "type": "style", + "given": "$", + "then": { + "field": "tags", + "function": "alphabetical", + "functionOptions": { + "keyedBy": "name" + } + } + }, + "asyncapi-tags": { + "description": "AsyncAPI object should have non-empty `tags` array.", + "recommended": true, + "type": "style", + "given": "$", + "then": { + "field": "tags", + "function": "truthy" + } + }, + "asyncapi-unused-components-schema": { + "description": "Potentially unused components schema has been detected.", + "recommended": true, + "type": "style", + "resolved": false, + "given": "$.components.schemas", + "then": { + "function": "unreferencedReusableObject", + "functionOptions": { + "reusableObjectsLocation": "#/components/schemas" + } + } + } + } +} \ No newline at end of file diff --git a/src/rulesets/asyncapi/schemas/schema.asyncapi2.json b/src/rulesets/asyncapi/schemas/schema.asyncapi2.json new file mode 100644 index 000000000..df47a0fe5 --- /dev/null +++ b/src/rulesets/asyncapi/schemas/schema.asyncapi2.json @@ -0,0 +1,1599 @@ +{ + "title": "AsyncAPI 2.0.0 schema.", + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "asyncapi2", + "type": "object", + "required": [ + "asyncapi", + "info", + "channels" + ], + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "asyncapi": { + "type": "string", + "enum": [ + "2.0.0" + ], + "description": "The AsyncAPI specification version of this document." + }, + "id": { + "type": "string", + "description": "A unique id representing the application.", + "format": "uri" + }, + "info": { + "$ref": "#/definitions/info" + }, + "servers": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/server" + } + }, + "defaultContentType": { + "type": "string" + }, + "channels": { + "$ref": "#/definitions/channels" + }, + "components": { + "$ref": "#/definitions/components" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/tag" + }, + "uniqueItems": true + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + } + }, + "definitions": { + "Reference": { + "type": "object", + "required": [ + "$ref" + ], + "properties": { + "$ref": { + "$ref": "#/definitions/ReferenceObject" + } + } + }, + "ReferenceObject": { + "type": "string", + "format": "uri-reference" + }, + "info": { + "type": "object", + "description": "General information about the API.", + "required": [ + "version", + "title" + ], + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "title": { + "type": "string", + "description": "A unique and precise title of the API." + }, + "version": { + "type": "string", + "description": "A semantic version number of the API." + }, + "description": { + "type": "string", + "description": "A longer description of the API. Should be different from the title. CommonMark is allowed." + }, + "termsOfService": { + "type": "string", + "description": "A URL to the Terms of Service for the API. MUST be in the format of a URL.", + "format": "uri" + }, + "contact": { + "$ref": "#/definitions/contact" + }, + "license": { + "$ref": "#/definitions/license" + } + } + }, + "contact": { + "type": "object", + "description": "Contact information for the owners of the API.", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The identifying name of the contact person/organization." + }, + "url": { + "type": "string", + "description": "The URL pointing to the contact information.", + "format": "uri" + }, + "email": { + "type": "string", + "description": "The email address of the contact person/organization.", + "format": "email" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + } + }, + "license": { + "type": "object", + "required": [ + "name" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the license type. It's encouraged to use an OSI compatible license." + }, + "url": { + "type": "string", + "description": "The URL pointing to the license.", + "format": "uri" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + } + }, + "server": { + "type": "object", + "description": "An object representing a Server.", + "required": [ + "url", + "protocol" + ], + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "url": { + "type": "string" + }, + "description": { + "type": "string" + }, + "protocol": { + "type": "string", + "description": "The transfer protocol." + }, + "protocolVersion": { + "type": "string" + }, + "variables": { + "$ref": "#/definitions/serverVariables" + }, + "security": { + "type": "array", + "items": { + "$ref": "#/definitions/SecurityRequirement" + } + }, + "bindings": { + "$ref": "#/definitions/bindingsObject" + } + } + }, + "serverVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/serverVariable" + } + }, + "serverVariable": { + "type": "object", + "description": "An object representing a Server Variable for server URL template substitution.", + "minProperties": 1, + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "enum": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "examples": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "channels": { + "type": "object", + "propertyNames": { + "type": "string", + "format": "uri-template", + "minLength": 1 + }, + "additionalProperties": { + "$ref": "#/definitions/channelItem" + } + }, + "components": { + "type": "object", + "description": "An object to hold a set of reusable objects for different aspects of the AsyncAPI Specification.", + "additionalProperties": false, + "properties": { + "schemas": { + "$ref": "#/definitions/schemas" + }, + "messages": { + "$ref": "#/definitions/messages" + }, + "securitySchemes": { + "type": "object", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/SecurityScheme" + } + ] + } + } + }, + "parameters": { + "$ref": "#/definitions/parameters" + }, + "correlationIds": { + "type": "object", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/correlationId" + } + ] + } + } + }, + "operationTraits": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/operationTrait" + } + }, + "messageTraits": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/messageTrait" + } + }, + "serverBindings": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/bindingsObject" + } + }, + "channelBindings": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/bindingsObject" + } + }, + "operationBindings": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/bindingsObject" + } + }, + "messageBindings": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/bindingsObject" + } + } + } + }, + "schemas": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/schema" + }, + "description": "JSON objects describing schemas the API uses." + }, + "messages": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/message" + }, + "description": "JSON objects describing the messages being consumed and produced by the API." + }, + "parameters": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/parameter" + }, + "description": "JSON objects describing re-usable channel parameters." + }, + "schema": { + "$id": "#schemaObject", + "allOf": [ + { + "definitions": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/schema/allOf/0" + } + }, + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "allOf": [ + { + "$ref": "#/definitions/schema/allOf/0/definitions/nonNegativeInteger" + }, + { + "default": 0 + } + ] + }, + "simpleTypes": { + "enum": [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string" + ] + }, + "stringArray": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true, + "default": [] + } + }, + "type": [ + "object", + "boolean" + ], + "properties": { + "$id": { + "type": "string", + "format": "uri-reference" + }, + "$schema": { + "type": "string", + "format": "uri" + }, + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "$comment": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": true, + "readOnly": { + "type": "boolean", + "default": false + }, + "writeOnly": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "items": true + }, + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "maxLength": { + "$ref": "#/definitions/schema/allOf/0/definitions/nonNegativeInteger" + }, + "minLength": { + "$ref": "#/definitions/schema/allOf/0/definitions/nonNegativeIntegerDefault0" + }, + "pattern": { + "type": "string", + "format": "regex" + }, + "additionalItems": { + "$ref": "#/definitions/schema/allOf/0" + }, + "items": { + "anyOf": [ + { + "$ref": "#/definitions/schema/allOf/0" + }, + { + "$ref": "#/definitions/schema/allOf/0/definitions/schemaArray" + } + ], + "default": true + }, + "maxItems": { + "$ref": "#/definitions/schema/allOf/0/definitions/nonNegativeInteger" + }, + "minItems": { + "$ref": "#/definitions/schema/allOf/0/definitions/nonNegativeIntegerDefault0" + }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "contains": { + "$ref": "#/definitions/schema/allOf/0" + }, + "maxProperties": { + "$ref": "#/definitions/schema/allOf/0/definitions/nonNegativeInteger" + }, + "minProperties": { + "$ref": "#/definitions/schema/allOf/0/definitions/nonNegativeIntegerDefault0" + }, + "required": { + "$ref": "#/definitions/schema/allOf/0/definitions/stringArray" + }, + "additionalProperties": { + "$ref": "#/definitions/schema/allOf/0" + }, + "definitions": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/schema/allOf/0" + }, + "default": {} + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/schema/allOf/0" + }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/schema/allOf/0" + }, + "propertyNames": { + "format": "regex" + }, + "default": {} + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/definitions/schema/allOf/0" + }, + { + "$ref": "#/definitions/schema/allOf/0/definitions/stringArray" + } + ] + } + }, + "propertyNames": { + "$ref": "#/definitions/schema/allOf/0" + }, + "const": true, + "enum": { + "type": "array", + "items": true, + "minItems": 1, + "uniqueItems": true + }, + "type": { + "anyOf": [ + { + "$ref": "#/definitions/schema/allOf/0/definitions/simpleTypes" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/schema/allOf/0/definitions/simpleTypes" + }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "format": { + "type": "string" + }, + "contentMediaType": { + "type": "string" + }, + "contentEncoding": { + "type": "string" + }, + "if": { + "$ref": "#/definitions/schema/allOf/0" + }, + "then": { + "$ref": "#/definitions/schema/allOf/0" + }, + "else": { + "$ref": "#/definitions/schema/allOf/0" + }, + "allOf": { + "$ref": "#/definitions/schema/allOf/0/definitions/schemaArray" + }, + "anyOf": { + "$ref": "#/definitions/schema/allOf/0/definitions/schemaArray" + }, + "oneOf": { + "$ref": "#/definitions/schema/allOf/0/definitions/schemaArray" + }, + "not": { + "$ref": "#/definitions/schema/allOf/0" + } + }, + "default": true + }, + { + "type": "object", + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/definitions/schema" + }, + { + "type": "boolean" + } + ], + "default": {} + }, + "items": { + "anyOf": [ + { + "$ref": "#/definitions/schema" + }, + { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/schema" + } + } + ], + "default": {} + }, + "allOf": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/schema" + } + }, + "oneOf": { + "type": "array", + "minItems": 2, + "items": { + "$ref": "#/definitions/schema" + } + }, + "anyOf": { + "type": "array", + "minItems": 2, + "items": { + "$ref": "#/definitions/schema" + } + }, + "not": { + "$ref": "#/definitions/schema" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/schema" + }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/schema" + }, + "default": {} + }, + "propertyNames": { + "$ref": "#/definitions/schema" + }, + "contains": { + "$ref": "#/definitions/schema" + }, + "discriminator": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + }, + "deprecated": { + "type": "boolean", + "default": false + } + } + } + ] + }, + "externalDocs": { + "type": "object", + "additionalProperties": false, + "description": "information about external documentation", + "required": [ + "url" + ], + "properties": { + "description": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + } + }, + "channelItem": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "minProperties": 1, + "properties": { + "$ref": { + "$ref": "#/definitions/ReferenceObject" + }, + "parameters": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/parameter" + } + }, + "description": { + "type": "string", + "description": "A description of the channel." + }, + "publish": { + "$ref": "#/definitions/operation" + }, + "subscribe": { + "$ref": "#/definitions/operation" + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "bindings": { + "$ref": "#/definitions/bindingsObject" + } + } + }, + "parameter": { + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "description": { + "type": "string", + "description": "A brief description of the parameter. This could contain examples of use. GitHub Flavored Markdown is allowed." + }, + "schema": { + "$ref": "#/definitions/schema" + }, + "location": { + "type": "string", + "description": "A runtime expression that specifies the location of the parameter value", + "pattern": "^\\$message\\.(header|payload)\\#(\\/(([^\\/~])|(~[01]))*)*" + }, + "$ref": { + "$ref": "#/definitions/ReferenceObject" + } + } + }, + "operation": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "traits": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/operationTrait" + }, + { + "type": "array", + "items": [ + { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/operationTrait" + } + ] + }, + { + "type": "object", + "additionalItems": true + } + ] + } + ] + } + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/tag" + }, + "uniqueItems": true + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + }, + "operationId": { + "type": "string" + }, + "bindings": { + "$ref": "#/definitions/bindingsObject" + }, + "message": { + "$ref": "#/definitions/message" + } + } + }, + "message": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "oneOf": [ + { + "type": "object", + "required": [ + "oneOf" + ], + "additionalProperties": false, + "properties": { + "oneOf": { + "type": "array", + "items": { + "$ref": "#/definitions/message" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "schemaFormat": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "headers": { + "$ref": "#/definitions/schema" + }, + "payload": {}, + "correlationId": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/correlationId" + } + ] + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/tag" + }, + "uniqueItems": true + }, + "summary": { + "type": "string", + "description": "A brief summary of the message." + }, + "name": { + "type": "string", + "description": "Name of the message." + }, + "title": { + "type": "string", + "description": "A human-friendly title for the message." + }, + "description": { + "type": "string", + "description": "A longer description of the message. CommonMark is allowed." + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "items": { + "type": "object" + } + }, + "bindings": { + "$ref": "#/definitions/bindingsObject" + }, + "traits": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/messageTrait" + }, + { + "type": "array", + "items": [ + { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/messageTrait" + } + ] + }, + { + "type": "object", + "additionalItems": true + } + ] + } + ] + } + } + } + } + ] + } + ] + }, + "bindingsObject": { + "type": "object", + "additionalProperties": true, + "properties": { + "http": {}, + "ws": {}, + "amqp": {}, + "amqp1": {}, + "mqtt": {}, + "mqtt5": {}, + "kafka": {}, + "nats": {}, + "jms": {}, + "sns": {}, + "sqs": {}, + "stomp": {}, + "redis": {} + } + }, + "correlationId": { + "type": "object", + "required": [ + "location" + ], + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "description": { + "type": "string", + "description": "A optional description of the correlation ID. GitHub Flavored Markdown is allowed." + }, + "location": { + "type": "string", + "description": "A runtime expression that specifies the location of the correlation ID", + "pattern": "^\\$message\\.(header|payload)\\#(\\/(([^\\/~])|(~[01]))*)*" + } + } + }, + "specificationExtension": { + "description": "Any property starting with x- is valid.", + "additionalProperties": true, + "additionalItems": true + }, + "tag": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + } + }, + "operationTrait": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/tag" + }, + "uniqueItems": true + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + }, + "operationId": { + "type": "string" + }, + "bindings": { + "$ref": "#/definitions/bindingsObject" + } + } + }, + "messageTrait": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "schemaFormat": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "headers": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/schema" + } + ] + }, + "correlationId": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/correlationId" + } + ] + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/tag" + }, + "uniqueItems": true + }, + "summary": { + "type": "string", + "description": "A brief summary of the message." + }, + "name": { + "type": "string", + "description": "Name of the message." + }, + "title": { + "type": "string", + "description": "A human-friendly title for the message." + }, + "description": { + "type": "string", + "description": "A longer description of the message. CommonMark is allowed." + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "items": { + "type": "object" + } + }, + "bindings": { + "$ref": "#/definitions/bindingsObject" + } + } + }, + "SecurityScheme": { + "oneOf": [ + { + "$ref": "#/definitions/userPassword" + }, + { + "$ref": "#/definitions/apiKey" + }, + { + "$ref": "#/definitions/X509" + }, + { + "$ref": "#/definitions/symmetricEncryption" + }, + { + "$ref": "#/definitions/asymmetricEncryption" + }, + { + "$ref": "#/definitions/HTTPSecurityScheme" + }, + { + "$ref": "#/definitions/oauth2Flows" + }, + { + "$ref": "#/definitions/openIdConnect" + } + ] + }, + "userPassword": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "userPassword" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "apiKey": { + "type": "object", + "required": [ + "type", + "in" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "apiKey" + ] + }, + "in": { + "type": "string", + "enum": [ + "user", + "password" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "X509": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "X509" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "symmetricEncryption": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "symmetricEncryption" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "asymmetricEncryption": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "asymmetricEncryption" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "HTTPSecurityScheme": { + "oneOf": [ + { + "$ref": "#/definitions/NonBearerHTTPSecurityScheme" + }, + { + "$ref": "#/definitions/BearerHTTPSecurityScheme" + }, + { + "$ref": "#/definitions/APIKeyHTTPSecurityScheme" + } + ] + }, + "NonBearerHTTPSecurityScheme": { + "not": { + "type": "object", + "properties": { + "scheme": { + "type": "string", + "enum": [ + "bearer" + ] + } + } + }, + "type": "object", + "required": [ + "scheme", + "type" + ], + "properties": { + "scheme": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "http" + ] + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "BearerHTTPSecurityScheme": { + "type": "object", + "required": [ + "type", + "scheme" + ], + "properties": { + "scheme": { + "type": "string", + "enum": [ + "bearer" + ] + }, + "bearerFormat": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "http" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "APIKeyHTTPSecurityScheme": { + "type": "object", + "required": [ + "type", + "name", + "in" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "httpApiKey" + ] + }, + "name": { + "type": "string" + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "cookie" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "oauth2Flows": { + "type": "object", + "required": [ + "type", + "flows" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "oauth2" + ] + }, + "description": { + "type": "string" + }, + "flows": { + "type": "object", + "properties": { + "implicit": { + "allOf": [ + { + "$ref": "#/definitions/oauth2Flow" + }, + { + "required": [ + "authorizationUrl", + "scopes" + ] + }, + { + "not": { + "required": [ + "tokenUrl" + ] + } + } + ] + }, + "password": { + "allOf": [ + { + "$ref": "#/definitions/oauth2Flow" + }, + { + "required": [ + "tokenUrl", + "scopes" + ] + }, + { + "not": { + "required": [ + "authorizationUrl" + ] + } + } + ] + }, + "clientCredentials": { + "allOf": [ + { + "$ref": "#/definitions/oauth2Flow" + }, + { + "required": [ + "tokenUrl", + "scopes" + ] + }, + { + "not": { + "required": [ + "authorizationUrl" + ] + } + } + ] + }, + "authorizationCode": { + "allOf": [ + { + "$ref": "#/definitions/oauth2Flow" + }, + { + "required": [ + "authorizationUrl", + "tokenUrl", + "scopes" + ] + } + ] + } + }, + "additionalProperties": false, + "minProperties": 1 + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + } + }, + "oauth2Flow": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri" + }, + "tokenUrl": { + "type": "string", + "format": "uri" + }, + "refreshUrl": { + "type": "string", + "format": "uri" + }, + "scopes": { + "$ref": "#/definitions/oauth2Scopes" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "oauth2Scopes": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "openIdConnect": { + "type": "object", + "required": [ + "type", + "openIdConnectUrl" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "openIdConnect" + ] + }, + "description": { + "type": "string" + }, + "openIdConnectUrl": { + "type": "string", + "format": "uri" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "SecurityRequirement": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + } + } +} diff --git a/src/rulesets/reader.ts b/src/rulesets/reader.ts index 783cb2f35..bd1d71b41 100644 --- a/src/rulesets/reader.ts +++ b/src/rulesets/reader.ts @@ -65,15 +65,12 @@ const createRulesetProcessor = ( dereferenceInline: false, uriCache, async parseResolveResult(opts) { - try { - opts.result = parse(opts.result); - } catch { - // happens - } + opts.result = parse(opts.result); return opts; }, }, ); + const ruleset = assertValidRuleset(JSON.parse(JSON.stringify(result))); const rules = {}; const functions = {}; diff --git a/test-harness/scenarios/asyncapi2-streetlights.scenario b/test-harness/scenarios/asyncapi2-streetlights.scenario new file mode 100644 index 000000000..67293ee59 --- /dev/null +++ b/test-harness/scenarios/asyncapi2-streetlights.scenario @@ -0,0 +1,226 @@ +====test==== +Validate streetlights.yaml AsyncAPI 2.0 sample +====document==== +asyncapi: '2.0.0' +info: + title: Streetlights API + version: '1.0.0' + description: | + The Smartylighting Streetlights API allows you to remotely manage the city lights. + + ### Check out its awesome features: + + * Turn a specific streetlight on/off 🌃 + * Dim a specific streetlight 😎 + * Receive real-time information about environmental lighting conditions 📈 + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0 + +servers: + production: + url: test.mosquitto.org:{port} + protocol: mqtt + description: Test broker + variables: + port: + description: Secure connection (TLS) is available through port 8883. + default: '1883' + enum: + - '1883' + - '8883' + security: + - apiKey: [] + - supportedOauthFlows: + - streetlights:on + - streetlights:off + - streetlights:dim + - openIdConnectWellKnown: [] + +defaultContentType: application/json + +channels: + smartylighting/streetlights/1/0/event/{streetlightId}/lighting/measured: + description: The topic on which measured values may be produced and consumed. + parameters: + streetlightId: + $ref: '#/components/parameters/streetlightId' + publish: + summary: Inform about environmental lighting conditions of a particular streetlight. + operationId: receiveLightMeasurement + traits: + - $ref: '#/components/operationTraits/kafka' + message: + $ref: '#/components/messages/lightMeasured' + + smartylighting/streetlights/1/0/action/{streetlightId}/turn/on: + parameters: + streetlightId: + $ref: '#/components/parameters/streetlightId' + subscribe: + operationId: turnOn + traits: + - $ref: '#/components/operationTraits/kafka' + message: + $ref: '#/components/messages/turnOnOff' + + smartylighting/streetlights/1/0/action/{streetlightId}/turn/off: + parameters: + streetlightId: + $ref: '#/components/parameters/streetlightId' + subscribe: + operationId: turnOff + traits: + - $ref: '#/components/operationTraits/kafka' + message: + $ref: '#/components/messages/turnOnOff' + + smartylighting/streetlights/1/0/action/{streetlightId}/dim: + parameters: + streetlightId: + $ref: '#/components/parameters/streetlightId' + subscribe: + operationId: dimLight + traits: + - $ref: '#/components/operationTraits/kafka' + message: + $ref: '#/components/messages/dimLight' + +components: + messages: + lightMeasured: + name: lightMeasured + title: Light measured + summary: Inform about environmental lighting conditions of a particular streetlight. + contentType: application/json + traits: + - $ref: '#/components/messageTraits/commonHeaders' + payload: + $ref: "#/components/schemas/lightMeasuredPayload" + turnOnOff: + name: turnOnOff + title: Turn on/off + summary: Command a particular streetlight to turn the lights on or off. + traits: + - $ref: '#/components/messageTraits/commonHeaders' + payload: + $ref: "#/components/schemas/turnOnOffPayload" + dimLight: + name: dimLight + title: Dim light + summary: Command a particular streetlight to dim the lights. + traits: + - $ref: '#/components/messageTraits/commonHeaders' + payload: + $ref: "#/components/schemas/dimLightPayload" + + schemas: + lightMeasuredPayload: + type: object + properties: + lumens: + type: integer + minimum: 0 + description: Light intensity measured in lumens. + sentAt: + $ref: "#/components/schemas/sentAt" + turnOnOffPayload: + type: object + properties: + command: + type: string + enum: + - on + - off + description: Whether to turn on or off the light. + sentAt: + $ref: "#/components/schemas/sentAt" + dimLightPayload: + type: object + properties: + percentage: + type: integer + description: Percentage to which the light should be dimmed to. + minimum: 0 + maximum: 100 + sentAt: + $ref: "#/components/schemas/sentAt" + sentAt: + type: string + format: date-time + description: Date and time when the message was sent. + + securitySchemes: + apiKey: + type: apiKey + in: user + description: Provide your API key as the user and leave the password empty. + supportedOauthFlows: + type: oauth2 + description: Flows to support OAuth 2.0 + flows: + implicit: + authorizationUrl: 'https://authserver.example/auth' + scopes: + 'streetlights:on': Ability to switch lights on + 'streetlights:off': Ability to switch lights off + 'streetlights:dim': Ability to dim the lights + password: + tokenUrl: 'https://authserver.example/token' + scopes: + 'streetlights:on': Ability to switch lights on + 'streetlights:off': Ability to switch lights off + 'streetlights:dim': Ability to dim the lights + clientCredentials: + tokenUrl: 'https://authserver.example/token' + scopes: + 'streetlights:on': Ability to switch lights on + 'streetlights:off': Ability to switch lights off + 'streetlights:dim': Ability to dim the lights + authorizationCode: + authorizationUrl: 'https://authserver.example/auth' + tokenUrl: 'https://authserver.example/token' + refreshUrl: 'https://authserver.example/refresh' + scopes: + 'streetlights:on': Ability to switch lights on + 'streetlights:off': Ability to switch lights off + 'streetlights:dim': Ability to dim the lights + openIdConnectWellKnown: + type: openIdConnect + openIdConnectUrl: 'https://authserver.example/.well-known' + + parameters: + streetlightId: + description: The ID of the streetlight. + schema: + type: string + + messageTraits: + commonHeaders: + headers: + type: object + properties: + my-app-header: + type: integer + minimum: 0 + maximum: 100 + + operationTraits: + kafka: + bindings: + kafka: + clientId: my-app-id +====command==== +{bin} lint {document} +====stdout==== +AsyncAPI 2.x detected + +{document} + 1:1 warning asyncapi-tags AsyncAPI object should have non-empty `tags` array. + 2:6 warning asyncapi-info-contact Info object should contain `contact` object. + 45:13 warning asyncapi-operation-description Operation `description` must be present and non-empty string. + 57:15 warning asyncapi-operation-description Operation `description` must be present and non-empty string. + 68:15 warning asyncapi-operation-description Operation `description` must be present and non-empty string. + 79:15 warning asyncapi-operation-description Operation `description` must be present and non-empty string. + +✖ 6 problems (0 errors, 6 warnings, 0 infos, 0 hints) diff --git a/test-harness/scenarios/rules-matching-multiple-places.scenario b/test-harness/scenarios/rules-matching-multiple-places.scenario index 6d26fe3c0..e412e512d 100644 --- a/test-harness/scenarios/rules-matching-multiple-places.scenario +++ b/test-harness/scenarios/rules-matching-multiple-places.scenario @@ -33,7 +33,7 @@ rules: 1 ====stdout==== {document} - 1:1 warning unrecognized-format The provided document does not match any of the registered formats [oas2, oas3, json-schema, json-schema-loose, json-schema-draft4, json-schema-draft6, json-schema-draft7, json-schema-2019-09] + 1:1 warning unrecognized-format The provided document does not match any of the registered formats [oas2, oas3, asyncapi2, json-schema, json-schema-loose, json-schema-draft4, json-schema-draft6, json-schema-draft7, json-schema-2019-09] 6:15 error valid-user-properties must match the pattern '/^string$/' 10:15 error valid-user-properties must match the pattern '/^string$/' 11:11 error require-user-and-address `address` property is not truthy diff --git a/test-harness/scenarios/unrecognized-format.scenario b/test-harness/scenarios/unrecognized-format.scenario index 9c97b0ff7..1ac19f4b4 100644 --- a/test-harness/scenarios/unrecognized-format.scenario +++ b/test-harness/scenarios/unrecognized-format.scenario @@ -7,6 +7,6 @@ info: {} {bin} lint {document} ====stdout==== {document} - 1:1 warning unrecognized-format The provided document does not match any of the registered formats [oas2, oas3, json-schema, json-schema-loose, json-schema-draft4, json-schema-draft6, json-schema-draft7, json-schema-2019-09] + 1:1 warning unrecognized-format The provided document does not match any of the registered formats [oas2, oas3, asyncapi2, json-schema, json-schema-loose, json-schema-draft4, json-schema-draft6, json-schema-draft7, json-schema-2019-09] ✖ 1 problem (0 errors, 1 warning, 0 infos, 0 hints) diff --git a/yarn.lock b/yarn.lock index 94a2aa649..24f3f49b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -462,6 +462,13 @@ magic-string "^0.25.2" resolve "^1.11.0" +"@rollup/plugin-json@^4.0.2": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@rollup/plugin-json/-/plugin-json-4.0.2.tgz#482185ee36ac7dd21c346e2dbcc22ffed0c6f2d6" + integrity sha512-t4zJMc98BdH42mBuzjhQA7dKh0t4vMJlUka6Fz0c+iO5IVnWaEMiYBy1uBj9ruHZzXBW23IPDGL9oCzBkQ9Udg== + dependencies: + "@rollup/pluginutils" "^3.0.4" + "@rollup/plugin-node-resolve@^7.1.3": version "7.1.3" resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-7.1.3.tgz#80de384edfbd7bfc9101164910f86078151a3eca" @@ -473,6 +480,15 @@ is-module "^1.0.0" resolve "^1.14.2" +"@rollup/pluginutils@^3.0.4": + version "3.0.9" + resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.0.9.tgz#aa6adca2c45e5a1b950103a999e3cddfe49fd775" + integrity sha512-TLZavlfPAZYI7v33wQh4mTP6zojne14yok3DNSLcjoG/Hirxfkonn6icP5rrNWRn8nZsirJBFFpijVOJzkUHDg== + dependencies: + "@types/estree" "0.0.39" + estree-walker "^1.0.1" + micromatch "^4.0.2" + "@rollup/pluginutils@^3.0.8": version "3.0.8" resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.0.8.tgz#4e94d128d94b90699e517ef045422960d18c8fde"