diff --git a/.circleci/config.yml b/.circleci/config.yml index a649e1b1c..0a4eef767 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -167,7 +167,7 @@ commands: - run: name: Install ldid working_directory: /tmp/ldid - command: make install + command: sudo make install lint-code: steps: @@ -193,7 +193,7 @@ commands: parameters: max-workers: type: integer - default: 2 + default: 3 steps: - run: yarn pretest - run: @@ -212,7 +212,7 @@ commands: type: string max-workers: type: integer - default: 2 + default: 3 steps: - compute-hashes - restore_cache: @@ -276,7 +276,7 @@ jobs: - test-harness: os: linux node-version: << parameters.node-version >> - max-workers: 2 + max-workers: 3 test-browser: docker: @@ -302,7 +302,7 @@ jobs: - test-harness: os: windows node-version: *node-active-lts - max-workers: 2 + max-workers: 3 build-nix-binaries: docker: diff --git a/.eslintignore b/.eslintignore index 5c2348a5f..53ca1704e 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,5 +2,6 @@ /test-harness/tests/ /packages/*/dist /packages/rulesets/src/oas/schemas/validators.ts +/packages/rulesets/src/arazzo/schemas/validators.ts /packages/*/CHANGELOG.md packages/formatters/src/html/templates.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ea0aef530..cb052e1fb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,9 +1,3 @@ * @stoplightio/oss-spectral -README.md @stoplightio/documentation - -toc.json @stoplightio/documentation - -/docs @stoplightio/documentation - /packages/rulesets/src/asyncapi @magicmatatjahu @smoya @jonaslagoni diff --git a/README.md b/README.md index cd990c5cd..d7a4e5096 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CircleCI](https://img.shields.io/circleci/build/github/stoplightio/spectral/develop)](https://circleci.com/gh/stoplightio/spectral) [![npm Downloads](https://img.shields.io/npm/dw/@stoplight/spectral-core?color=blue)](https://www.npmjs.com/package/@stoplight/spectral-core) [![Stoplight Forest](https://img.shields.io/ecologi/trees/stoplightinc)][stoplight_forest] - **Custom Rulesets**: Create custom rules to lint JSON or YAML objects -- **Ready-to-use Rulesets**: Validate and lint **OpenAPI v2 & v3.x** and **AsyncAPI** Documents +- **Ready-to-use Rulesets**: Validate and lint **OpenAPI v2 & v3.x**, **AsyncAPI**, and **Arazzo v1** Documents - **API Style Guides**: Automated [API Style Guides](https://stoplight.io/api-style-guides-guidelines-and-best-practices?utm_source=github.com&utm_medium=referral&utm_campaign=github_repo_spectral) using rulesets improve consistency across all your APIs - **Ready-to-use Functions**: Built-in set of functions to help [create custom rules](https://meta.stoplight.io/docs/spectral/e5b9616d6d50c-custom-rulesets#adding-rules). Functions include pattern checks, parameter checks, alphabetical ordering, a specified number of characters, provided keys are present in an object, etc. - **Custom Functions**: Create custom functions for advanced use cases @@ -40,10 +40,10 @@ There are also [additional installation options](https://meta.stoplight.io/docs/ Spectral, being a generic YAML/JSON linter, **needs a ruleset** to lint files. A ruleset is a JSON, YAML, or JavaScript/TypeScript file (often the file is called `.spectral.yaml` for a YAML ruleset) that contains a collection of rules, which can be used to lint other JSON or YAML files such as an API description. -To get started, run this command in your terminal to create a `.spectral.yaml` file that uses the Spectral predefined rulesets based on OpenAPI or AsyncAPI: +To get started, run this command in your terminal to create a `.spectral.yaml` file that uses the Spectral predefined rulesets based on OpenAPI, Arazzo or AsyncAPI: ```bash -echo 'extends: ["spectral:oas", "spectral:asyncapi"]' > .spectral.yaml +echo 'extends: ["spectral:oas", "spectral:asyncapi", "spectral:arazzo"]' > .spectral.yaml ``` If you would like to create your own rules, check out the [Custom Rulesets](https://meta.stoplight.io/docs/spectral/01baf06bdd05a-rulesets) page. @@ -114,7 +114,7 @@ Check out some additional style guides here: ## 🏁 Help Others Utilize Spectral -If you're using Spectral for an interesting use case, [contact Stoplight](mailto:growth@stoplight.io) for a case study. 🎉 +If you're using Spectral for an interesting use case, create an issue with details on how you're using it. We'll add it to a list here. Spread the goodness 🎉 ## 👏 Contributing diff --git a/docs/getting-started/6-arazzo.md b/docs/getting-started/6-arazzo.md new file mode 100644 index 000000000..c226847ee --- /dev/null +++ b/docs/getting-started/6-arazzo.md @@ -0,0 +1,7 @@ +# Arazzo Support + +Spectral has a built-in [Arazzo v1](https://spec.openapis.org/arazzo/v1.0.0.html) ruleset that you can use to validate your Arazzo files. + +Add `extends: "spectral:arazzo"` to your ruleset file to apply rules for Arazzo v1. + +You can see a full list of the rules in this ruleset in [Arazzo Rules](../reference/arazzo-rules.md). diff --git a/docs/guides/2-cli.md b/docs/guides/2-cli.md index 96879bc29..f0edd3e7c 100644 --- a/docs/guides/2-cli.md +++ b/docs/guides/2-cli.md @@ -27,13 +27,13 @@ spectral lint ./reference/**/*.oas*.{json,yml,yaml} Other options include: ``` - --version Show version number [boolean] - --help Show help [boolean] + --version Show version number [boolean] + --help Show help [boolean] -e, --encoding text encoding to use [string] [choices: "utf8", "ascii", "utf-8", "utf16le", "ucs2", "ucs-2", "base64", "latin1"] [default: "utf8"] -f, --format formatters to use for outputting results, more than one can be provided by using multiple flags - [string] [choices: "json", "stylish", "junit", "html", "text", "teamcity", "pretty", "github-actions", "sarif"] + [string] [choices: "json", "stylish", "junit", "html", "text", "teamcity", "pretty", "github-actions", "sarif", "markdown","gitlab"] [default: "stylish"] -o, --output where to output results, can be a single file name, multiple "output." or missing to print to stdout [string] @@ -41,12 +41,12 @@ Other options include: --resolver path to custom json-ref-resolver instance [string] -r, --ruleset path/URL to a ruleset file [string] -F, --fail-severity results of this level or above will trigger a failure exit code - [string] [choices: "error", "warn", "info", "hint"] [default: "error"] - -D, --display-only-failures only output results equal to or greater than --fail-severity [boolean] [default: false] - --ignore-unknown-format do not warn about unmatched formats [boolean] [default: false] - --fail-on-unmatched-globs fail on unmatched glob patterns [boolean] [default: false] - -v, --verbose increase verbosity [boolean] - -q, --quiet no logging - output only [boolean] + [string] [choices: "error", "warn", "info", "hint"] [default: "error"] + -D, --display-only-failures only output results equal to or greater than --fail-severity [boolean] [default: false] + --ignore-unknown-format do not warn about unmatched formats [boolean] [default: false] + --fail-on-unmatched-globs fail on unmatched glob patterns [boolean] [default: false] + -v, --verbose increase verbosity [boolean] + -q, --quiet no logging - output only [boolean] ``` The Spectral CLI supports loading documents as YAML or JSON, and validation of OpenAPI v2/v3 documents via the built-in ruleset. diff --git a/docs/img/readme-header.svg b/docs/img/readme-header.svg index c6241b9ed..f5740b697 100644 --- a/docs/img/readme-header.svg +++ b/docs/img/readme-header.svg @@ -1,11 +1,14 @@ - + + - - - + + + + + @@ -175,7 +178,7 @@ - + @@ -208,14 +211,16 @@ - + + + - - + + - - + + - + diff --git a/docs/reference/arazzo-rules.md b/docs/reference/arazzo-rules.md new file mode 100644 index 000000000..858ea1807 --- /dev/null +++ b/docs/reference/arazzo-rules.md @@ -0,0 +1,301 @@ +# Arazzo Rules + +Spectral has a built-in "arazzo" ruleset for the [Arazzo Specification](https://spec.openapis.org/arazzo/v1.0.0.html). + +In your ruleset file you can add `extends: "spectral:arazzo"` and you'll get all of the following rules applied. + +### arazzo-document-schema + +Validate structure of an Arazzo Document against the schema of the Arazzo v1 specification. + +**Recommended:** Yes + +### arazzo-workflowId-unique + +`workflowId` must be unique across all the workflows defined within an Arazzo Document. + +**Recommended:** Yes + +### arazzo-workflow-output-validation + +Every workflow output must have unique name and its value must be a valid runtime expression. + +Additionally, if output values use expressions like `$workflows.foo.steps.bar`, the rule will verify the existence of workflow `foo` and step `bar`. + +**Recommended:** Yes + +**Good Example** + +```yaml + // Assuming that `TokenStep` is a defined step and that it exposes an output of `tokenResponse` + outputs: + access_token: $steps.TokenStep.outputs.tokenResponse +``` + +**Bad Example** + +```yaml +outputs: + access_token: $foo +``` + +### arazzo-workflow-stepId-unique + +Every `stepId` defined within a workflow must be unique + +**Recommended:** Yes + +**Good Example** + +```yaml +workflows: + - workflowId: someWorkflow + parameters: + - in: cookie + name: workflowLevelParamOne + value: someValue + - in: header + name: workflowLevelParamTwo + value: someValue + steps: + - stepId: post-step + parameters: + - in: cookie + name: foo + value: some_value + operationId: createResource + - stepId: get-step + operationId: getResource +``` + +**Bad Example** + +```yaml +workflows: + - workflowId: someWorkflow + parameters: + - in: cookie + name: workflowLevelParamOne + value: someValue + - in: header + name: workflowLevelParamTwo + value: someValue + steps: + - stepId: post-step + parameters: + - in: cookie + name: foo + value: some_value + operationId: createResource + - stepId: post-step + operationId: getResource +``` + +### arazzo-step-output-validation + +Every step output must have unique name and its value must be a valid runtime expression. + +Then validating the expression the rule checks against known prefixes described in the [Arazzo Specification Runtime Expressions](https://spec.openapis.org/arazzo/v1.0.0.html#runtime-expressions). Additionally, if output values use expressions like `$workflows.foo.steps.bar`, the rule will verify the existence of workflow `foo` and step `bar`. + +**Recommended:** Yes + +### arazzo-step-parameters-validation + +Parameters must be unique based on their combination of `name` and `in` properties when defined at the workflow level or within a step. Step parameters can override workflow level parameters. + +Additionally rule checks: + +- reusable parameter references must be a valid Arazzo runtime expression (e.g. `$components.parameters.`) and the referenced parameter must be existing within the components parameters +- parameter values using expressions, must be a valid Arazzo Specification Runtime Expressions](https://spec.openapis.org/arazzo/v1.0.0.html#runtime-expressions). Additionally, if parameter values use expressions like `$workflows.foo.steps.bar`, the rule will verify the existence of workflow `foo` and step `bar`. + +**Recommended:** Yes + +**Good Example** + +```yaml +parameters: + - name: username + in: query + value: $inputs.username +``` + +**Bad Example** + +```yaml +parameters: + - name: username + in: query + value: $foo +``` + +### arazzo-step-failure-actions-validation + +Every failure action must have a unique `name`, and the fields `workflowId` and `stepId` are mutually exclusive. Any runtime expressions used for `workflowId` or `stepId` must be valid and resolve to a defined workflow or step respectively. + +Additionally rule checks: + +- reusable failure action references must be a valid Arazzo runtime expression (e.g. `$components.failureActions.`) and the referenced action must be existing within the components parameters + +**Recommended:** Yes + +### arazzo-step-success-actions-validation + +Every success action must have a unique `name`, and the fields `workflowId` and `stepId` are mutually exclusive. Any runtime expressions used for `workflowId` or `stepId` must be valid and resolve to a defined workflow or step respectively. + +Additionally rule checks: + +- reusable success action references must be a valid Arazzo runtime expression (e.g. `$components.successActions.`) and the referenced action must be existing within the components parameters + +**Recommended:** Yes + +### arazzo-workflow-depends-on-validation + +The list of defined workflows within the `dependsOn` property must be unique and must be valid (e.g. the runtime expression must resolve to a defined workflow). + +**Recommended:** Yes + +### arazzo-step-success-criteria-validation + +Every success criteria must have a valid context, conditions, and types. + +Rule checks: + +- `condition` must be specified +- if `type` is defined then a `context` must be provided +- if `type` is an object then it must conform to an [Arazzo Specification Criterion Expression Type Object](https://spec.openapis.org/arazzo/v1.0.0.html#criterion-expression-type-object) +- if `type` is specified as "regex", then the condition must be a valid regex +- `context` must be a valid [Arazzo Specification Runtime Expressions](https://spec.openapis.org/arazzo/v1.0.0.html#runtime-expressions) + +**Recommended:** Yes + +**Good Example** + +```yaml +- context: $statusCode + condition: "^200$" + type: regex +``` + +**Bad Example** + +```yaml +- context: hello + condition: "^200$" + type: regex +``` + +### arazzo-step-validation + +Every step must have a valid `stepId` and either a valid `operationId` or `operationPath` or `workflowId`. Defined runtime expressions are also validated. + +**Recommended:** Yes + +### arazzo-no-script-tags-in-markdown + +This rule protects against a potential hack, for anyone bringing in Arazzo documents from third parties and then generating HTML documentation. If one of those third parties does something shady like injecting `', +``` + +### arazzo-info-description + +Arazzo 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 what the workflows contained can do and how you can get up and running. + +**Recommended:** Yes + +**Good Example** + +```yaml +arazzo: 1.0.0 +info: + title: BNPL Workflow Description + version: 1.0.0 + description: | + ## Overview + + This workflow guides the process of applying for a loan at checkout using a "Buy Now, Pay Later" (BNPL) platform. It orchestrates a series of API interactions to ensure a seamless and efficient loan application process, integrating multiple services across different API providers. + + ### Key Features + - **Multi-step Loan Application:** The workflow includes multiple steps to check product eligibility, retrieve terms and conditions, create customer profiles, initiate the loan, and finalize the payment plan. + - **Dynamic Decision Making:** Based on the API responses, the workflow adapts the flow, for example, skipping customer creation if the customer is already registered or ending the workflow if no eligible products are found. + - **User-Centric:** The workflow handles both existing and new customers, providing a flexible approach to customer onboarding and loan authorization. +``` + +### arazzo-source-descriptions-type + +Source Description `type` should be present. This means that tooling does not need to immediately parse/resolve the `sourceDescriptions` to know what type of document they are. + +**Recommended:** Yes + +**Good Example** + +```yaml +sourceDescriptions: + - name: BnplApi + url: https://raw.githubusercontent.com/OAI/Arazzo-Specification/main/examples/1.0.0/bnpl-openapi.yaml + type: openapi +``` + +### arazzo-workflow-workflowId + +Workflow `workflowId` defined should follow the pattern `^[A-Za-z0-9_\\-]+$`. This is good practice as tools and libraries can use the `workflowId` to uniquely identify a workflow. + +**Recommended:** Yes + +### arazzo-workflow-description + +In order to improve consumer experience, Workflow `description` should be present and a non-empty string. + +**Recommend:** Yes + +### arazzo-workflow-summary + +In order to improve consumer experience, Workflow `summary` should be present and a non-empty string. + +**Recommend:** Yes + +### arazzo-step-stepId + +Step `stepId` defined should follow the pattern `^[A-Za-z0-9_\\-]+$`. This is good practice as tools and libraries can use the `stepId` to uniquely identify a step. + +**Recommended:** Yes + +### arazzo-step-description + +In order to improve consumer experience, Step `description` should be present and a non-empty string. + +**Recommend:** Yes + +### arazzo-step-operationPath + +It is recommended to use `operationId` rather than `operationPath` within a step to reference an API operation. + +**Recommended:** Yes + +### arazzo-step-request-body-validation + +Every step request body must have an expected `contentType` and expected use of runtime expressions. + +The contentType value will be checked against the following regex: + +```regex +/^(application|audio|font|example|image|message|model|multipart|text|video)\/[a-zA-Z0-9!#$&^_.+-]{1,127}$/ +``` + +Rule Checks: + +- if `payload` uses full runtime expression (e.g. $steps.steps1.outputs.responseBody) then it must be a valid/expected runtime expression +- If `replacements` are specified, then if a `value` uses a runtime expression it must be valid. + +> \_inline use of runtime expressions within `payload` are not yet validated + +**Recommended:** Yes diff --git a/package.json b/package.json index d99759294..7b8f60c50 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "validator", "OpenAPI", "Swagger", + "Arazzo", + "AsyncAPI", "schema", "API" ], @@ -32,7 +34,7 @@ "lint": "yarn prelint && yarn lint.prettier && yarn lint.eslint", "lint.fix": "yarn lint.prettier --write && yarn lint.eslint --fix", "lint.eslint": "eslint --cache --cache-location .cache/.eslintcache --ext=.js,.mjs,.ts packages test-harness", - "lint.prettier": "prettier --ignore-path .eslintignore --ignore-unknown --check packages/core/src/ruleset/meta/*.json packages/rulesets/src/{asyncapi,oas}/schemas/**/*.json docs/**/*.md README.md", + "lint.prettier": "prettier --ignore-path .eslintignore --ignore-unknown --check packages/core/src/ruleset/meta/*.json packages/rulesets/src/{asyncapi,oas,arazzo}/schemas/**/*.json docs/**/*.md README.md", "pretest": "yarn workspaces foreach run pretest", "test": "yarn pretest && yarn test.karma && yarn test.jest", "pretest.harness": "ts-node -T test-harness/scripts/generate-tests.ts", @@ -96,7 +98,7 @@ "@types/node-fetch": "^2.5.12", "@types/node-powershell": "^3.1.1", "@types/text-table": "^0.2.2", - "@typescript-eslint/eslint-plugin": "^5.34.0", + "@typescript-eslint/eslint-plugin": "^5.35.1", "@typescript-eslint/parser": "^5.34.0", "eslint": "^8.22.0", "eslint-config-prettier": "^8.5.0", @@ -138,7 +140,7 @@ "packages/core/src/ruleset/meta/*.json": [ "prettier --ignore-path .eslintignore --write" ], - "packages/rulesets/src/{asyncapi,oas}/schemas/**/*.json": [ + "packages/rulesets/src/{asyncapi,oas,arazzo}/schemas/**/*.json": [ "prettier --ignore-path .eslintignore --write" ] }, diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 8385065fd..d5d1a3a3e 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,3 +1,25 @@ +## @stoplight/spectral-cli [6.13.1](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-cli-6.13.0...@stoplight/spectral-cli-6.13.1) (2024-09-21) + + +### Bug Fixes + +* **deps:** fix package.json ([0161072](https://github.com/stoplightio/spectral/commit/016107250f88420d224d798c327910faed250d36)) + +# @stoplight/spectral-cli [6.13.0](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-cli-6.12.0...@stoplight/spectral-cli-6.13.0) (2024-09-13) + + +### Features + +* **formatters:** add code climate (GitLab) formatter ([#2648](https://github.com/stoplightio/spectral/issues/2648)) ([41eca61](https://github.com/stoplightio/spectral/commit/41eca612d292520142ace3bd97cde630c33366f1)) +* **formatters:** add markdown formatter ([#2662](https://github.com/stoplightio/spectral/issues/2662)) ([b5edf5e](https://github.com/stoplightio/spectral/commit/b5edf5e9b61c986097e6d77988489ed12a48611f)) + +# @stoplight/spectral-cli [6.12.0](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-cli-6.11.1...@stoplight/spectral-cli-6.12.0) (2024-09-12) + + +### Features + +* **rulesets:** initial rulesets for the Arazzo Specification ([#2672](https://github.com/stoplightio/spectral/issues/2672)) ([8443232](https://github.com/stoplightio/spectral/commit/84432325cd9eb87c4ce32897bd4a23e83aabb856)) + ## @stoplight/spectral-cli [6.11.1](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-cli-6.11.0...@stoplight/spectral-cli-6.11.1) (2024-04-04) diff --git a/packages/cli/README.md b/packages/cli/README.md index 303102b71..e436b29d0 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2,7 +2,7 @@ [![CircleCI](https://img.shields.io/circleci/build/github/stoplightio/spectral/develop)](https://circleci.com/gh/stoplightio/spectral) [![NPM Downloads](https://img.shields.io/npm/dw/@stoplight/spectral-cli?color=blue)](https://www.npmjs.com/package/@stoplight/spectral-cli) [![Stoplight Forest](https://img.shields.io/ecologi/trees/stoplightinc)][stoplight_forest] - **Custom Rulesets**: Create custom rules to lint JSON or YAML objects -- **Ready-to-use Rulesets**: Validate and lint **OpenAPI v2 & v3** and **AsyncAPI** Documents +- **Ready-to-use Rulesets**: Validate and lint **OpenAPI v2 & v3.x**, **AsyncAPI**, and **Arazzo v1** Documents - **JSON Path Support**: Use JSON path to apply rules to specific parts of your objects - **Ready-to-use Functions**: Built-in set of functions to help [create custom rules](https://meta.stoplight.io/docs/spectral/docs/guides/4-custom-rulesets.md#adding-rules). Functions include pattern checks, parameter checks, alphabetical ordering, a specified number of characters, provided keys are present in an object, etc. - **Custom Functions**: Create custom functions for advanced use cases @@ -64,7 +64,7 @@ No problem! A hosted version of Spectral comes **free** with the Stoplight platf ### What is the difference between Spectral and Speccy -[Speccy](https://github.com/wework/speccy) was a great inspiration for Spectral, but was designed to work only with OpenAPI v3. Spectral can apply rules to _any_ JSON/YAML object (including OpenAPI v2/v3 and AsyncAPI). It's mostly been abandoned now, and is JavaScript not TypeScript. +[Speccy](https://github.com/wework/speccy) was a great inspiration for Spectral, but was designed to work only with OpenAPI v3. Spectral can apply rules to _any_ JSON/YAML object (including OpenAPI v2/v3, Arazzo, and AsyncAPI). Speccy has mostly been abandoned now, and is JavaScript not TypeScript. ## ⚙️ Integrations @@ -74,7 +74,7 @@ No problem! A hosted version of Spectral comes **free** with the Stoplight platf ## 🏁 Help Others Utilize Spectral -If you're using Spectral for an interesting use case, [contact us](mailto:growth@stoplight.io) for a case study. We'll add it to a list here. Spread the goodness 🎉 +If you're using Spectral for an interesting use case, create an issue with details on how you're using it. We'll add it to a list here. Spread the goodness 🎉 ## 👏 Contributing diff --git a/packages/cli/package.json b/packages/cli/package.json index f559873f6..bb48f046d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@stoplight/spectral-cli", - "version": "6.11.1", + "version": "6.13.1", "homepage": "https://github.com/stoplightio/spectral", "bugs": "https://github.com/stoplightio/spectral/issues", "author": "Stoplight ", @@ -40,8 +40,8 @@ "@stoplight/spectral-formatters": "^1.3.0", "@stoplight/spectral-parsers": "^1.0.3", "@stoplight/spectral-ref-resolver": "^1.0.4", - "@stoplight/spectral-ruleset-bundler": "^1.5.2", - "@stoplight/spectral-ruleset-migrator": "^1.9.5", + "@stoplight/spectral-ruleset-bundler": "^1.5.4", + "@stoplight/spectral-ruleset-migrator": "^1.9.6", "@stoplight/spectral-rulesets": ">=1", "@stoplight/spectral-runtime": "^1.1.2", "@stoplight/types": "^13.6.0", diff --git a/packages/cli/src/services/config.ts b/packages/cli/src/services/config.ts index b0ec0213c..03047950e 100644 --- a/packages/cli/src/services/config.ts +++ b/packages/cli/src/services/config.ts @@ -13,6 +13,9 @@ export enum OutputFormat { PRETTY = 'pretty', GITHUB_ACTIONS = 'github-actions', SARIF = 'sarif', + CODE_CLIMATE = 'code-climate', + GITLAB = 'gitlab', + MARKDOWN = 'markdown', } export interface ILintConfig { diff --git a/packages/cli/src/services/output.ts b/packages/cli/src/services/output.ts index dc07e221f..23015090a 100644 --- a/packages/cli/src/services/output.ts +++ b/packages/cli/src/services/output.ts @@ -11,6 +11,8 @@ import { pretty, githubActions, sarif, + codeClimate, + markdown, } from '@stoplight/spectral-formatters'; import type { Formatter, FormatterOptions } from '@stoplight/spectral-formatters'; import type { OutputFormat } from './config'; @@ -26,6 +28,9 @@ const formatters: Record = { teamcity, 'github-actions': githubActions, sarif, + 'code-climate': codeClimate, + gitlab: codeClimate, + markdown, }; export function formatOutput( diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index adc810554..5e57f3c08 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,3 +1,17 @@ +## @stoplight/spectral-core [1.19.1](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-core-1.19.0...@stoplight/spectral-core-1.19.1) (2024-09-13) + + +### Bug Fixes + +* **core:** fix for TypeError "this.formats.has is not a function" ([#2664](https://github.com/stoplightio/spectral/issues/2664)) ([75d642d](https://github.com/stoplightio/spectral/commit/75d642dae6200136839a8ab67d8860b2e781a8d2)) + +# @stoplight/spectral-core [1.19.0](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-core-1.18.3...@stoplight/spectral-core-1.19.0) (2024-09-12) + + +### Features + +* **rulesets:** initial rulesets for the Arazzo Specification ([#2672](https://github.com/stoplightio/spectral/issues/2672)) ([8443232](https://github.com/stoplightio/spectral/commit/84432325cd9eb87c4ce32897bd4a23e83aabb856)) + ## @stoplight/spectral-core [1.18.3](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-core-1.18.2...@stoplight/spectral-core-1.18.3) (2023-07-18) diff --git a/packages/core/package.json b/packages/core/package.json index bd9be1517..95f8612b9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@stoplight/spectral-core", - "version": "1.18.3", + "version": "1.19.1", "sideEffects": false, "homepage": "https://github.com/stoplightio/spectral", "bugs": "https://github.com/stoplightio/spectral/issues", @@ -43,7 +43,7 @@ "@stoplight/types": "~13.6.0", "@types/es-aggregate-error": "^1.0.2", "@types/json-schema": "^7.0.11", - "ajv": "^8.6.0", + "ajv": "^8.17.1", "ajv-errors": "~3.0.0", "ajv-formats": "~2.1.0", "es-aggregate-error": "^1.0.7", @@ -57,7 +57,7 @@ "tslib": "^2.3.0" }, "devDependencies": { - "@stoplight/spectral-formats": "*", + "@stoplight/spectral-formats": "^1.7.0", "@stoplight/spectral-functions": "*", "@stoplight/spectral-parsers": "*", "@stoplight/yaml": "^4.2.2", diff --git a/packages/core/src/ruleset/rule.ts b/packages/core/src/ruleset/rule.ts index 1b10ff12c..1bf53c2cb 100644 --- a/packages/core/src/ruleset/rule.ts +++ b/packages/core/src/ruleset/rule.ts @@ -162,8 +162,8 @@ export class Rule implements IRule { return false; } - for (const format of formats) { - if (this.formats.has(format)) { + for (const format of this.formats) { + if (formats.has(format)) { return true; } } diff --git a/packages/formats/CHANGELOG.md b/packages/formats/CHANGELOG.md index cb58fc565..c94c96e29 100644 --- a/packages/formats/CHANGELOG.md +++ b/packages/formats/CHANGELOG.md @@ -1,3 +1,11 @@ +# @stoplight/spectral-formats [1.7.0](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-formats-1.6.0...@stoplight/spectral-formats-1.7.0) (2024-09-12) + + +### Features + +* **formats:** add arazzo format ([#2663](https://github.com/stoplightio/spectral/issues/2663)) ([dc1a8ef](https://github.com/stoplightio/spectral/commit/dc1a8ef003e198ab9943bf50ee17cd64ca2b6307)) +* **rulesets:** initial rulesets for the Arazzo Specification ([#2672](https://github.com/stoplightio/spectral/issues/2672)) ([8443232](https://github.com/stoplightio/spectral/commit/84432325cd9eb87c4ce32897bd4a23e83aabb856)) + # @stoplight/spectral-formats [1.6.0](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-formats-v1.5.0...@stoplight/spectral-formats-1.6.0) (2023-11-07) diff --git a/packages/formats/package.json b/packages/formats/package.json index ac441a391..e25c4136a 100644 --- a/packages/formats/package.json +++ b/packages/formats/package.json @@ -1,6 +1,6 @@ { "name": "@stoplight/spectral-formats", - "version": "1.6.0", + "version": "1.7.0", "sideEffects": false, "homepage": "https://github.com/stoplightio/spectral", "bugs": "https://github.com/stoplightio/spectral/issues", diff --git a/packages/formats/src/__tests__/arazzo.test.ts b/packages/formats/src/__tests__/arazzo.test.ts new file mode 100644 index 000000000..f9557463e --- /dev/null +++ b/packages/formats/src/__tests__/arazzo.test.ts @@ -0,0 +1,27 @@ +import { arazzo1_0 } from '../arazzo'; + +describe('Arazzo format', () => { + describe('Arazzo 1.0.x', () => { + it.each(['1.0.0', '1.0', '1.0.1', '1.0.2', '1.0.99'])('recognizes %s version correctly', version => { + expect(arazzo1_0({ arazzo: version }, null)).toBe(true); + }); + + const testCases = [ + { arazzo: '0.1' }, + { arazzo: '1.1.0' }, + { arazzo: '2' }, + { arazzo: '2.0' }, + { arazzo: '2.0.' }, + { arazzo: '2.0.01' }, + { arazzo: 2 }, + { arazzo: null }, + { arazzo: '4.0' }, + {}, + null, + ]; + + it.each(testCases)('does not recognize invalid document %o', document => { + expect(arazzo1_0(document, null)).toBe(false); + }); + }); +}); diff --git a/packages/formats/src/arazzo.ts b/packages/formats/src/arazzo.ts new file mode 100644 index 000000000..787449e5a --- /dev/null +++ b/packages/formats/src/arazzo.ts @@ -0,0 +1,12 @@ +import type { Format } from '@stoplight/spectral-core'; +import { isPlainObject } from '@stoplight/json'; + +type MaybeArazzo = { arazzo: unknown } & Record; + +const arazzo1_0Regex = /^1\.0(?:\.[0-9]*)?$/; + +const isArazzo = (document: unknown): document is { arazzo: string } & Record => + isPlainObject(document) && 'arazzo' in document && arazzo1_0Regex.test(String((document as MaybeArazzo).arazzo)); + +export const arazzo1_0: Format = isArazzo; +arazzo1_0.displayName = 'Arazzo 1.0.x'; diff --git a/packages/formats/src/index.ts b/packages/formats/src/index.ts index 0451e893f..071d1ae46 100644 --- a/packages/formats/src/index.ts +++ b/packages/formats/src/index.ts @@ -1,3 +1,4 @@ export * from './openapi'; export * from './asyncapi'; export * from './jsonSchema'; +export * from './arazzo'; diff --git a/packages/formatters/CHANGELOG.md b/packages/formatters/CHANGELOG.md index 0e9516df6..bd745ce7d 100644 --- a/packages/formatters/CHANGELOG.md +++ b/packages/formatters/CHANGELOG.md @@ -1,3 +1,11 @@ +# @stoplight/spectral-formatters [1.4.0](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-formatters-1.3.0...@stoplight/spectral-formatters-1.4.0) (2024-09-13) + + +### Features + +* **formatters:** add code climate (GitLab) formatter ([#2648](https://github.com/stoplightio/spectral/issues/2648)) ([41eca61](https://github.com/stoplightio/spectral/commit/41eca612d292520142ace3bd97cde630c33366f1)) +* **formatters:** add markdown formatter ([#2662](https://github.com/stoplightio/spectral/issues/2662)) ([b5edf5e](https://github.com/stoplightio/spectral/commit/b5edf5e9b61c986097e6d77988489ed12a48611f)) + # @stoplight/spectral-formatters [1.3.0](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-formatters-1.2.0...@stoplight/spectral-formatters-1.3.0) (2023-09-14) diff --git a/packages/formatters/README.md b/packages/formatters/README.md index 809d02e99..06debf878 100644 --- a/packages/formatters/README.md +++ b/packages/formatters/README.md @@ -28,9 +28,12 @@ console.error(output); - html - text - teamcity +- markdown (example: [markdown_example.md](markdown_example.md)) ### Node.js only - pretty - github-actions - sarif +- gitlab +- code-climate diff --git a/packages/formatters/markdown_example.md b/packages/formatters/markdown_example.md new file mode 100644 index 000000000..0e96a12e6 --- /dev/null +++ b/packages/formatters/markdown_example.md @@ -0,0 +1,5 @@ +| Code | Path | Message | Severity | Start | End | Source | +| ---------------------------------------------------------------------- | ---------------------------- | -------------------------------------------- | -------- | ----- | ---- | --------------------------------------------------- | +| [operation-description](https://rule-documentation-url.com) | paths.\/pets.get.description | paths.\/pets.get.description is not truthy | Error | 1:0 | 10:1 | .\/src\/\_\_tests\_\_\/fixtures\/petstore.oas2.yaml | +| [operation-tags](https://ruleset-documentation-url.com#operation-tags) | paths.\/pets.get.tags | paths.\/pets.get.tags is not truthy | Warning | 11:0 | 20:1 | .\/src\/\_\_tests\_\_\/fixtures\/petstore.oas2.yaml | +| rule-from-other-ruleset | paths | i should not have any documentation url link | Warning | 21:0 | 30:1 | .\/src\/\_\_tests\_\_\/fixtures\/petstore.oas2.yaml | diff --git a/packages/formatters/package.json b/packages/formatters/package.json index 9339c07c0..feff31eda 100644 --- a/packages/formatters/package.json +++ b/packages/formatters/package.json @@ -1,6 +1,6 @@ { "name": "@stoplight/spectral-formatters", - "version": "1.3.0", + "version": "1.4.0", "homepage": "https://github.com/stoplightio/spectral", "bugs": "https://github.com/stoplightio/spectral/issues", "author": "Stoplight ", @@ -38,9 +38,11 @@ "@stoplight/spectral-core": "^1.15.1", "@stoplight/spectral-runtime": "^1.1.0", "@stoplight/types": "^13.15.0", + "@types/markdown-escape": "^1.1.3", "chalk": "4.1.2", "cliui": "7.0.4", "lodash": "^4.17.21", + "markdown-escape": "^2.0.0", "node-sarif-builder": "^2.0.3", "strip-ansi": "6.0", "text-table": "^0.2.0", diff --git a/packages/formatters/src/__tests__/code-climate.jest.test.ts b/packages/formatters/src/__tests__/code-climate.jest.test.ts new file mode 100644 index 000000000..92f3ad935 --- /dev/null +++ b/packages/formatters/src/__tests__/code-climate.jest.test.ts @@ -0,0 +1,89 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import type { IRuleResult } from '@stoplight/spectral-core'; +import { codeClimate } from '../code-climate'; + +const cwd = process.cwd(); +const results: IRuleResult[] = [ + { + code: 'operation-description', + message: 'paths./pets.get.description is not truthy', + path: ['paths', '/pets', 'get', 'description'], + severity: 1, + source: `${cwd}/__tests__/fixtures/petstore.oas2.yaml`, + range: { + start: { + line: 60, + character: 8, + }, + end: { + line: 71, + character: 60, + }, + }, + }, + { + code: 'operation-tags', + message: 'paths./pets.get.tags is not truthy', + path: ['paths', '/pets', 'get', 'tags'], + severity: 1, + source: `${cwd}/__tests__/fixtures/petstore.oas2.yaml`, + range: { + start: { + line: 60, + character: 8, + }, + end: { + line: 71, + character: 60, + }, + }, + }, +]; + +describe('Code climate formatter', () => { + test('should include ranges', () => { + expect(JSON.parse(codeClimate(results, { failSeverity: DiagnosticSeverity.Error }))).toEqual([ + expect.objectContaining({ + location: { + path: '__tests__/fixtures/petstore.oas2.yaml', + positions: { + begin: { + line: 60, + column: 8, + }, + end: { + line: 71, + column: 60, + }, + }, + }, + }), + expect.objectContaining({ + location: { + path: '__tests__/fixtures/petstore.oas2.yaml', + positions: { + begin: { + line: 60, + column: 8, + }, + end: { + line: 71, + column: 60, + }, + }, + }, + }), + ]); + }); + + test('should include description', () => { + expect(JSON.parse(codeClimate(results, { failSeverity: DiagnosticSeverity.Error }))).toEqual([ + expect.objectContaining({ + description: 'paths./pets.get.description is not truthy', + }), + expect.objectContaining({ + description: 'paths./pets.get.tags is not truthy', + }), + ]); + }); +}); diff --git a/packages/formatters/src/__tests__/markdown.test.ts b/packages/formatters/src/__tests__/markdown.test.ts new file mode 100644 index 000000000..360df1e8e --- /dev/null +++ b/packages/formatters/src/__tests__/markdown.test.ts @@ -0,0 +1,111 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import type { IRuleResult } from '@stoplight/spectral-core'; +import { FormatterContext } from '../types'; +import { markdown } from '../markdown'; + +const results: IRuleResult[] = [ + { + code: 'operation-description', + message: 'paths./pets.get.description is not truthy', + path: ['paths', '/pets', 'get', 'description'], + severity: DiagnosticSeverity.Error, + source: './src/__tests__/fixtures/petstore.oas2.yaml', + range: { + start: { + line: 1, + character: 0, + }, + end: { + line: 10, + character: 1, + }, + }, + }, + { + code: 'operation-tags', + message: 'paths./pets.get.tags is not truthy', + path: ['paths', '/pets', 'get', 'tags'], + severity: DiagnosticSeverity.Warning, + source: './src/__tests__/fixtures/petstore.oas2.yaml', + range: { + start: { + line: 11, + character: 0, + }, + end: { + line: 20, + character: 1, + }, + }, + }, + { + code: 'rule-from-other-ruleset', + message: 'i should not have any documentation url link', + path: ['paths'], + severity: DiagnosticSeverity.Warning, + source: './src/__tests__/fixtures/petstore.oas2.yaml', + range: { + start: { + line: 21, + character: 0, + }, + end: { + line: 30, + character: 1, + }, + }, + }, +]; + +const context = { + ruleset: { + rules: { + 'operation-description': { + documentationUrl: 'https://rule-documentation-url.com', + owner: { + definition: { + documentationUrl: 'https://ruleset-documentation-url.com', + }, + }, + }, + 'operation-tags': { + documentationUrl: '', //nothing + owner: { + definition: { + documentationUrl: 'https://ruleset-documentation-url.com', + }, + }, + }, + 'rule-from-other-ruleset': { + documentationUrl: '', //nothing + owner: { + definition: { + documentationUrl: '', //nothing + }, + }, + }, + }, + }, +} as unknown as FormatterContext; + +const expectedMd = String.raw` +| Code | Path | Message | Severity | Start | End | Source | +| ---------------------------------------------------------------------- | ---------------------------- | -------------------------------------------- | -------- | ----- | ---- | --------------------------------------------------- | +| [operation-description](https://rule-documentation-url.com) | paths.\/pets.get.description | paths.\/pets.get.description is not truthy | Error | 1:0 | 10:1 | .\/src\/\_\_tests\_\_\/fixtures\/petstore.oas2.yaml | +| [operation-tags](https://ruleset-documentation-url.com#operation-tags) | paths.\/pets.get.tags | paths.\/pets.get.tags is not truthy | Warning | 11:0 | 20:1 | .\/src\/\_\_tests\_\_\/fixtures\/petstore.oas2.yaml | +| rule-from-other-ruleset | paths | i should not have any documentation url link | Warning | 21:0 | 30:1 | .\/src\/\_\_tests\_\_\/fixtures\/petstore.oas2.yaml | +`; + +describe('Markdown formatter', () => { + test('should format as markdown table', () => { + const CRLF = '\r\n'; + const md = markdown(results, { failSeverity: DiagnosticSeverity.Warning }, context); + + // We normalize the line-breaks and trailing whitespaces because the expected markdown file is can be created on a Windows machine + // and prettier instert a line break automatically + const normalizedMd = md.replace(new RegExp(CRLF, 'g'), '\n').trim(); + const normalizedExpectedMd = expectedMd.replace(new RegExp(CRLF, 'g'), '\n').trim(); + + expect(normalizedMd).toEqual(normalizedExpectedMd); + }); +}); diff --git a/packages/formatters/src/code-climate.ts b/packages/formatters/src/code-climate.ts new file mode 100644 index 000000000..04f4a2af0 --- /dev/null +++ b/packages/formatters/src/code-climate.ts @@ -0,0 +1,69 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import { Formatter } from './types'; +import { relative } from '@stoplight/path'; + +/** + * @see https://github.com/codeclimate/platform/blob/690633cb2a08839a5bfa350ed925ddb6de55bbdc/spec/analyzers/SPEC.md#data-types + */ +interface CodeClimateIssue { + type: 'issue'; + check_name: string; + description: string; + categories: CodeClimateIssueCategory[]; + location: CodeClimateIssueLocation; + content?: { body: string }; + trace?: CodeClimateIssueTrace; + remediation_points?: number; + severity?: CodeClimateIssueSeverity; + fingerprint?: string; +} +type CodeClimateIssueCategory = + | 'Bug Risk' + | 'Clarity' + | 'Compatibility' + | 'Complexity' + | 'Duplication' + | 'Performance' + | 'Security' + | 'Style'; +interface CodeClimateIssueLocation { + path: string; + positions: { + begin: { line: number; column: number }; + end: { line: number; column: number }; + }; +} +interface CodeClimateIssueTrace { + locations: CodeClimateIssueLocation[]; + stackTrace: boolean; +} +type CodeClimateIssueSeverity = 'info' | 'minor' | 'major' | 'critical' | 'blocker'; +const severityMap: Record = { + [DiagnosticSeverity.Error]: 'critical', + [DiagnosticSeverity.Warning]: 'major', + [DiagnosticSeverity.Information]: 'minor', + [DiagnosticSeverity.Hint]: 'info', +}; + +export const codeClimate: Formatter = results => { + const outputJson: CodeClimateIssue[] = results.map(result => { + const relPath = relative(process.cwd(), result.source ?? '').replace(/\\/g, '/'); + const fingerprint = `${relPath}:${result.path.join('.')}:${result.code}`; + return { + type: 'issue' as const, + check_name: result.code.toString(), + description: result.message, + categories: ['Style'], + location: { + path: relPath, + positions: { + begin: { line: result.range.start.line, column: result.range.start.character }, + end: { line: result.range.end.line, column: result.range.end.character }, + }, + }, + severity: severityMap[result.severity], + fingerprint, + }; + }); + return JSON.stringify(outputJson, null, '\t'); +}; diff --git a/packages/formatters/src/index.node.ts b/packages/formatters/src/index.node.ts index e9d7a5b39..a33aacbe4 100644 --- a/packages/formatters/src/index.node.ts +++ b/packages/formatters/src/index.node.ts @@ -1,5 +1,6 @@ -export { html, json, junit, text, stylish, teamcity } from './index'; +export { html, json, junit, text, stylish, teamcity, markdown } from './index'; export type { Formatter, FormatterOptions } from './index'; export { pretty } from './pretty'; export { githubActions } from './github-actions'; export { sarif } from './sarif'; +export { codeClimate } from './code-climate'; diff --git a/packages/formatters/src/index.ts b/packages/formatters/src/index.ts index 23f612c6d..ad4c68913 100644 --- a/packages/formatters/src/index.ts +++ b/packages/formatters/src/index.ts @@ -4,6 +4,7 @@ export * from './junit'; export * from './html'; export * from './text'; export * from './teamcity'; +export * from './markdown'; import type { Formatter } from './types'; export type { Formatter, FormatterOptions } from './types'; @@ -18,3 +19,7 @@ export const githubActions: Formatter = () => { export const sarif: Formatter = () => { throw Error('sarif formatter is available only in Node.js'); }; + +export const codeClimate: Formatter = () => { + throw Error('sarif formatter is available only in Node.js'); +}; diff --git a/packages/formatters/src/markdown.ts b/packages/formatters/src/markdown.ts new file mode 100644 index 000000000..cea57f6ed --- /dev/null +++ b/packages/formatters/src/markdown.ts @@ -0,0 +1,71 @@ +import { printPath, PrintStyle } from '@stoplight/spectral-runtime'; +import { Formatter, FormatterContext } from './types'; +import { groupBySource } from './utils'; +import { DiagnosticSeverity } from '@stoplight/types'; +import markdownEscape from 'markdown-escape'; +import { getRuleDocumentationUrl } from './utils/getDocumentationUrl'; + +export const markdown: Formatter = (results, { failSeverity }, ctx?: FormatterContext) => { + const groupedResults = groupBySource(results); + + const lines: string[][] = []; + for (const [source, validationResults] of Object.entries(groupedResults)) { + validationResults.sort((a, b) => a.range.start.line - b.range.start.line); + + if (validationResults.length > 0) { + const filteredValidationResults = validationResults.filter(result => result.severity <= failSeverity); + + for (const result of filteredValidationResults) { + const ruleDocumentationUrl = getRuleDocumentationUrl(result.code, ctx); + const codeWithOptionalLink = + ruleDocumentationUrl != null + ? `[${result.code.toString()}](${ruleDocumentationUrl})` + : result.code.toString(); + const escapedPath = markdownEscape(printPath(result.path, PrintStyle.Dot)); + const escapedMessage = markdownEscape(result.message); + const severityString = DiagnosticSeverity[result.severity]; + const start = `${result.range.start.line}:${result.range.start.character}`; + const end = `${result.range.end.line}:${result.range.end.character}`; + const escapedSource = markdownEscape(source); + lines.push([codeWithOptionalLink, escapedPath, escapedMessage, severityString, start, end, escapedSource]); + } + } + } + + const headers = ['Code', 'Path', 'Message', 'Severity', 'Start', 'End', 'Source']; + return createMdTable(headers, lines); +}; + +function createMdTable(headers: string[], lines: string[][]): string { + //find lenght of each column + const columnLengths = headers.map((_, i) => Math.max(...lines.map(line => line[i].length), headers[i].length)); + + let string = ''; + //create markdown table header + string += '|'; + for (const header of headers) { + string += ` ${header}`; + string += ' '.repeat(columnLengths[headers.indexOf(header)] - header.length); + string += ' |'; + } + + //create markdown table rows delimiter + string += '\n|'; + for (const _ of headers) { + string += ' '; + string += '-'.repeat(columnLengths[headers.indexOf(_)]); + string += ' |'; + } + + //create markdown table rows + for (const line of lines) { + string += '\n|'; + for (const cell of line) { + string += ` ${cell}`; + string += ' '.repeat(columnLengths[line.indexOf(cell)] - cell.length); + string += ' |'; + } + } + + return string; +} diff --git a/packages/formatters/src/utils/getDocumentationUrl.ts b/packages/formatters/src/utils/getDocumentationUrl.ts new file mode 100644 index 000000000..85f2190e3 --- /dev/null +++ b/packages/formatters/src/utils/getDocumentationUrl.ts @@ -0,0 +1,22 @@ +import { FormatterContext } from '../types'; + +/// Returns the documentation URL, either directly from the rule or by combining the ruleset documentation URL with the rule code. +export function getRuleDocumentationUrl(ruleCode: string | number, ctx?: FormatterContext): string | undefined { + if (!ctx?.ruleset) { + return undefined; + } + + const rule = ctx.ruleset.rules[ruleCode.toString()]; + //if rule.documentationUrl is not null and not empty and not undefined, return it + if (rule.documentationUrl != null && rule.documentationUrl) { + return rule.documentationUrl; + } + + //otherwise use the ruleset documentationUrl and append the rulecode as an anchor + const rulesetDocumentationUrl = rule.owner?.definition.documentationUrl; + if (rulesetDocumentationUrl != null && rulesetDocumentationUrl) { + return `${rulesetDocumentationUrl}#${ruleCode}`; + } + + return undefined; +} diff --git a/packages/functions/CHANGELOG.md b/packages/functions/CHANGELOG.md index b18b5ec5f..f0941e3ef 100644 --- a/packages/functions/CHANGELOG.md +++ b/packages/functions/CHANGELOG.md @@ -1,3 +1,10 @@ +# @stoplight/spectral-functions [1.9.0](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-functions-1.8.0...@stoplight/spectral-functions-1.9.0) (2024-09-12) + + +### Features + +* **rulesets:** initial rulesets for the Arazzo Specification ([#2672](https://github.com/stoplightio/spectral/issues/2672)) ([8443232](https://github.com/stoplightio/spectral/commit/84432325cd9eb87c4ce32897bd4a23e83aabb856)) + # @stoplight/spectral-functions [1.8.0](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-functions-v1.7.2...@stoplight/spectral-functions-1.8.0) (2024-06-07) diff --git a/packages/functions/package.json b/packages/functions/package.json index 96b2bda40..10ae26e85 100644 --- a/packages/functions/package.json +++ b/packages/functions/package.json @@ -1,6 +1,6 @@ { "name": "@stoplight/spectral-functions", - "version": "1.8.0", + "version": "1.9.0", "sideEffects": false, "homepage": "https://github.com/stoplightio/spectral", "bugs": "https://github.com/stoplightio/spectral/issues", @@ -22,9 +22,9 @@ "@stoplight/better-ajv-errors": "1.0.3", "@stoplight/json": "^3.17.1", "@stoplight/spectral-core": "^1.7.0", - "@stoplight/spectral-formats": "^1.0.0", + "@stoplight/spectral-formats": "^1.7.0", "@stoplight/spectral-runtime": "^1.1.0", - "ajv": "^8.6.3", + "ajv": "^8.17.1", "ajv-draft-04": "~1.0.0", "ajv-errors": "~3.0.0", "ajv-formats": "~2.1.0", diff --git a/packages/ruleset-bundler/CHANGELOG.md b/packages/ruleset-bundler/CHANGELOG.md index 283b64765..2300a1fdc 100644 --- a/packages/ruleset-bundler/CHANGELOG.md +++ b/packages/ruleset-bundler/CHANGELOG.md @@ -1,3 +1,10 @@ +# @stoplight/spectral-ruleset-bundler [1.6.0](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-ruleset-bundler-v1.5.2...@stoplight/spectral-ruleset-bundler-1.6.0) (2024-09-12) + + +### Features + +* **rulesets:** initial rulesets for the Arazzo Specification ([#2672](https://github.com/stoplightio/spectral/issues/2672)) ([8443232](https://github.com/stoplightio/spectral/commit/84432325cd9eb87c4ce32897bd4a23e83aabb856)) + # [@stoplight/spectral-ruleset-bundler-v1.5.2](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-ruleset-bundler-v1.5.1...@stoplight/spectral-ruleset-bundler-v1.5.2) (2023-05-17) diff --git a/packages/ruleset-bundler/package.json b/packages/ruleset-bundler/package.json index b224a348d..4c3af47ca 100644 --- a/packages/ruleset-bundler/package.json +++ b/packages/ruleset-bundler/package.json @@ -1,6 +1,6 @@ { "name": "@stoplight/spectral-ruleset-bundler", - "version": "1.5.2", + "version": "1.6.0", "homepage": "https://github.com/stoplightio/spectral", "bugs": "https://github.com/stoplightio/spectral/issues", "author": "Stoplight ", @@ -38,11 +38,11 @@ "@rollup/plugin-commonjs": "~22.0.2", "@stoplight/path": "1.3.2", "@stoplight/spectral-core": ">=1", - "@stoplight/spectral-formats": ">=1", + "@stoplight/spectral-formats": "^1.7.0", "@stoplight/spectral-functions": ">=1", "@stoplight/spectral-parsers": ">=1", "@stoplight/spectral-ref-resolver": "^1.0.4", - "@stoplight/spectral-ruleset-migrator": "^1.7.4", + "@stoplight/spectral-ruleset-migrator": "^1.9.6", "@stoplight/spectral-rulesets": ">=1", "@stoplight/spectral-runtime": "^1.1.0", "@stoplight/types": "^13.6.0", diff --git a/packages/ruleset-bundler/src/plugins/__tests__/builtins.spec.ts b/packages/ruleset-bundler/src/plugins/__tests__/builtins.spec.ts index 3a29a63c0..79c12c3f3 100644 --- a/packages/ruleset-bundler/src/plugins/__tests__/builtins.spec.ts +++ b/packages/ruleset-bundler/src/plugins/__tests__/builtins.spec.ts @@ -75,6 +75,7 @@ const xor = globalThis[Symbol.for('@stoplight-spectral/builtins')]['822928']['@s const oas = globalThis[Symbol.for('@stoplight-spectral/builtins')]['822928']['@stoplight/spectral-rulesets']['oas']; const asyncapi = globalThis[Symbol.for('@stoplight-spectral/builtins')]['822928']['@stoplight/spectral-rulesets']['asyncapi']; +const arazzo = globalThis[Symbol.for('@stoplight-spectral/builtins')]['822928']['@stoplight/spectral-rulesets']['arazzo']; var input = { extends: [oas], diff --git a/packages/ruleset-migrator/CHANGELOG.md b/packages/ruleset-migrator/CHANGELOG.md index 3d3e6b632..91d76730e 100644 --- a/packages/ruleset-migrator/CHANGELOG.md +++ b/packages/ruleset-migrator/CHANGELOG.md @@ -1,3 +1,10 @@ +# @stoplight/spectral-ruleset-migrator [1.10.0](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-ruleset-migrator-1.9.5...@stoplight/spectral-ruleset-migrator-1.10.0) (2024-09-12) + + +### Features + +* **rulesets:** initial rulesets for the Arazzo Specification ([#2672](https://github.com/stoplightio/spectral/issues/2672)) ([8443232](https://github.com/stoplightio/spectral/commit/84432325cd9eb87c4ce32897bd4a23e83aabb856)) + ## @stoplight/spectral-ruleset-migrator [1.9.5](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-ruleset-migrator-1.9.4...@stoplight/spectral-ruleset-migrator-1.9.5) (2023-07-26) diff --git a/packages/ruleset-migrator/package.json b/packages/ruleset-migrator/package.json index 5e08e9a5e..742c8294e 100644 --- a/packages/ruleset-migrator/package.json +++ b/packages/ruleset-migrator/package.json @@ -1,6 +1,6 @@ { "name": "@stoplight/spectral-ruleset-migrator", - "version": "1.9.5", + "version": "1.10.0", "homepage": "https://github.com/stoplightio/spectral", "bugs": "https://github.com/stoplightio/spectral/issues", "author": "Stoplight ", @@ -29,7 +29,7 @@ "@stoplight/types": "^13.6.0", "@stoplight/yaml": "~4.2.3", "@types/node": "*", - "ajv": "^8.6.0", + "ajv": "^8.17.1", "ast-types": "0.14.2", "astring": "^1.7.5", "reserved": "0.1.2", diff --git a/packages/ruleset-migrator/src/transformers/extends.ts b/packages/ruleset-migrator/src/transformers/extends.ts index ee8c96330..904b590db 100644 --- a/packages/ruleset-migrator/src/transformers/extends.ts +++ b/packages/ruleset-migrator/src/transformers/extends.ts @@ -9,6 +9,7 @@ import { isBasicRuleset } from '../utils/isBasicRuleset'; const REPLACEMENTS = { 'spectral:oas': 'oas', 'spectral:asyncapi': 'asyncapi', + 'spectral:arazzo': 'arazzo', }; export { transformer as default }; diff --git a/packages/ruleset-migrator/src/transformers/formats.ts b/packages/ruleset-migrator/src/transformers/formats.ts index 8216c7cb7..96fc7e3bc 100644 --- a/packages/ruleset-migrator/src/transformers/formats.ts +++ b/packages/ruleset-migrator/src/transformers/formats.ts @@ -12,6 +12,8 @@ const FORMATS = [ 'oas3', 'oas3.0', 'oas3.1', + 'arazzo1', + 'arazzo1.0', 'asyncapi2', 'json-schema', 'json-schema-loose', diff --git a/packages/rulesets/CHANGELOG.md b/packages/rulesets/CHANGELOG.md index c402ecd63..840b26523 100644 --- a/packages/rulesets/CHANGELOG.md +++ b/packages/rulesets/CHANGELOG.md @@ -1,3 +1,24 @@ +## @stoplight/spectral-rulesets [1.20.2](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-rulesets-1.20.1...@stoplight/spectral-rulesets-1.20.2) (2024-09-17) + + +### Bug Fixes + +* **rulesets:** remove step summary rule ([#2692](https://github.com/stoplightio/spectral/issues/2692)) ([d5a566f](https://github.com/stoplightio/spectral/commit/d5a566f13d831bed76c69c2efe3de7b7a111d6d0)) + +## @stoplight/spectral-rulesets [1.20.1](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-rulesets-1.20.0...@stoplight/spectral-rulesets-1.20.1) (2024-09-13) + + +### Bug Fixes + +* **rulesets:** use uri-reference for oauth security schemes ([#2652](https://github.com/stoplightio/spectral/issues/2652)) ([c411e63](https://github.com/stoplightio/spectral/commit/c411e63f5fd1bc13c55ee83da0bd0b3120c2a87d)) + +# @stoplight/spectral-rulesets [1.20.0](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-rulesets-1.19.1...@stoplight/spectral-rulesets-1.20.0) (2024-09-12) + + +### Features + +* **rulesets:** initial rulesets for the Arazzo Specification ([#2672](https://github.com/stoplightio/spectral/issues/2672)) ([8443232](https://github.com/stoplightio/spectral/commit/84432325cd9eb87c4ce32897bd4a23e83aabb856)) + ## @stoplight/spectral-rulesets [1.19.1](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-rulesets-1.19.0...@stoplight/spectral-rulesets-1.19.1) (2024-06-10) diff --git a/packages/rulesets/package.json b/packages/rulesets/package.json index 672e1026b..0fc2aa927 100644 --- a/packages/rulesets/package.json +++ b/packages/rulesets/package.json @@ -1,6 +1,6 @@ { "name": "@stoplight/spectral-rulesets", - "version": "1.19.1", + "version": "1.20.2", "homepage": "https://github.com/stoplightio/spectral", "bugs": "https://github.com/stoplightio/spectral/issues", "author": "Stoplight ", @@ -22,12 +22,12 @@ "@stoplight/better-ajv-errors": "1.0.3", "@stoplight/json": "^3.17.0", "@stoplight/spectral-core": "^1.8.1", - "@stoplight/spectral-formats": "^1.5.0", + "@stoplight/spectral-formats": "^1.7.0", "@stoplight/spectral-functions": "^1.5.1", "@stoplight/spectral-runtime": "^1.1.1", "@stoplight/types": "^13.6.0", "@types/json-schema": "^7.0.7", - "ajv": "^8.12.0", + "ajv": "^8.17.1", "ajv-formats": "~2.1.0", "json-schema-traverse": "^1.0.0", "leven": "3.1.0", diff --git a/packages/rulesets/scripts/compile-schemas.ts b/packages/rulesets/scripts/compile-schemas.ts index 7378ed7c7..e1b154cdf 100644 --- a/packages/rulesets/scripts/compile-schemas.ts +++ b/packages/rulesets/scripts/compile-schemas.ts @@ -21,6 +21,7 @@ const schemas = [ 'oas/schemas/oas/v3.1/dialect.schema.json', 'oas/schemas/oas/v3.1/meta.schema.json', 'oas/schemas/oas/v3.1/index.json', + 'arazzo/schemas/arazzo/v1.0/index.json', ].map(async schema => JSON.parse(await fs.promises.readFile(path.join(cwd, schema), 'utf8'))); const log = process.argv.includes('--quiet') @@ -50,11 +51,13 @@ Promise.all(schemas) ajvErrors(ajv); const target = path.join(cwd, 'oas/schemas/validators.ts'); + const arazzoTarget = path.join(cwd, 'arazzo/schemas/validators.ts'); const basename = path.basename(target); const code = standaloneCode(ajv, { oas2_0: 'http://swagger.io/v2/schema.json', oas3_0: 'https://spec.openapis.org/oas/3.0/schema/2019-04-02', oas3_1: 'https://spec.openapis.org/oas/3.1/schema/2021-09-28', + arazzo1_0: 'https://spec.openapis.org/arazzo/1.0/schema/2024-08-01', }); const minified = ( @@ -79,6 +82,16 @@ Promise.all(schemas) ); await fs.promises.writeFile(path.join(target, '..', basename), ['// @ts-nocheck', minified].join('\n')); + + log( + 'writing %s size is %dKB (original), %dKB (minified) %dKB (minified + gzipped)', + path.join(arazzoTarget, '..', basename), + Math.round((code.length / 1024) * 100) / 100, + Math.round((minified.length / 1024) * 100) / 100, + Math.round((sync(minified) / 1024) * 100) / 100, + ); + + await fs.promises.writeFile(path.join(arazzoTarget, '..', basename), ['// @ts-nocheck', minified].join('\n')); }) .then(() => { log(chalk.green('Validators generated.')); diff --git a/packages/rulesets/src/__tests__/__helpers__/tester.ts b/packages/rulesets/src/__tests__/__helpers__/tester.ts index 3ad1000e1..d850fd187 100644 --- a/packages/rulesets/src/__tests__/__helpers__/tester.ts +++ b/packages/rulesets/src/__tests__/__helpers__/tester.ts @@ -3,6 +3,7 @@ import { IRuleResult, Spectral, Document, RulesetDefinition } from '@stoplight/s import { httpAndFileResolver } from '@stoplight/spectral-ref-resolver'; import oasRuleset from '../../oas/index'; import aasRuleset from '../../asyncapi/index'; +import arazzoRuleset from '../../arazzo/index'; type Ruleset = typeof oasRuleset & typeof aasRuleset; export type RuleName = keyof Ruleset['rules']; @@ -43,6 +44,7 @@ export function createWithRules(rules: (keyof Ruleset['rules'])[]): Spectral { extends: [ [aasRuleset as RulesetDefinition, 'off'], [oasRuleset as RulesetDefinition, 'off'], + [arazzoRuleset as RulesetDefinition, 'off'], ], rules: rules.reduce((obj, name) => { obj[name] = true; diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoDocumentSchema.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoDocumentSchema.test.ts new file mode 100644 index 000000000..175fbfa67 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoDocumentSchema.test.ts @@ -0,0 +1,284 @@ +import type { RulesetFunctionContext } from '@stoplight/spectral-core'; +import arazzoDocumentSchema from '../arazzoDocumentSchema'; +import { arazzo1_0 } from '@stoplight/spectral-formats'; + +function runSchema(target: unknown, context?: Partial) { + return arazzoDocumentSchema(target, null, { + path: [], + documentInventory: {}, + document: { + formats: new Set([arazzo1_0]), + source: '', + diagnostics: [], + getRangeForJsonPath: jest.fn(), // Mocked function + trapAccess: jest.fn(), // Mocked function + data: target, + }, + ...context, + } as RulesetFunctionContext); +} + +describe('arazzoDocumentSchema', () => { + test('should pass for a valid Arazzo document', () => { + const validDocument = { + arazzo: '1.0.0', + info: { + title: 'Valid Arazzo', + version: '1.0.0', + }, + sourceDescriptions: [{ name: 'source1', url: 'https://example.com', type: 'arazzo' }], + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + operationId: 'operation1', + }, + ], + }, + ], + }; + + const results = runSchema(validDocument); + expect(results).not.toBeUndefined(); + expect(results).toHaveLength(0); + }); + + test('should fail when required fields are missing', () => { + const invalidDocument = { + arazzo: '1.0.0', + // Missing info, sourceDescriptions, and workflows + }; + + const results = runSchema(invalidDocument); + expect(results).toHaveLength(3); // Expect 3 errors for the missing fields + expect(results[0].message).toContain('must have required property "info"'); + expect(results[1].message).toContain('must have required property "sourceDescriptions"'); + expect(results[2].message).toContain('must have required property "workflows"'); + }); + + test('should fail when arazzo version is invalid', () => { + const invalidVersionDocument = { + arazzo: '2.0.0', // Invalid version pattern + info: { + title: 'Invalid Arazzo', + version: '1.0.0', + }, + sourceDescriptions: [{ name: 'source1', url: 'https://example.com', type: 'arazzo' }], + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + operationId: 'operation1', + }, + ], + }, + ], + }; + + const results = runSchema(invalidVersionDocument); + expect(results).toHaveLength(1); + expect(results[0].message).toContain('"arazzo" property must match pattern "^1\\.0\\.\\d+(-.+)?$"'); + }); + + test('should fail when source description name is invalid', () => { + const invalidSourceNameDocument = { + arazzo: '1.0.0', + info: { + title: 'Arazzo with Invalid Source Name', + version: '1.0.0', + }, + sourceDescriptions: [ + { name: 'Invalid Name!', url: 'https://example.com', type: 'arazzo' }, // Invalid name pattern + ], + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + operationId: 'operation1', + }, + ], + }, + ], + }; + + const results = runSchema(invalidSourceNameDocument); + expect(results).toHaveLength(1); + expect(results[0].message).toContain('"name" property must match pattern "^[A-Za-z0-9_\\-]+$"'); + }); + + test('should fail when stepId is missing from a workflow step', () => { + const invalidStepDocument = { + arazzo: '1.0.0', + info: { + title: 'Arazzo with Missing StepId', + version: '1.0.0', + }, + sourceDescriptions: [{ name: 'source1', url: 'https://example.com', type: 'arazzo' }], + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + operationId: 'operation1', // Missing stepId + }, + ], + }, + ], + }; + + const results = runSchema(invalidStepDocument); + expect(results).toHaveLength(1); + expect(results[0].message).toContain('must have required property "stepId"'); + }); + + test('should pass when success and failure actions are valid', () => { + const validActionsDocument = { + arazzo: '1.0.0', + info: { + title: 'Arazzo with Valid Actions', + version: '1.0.0', + }, + sourceDescriptions: [{ name: 'source1', url: 'https://example.com', type: 'arazzo' }], + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + operationId: 'operation1', + onSuccess: [{ name: 'successAction', type: 'goto', stepId: 'step2' }], + onFailure: [{ name: 'failureAction', type: 'retry', retryAfter: 5, retryLimit: 3 }], + }, + { + stepId: 'step2', + operationId: 'operation2', + }, + ], + }, + ], + }; + + const results = runSchema(validActionsDocument); + expect(results).not.toBeUndefined(); + expect(results).toHaveLength(0); + }); + + test('should fail when sourceDescriptions are missing required fields', () => { + const invalidSourceDescriptionDocument = { + arazzo: '1.0.0', + info: { + title: 'Arazzo with Missing Source Description Fields', + version: '1.0.0', + }, + sourceDescriptions: [ + { name: 'source1' }, // Missing url and type + ], + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + operationId: 'operation1', + }, + ], + }, + ], + }; + + const results = runSchema(invalidSourceDescriptionDocument); + expect(results).toHaveLength(1); // Missing url + expect(results[0].message).toContain('must have required property "url"'); + }); + + test('should pass when stepId or workflowId is not specified and type is end', () => { + const validActionsDocument = { + arazzo: '1.0.0', + info: { + title: 'Arazzo with Valid End Type Action', + version: '1.0.0', + }, + sourceDescriptions: [{ name: 'source1', url: 'https://example.com', type: 'arazzo' }], + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + operationId: 'operation1', + onSuccess: [{ name: 'successAction', type: 'end' }], + onFailure: [{ name: 'failureAction', type: 'end' }], + }, + ], + }, + ], + }; + + const results = runSchema(validActionsDocument); + expect(results).toHaveLength(0); + }); + + test('should fail when stepId is specified and type is end', () => { + const invalidActionsDocument = { + arazzo: '1.0.0', + info: { + title: 'Arazzo with Invalid StepId and End Type', + version: '1.0.0', + }, + sourceDescriptions: [{ name: 'source1', url: 'https://example.com', type: 'arazzo' }], + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + operationId: 'operation1', + onSuccess: [{ name: 'successAction', type: 'end', stepId: 'step2' }], + onFailure: [{ name: 'failureAction', type: 'end', stepId: 'step2' }], + }, + ], + }, + ], + }; + + const results = runSchema(invalidActionsDocument); + expect(results[0].message).toContain( + 'property must be equal to one of the allowed values: "goto". Did you mean "goto"?', + ); + }); + + test('should fail when workflowId is specified and type is end', () => { + const invalidActionsDocument = { + arazzo: '1.0.0', + info: { + title: 'Arazzo with Invalid WorkflowId and End Type', + version: '1.0.0', + }, + sourceDescriptions: [{ name: 'source1', url: 'https://example.com', type: 'arazzo' }], + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + operationId: 'operation1', + onSuccess: [{ name: 'successAction', type: 'end', workflowId: 'workflow2' }], + onFailure: [{ name: 'failureAction', type: 'end', workflowId: 'workflow2' }], + }, + ], + }, + ], + }; + + const results = runSchema(invalidActionsDocument); + expect(results[0].message).toContain( + 'property must be equal to one of the allowed values: "goto". Did you mean "goto"?', + ); + }); +}); diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepFailureActionsValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepFailureActionsValidation.test.ts new file mode 100644 index 000000000..79d6ea8f0 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepFailureActionsValidation.test.ts @@ -0,0 +1,466 @@ +import arazzoStepFailureActionsValidation from '../arazzoStepFailureActionsValidation'; +import type { RulesetFunctionContext } from '@stoplight/spectral-core'; +import { ArazzoSpecification } from '../types/arazzoTypes'; + +const runRule = (target: ArazzoSpecification, _contextOverrides: Partial = {}) => { + return arazzoStepFailureActionsValidation(target, null); +}; + +describe('validateFailureActions', () => { + test('should not report any errors for valid and unique failure actions', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + onFailure: [ + { name: 'action1', type: 'goto', stepId: 'step1' }, + { name: 'action2', type: 'end' }, + ], + }, + ], + }, + ], + components: { + failureActions: { + allDone: { + name: 'finishWorkflow', + type: 'end', + }, + }, + }, + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for duplicate failure actions within the same step', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + onFailure: [ + { name: 'action1', type: 'goto', stepId: 'step1' }, + { name: 'action1', type: 'end' }, + ], + }, + ], + }, + ], + components: { + failureActions: {}, + }, + }); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ + message: `"action1" must be unique within the combined failure actions.`, + path: ['workflows', 0, 'steps', 0, 'onFailure', 1], + }); + }); + + test('should report an error for mutually exclusive workflowId and stepId', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + onFailure: [{ name: 'action1', type: 'goto', stepId: 'step1', workflowId: 'workflow1' }], + }, + ], + }, + ], + components: { failureActions: {} }, + }); + + expect(results).toHaveLength(1); // The second failure should be added based on the conflict between workflowId and stepId + expect(results[0]).toMatchObject({ + message: `"workflowId" and "stepId" are mutually exclusive and cannot be specified together.`, + path: ['workflows', 0, 'steps', 0, 'onFailure', 0], + }); + }); + + test('should override workflow level onFailure action with step level onFailure action', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + failureActions: [{ name: 'action1', type: 'end' }], + steps: [ + { + stepId: 'step1', + onFailure: [{ name: 'action1', type: 'goto', stepId: 'step1' }], + }, + ], + }, + ], + components: { failureActions: {} }, + }); + + expect(results).toHaveLength(0); // No errors as step level onFailure overrides workflow level action + }); + + test('should report an error for missing condition in Criterion', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + onFailure: [ + { + name: 'action1', + type: 'goto', + stepId: 'step1', + criteria: [ + { + context: '$response.body', + condition: '', + }, + ], + }, + ], + }, + ], + }, + ], + components: { failureActions: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `Missing or invalid "condition" in Criterion Object.`, + path: ['workflows', 0, 'steps', 0, 'onFailure', 0, 'criteria', 0, 'condition'], + }); + }); + + test('should report an error for invalid regex pattern in Criterion condition', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + onFailure: [ + { + name: 'action1', + type: 'goto', + stepId: 'step1', + criteria: [{ context: '$statusCode', condition: '^(200$', type: 'regex' }], + }, + ], + }, + ], + }, + ], + components: { failureActions: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"condition" contains an invalid regex pattern.`, + path: ['workflows', 0, 'steps', 0, 'onFailure', 0, 'criteria', 0, 'condition'], + }); + }); + + test('should report an error for missing context when type is specified in Criterion', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + onFailure: [ + { + name: 'action1', + type: 'goto', + stepId: 'step1', + criteria: [{ condition: '$response.body', type: 'jsonpath' }], + }, + ], + }, + ], + }, + ], + components: { failureActions: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `A "context" must be specified for a Criterion Object with type "jsonpath".`, + path: ['workflows', 0, 'steps', 0, 'onFailure', 0, 'criteria', 0, 'context'], + }); + }); + + test('should not report any errors for valid reference to a failure action in components', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + onFailure: [{ reference: '$components.failureActions.refreshToken' }], + }, + ], + }, + ], + components: { + failureActions: { + refreshToken: { + name: 'refreshExpiredToken', + type: 'retry', + retryAfter: 1, + retryLimit: 5, + }, + }, + }, + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for a non-existing failure action reference in components', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'apply-coupon', + steps: [ + { + stepId: 'find-pet', + onFailure: [ + { reference: '$components.failureActions.foo' }, // This action doesn't exist + { name: 'retryStep', type: 'retry', retryAfter: 1, retryLimit: 5 }, + ], + }, + ], + }, + ], + components: { + failureActions: { + refreshToken: { + name: 'refreshExpiredToken', + type: 'retry', + retryAfter: 1, + retryLimit: 5, + workflowId: 'refreshTokenWorkflowId', + }, + }, + }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: 'Invalid runtime expression for reusable action reference: "$components.failureActions.foo".', + path: ['workflows', 0, 'steps', 0, 'onFailure', 0], + }); + }); + + test('should report an error for an invalid runtime expression in a reusable failure action reference', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + onFailure: [ + { reference: 'invalidExpression' }, + { name: 'retryStep', type: 'retry', retryAfter: 1, retryLimit: 5 }, + ], + }, + ], + }, + ], + components: { + failureActions: { + refreshToken: { + name: 'refreshExpiredToken', + type: 'retry', + retryAfter: 1, + retryLimit: 5, + workflowId: 'refreshTokenWorkflowId', + }, + }, + }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: 'Invalid runtime expression for reusable action reference: "invalidExpression".', + path: ['workflows', 0, 'steps', 0, 'onFailure', 0], + }); + }); + + test('should report an error for a reference to a non-existing failure action in components', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + onFailure: [{ reference: '$components.failureActions.nonExistingAction' }], + }, + ], + }, + ], + components: { + failureActions: { + refreshToken: { + name: 'refreshExpiredToken', + type: 'retry', + retryAfter: 1, + retryLimit: 5, + workflowId: 'refreshTokenWorkflowId', + }, + }, + }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: + 'Invalid runtime expression for reusable action reference: "$components.failureActions.nonExistingAction".', + path: ['workflows', 0, 'steps', 0, 'onFailure', 0], + }); + }); + + test('should report an error when stepId in failure action does not exist in the current workflow', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + onFailure: [ + { name: 'action1', type: 'goto', stepId: 'nonExistingStep' }, // This stepId doesn't exist + ], + }, + ], + }, + ], + components: { failureActions: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"stepId" "nonExistingStep" does not exist within the current workflow.`, + path: ['workflows', 0, 'steps', 0, 'onFailure', 0], + }); + }); + + test('should report an error when workflowId is a runtime expression that does not exist in sourceDescriptions', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + onFailure: [ + { name: 'action1', type: 'goto', workflowId: '$sourceDescriptions.invalidName.invalidWorkflow' }, // Invalid name in sourceDescriptions + ], + }, + ], + }, + ], + sourceDescriptions: [{ name: 'validName', url: './valid.url', type: 'openapi' }], + components: { failureActions: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"workflowId" "$sourceDescriptions.invalidName.invalidWorkflow" is not a valid reference or does not exist in sourceDescriptions.`, + path: ['workflows', 0, 'steps', 0, 'onFailure', 0], + }); + }); + + test('should report an error when workflowId in failure action does not exist within local workflows', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + onFailure: [ + { name: 'action1', type: 'goto', workflowId: 'nonExistingWorkflow' }, // This workflowId doesn't exist + ], + }, + ], + }, + ], + components: { failureActions: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"workflowId" "nonExistingWorkflow" does not exist within the local Arazzo Document workflows.`, + path: ['workflows', 0, 'steps', 0, 'onFailure', 0], + }); + }); + + test('should not report an error for valid stepId and workflowId references in failure actions', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { stepId: 'step1' }, + { + stepId: 'step2', + onFailure: [ + { name: 'action1', type: 'goto', stepId: 'step1' }, // Valid stepId + { name: 'action2', type: 'goto', workflowId: 'workflow2' }, // Valid workflowId + ], + }, + ], + }, + { + workflowId: 'workflow2', + steps: [{ stepId: 'step1' }], + }, + ], + components: { failureActions: {} }, + }); + + expect(results).toHaveLength(0); // No errors for valid references + }); + + test('should report an error when workflowId and stepId are used together in a failure action', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + onFailure: [ + { name: 'action1', type: 'goto', workflowId: 'workflow2', stepId: 'step1' }, // Both workflowId and stepId are used + ], + }, + ], + }, + ], + components: { failureActions: {} }, + }); + + expect(results).toHaveLength(2); + expect(results[1]).toMatchObject({ + message: `"workflowId" and "stepId" are mutually exclusive and cannot be specified together.`, + path: ['workflows', 0, 'steps', 0, 'onFailure', 0], + }); + }); +}); diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepIdUniqueness.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepIdUniqueness.test.ts new file mode 100644 index 000000000..eac61c470 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepIdUniqueness.test.ts @@ -0,0 +1,49 @@ +import { DeepPartial } from '@stoplight/types'; +import arazzoStepIdUniqueness from '../arazzoStepIdUniqueness'; +import type { RulesetFunctionContext } from '@stoplight/spectral-core'; + +const runRule = (target: { steps: Array<{ stepId: string }> }) => { + const context: DeepPartial = { + path: [], + documentInventory: { + graph: {} as any, // Mock the graph property + referencedDocuments: {} as any, // Mock the referencedDocuments property as a Dictionary + findAssociatedItemForPath: jest.fn(), // Mock the findAssociatedItemForPath function + }, + document: { + formats: new Set(), // Mock the formats property correctly + }, + }; + + return arazzoStepIdUniqueness(target, null, context as RulesetFunctionContext); +}; + +describe('arazzoStepIdUniqueness', () => { + test('should not report any errors for unique stepIds', () => { + const results = runRule({ + steps: [{ stepId: 'step1' }, { stepId: 'step2' }], + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for duplicate stepIds', () => { + const results = runRule({ + steps: [{ stepId: 'step1' }, { stepId: 'step1' }], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"stepId" must be unique within the workflow.`, + path: ['steps', 1, 'stepId'], + }); + }); + + test('should not report an error for case-sensitive unique stepIds', () => { + const results = runRule({ + steps: [{ stepId: 'step1' }, { stepId: 'Step1' }], + }); + + expect(results).toHaveLength(0); + }); +}); diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepOutputNamesValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepOutputNamesValidation.test.ts new file mode 100644 index 000000000..062d64402 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepOutputNamesValidation.test.ts @@ -0,0 +1,198 @@ +import arazzoStepOutputNamesValidation from '../arazzoStepOutputNamesValidation'; +import { DeepPartial } from '@stoplight/types'; +import type { RulesetFunctionContext } from '@stoplight/spectral-core'; + +const runRule = ( + target: { + workflows: Array<{ + workflowId: string; + steps: Array<{ + stepId: string; + outputs?: { [key: string]: string }; + }>; + }>; + components?: Record; + }, + contextOverrides: Partial = {}, +) => { + const context: DeepPartial = { + path: [], + documentInventory: { + graph: {} as any, // Mock the graph property + referencedDocuments: {} as any, // Mock the referencedDocuments property as a Dictionary + findAssociatedItemForPath: jest.fn(), // Mock the findAssociatedItemForPath function + }, + document: { + formats: new Set(), // Mock the formats property correctly + }, + ...contextOverrides, + }; + + return arazzoStepOutputNamesValidation(target, null, context as RulesetFunctionContext); +}; + +describe('arazzoStepOutputNamesValidation', () => { + test('should not report any errors for valid and unique output names', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + outputs: { + output1: '$url', + output2: '$response.body#/status', + }, + stepId: 'step1', + }, + { + outputs: { output3: '$steps.step1.outputs.output1' }, + stepId: 'step2', + }, + ], + }, + ], + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for invalid output names', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + outputs: { + 'invalid name': '$url', + output2: '$statusCode', + }, + stepId: 'step1', + }, + ], + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"invalid name" does not match the required pattern "^[a-zA-Z0-9.\\-_]+$".`, + path: ['workflows', 0, 'steps', 0, 'outputs', 'invalid name', 0], + }); + }); + + test('should report an error for invalid step name in output expression', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + outputs: { + output1: '$statusCode', + }, + stepId: 'step1', + }, + { + outputs: { + foo: '$steps.non-existing-step.outputs.output1', + }, + stepId: 'step2', + }, + ], + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"$steps.non-existing-step.outputs.output1" is not a valid runtime expression.`, + path: ['workflows', 0, 'steps', 1, 'outputs', 'foo', 0], + }); + }); + + test('should not report an error for duplicate output names across different steps', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { outputs: { output1: '$response.body' }, stepId: 'step1' }, + { outputs: { output1: '$response.body' }, stepId: 'step2' }, // Duplicate output name across different steps + ], + }, + ], + }); + + expect(results).toHaveLength(0); + }); + + test('should not report any errors for valid runtime expressions', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + outputs: { + output1: '$response.body#/status', + output2: '$steps.step1.outputs.value', + }, + stepId: 'step1', + }, + ], + }, + ], + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for invalid runtime expressions', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + outputs: { output1: 'invalid expression' }, + stepId: 'step1', + }, + ], + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"invalid expression" is not a valid runtime expression.`, + path: ['workflows', 0, 'steps', 0, 'outputs', 'output1', 0], + }); + }); + + test('should handle valid and invalid expressions mixed', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + outputs: { + validOutput: '$response.body#/status', + invalidOutput: 'invalid expression', + }, + stepId: 'step1', + }, + ], + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"invalid expression" is not a valid runtime expression.`, + path: ['workflows', 0, 'steps', 0, 'outputs', 'invalidOutput', 1], + }); + }); +}); diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepParametersValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepParametersValidation.test.ts new file mode 100644 index 000000000..00efb026c --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepParametersValidation.test.ts @@ -0,0 +1,615 @@ +import arazzoStepParametersValidation from '../arazzoStepParametersValidation'; +import type { RulesetFunctionContext } from '@stoplight/spectral-core'; +import { ArazzoSpecification } from '../types/arazzoTypes'; + +const runRule = (target: ArazzoSpecification, _contextOverrides: Partial = {}) => { + return arazzoStepParametersValidation(target, null); +}; + +describe('arazzoStepParametersValidation', () => { + test('should not report any errors for valid and unique parameters', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + parameters: [ + { name: 'param1', in: 'query', value: 'value1' }, + { name: 'param2', in: 'header', value: 'value2' }, + ], + stepId: 'step1', + }, + ], + }, + ], + components: { parameters: {} }, + }); + + expect(results).toHaveLength(0); + }); + + test('should not report any errors for valid and unique parameters at step and workflow level', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + parameters: [ + { name: 'param1', in: 'query', value: 'value1' }, + { name: 'param2', in: 'header', value: 'value2' }, + ], + stepId: 'step1', + }, + ], + }, + ], + components: { parameters: { param1: { name: 'param3', in: 'cookie', value: 'value3' } } }, + }); + + expect(results).toHaveLength(0); + }); + + test('should handle combined parameters from step and workflow without "in" when "workflowId" is specified', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + workflowId: 'workflow1', + parameters: [{ name: 'param1', value: 'value1' }], + stepId: 'step1', + }, + { + workflowId: 'workflow1', + parameters: [{ name: 'param2', value: 'value2' }], + stepId: 'step2', + }, + ], + }, + ], + components: { parameters: {} }, + }); + + expect(results).toHaveLength(0); + }); + + test('should handle combined parameters from step and workflow with "in" when "operationPath" is specified', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + operationPath: '/path1', + parameters: [{ name: 'param1', in: 'query', value: 'value1' }], + stepId: 'step1', + }, + { + operationPath: '/path2', + parameters: [{ name: 'param2', in: 'header', value: 'value2' }], + stepId: 'step2', + }, + ], + }, + ], + components: { parameters: {} }, + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for duplicate parameters within the same step', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + parameters: [ + { name: 'param1', in: 'query', value: 'value1' }, + { name: 'param1', in: 'query', value: 'value2' }, + ], + stepId: 'step1', + }, + ], + }, + ], + components: { parameters: {} }, + }); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ + message: `"param1" must be unique within the combined parameters.`, + path: ['workflows', 0, 'steps', 0, 'parameters', 1], + }); + }); + + test('should report an error for duplicate reusable parameters', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + parameters: [ + { reference: '$components.parameters.param1' }, + { reference: '$components.parameters.param1' }, + ], + stepId: 'step1', + }, + ], + }, + ], + components: { + parameters: { + param1: { + name: 'param1', + in: 'query', + value: 'value1', + }, + }, + }, + }); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ + message: `"param1" must be unique within the combined parameters.`, + path: ['workflows', 0, 'steps', 0, 'parameters', 1], + }); + }); + + test('should handle combined duplicate parameters from step and workflow level (override scenario)', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + workflowId: 'workflow1', + parameters: [{ name: 'param1', value: 'value1' }], + stepId: 'step1', + }, + ], + }, + ], + components: { parameters: {} }, + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for mixed "in" presence when "workflowId" is present', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + workflowId: 'workflow1', + parameters: [ + { name: 'param1', value: 'value1' }, + { name: 'param2', in: 'query', value: 'value2' }, + ], + stepId: 'step1', + }, + ], + }, + ], + components: { parameters: {} }, + }); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ + message: `Parameters must not mix "in" field presence.`, + path: ['workflows', 0, 'steps', 0, 'parameters'], + }); + }); + + test('should report an error for parameters containing "in" when "workflowId" is present', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + workflowId: 'workflow1', + parameters: [ + { name: 'param1', in: 'header', value: 'value1' }, + { name: 'param2', in: 'query', value: 'value2' }, + ], + stepId: 'step1', + }, + ], + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `Step with "workflowId" must not have parameters with an "in" field.`, + path: ['workflows', 0, 'steps', 0, 'parameters'], + }); + }); + + test('should report an error for parameters missing "in" when "operationId" is present', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + operationId: 'operation1', + parameters: [ + { name: 'param1', value: 'value1' }, + { name: 'param2', value: 'value2' }, + ], + stepId: 'step1', + }, + ], + }, + ], + components: { parameters: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `Step with "operationId" or "operationPath" must have parameters with an "in" field.`, + path: ['workflows', 0, 'steps', 0, 'parameters'], + }); + }); + + test('should handle combined duplicate parameters from step and workflow with "in" when "operationId" is specified (override scenario)', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + operationId: 'operation1', + parameters: [{ name: 'param1', in: 'query', value: 'value1' }], + stepId: 'step1', + }, + ], + }, + ], + components: { parameters: {} }, + }); + + expect(results).toHaveLength(0); + }); + + test('should handle combined parameters from step and workflow with "in" when "operationId" is specified', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + operationId: 'operation1', + parameters: [{ name: 'param1', in: 'query', value: 'value1' }], + stepId: 'step1', + }, + { + operationId: 'operation2', + parameters: [{ name: 'param2', in: 'header', value: 'value2' }], + stepId: 'step2', + }, + ], + }, + ], + components: { parameters: {} }, + }); + + expect(results).toHaveLength(0); + }); + + test('should handle combined duplicate parameters from step and workflow with "in" when "operationPath" is specified (override scenario)', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + operationPath: '/path1', + parameters: [{ name: 'param1', in: 'query', value: 'value1' }], + stepId: 'step1', + }, + ], + }, + ], + components: { parameters: {} }, + }); + + expect(results).toHaveLength(0); + }); + + // New Tests for Runtime Expressions + + test('should report an error for invalid $steps expression in parameter value', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + parameters: [{ name: 'foo', in: 'query', value: '$steps.invalidStep.outputs.param' }], + stepId: 'step1', + }, + ], + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `Invalid runtime expression: "$steps.invalidStep.outputs.param" for parameter.`, + path: ['workflows', 0, 'steps', 0, 'parameters', 0], + }); + }); + + test('should not report errors for valid $steps expression in parameter name', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + parameters: [{ name: '$steps.validStep.outputs.param', in: 'query', value: 'value1' }], + stepId: 'step1', + }, + { + stepId: 'validStep', + }, + ], + }, + ], + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for invalid $workflows expression in parameter value', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + parameters: [{ name: 'foo', in: 'query', value: '$workflows.invalidWorkflow.steps.step1.outputs.param' }], + stepId: 'step1', + }, + ], + }, + { + workflowId: 'validWorkflow', + steps: [ + { + stepId: 'step1', + parameters: [{ name: 'param', in: 'query', value: 'value2' }], + }, + ], + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `Invalid runtime expression: "$workflows.invalidWorkflow.steps.step1.outputs.param" for parameter.`, + path: ['workflows', 0, 'steps', 0, 'parameters', 0], + }); + }); + + test('should not report errors for valid $workflows expression in parameter name', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + parameters: [ + { name: '$workflows.validWorkflow.steps.step1.outputs.param', in: 'query', value: 'value1' }, + ], + stepId: 'step1', + }, + ], + }, + { + workflowId: 'validWorkflow', + steps: [ + { + stepId: 'step1', + parameters: [{ name: 'param', in: 'query', value: 'value2' }], + }, + ], + }, + ], + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for invalid $inputs expression in parameter value', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + parameters: [{ name: 'foo', in: 'query', value: '$inputs.invalidInput' }], + stepId: 'step1', + }, + ], + }, + { + workflowId: 'workflow1', + inputs: { + validInput: 'value2', + }, + steps: [ + { + stepId: 'step1', + parameters: [{ name: 'param', in: 'query', value: 'value3' }], + }, + ], + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `Invalid runtime expression: "$inputs.invalidInput" for parameter.`, + path: ['workflows', 0, 'steps', 0, 'parameters', 0], + }); + }); + + test('should not report errors for valid $inputs expression in parameter name', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + inputs: { + type: 'object', + properties: { + validInput: { type: 'string' }, + }, + }, + steps: [ + { + stepId: 'step1', + parameters: [{ name: 'value1', in: 'query', value: '$inputs.validInput' }], + }, + ], + }, + ], + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for invalid $components.parameters expression in parameter reference', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + parameters: [{ reference: '$components.parameters.invalidParam' }], + stepId: 'step1', + }, + ], + }, + ], + components: { + parameters: { + validParam: { + name: 'param1', + in: 'query', + value: 'hello', + }, + }, + }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `Invalid runtime expression for reusable parameter reference: "$components.parameters.invalidParam".`, + path: ['workflows', 0, 'steps', 0, 'parameters', 0], + }); + }); + + test('should not report errors for valid $components.parameters expression in parameter reference', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + parameters: [{ reference: '$components.parameters.validParam' }], + stepId: 'step1', + }, + ], + }, + ], + components: { + parameters: { + validParam: { + name: 'param1', + in: 'query', + value: 'value1', + }, + }, + }, + }); + + expect(results).toHaveLength(0); + }); + + test('should not report errors for valid $ref in workflow inputs', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + inputs: { + $ref: '#/components/inputs/myInputRef', + }, + steps: [ + { + stepId: 'step1', + parameters: [{ name: 'foo', in: 'query', value: '$inputs.validInput' }], + }, + ], + }, + ], + components: { + inputs: { + myInputRef: { + type: 'object', + properties: { + validInput: { type: 'string' }, + }, + }, + }, + }, + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for invalid $ref in workflow inputs', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + inputs: { + $ref: '#/components/inputs/myInputRef', + }, + steps: [ + { + stepId: 'step1', + parameters: [{ name: 'foo', in: 'query', value: '$inputs.invalidInput' }], + }, + ], + }, + ], + components: { + inputs: { + myInputRef: { + type: 'object', + properties: { + validInput: { type: 'string' }, + }, + }, + }, + }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `Invalid runtime expression: "$inputs.invalidInput" for parameter.`, + path: ['workflows', 0, 'steps', 0, 'parameters', 0], + }); + }); +}); diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepRequestBodyValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepRequestBodyValidation.test.ts new file mode 100644 index 000000000..8a2e0f702 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepRequestBodyValidation.test.ts @@ -0,0 +1,189 @@ +import arazzoStepRequestBodyValidation from '../arazzoStepRequestBodyValidation'; +import type { RulesetFunctionContext } from '@stoplight/spectral-core'; +import { ArazzoSpecification } from '../types/arazzoTypes'; + +const runRule = (target: ArazzoSpecification, _contextOverrides: Partial = {}) => { + return arazzoStepRequestBodyValidation(target, null); +}; + +describe('validateRequestBody', () => { + test('should not report any errors for valid requestBody', () => { + const results = runRule({ + workflows: [ + { + steps: [ + { + requestBody: { + contentType: 'application/json', + payload: { key: 'value' }, + replacements: [{ target: '/key', value: 'newValue' }], + }, + stepId: 'step1', + }, + ], + workflowId: 'workflow1', + }, + ], + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for invalid MIME type in contentType', () => { + const results = runRule({ + workflows: [ + { + steps: [ + { + requestBody: { + contentType: 'invalid/type', + payload: { key: 'value' }, + }, + stepId: 'step1', + }, + ], + workflowId: 'workflow1', + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: 'Invalid MIME type in contentType: invalid/type', + path: ['workflows', 0, 'steps', 0, 'requestBody', 'contentType'], + }); + }); + + test('should report an error for invalid runtime expression in payload', () => { + const results = runRule({ + workflows: [ + { + steps: [ + { + requestBody: { + contentType: 'application/json', + payload: '$invalid.runtime.expression', + }, + stepId: 'step1', + }, + ], + workflowId: 'workflow1', + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: 'Invalid runtime expression in payload: $invalid.runtime.expression', + path: ['workflows', 0, 'steps', 0, 'requestBody', 'payload'], + }); + }); + + test('should report an error for missing target in Payload Replacement', () => { + const results = runRule({ + workflows: [ + { + steps: [ + { + requestBody: { + contentType: 'application/json', + payload: { key: 'value' }, + replacements: [{ target: '', value: 'newValue' }], + }, + stepId: 'step1', + }, + ], + workflowId: 'workflow1', + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: '"target" is required in Payload Replacement.', + path: ['workflows', 0, 'steps', 0, 'requestBody', 'replacements', 0, 'target'], + }); + }); + + test('should report an error for invalid runtime expression in replacement value', () => { + const results = runRule({ + workflows: [ + { + steps: [ + { + requestBody: { + contentType: 'application/json', + payload: { key: 'value' }, + replacements: [{ target: '/key', value: '$invalid.runtime.expression' }], + }, + stepId: 'step1', + }, + ], + workflowId: 'workflow1', + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: 'Invalid runtime expression in replacement value: $invalid.runtime.expression', + path: ['workflows', 0, 'steps', 0, 'requestBody', 'replacements', 0, 'value'], + }); + }); + + test('should report an error for invalid runtime expression in replacement value for non-existing input', () => { + const results = runRule({ + workflows: [ + { + steps: [ + { + requestBody: { + contentType: 'application/json', + payload: { key: 'value' }, + replacements: [{ target: '/key', value: '$inputs.foo' }], + }, + stepId: 'step1', + }, + ], + workflowId: 'workflow1', + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: 'Invalid runtime expression in replacement value: $inputs.foo', + path: ['workflows', 0, 'steps', 0, 'requestBody', 'replacements', 0, 'value'], + }); + }); + + test('should not report any errors for valid runtime expressions in payload and replacements', () => { + const results = runRule({ + workflows: [ + { + steps: [ + { + requestBody: { + contentType: 'application/json', + payload: '$inputs.validExpression', + replacements: [{ target: '/key', value: '$outputs.someOutput' }], + }, + stepId: 'step1', + }, + ], + workflowId: 'workflow1', + inputs: { + type: 'object', + properties: { + validExpression: { + type: 'string', + }, + }, + }, + }, + ], + }); + + expect(results).toHaveLength(0); + }); +}); diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessActionsValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessActionsValidation.test.ts new file mode 100644 index 000000000..85058a124 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessActionsValidation.test.ts @@ -0,0 +1,322 @@ +import arazzoStepSuccessActionsValidation from '../arazzoStepSuccessActionsValidation'; +import type { RulesetFunctionContext } from '@stoplight/spectral-core'; +import { ArazzoSpecification } from '../types/arazzoTypes'; + +const runRule = (target: ArazzoSpecification, _contextOverrides: Partial = {}) => { + return arazzoStepSuccessActionsValidation(target, null); +}; + +describe('validateSuccessActions', () => { + test('should not report any errors for valid and unique success actions', () => { + const results = runRule({ + workflows: [ + { + steps: [ + { + onSuccess: [ + { name: 'action1', type: 'goto', stepId: 'step1' }, + { name: 'action2', type: 'end' }, + ], + stepId: 'step1', + }, + ], + workflowId: 'workflow1', + }, + ], + components: { successActions: {} }, + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for duplicate success actions within the same step', () => { + const results = runRule({ + workflows: [ + { + steps: [ + { + onSuccess: [ + { name: 'action1', type: 'goto', stepId: 'step1' }, + { name: 'action1', type: 'end' }, + ], + stepId: 'step1', + }, // Duplicate action name + ], + workflowId: 'workflow1', + }, + ], + components: { successActions: {} }, + }); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ + message: `"action1" must be unique within the combined success actions.`, + path: ['workflows', 0, 'steps', 0, 'onSuccess', 1], + }); + }); + + test('should report an error for mutually exclusive workflowId and stepId', () => { + const results = runRule({ + workflows: [ + { + steps: [ + { + onSuccess: [{ name: 'action1', type: 'goto', stepId: 'step1', workflowId: 'workflow1' }], + stepId: 'step1', + }, + ], + workflowId: 'workflow1', + }, + ], + components: { successActions: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"workflowId" and "stepId" are mutually exclusive and cannot be specified together.`, + path: ['workflows', 0, 'steps', 0, 'onSuccess', 0], + }); + }); + + test('should override workflow level success action with step level success action', () => { + const results = runRule({ + workflows: [ + { + steps: [ + { + onSuccess: [{ name: 'action1', type: 'goto', stepId: 'step1' }], + stepId: 'step1', + }, + ], + successActions: [{ name: 'action1', type: 'end' }], + workflowId: 'workflow1', + }, + ], + components: { successActions: {} }, + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for an invalid runtime expression in a reusable action reference', () => { + const results = runRule({ + workflows: [ + { + steps: [ + { + onSuccess: [{ reference: 'invalidExpression' }], + stepId: 'step1', + }, + ], + workflowId: 'workflow1', + }, + ], + components: { + successActions: { + completeWorkflow: { + name: 'finish', + type: 'end', + }, + }, + }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: 'Invalid runtime expression for reusable action reference: "invalidExpression".', + path: ['workflows', 0, 'steps', 0, 'onSuccess', 0], + }); + }); + + test('should report an error for non-existing reusable action reference', () => { + const results = runRule({ + workflows: [ + { + steps: [ + { + onSuccess: [{ reference: '$components.successActions.nonExistingAction' }], + stepId: 'step1', + }, + ], + workflowId: 'workflow1', + }, + ], + components: { + successActions: { + completeWorkflow: { + name: 'finish', + type: 'end', + }, + }, + }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: + 'Invalid runtime expression for reusable action reference: "$components.successActions.nonExistingAction".', + path: ['workflows', 0, 'steps', 0, 'onSuccess', 0], + }); + }); + + test('should report an error for missing condition in Criterion', () => { + const results = runRule({ + workflows: [ + { + steps: [ + { + onSuccess: [ + { + name: 'action1', + type: 'goto', + stepId: 'step1', + criteria: [ + { + context: '$response.body', + condition: '', + }, + ], // Missing condition + }, + ], + stepId: 'step1', + }, + ], + workflowId: 'workflow1', + }, + ], + components: { successActions: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `Missing or invalid "condition" in Criterion Object.`, + path: ['workflows', 0, 'steps', 0, 'onSuccess', 0, 'criteria', 0, 'condition'], + }); + }); + + test('should report an error for invalid regex pattern in Criterion condition', () => { + const results = runRule({ + workflows: [ + { + steps: [ + { + onSuccess: [ + { + name: 'action1', + type: 'goto', + stepId: 'step1', + criteria: [{ context: '$statusCode', condition: '^(200$', type: 'regex' }], // Invalid regex + }, + ], + stepId: 'step1', + }, + ], + workflowId: 'workflow1', + }, + ], + components: { successActions: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"condition" contains an invalid regex pattern.`, + path: ['workflows', 0, 'steps', 0, 'onSuccess', 0, 'criteria', 0, 'condition'], + }); + }); + + test('should report an error for missing context when type is specified in Criterion', () => { + const results = runRule({ + workflows: [ + { + steps: [ + { + onSuccess: [ + { + name: 'action1', + type: 'goto', + stepId: 'step1', + criteria: [{ condition: '$response.body', type: 'jsonpath' }], // Missing context + }, + ], + stepId: 'step1', + }, + ], + workflowId: 'workflow1', + }, + ], + components: { successActions: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `A "context" must be specified for a Criterion Object with type "jsonpath".`, + path: ['workflows', 0, 'steps', 0, 'onSuccess', 0, 'criteria', 0, 'context'], + }); + }); + + test('should report an error for a non-existing stepId in success action', () => { + const results = runRule({ + workflows: [ + { + steps: [ + { + onSuccess: [{ name: 'action1', type: 'goto', stepId: 'nonExistentStep' }], + stepId: 'step1', + }, + ], + workflowId: 'workflow1', + }, + ], + components: { successActions: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"stepId" "nonExistentStep" does not exist within the current workflow.`, + path: ['workflows', 0, 'steps', 0, 'onSuccess', 0], + }); + }); + + test('should report an error for an invalid workflowId expression in success action', () => { + const results = runRule({ + workflows: [ + { + steps: [ + { + onSuccess: [{ name: 'action1', type: 'goto', workflowId: 'invalidWorkflowIdExpression' }], + stepId: 'step1', + }, + ], + workflowId: 'workflow1', + }, + ], + components: { successActions: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"workflowId" "invalidWorkflowIdExpression" does not exist within the local Arazzo Document workflows.`, + path: ['workflows', 0, 'steps', 0, 'onSuccess', 0], + }); + }); + + test('should not report an error for a valid workflowId expression in success action', () => { + const results = runRule({ + workflows: [ + { + steps: [ + { + onSuccess: [{ name: 'action1', type: 'goto', workflowId: '$sourceDescriptions.pet-coupons.workflow1' }], + stepId: 'step1', + }, + ], + workflowId: 'workflow1', + }, + ], + sourceDescriptions: [{ name: 'pet-coupons', url: 'some-url', type: 'openapi' }], + components: { successActions: {} }, + }); + + expect(results).toHaveLength(0); + }); +}); diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessCriteriaValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessCriteriaValidation.test.ts new file mode 100644 index 000000000..b2c724cc6 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessCriteriaValidation.test.ts @@ -0,0 +1,71 @@ +import arazzoStepSuccessCriteriaValidation from '../arazzoStepSuccessCriteriaValidation'; +import type { RulesetFunctionContext } from '@stoplight/spectral-core'; +import { ArazzoSpecification } from '../types/arazzoTypes'; + +const runRule = (target: ArazzoSpecification, _contextOverrides: Partial = {}) => { + return arazzoStepSuccessCriteriaValidation(target, null); +}; + +describe('arazzoStepSuccessCriteriaValidation', () => { + test('should not report any errors for valid success criteria', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + successCriteria: [{ condition: '$statusCode == 200' }], + }, + ], + }, + ], + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for invalid context in success criteria', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + successCriteria: [{ context: 'invalidContext', condition: '$statusCode == 200' }], + }, + ], + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"context" contains an invalid runtime expression.`, + path: ['workflows', 0, 'steps', 0, 'successCriteria', 0, 'context'], + }); + }); + + test('should report an error for missing condition in success criteria', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + successCriteria: [{ context: '$response.body', condition: '' }], + }, + ], + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `Missing or invalid "condition" in Criterion Object.`, + path: ['workflows', 0, 'steps', 0, 'successCriteria', 0, 'condition'], + }); + }); +}); diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepValidation.test.ts new file mode 100644 index 000000000..90e8b025f --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepValidation.test.ts @@ -0,0 +1,130 @@ +import arazzoStepValidation from '../arazzoStepValidation'; +import type { IFunctionResult } from '@stoplight/spectral-core'; +import { ArazzoSpecification } from '../types/arazzoTypes'; + +const runRule = (target: ArazzoSpecification): IFunctionResult[] => { + return arazzoStepValidation(target, null); +}; + +describe('arazzoStepValidation', () => { + test('should not report any errors for valid operationId, operationPath, and workflowId', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + operationId: '$sourceDescriptions.validSource.operationId', + }, + { + stepId: 'step2', + operationPath: '{$sourceDescriptions.validSource.url}#/paths/~1pet~1findByStatus', + }, + { + stepId: 'step3', + workflowId: '$sourceDescriptions.validSource.workflowId', + }, + ], + }, + ], + sourceDescriptions: [{ name: 'validSource', url: 'http://example.com', type: 'arazzo' }], + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for invalid operationId runtime expression', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + operationId: '$invalidSourceDescription.operationId', + }, + ], + }, + ], + sourceDescriptions: [{ name: 'validSource', url: 'http://example.com', type: 'arazzo' }], + }); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ + message: 'Runtime expression "$invalidSourceDescription.operationId" is invalid in step "step1".', + path: ['workflows', 0, 'steps', 0, 'operationId'], + }); + }); + + test('should report an error for invalid operationPath format', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + operationPath: 'invalidOperationPathFormat', + }, + ], + }, + ], + sourceDescriptions: [{ name: 'validSource', url: 'http://example.com', type: 'arazzo' }], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: + 'OperationPath "invalidOperationPathFormat" must be a valid runtime expression following the format "{$sourceDescriptions..url}#".', + path: ['workflows', 0, 'steps', 0, 'operationPath'], + }); + }); + + test('should report an error for invalid workflowId runtime expression', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + workflowId: '$invalidSourceDescription.workflowId', + }, + ], + }, + ], + sourceDescriptions: [{ name: 'validSource', url: 'http://example.com', type: 'arazzo' }], + }); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ + message: 'Runtime expression "$invalidSourceDescription.workflowId" is invalid in step "step1".', + path: ['workflows', 0, 'steps', 0, 'workflowId'], + }); + }); + + test('should report an error for missing source description in operationPath', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + operationPath: '{$sourceDescriptions.missingSource.url}#foo', + }, + ], + }, + ], + sourceDescriptions: [{ name: 'validSource', url: 'http://example.com', type: 'arazzo' }], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: + 'Source description "missingSource" not found for operationPath "{$sourceDescriptions.missingSource.url}#foo" in step "step1".', + path: ['workflows', 0, 'steps', 0, 'operationPath'], + }); + }); +}); diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowIdUniqueness.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowIdUniqueness.test.ts new file mode 100644 index 000000000..45c4e6cfb --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowIdUniqueness.test.ts @@ -0,0 +1,35 @@ +import { IFunctionResult } from '@stoplight/spectral-core'; +import arazzoWorkflowIdUniqueness from '../arazzoWorkflowIdUniqueness'; +import { ArazzoSpecification } from '../types/arazzoTypes'; + +const runRule = (target: ArazzoSpecification): IFunctionResult[] => { + return arazzoWorkflowIdUniqueness(target, null); +}; + +describe('arazzoWorkflowIdUniqueness', () => { + test('should not report any errors for unique workflowIds', async () => { + const results = runRule({ + workflows: [ + { workflowId: 'workflow1', steps: [] }, + { workflowId: 'workflow2', steps: [] }, + ], + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for duplicate workflowIds', async () => { + const results = runRule({ + workflows: [ + { workflowId: 'workflow1', steps: [] }, + { workflowId: 'workflow1', steps: [] }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"workflowId" must be unique across all workflows. "workflow1" is duplicated.`, + path: ['workflows', 1, 'workflowId'], + }); + }); +}); diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowOutputNamesValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowOutputNamesValidation.test.ts new file mode 100644 index 000000000..2dfae2e38 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowOutputNamesValidation.test.ts @@ -0,0 +1,308 @@ +import arazzoWorkflowOutputNamesValidation from '../arazzoWorkflowOutputNamesValidation'; +import { DeepPartial } from '@stoplight/types'; +import type { RulesetFunctionContext } from '@stoplight/spectral-core'; +import { ArazzoSpecification } from '../types/arazzoTypes'; + +const runRule = (target: ArazzoSpecification, contextOverrides: Partial = {}) => { + const context: DeepPartial = { + path: [], + documentInventory: { + graph: {} as any, // Mock the graph property + referencedDocuments: {} as any, // Mock the referencedDocuments property as a Dictionary + findAssociatedItemForPath: jest.fn(), // Mock the findAssociatedItemForPath function + }, + document: { + formats: new Set(), // Mock the formats property correctly + }, + ...contextOverrides, + }; + + return arazzoWorkflowOutputNamesValidation(target, null, context as RulesetFunctionContext); +}; + +describe('arazzoWorkflowOutputNamesValidation', () => { + test('should not report any errors for valid and unique output names', () => { + const results = runRule({ + workflows: [ + { + outputs: { + output1: '$url', + output2: '$statusCode', + }, + workflowId: 'workflow§', + steps: [], + }, + { + outputs: { output3: '$statusCode' }, + workflowId: 'workflow2', + steps: [], + }, + ], + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for invalid output names', () => { + const results = runRule({ + workflows: [ + { + outputs: { + 'invalid name': 'value1', + output2: 'value2', + }, + workflowId: 'workflow1', + steps: [], + }, + ], + }); + + expect(results).toHaveLength(3); + expect(results[0]).toMatchObject({ + message: `"invalid name" does not match the required pattern "^[a-zA-Z0-9.\\-_]+$".`, + path: ['workflows', 0, 'outputs', 'invalid name', 0], + }); + }); + + test('should not report an error for duplicate output names across different workflows', () => { + const results = runRule({ + workflows: [ + { + outputs: { output1: '$statusCode' }, + workflowId: 'workflow1', + steps: [], + }, + { + outputs: { output1: '$statusCode' }, + workflowId: 'workflow2', + steps: [], + }, // Duplicate output name across different workflows + ], + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for invalid runtime expressions', () => { + const results = runRule({ + workflows: [ + { + outputs: { + output1: 'invalid expression', + output2: '$statusCode', + }, + workflowId: 'workflow1', + steps: [], + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"invalid expression" is not a valid runtime expression.`, + path: ['workflows', 0, 'outputs', 'output1', 0], + }); + }); + + test('should report an error for runtime expression referencing step that does not exist', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [{ stepId: 'step1', outputs: { output1: '$statusCode' } }], + outputs: { + output1: '$steps.non-existing.outputs.output1', + }, + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"$steps.non-existing.outputs.output1" is not a valid runtime expression.`, + path: ['workflows', 0, 'outputs', 'output1', 0], + }); + }); + + test('should handle runtime expression referencing step that exists', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [{ stepId: 'step-1', outputs: { output1: '$statusCode' } }], + outputs: { + output1: '$steps.step-1.outputs.output1', + }, + }, + ], + }); + + expect(results).toHaveLength(0); + }); + + test('should handle runtime expression referencing a step within a different workflow', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'place-order1', + steps: [ + { + stepId: 'place-order', + operationId: 'placeOrder', + outputs: { step_order_id: '$statusCode' }, + }, + ], + outputs: { + workflow_order_id: '$steps.place-order.outputs.step_order_id', + }, + }, + ], + }); + + expect(results).toHaveLength(0); + }); + + test('should handle runtime expression referencing step that exists within different workflow', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [{ stepId: 'step1', outputs: { output1: '$statusCode' } }], + outputs: { + output1: '$steps.step1.outputs.output1', + }, + }, + { + workflowId: 'workflow2', + steps: [{ stepId: 'step1', outputs: { output1: '$statusCode' } }], + outputs: { + output1: '$workflows.workflow1.steps.step1.outputs.output1', + }, + }, + ], + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for runtime expression referencing a workflow that does not exist', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [{ stepId: 'step1', outputs: { output1: '$statusCode' } }], + outputs: { + output1: '$statusCode', + }, + }, + { + workflowId: 'workflow2', + steps: [{ stepId: 'step1', outputs: { output1: '$statusCode' } }], + outputs: { + output1: '$workflows.non-existing-workflow.steps.foo.outputs.output1', + }, + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"$workflows.non-existing-workflow.steps.foo.outputs.output1" is not a valid runtime expression.`, + path: ['workflows', 1, 'outputs', 'output1', 0], + }); + }); + + test('should report an error for runtime expression referencing a separate existing workflow but with non-existing step', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [{ stepId: 'step1', outputs: { output1: '$statusCode' } }], + outputs: { + output1: '$statusCode', + }, + }, + { + workflowId: 'workflow2', + steps: [{ stepId: 'step1', outputs: { output1: '$statusCode' } }], + outputs: { + output1: '$workflows.workflow1.steps.non-existing.outputs.output1', + }, + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"$workflows.workflow1.steps.non-existing.outputs.output1" is not a valid runtime expression.`, + path: ['workflows', 1, 'outputs', 'output1', 0], + }); + }); + + test('should handle runtime expression referencing a step within the same workflow', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'buy-available-pet', + steps: [ + { + stepId: 'find-pet', + operationId: 'findPetsByStatus', + outputs: { my_pet_id: '$response.outputs[0].id' }, + }, + { + stepId: 'place-order', + workflowId: 'place-order1', + outputs: { my_order_id: '$workflows.place-order1.outputs.workflow_order_id' }, + }, + ], + outputs: { + buy_pet_order_id: '$steps.place-order.outputs.my_order_id', + }, + }, + { + workflowId: 'place-order', + steps: [ + { + stepId: 'place-order', + operationId: 'placeOrder', + outputs: { step_order_id: '$statusCode' }, + }, + ], + outputs: { + workflow_order_id: '$steps.place-order.outputs.step_order_id', + }, + }, + ], + }); + + expect(results).toHaveLength(0); + }); + + test('should report error if workflow or step does not exist', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'buy-available-pet', + steps: [ + { + stepId: 'find-pet', + operationId: 'findPetsByStatus', + outputs: { my_pet_id: '$response.outputs[0].id' }, + }, + { + stepId: 'place-order', + workflowId: 'non-existing-workflow', + outputs: { my_order_id: '$workflows.place-order.outputs.workflow_order_id' }, + }, + ], + outputs: { + buy_pet_order_id: '$steps.non-existing-step.outputs.non_existing', + }, + }, + ], + }); + + expect(results).not.toHaveLength(0); + }); +}); diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowsDependsOnValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowsDependsOnValidation.test.ts new file mode 100644 index 000000000..e1d88757c --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowsDependsOnValidation.test.ts @@ -0,0 +1,166 @@ +import arazzoWorkflowDependsOnValidation from '../arazzoWorkflowDependsOnValidation'; +import { IFunctionResult } from '@stoplight/spectral-core'; +import { ArazzoSpecification } from '../types/arazzoTypes'; + +const runRule = (target: ArazzoSpecification): IFunctionResult[] => { + return arazzoWorkflowDependsOnValidation(target, null); +}; + +describe('arazzoWorkflowDependsOnValidation', () => { + test('should not report any errors for valid dependsOn references', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [], + }, + { + workflowId: 'workflow2', + dependsOn: ['workflow1'], + steps: [], + }, + ], + sourceDescriptions: [{ name: 'source1', url: 'http://example.com', type: 'arazzo' }], + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for duplicate workflowId in dependsOn', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [], + }, + { + workflowId: 'workflow2', + dependsOn: ['workflow1', 'workflow1'], + steps: [], + }, + ], + sourceDescriptions: [{ name: 'source1', url: 'http://example.com', type: 'arazzo' }], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: 'Duplicate workflowId "workflow1" in dependsOn for workflow "workflow2".', + path: ['workflows', 1, 'dependsOn', 1], + }); + }); + + test('should report an error for non-existent local workflowId in dependsOn', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [], + }, + { + workflowId: 'workflow2', + dependsOn: ['workflow3'], + steps: [], + }, + ], + sourceDescriptions: [{ name: 'source1', url: 'http://example.com', type: 'arazzo' }], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: 'WorkflowId "workflow3" not found in local Arazzo workflows "workflow2".', + path: ['workflows', 1, 'dependsOn', 0], + }); + }); + + test('should report an error for non-existent source description in dependsOn', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [], + }, + { + workflowId: 'workflow2', + dependsOn: ['$sourceDescriptions.nonExistent.workflow3'], + steps: [], + }, + ], + sourceDescriptions: [{ name: 'source1', url: 'http://example.com', type: 'arazzo' }], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: 'Source description "nonExistent" not found for workflowId "$sourceDescriptions.nonExistent.workflow3".', + path: ['workflows', 1, 'dependsOn', 0], + }); + }); + + test('should report an error for missing workflowId part in runtime expression', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [], + }, + { + workflowId: 'workflow2', + dependsOn: ['$sourceDescriptions.source1'], + steps: [], + }, + ], + sourceDescriptions: [{ name: 'source1', url: 'http://example.com', type: 'arazzo' }], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: 'WorkflowId part is missing in the expression "$sourceDescriptions.source1".', + path: ['workflows', 1, 'dependsOn', 0], + }); + }); + + test('should report an error for non-arazzo type in source description', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [], + }, + { + workflowId: 'workflow2', + dependsOn: ['$sourceDescriptions.source1.workflow3'], + steps: [], + }, + ], + sourceDescriptions: [{ name: 'source1', url: 'http://example.com', type: 'openapi' }], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: 'Source description "source1" must have a type of "arazzo".', + path: ['workflows', 1, 'dependsOn', 0], + }); + }); + + test('should report an error for invalid runtime expression in dependsOn', () => { + const results = runRule({ + workflows: [ + { + workflowId: 'workflow1', + steps: [], + }, + { + workflowId: 'workflow2', + dependsOn: ['$invalid.source1.expression'], + steps: [], + }, + ], + sourceDescriptions: [{ name: 'source1', url: 'http://example.com', type: 'arazzo' }], + }); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ + message: 'Runtime expression "$invalid.source1.expression" is invalid.', + path: ['workflows', 1, 'dependsOn', 0], + }); + }); +}); diff --git a/packages/rulesets/src/arazzo/functions/arazzoCriterionValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoCriterionValidation.ts new file mode 100644 index 000000000..1debce61e --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoCriterionValidation.ts @@ -0,0 +1,62 @@ +import { IFunctionResult } from '@stoplight/spectral-core'; +import validateRuntimeExpression from './arazzoRuntimeExpressionValidation'; +import { Criterion, ArazzoSpecification } from './types/arazzoTypes'; + +export default function arazzoCriterionValidation( + criterion: Criterion, + contextPath: (string | number)[], + arazzoSpec: ArazzoSpecification, // Updated from Workflow to ArazzoSpecification +): IFunctionResult[] { + const results: IFunctionResult[] = []; + + // Validate that condition exists + if (!criterion.condition || typeof criterion.condition !== 'string' || criterion.condition.trim() === '') { + results.push({ + message: `Missing or invalid "condition" in Criterion Object.`, + path: [...contextPath, 'condition'], + }); + } + + // If type is defined, validate context presence + if (criterion.type !== undefined && criterion.type !== null && criterion.context == null) { + results.push({ + message: `A "context" must be specified for a Criterion Object with type "${criterion.type as string}".`, + path: [...contextPath, 'context'], + }); + } + + // Validate Criterion Expression Type Object if type is an object + if (typeof criterion.type === 'object') { + const { type, version } = criterion.type; + if (!type || !version) { + results.push({ + message: `"type" and "version" must be specified in the Criterion Expression Type Object.`, + path: [...contextPath, 'type'], + }); + } + } + + // Validate regex pattern + if (criterion.type === 'regex') { + try { + new RegExp(criterion.condition); // Test if the regex is valid + } catch { + results.push({ + message: `"condition" contains an invalid regex pattern.`, + path: [...contextPath, 'condition'], + }); + } + } + + // Validate context using arazzoRuntimeExpressionValidation + if (criterion.context != null && !validateRuntimeExpression(criterion.context, arazzoSpec)) { + results.push({ + message: `"context" contains an invalid runtime expression.`, + path: [...contextPath, 'context'], + }); + } + + // Add JSONPath, XPath, and other advanced checks as needed + + return results; +} diff --git a/packages/rulesets/src/arazzo/functions/arazzoDocumentSchema.ts b/packages/rulesets/src/arazzo/functions/arazzoDocumentSchema.ts new file mode 100644 index 000000000..f4d469eb1 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoDocumentSchema.ts @@ -0,0 +1,126 @@ +import { createRulesetFunction } from '@stoplight/spectral-core'; +import type { IFunctionResult } from '@stoplight/spectral-core'; +import { arazzo1_0 } from '@stoplight/spectral-formats'; +import { isPlainObject, resolveInlineRef } from '@stoplight/json'; +import type { ErrorObject } from 'ajv'; +import leven from 'leven'; + +import * as validators from '../schemas/validators'; + +export default createRulesetFunction( + { + input: null, + options: null, + }, + function arazzoDocumentSchema(input, _opts, context) { + const formats = context.document.formats; + if (formats === null || formats === void 0) return []; + + const schema = formats.has(arazzo1_0) ? 'arazzo1_0' : null; + if (!schema) return; + + const validator = validators.arazzo1_0; + + if (typeof validator !== 'function') { + throw new Error(`Validator for schema "${schema}" is not a function`); + } + + validator(input); + + const errors = validator['errors'] as ErrorObject[] | null; + + return errors?.reduce((errors, e) => processError(errors, input, e), []) ?? []; + }, +); + +function isRelevantError(error: ErrorObject): boolean { + return error.keyword !== 'if'; +} + +function processError(errors: IFunctionResult[], input: unknown, error: ErrorObject): IFunctionResult[] { + if (!isRelevantError(error)) { + return errors; + } + + const path = error.instancePath === '' ? [] : error.instancePath.slice(1).split('/'); + const property = path.length === 0 ? null : path[path.length - 1]; + + let message: string; + + switch (error.keyword) { + case 'additionalProperties': { + const additionalProperty = error.params['additionalProperty'] as string; + path.push(additionalProperty); + message = `Property "${additionalProperty}" is not expected to be here`; + break; + } + + case 'enum': { + const allowedValues = error.params['allowedValues'] as unknown[]; + const printedValues = allowedValues.map(value => JSON.stringify(value)).join(', '); + let suggestion: string; + + if (!isPlainObject(input)) { + suggestion = ''; + } else { + const value = resolveInlineRef(input, `#${error.instancePath}`); + if (typeof value !== 'string') { + suggestion = ''; + } else { + const bestMatch = findBestMatch(value, allowedValues); + + if (bestMatch !== null) { + suggestion = `. Did you mean "${bestMatch}"?`; + } else { + suggestion = ''; + } + } + } + + message = `${cleanAjvMessage(property, error.message)}: ${printedValues}${suggestion}`; + break; + } + + case 'errorMessage': + message = String(error.message); + break; + + default: + message = cleanAjvMessage(property, error.message); + } + + errors.push({ + message, + path, + }); + + return errors; +} + +function findBestMatch(value: string, allowedValues: unknown[]): string | null { + const matches = allowedValues + .filter((value): value is string => typeof value === 'string') + .map(allowedValue => ({ + value: allowedValue, + weight: leven(value, allowedValue), + })) + .sort((x, y) => (x.weight > y.weight ? 1 : x.weight < y.weight ? -1 : 0)); + + if (matches.length === 0) { + return null; + } + + const bestMatch = matches[0]; + + return allowedValues.length === 1 || bestMatch.weight < bestMatch.value.length ? bestMatch.value : null; +} + +const QUOTES = /['"]/g; +const NOT = /NOT/g; + +function cleanAjvMessage(prop: string | null, message: string | undefined): string { + if (typeof message !== 'string') return ''; + + const cleanedMessage = message.replace(QUOTES, '"').replace(NOT, 'not'); + return prop === null ? cleanedMessage : `"${prop}" property ${cleanedMessage}`; +} diff --git a/packages/rulesets/src/arazzo/functions/arazzoRuntimeExpressionValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoRuntimeExpressionValidation.ts new file mode 100644 index 000000000..e8e014e25 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoRuntimeExpressionValidation.ts @@ -0,0 +1,243 @@ +import { ArazzoSpecification, Step } from './types/arazzoTypes'; + +function isNonNullObject(value: unknown): value is Record { + return value !== null && typeof value === 'object'; +} + +function validateReusableParameterExpression(expression: string, arazzoSpec: ArazzoSpecification): boolean { + const parametersRegex = /^\$components\.parameters\.([A-Za-z0-9_\\-]+)$/; + const match = parametersRegex.exec(expression); + + if (!match) { + return false; // The expression didn't match the expected pattern + } + + const [, paramName] = match; + + if (arazzoSpec.components?.parameters && paramName in arazzoSpec.components.parameters) { + return true; // The parameter exists in the components.parameters + } + + return false; // The parameter does not exist +} + +function validateStepsExpression( + stepsExpression: string, + arazzoSpec: ArazzoSpecification, + currentWorkflowIndex?: number, +): boolean { + const stepsRegex = /^\$steps\.([A-Za-z0-9_\\-]+)\.(.*)$/; + const match = stepsRegex.exec(stepsExpression); + + if (!match) { + return false; // The expression didn't match the expected pattern + } + + const [, stepId] = match; + + if (arazzoSpec == null || !Array.isArray(arazzoSpec.workflows) || arazzoSpec.workflows.length === 0) { + return false; + } + + let stepsToSearch: Step[] = []; + if ( + currentWorkflowIndex !== undefined && + currentWorkflowIndex >= 0 && + arazzoSpec.workflows[currentWorkflowIndex] != null + ) { + stepsToSearch = arazzoSpec.workflows[currentWorkflowIndex].steps ?? []; + } else { + stepsToSearch = arazzoSpec.workflows.flatMap(workflow => workflow.steps ?? []); + } + + if (stepsToSearch == null || stepsToSearch.length === 0) { + return false; + } + + const step = stepsToSearch.find(step => step.stepId === stepId); + if (!step) { + return false; + } + + return true; +} + +function validateWorkflowsExpression(workflowsExpression: string, arazzoSpec: ArazzoSpecification): boolean { + const workflowsRegex = /^\$workflows\.([A-Za-z0-9_\\-]+)\.(.*)$/; + const match = workflowsRegex.exec(workflowsExpression); + + if (!match) { + return false; + } + + const [, workflowId, remainingPath] = match; + + if (arazzoSpec == null || !Array.isArray(arazzoSpec.workflows) || arazzoSpec.workflows.length === 0) { + return false; + } + + const workflowIndex = arazzoSpec.workflows.findIndex(workflow => workflow.workflowId === workflowId); + if (workflowIndex === -1) { + return false; + } + + if (remainingPath.startsWith('steps.')) { + return validateStepsExpression(`$steps.${remainingPath.slice(6)}`, arazzoSpec, workflowIndex); + } + + return true; +} + +function validateInputsExpression( + inputsExpression: string, + arazzoSpec: ArazzoSpecification, + currentWorkflowIndex?: number, +): boolean { + const inputsRegex = /^\$inputs\.([A-Za-z0-9_\\-]+)$/; + const match = inputsRegex.exec(inputsExpression); + + if (!match) { + return false; // The expression didn't match the expected pattern + } + + const [, inputName] = match; + + if ( + arazzoSpec == null || + !Array.isArray(arazzoSpec.workflows) || + arazzoSpec.workflows.length === 0 || + currentWorkflowIndex === undefined + ) { + return false; + } + + const currentWorkflow = arazzoSpec.workflows[currentWorkflowIndex]; + + if (!currentWorkflow.inputs) { + return false; + } + + // If inputs are defined directly + if ('properties' in currentWorkflow.inputs) { + const properties = (currentWorkflow.inputs as { properties?: Record }).properties; + return properties ? inputName in properties : false; + } + + // If inputs are referenced via $ref + if ('$ref' in currentWorkflow.inputs) { + const refPath = (currentWorkflow.inputs as { $ref: string }).$ref.replace(/^#\//, '').split('/'); + let refObject: unknown = arazzoSpec; + + for (const part of refPath) { + if (isNonNullObject(refObject) && part in refObject) { + refObject = refObject[part]; + } else { + return false; // The reference could not be resolved + } + } + + const properties = (refObject as { properties?: Record })?.properties; + return properties ? inputName in properties : false; + } + + return false; // The input does not exist in the workflow inputs or referenced schema +} + +function validateReusableSuccessActionExpression(expression: string, arazzoSpec: ArazzoSpecification): boolean { + const successActionsRegex = /^\$components\.successActions\.([A-Za-z0-9_\\-]+)$/; + const match = successActionsRegex.exec(expression); + + if (!match) { + return false; + } + + const [, actionName] = match; + + if (arazzoSpec.components?.successActions && actionName in arazzoSpec.components.successActions) { + return true; + } + + return false; +} + +function validateReusableFailureActionExpression(expression: string, arazzoSpec: ArazzoSpecification): boolean { + const failureActionsRegex = /^\$components\.failureActions\.([A-Za-z0-9_\\-]+)$/; + const match = failureActionsRegex.exec(expression); + + if (!match) { + return false; + } + + const [, actionName] = match; + + if (arazzoSpec.components?.failureActions && actionName in arazzoSpec.components.failureActions) { + return true; + } + + return false; +} + +function arazzoRuntimeExpressionValidation( + expression: string, + arazzoSpec?: ArazzoSpecification, + currentWorkflowIndex?: number, +): boolean { + if (!expression && !arazzoSpec) { + return false; + } + + const validPrefixes = [ + '$url', + '$method', + '$statusCode', + '$request.', + '$response.', + '$message.', + '$inputs.', + '$outputs.', + '$steps.', + '$workflows.', + '$sourceDescriptions.', + '$components.inputs.', + '$components.parameters.', + '$components.successActions.', + '$components.failureActions.', + ]; + + const isValidPrefix = validPrefixes.some(prefix => expression.startsWith(prefix)); + + if (!isValidPrefix) { + return false; + } + + if (expression.startsWith('$steps.') && arazzoSpec) { + return validateStepsExpression(expression, arazzoSpec, currentWorkflowIndex); + } + + if (expression.startsWith('$workflows.') && arazzoSpec) { + return validateWorkflowsExpression(expression, arazzoSpec); + } + + if (expression.startsWith('$inputs.') && arazzoSpec) { + return validateInputsExpression(expression, arazzoSpec, currentWorkflowIndex); + } + + if (expression.startsWith('$components.failureActions.') && arazzoSpec) { + return validateReusableFailureActionExpression(expression, arazzoSpec); + } + + if (expression.startsWith('$components.successActions.') && arazzoSpec) { + return validateReusableSuccessActionExpression(expression, arazzoSpec); + } + + // Validation for $components.parameters expressions + if (expression.startsWith('$components.parameters.') && arazzoSpec) { + return validateReusableParameterExpression(expression, arazzoSpec); + } + + // ToDo - add more validations for other prefixes and combos + + return true; +} + +export default arazzoRuntimeExpressionValidation; diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts new file mode 100644 index 000000000..2e14caf46 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts @@ -0,0 +1,130 @@ +import type { IFunctionResult } from '@stoplight/spectral-core'; +import getAllFailureActions from './utils/getAllFailureActions'; +import arazzoCriterionValidation from './arazzoCriterionValidation'; +import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidation'; +import { ArazzoSpecification } from './types/arazzoTypes'; + +export default function arazzoStepFailureActionsValidation( + target: ArazzoSpecification, + _options: null, +): IFunctionResult[] { + const results: IFunctionResult[] = []; + + if (Array.isArray(target.workflows)) { + target.workflows.forEach((workflow, workflowIndex) => { + if (Array.isArray(workflow.steps)) { + workflow.steps.forEach((step, stepIndex) => { + const resolvedActions = getAllFailureActions(step, workflow, target); + + if (Array.isArray(resolvedActions)) { + const seenNames: Set = new Set(); + resolvedActions.forEach((action, actionIndex) => { + const originalName = action.name + .replace('masked-invalid-reusable-failure-action-reference-', '') + .replace('masked-non-existing-failure-action-reference-', '') + .replace('masked-duplicate-', ''); + + if (seenNames.has(originalName)) { + results.push({ + message: `"${originalName}" must be unique within the combined failure actions.`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'onFailure', actionIndex], + }); + } else { + seenNames.add(originalName); + } + + if (action.name.startsWith('masked-invalid-reusable-failure-action-reference-')) { + results.push({ + message: `Invalid runtime expression for reusable action reference: "${originalName}".`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'onFailure', actionIndex], + }); + } + + if (action.name.startsWith('masked-non-existing-failure-action-reference-')) { + results.push({ + message: `Non-existing reusable action reference: "${originalName}".`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'onFailure', actionIndex], + }); + } + + if (action.name.startsWith('masked-duplicate-')) { + results.push({ + message: `Duplicate failure action name: "${originalName}".`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'onFailure', actionIndex], + }); + } + + if (action.type === 'goto' || action.type === 'retry') { + if (action.workflowId != null) { + // Check if workflowId is a runtime expression + if (action.workflowId.startsWith('$')) { + // Validate runtime expression and ensure is in sourceDescriptions + if ( + !arazzoRuntimeExpressionValidation(action.workflowId, target) || + !( + target.sourceDescriptions?.some( + desc => desc.name === (action.workflowId ?? '').split('.')[1], + ) ?? false + ) + ) { + results.push({ + message: `"workflowId" "${action.workflowId}" is not a valid reference or does not exist in sourceDescriptions.`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'onFailure', actionIndex], + }); + } + } else { + // Validate against local workflows + if (!target.workflows.some(wf => wf.workflowId === action.workflowId)) { + results.push({ + message: `"workflowId" "${action.workflowId}" does not exist within the local Arazzo Document workflows.`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'onFailure', actionIndex], + }); + } + } + } + + if (action.stepId != null) { + if (!workflow.steps.some(s => s.stepId === action.stepId)) { + results.push({ + message: `"stepId" "${action.stepId}" does not exist within the current workflow.`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'onFailure', actionIndex], + }); + } + } + + if (action.workflowId != null && action.stepId != null) { + results.push({ + message: `"workflowId" and "stepId" are mutually exclusive and cannot be specified together.`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'onFailure', actionIndex], + }); + } + } + + if (Array.isArray(action.criteria)) { + action.criteria.forEach((criterion, criterionIndex) => { + const criterionResults = arazzoCriterionValidation( + criterion, + [ + 'workflows', + workflowIndex, + 'steps', + stepIndex, + 'onFailure', + actionIndex, + 'criteria', + criterionIndex, + ], + target, + ); + results.push(...criterionResults); + }); + } + }); + } + }); + } + }); + } + + return results; +} diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepIdUniqueness.ts b/packages/rulesets/src/arazzo/functions/arazzoStepIdUniqueness.ts new file mode 100644 index 000000000..9b6d42a9f --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoStepIdUniqueness.ts @@ -0,0 +1,56 @@ +import { createRulesetFunction, IFunctionResult } from '@stoplight/spectral-core'; +import type { JsonPath } from '@stoplight/types'; + +export default createRulesetFunction<{ steps: Array<{ stepId?: string }> }, null>( + { + input: { + type: 'object', + properties: { + steps: { + type: 'array', + items: { + type: 'object', + properties: { + stepId: { + type: 'string', + }, + }, + }, + }, + }, + }, + options: null, + }, + function arazzoStepIdUniqueness(targetVal, _opts) { + const results: IFunctionResult[] = []; + const stepIds = new Set(); + + if (!Array.isArray(targetVal.steps)) { + return results; + } + + targetVal.steps.forEach((step, index) => { + const { stepId } = step; + + if (stepId == null) { + // Handle case where stepId is missing or undefined + results.push({ + message: `Step at index ${index} is missing a "stepId". Each step should have a unique "stepId".`, + path: ['steps', index] as JsonPath, + }); + return; + } + + if (stepIds.has(stepId)) { + results.push({ + message: `"stepId" must be unique within the workflow.`, + path: ['steps', index, 'stepId'] as JsonPath, + }); + } else { + stepIds.add(stepId); + } + }); + + return results; + }, +); diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepOutputNamesValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepOutputNamesValidation.ts new file mode 100644 index 000000000..6fef6615d --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoStepOutputNamesValidation.ts @@ -0,0 +1,88 @@ +import { createRulesetFunction, IFunctionResult } from '@stoplight/spectral-core'; +import type { JsonPath } from '@stoplight/types'; +import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidation'; +import { ArazzoSpecification } from './types/arazzoTypes'; + +const OUTPUT_NAME_PATTERN = /^[a-zA-Z0-9.\-_]+$/; + +export default createRulesetFunction( + { + input: { + type: 'object', + properties: { + workflows: { + type: 'array', + items: { + type: 'object', + properties: { + steps: { + type: 'array', + items: { + type: 'object', + properties: { + outputs: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + options: null, + }, + function arazzoStepOutputNamesValidation(targetVal, _opts) { + const results: IFunctionResult[] = []; + + if (!Array.isArray(targetVal.workflows)) { + return results; + } + + targetVal.workflows.forEach((workflow, workflowIndex) => { + workflow.steps.forEach((step, stepIndex) => { + if (step.outputs && typeof step.outputs === 'object') { + const seenOutputNames = new Set(); + + Object.entries(step.outputs).forEach(([outputName, outputValue], outputIndex) => { + // Validate output name + if (!OUTPUT_NAME_PATTERN.test(outputName)) { + results.push({ + message: `"${outputName}" does not match the required pattern "^[a-zA-Z0-9.\\-_]+$".`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'outputs', outputName, outputIndex] as JsonPath, + }); + } + + // Check for uniqueness within the step + if (seenOutputNames.has(outputName)) { + results.push({ + message: `"${outputName}" must be unique within the step outputs.`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'outputs', outputName, outputIndex] as JsonPath, + }); + } else { + seenOutputNames.add(outputName); + } + + // Validate runtime expression + if ( + !arazzoRuntimeExpressionValidation( + outputValue, + targetVal as unknown as ArazzoSpecification, + workflowIndex, + ) + ) { + results.push({ + message: `"${outputValue}" is not a valid runtime expression.`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'outputs', outputName, outputIndex] as JsonPath, + }); + } + }); + } + }); + }); + + return results; + }, +); diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepParametersValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepParametersValidation.ts new file mode 100644 index 000000000..c55be5e3d --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoStepParametersValidation.ts @@ -0,0 +1,115 @@ +import type { IFunctionResult } from '@stoplight/spectral-core'; +import getAllParameters from './utils/getAllParameters'; +import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidation'; +import { ArazzoSpecification } from './types/arazzoTypes'; + +export default function arazzoStepParametersValidation(target: ArazzoSpecification, _options: null): IFunctionResult[] { + const results: IFunctionResult[] = []; + + // Process each workflow + if (Array.isArray(target.workflows)) { + target.workflows.forEach((workflow, workflowIndex) => { + // Process steps in the workflow + workflow.steps.forEach((step, stepIndex) => { + if (!step.parameters) return; + + const { workflowId, operationId, operationPath } = step; + const stepParams = getAllParameters(step, workflow, target); + + if (Array.isArray(stepParams)) { + const seenNames: Set = new Set(); + stepParams.forEach((param, paramIndex) => { + const originalName = param.name + .replace('masked-invalid-reusable-parameter-reference-', '') + .replace('masked-unresolved-parameter-reference-', '') + .replace('masked-duplicate-', ''); + + if (seenNames.has(originalName)) { + results.push({ + message: `"${originalName}" must be unique within the combined parameters.`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'parameters', paramIndex], + }); + } else { + seenNames.add(originalName); + } + + if (param.name.startsWith('masked-invalid-reusable-parameter-reference-')) { + results.push({ + message: `Invalid runtime expression for reusable parameter reference: "${originalName}".`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'parameters', paramIndex], + }); + } + + if (param.name.startsWith('masked-unresolved-parameter-reference-')) { + results.push({ + message: `Unresolved reusable parameter reference: "${originalName}".`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'parameters', paramIndex], + }); + } + + if (param.name.startsWith('masked-duplicate-')) { + results.push({ + message: `Duplicate parameter: "${originalName}" must be unique within the combined parameters.`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'parameters', paramIndex], + }); + } + }); + } + + // Validate no mix of `in` presence + const hasInField = stepParams.some(param => 'in' in param && param.in !== undefined); + const noInField = stepParams.some(param => !('in' in param) || param.in === undefined); + + if (hasInField && noInField) { + results.push({ + message: `Parameters must not mix "in" field presence.`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'parameters'], + }); + } + + // if workflowId is present, there should be no `in` field + if (workflowId != null && hasInField) { + results.push({ + message: `Step with "workflowId" must not have parameters with an "in" field.`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'parameters'], + }); + } + + // if operationId or operationPath is present, all parameters should have an `in` field + if ((operationId != null || operationPath != null) && noInField) { + results.push({ + message: `Step with "operationId" or "operationPath" must have parameters with an "in" field.`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'parameters'], + }); + } + + // Perform runtime expression validation for parameter values + stepParams.forEach((param, paramIndex) => { + if (typeof param.value === 'string' && param.value.startsWith('$')) { + const validPatterns = [ + /^\$inputs\./, // Matches $inputs. + /^\$steps\.[A-Za-z0-9_-]+\./, // Matches $steps.name.* + /^\$workflows\.[A-Za-z0-9_-]+\.steps\.[A-Za-z0-9_-]+\./, // Matches $workflows.name.steps.stepname.* + ]; + + const isValidPattern = validPatterns.some(pattern => pattern.test(param.value as string)); + + if (!isValidPattern) { + results.push({ + message: `Invalid runtime expression: "${param.value}" for parameter.`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'parameters', paramIndex], + }); + } else if (!arazzoRuntimeExpressionValidation(param.value, target, workflowIndex)) { + results.push({ + message: `Invalid runtime expression: "${param.value}" for parameter.`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'parameters', paramIndex], + }); + } + } + }); + }); + }); + } + + return results; +} diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepRequestBodyValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepRequestBodyValidation.ts new file mode 100644 index 000000000..64879aa76 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoStepRequestBodyValidation.ts @@ -0,0 +1,93 @@ +import { IFunctionResult } from '@stoplight/spectral-core'; +import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidation'; +import { ArazzoSpecification } from './types/arazzoTypes'; + +const MIME_TYPE_REGEX = + /^(application|audio|font|example|image|message|model|multipart|text|video)\/[a-zA-Z0-9!#$&^_.+-]{1,127}$/; + +export function arazzoStepRequestBodyValidation(target: ArazzoSpecification, _options: null): IFunctionResult[] { + const results: IFunctionResult[] = []; + + // Validate each workflow + if (Array.isArray(target.workflows)) { + target.workflows.forEach((workflow, workflowIndex) => { + // Validate each step in the workflow + workflow.steps.forEach((step, stepIndex) => { + const requestBody = step.requestBody; + + if (!requestBody) { + return; // Skip steps without requestBody + } + + // Validate contentType + if (requestBody.contentType != null && !MIME_TYPE_REGEX.test(requestBody.contentType)) { + results.push({ + message: `Invalid MIME type in contentType: ${requestBody.contentType}`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'requestBody', 'contentType'], + }); + } + + // Validate payload + if ( + Boolean(requestBody.payload) && + typeof requestBody.payload === 'string' && + requestBody.payload.startsWith('$') + ) { + if (!arazzoRuntimeExpressionValidation(requestBody.payload, target, workflowIndex)) { + results.push({ + message: `Invalid runtime expression in payload: ${requestBody.payload}`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'requestBody', 'payload'], + }); + } + } + + // Validate replacements + if (Array.isArray(requestBody.replacements)) { + requestBody.replacements.forEach((replacement, replacementIndex) => { + if (!replacement.target) { + results.push({ + message: `"target" is required in Payload Replacement.`, + path: [ + 'workflows', + workflowIndex, + 'steps', + stepIndex, + 'requestBody', + 'replacements', + replacementIndex, + 'target', + ], + }); + } + + if ( + Boolean(replacement.value) && + typeof replacement.value === 'string' && + replacement.value.startsWith('$') + ) { + if (!arazzoRuntimeExpressionValidation(replacement.value, target, workflowIndex)) { + results.push({ + message: `Invalid runtime expression in replacement value: ${replacement.value}`, + path: [ + 'workflows', + workflowIndex, + 'steps', + stepIndex, + 'requestBody', + 'replacements', + replacementIndex, + 'value', + ], + }); + } + } + }); + } + }); + }); + } + + return results; +} + +export default arazzoStepRequestBodyValidation; diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts new file mode 100644 index 000000000..f2c074a57 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts @@ -0,0 +1,130 @@ +import type { IFunctionResult } from '@stoplight/spectral-core'; +import getAllSuccessActions from './utils/getAllSuccessActions'; +import arazzoCriterionValidation from './arazzoCriterionValidation'; +import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidation'; +import { ArazzoSpecification } from './types/arazzoTypes'; + +export default function arazzoStepSuccessActionsValidation( + target: ArazzoSpecification, + _options: null, +): IFunctionResult[] { + const results: IFunctionResult[] = []; + + if (Array.isArray(target.workflows)) { + target.workflows.forEach((workflow, workflowIndex) => { + if (Array.isArray(workflow.steps)) { + workflow.steps.forEach((step, stepIndex) => { + const resolvedActions = getAllSuccessActions(step, workflow, target); + + if (Array.isArray(resolvedActions)) { + const seenNames: Set = new Set(); + resolvedActions.forEach((action, actionIndex) => { + const originalName = action.name.replace( + /^(masked-(invalid-reusable-success-action-reference-|non-existing-success-action-reference-|duplicate-))/, + '', + ); + + if (seenNames.has(originalName)) { + results.push({ + message: `"${originalName}" must be unique within the combined success actions.`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'onSuccess', actionIndex], + }); + } else { + seenNames.add(originalName); + } + + if (action.name.startsWith('masked-invalid-reusable-success-action-reference-')) { + results.push({ + message: `Invalid runtime expression for reusable action reference: "${originalName}".`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'onSuccess', actionIndex], + }); + } + + if (action.name.startsWith('masked-non-existing-success-action-reference-')) { + results.push({ + message: `Non-existing reusable action reference: "${originalName}".`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'onSuccess', actionIndex], + }); + } + + if (action.name.startsWith('masked-duplicate-')) { + results.push({ + message: `Duplicate success action name: "${originalName}".`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'onSuccess', actionIndex], + }); + } + + if (action.type === 'goto') { + if (action.workflowId != null) { + // Check if workflowId is a runtime expression + if (action.workflowId.startsWith('$')) { + // Validate runtime expression and ensure is in sourceDescriptions + if ( + !arazzoRuntimeExpressionValidation(action.workflowId, target) || + !( + target.sourceDescriptions?.some( + desc => desc.name === (action.workflowId ?? '').split('.')[1], + ) ?? false + ) + ) { + results.push({ + message: `"workflowId" "${action.workflowId}" is not a valid reference or does not exist in sourceDescriptions.`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'onSuccess', actionIndex], + }); + } + } else { + // Validate against local workflows + if (!target.workflows.some(wf => wf.workflowId === action.workflowId)) { + results.push({ + message: `"workflowId" "${action.workflowId}" does not exist within the local Arazzo Document workflows.`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'onSuccess', actionIndex], + }); + } + } + } + + if (action.stepId != null) { + if (!workflow.steps.some(s => s.stepId === action.stepId)) { + results.push({ + message: `"stepId" "${action.stepId}" does not exist within the current workflow.`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'onSuccess', actionIndex], + }); + } + } + + if (action.workflowId != null && action.stepId != null) { + results.push({ + message: `"workflowId" and "stepId" are mutually exclusive and cannot be specified together.`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'onSuccess', actionIndex], + }); + } + } + + if (Array.isArray(action.criteria)) { + action.criteria.forEach((criterion, criterionIndex) => { + const criterionResults = arazzoCriterionValidation( + criterion, + [ + 'workflows', + workflowIndex, + 'steps', + stepIndex, + 'onSuccess', + actionIndex, + 'criteria', + criterionIndex, + ], + target, + ); + results.push(...criterionResults); + }); + } + }); + } + }); + } + }); + } + + return results; +} diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepSuccessCriteriaValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepSuccessCriteriaValidation.ts new file mode 100644 index 000000000..513a3043b --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoStepSuccessCriteriaValidation.ts @@ -0,0 +1,31 @@ +import { IFunctionResult } from '@stoplight/spectral-core'; +import arazzoCriterionValidation from './arazzoCriterionValidation'; +import { ArazzoSpecification } from './types/arazzoTypes'; + +export default function arazzoStepSuccessCriteriaValidation( + targetVal: ArazzoSpecification, + _options: null, +): IFunctionResult[] { + const results: IFunctionResult[] = []; + + if (Array.isArray(targetVal.workflows)) { + targetVal.workflows.forEach((workflow, workflowIndex) => { + if (Array.isArray(workflow.steps)) { + workflow.steps.forEach((step, stepIndex) => { + if (Array.isArray(step.successCriteria)) { + step.successCriteria.forEach((criterion, criterionIndex) => { + const criterionResults = arazzoCriterionValidation( + criterion, + ['workflows', workflowIndex, 'steps', stepIndex, 'successCriteria', criterionIndex], + targetVal, + ); + results.push(...criterionResults); + }); + } + }); + } + }); + } + + return results; +} diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepValidation.ts new file mode 100644 index 000000000..466e261b8 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoStepValidation.ts @@ -0,0 +1,95 @@ +import type { IFunctionResult } from '@stoplight/spectral-core'; +import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidation'; +import { ArazzoSpecification } from './types/arazzoTypes'; + +const OPERATION_PATH_REGEX = /^\{\$sourceDescriptions\.[a-zA-Z0-9_-]+\.(url)\}#.+$/; + +export default function arazzoStepValidation(targetVal: ArazzoSpecification, _options: null): IFunctionResult[] { + const results: IFunctionResult[] = []; + + if (!Array.isArray(targetVal.sourceDescriptions) || targetVal.sourceDescriptions.length === 0) { + results.push({ + message: 'sourceDescriptions is missing in the Arazzo Specification.', + path: ['sourceDescriptions'], + }); + return results; + } + + const sourceDescriptionNames = new Set(targetVal.sourceDescriptions.map(sd => sd.name)); + + targetVal.workflows.forEach((workflow, workflowIndex) => { + if (!Array.isArray(workflow.steps)) { + // If the steps array is not defined or is not an array, skip this workflow + return; + } + + workflow.steps.forEach((step, stepIndex) => { + const { operationId, operationPath, workflowId } = step; + + // Validate operationId + if (operationId != null) { + if (operationId.startsWith('$')) { + if (!arazzoRuntimeExpressionValidation(operationId, targetVal)) { + results.push({ + message: `Runtime expression "${operationId}" is invalid in step "${step.stepId}".`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'operationId'], + }); + } + + const parts = operationId.split('.'); + const sourceName = parts[1]; + + if (!sourceDescriptionNames.has(sourceName)) { + results.push({ + message: `Source description "${sourceName}" not found for operationId "${operationId}" in step "${step.stepId}".`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'operationId'], + }); + } + } + } + + // Validate operationPath as JSON Pointer with correct format + if (operationPath != null) { + if (!OPERATION_PATH_REGEX.test(operationPath)) { + results.push({ + message: `OperationPath "${operationPath}" must be a valid runtime expression following the format "{$sourceDescriptions..url}#".`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'operationPath'], + }); + } else { + const sourceName = operationPath.split('.')[1]; + + if (!sourceDescriptionNames.has(sourceName)) { + results.push({ + message: `Source description "${sourceName}" not found for operationPath "${operationPath}" in step "${step.stepId}".`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'operationPath'], + }); + } + } + } + + // Validate workflowId + if (workflowId != null) { + if (workflowId.startsWith('$')) { + if (!arazzoRuntimeExpressionValidation(workflowId)) { + results.push({ + message: `Runtime expression "${workflowId}" is invalid in step "${step.stepId}".`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'workflowId'], + }); + } + + const parts = workflowId.split('.'); + const sourceName = parts[1]; + + if (!sourceDescriptionNames.has(sourceName)) { + results.push({ + message: `Source description "${sourceName}" not found for workflowId "${workflowId}" in step "${step.stepId}".`, + path: ['workflows', workflowIndex, 'steps', stepIndex, 'workflowId'], + }); + } + } + } + }); + }); + + return results; +} diff --git a/packages/rulesets/src/arazzo/functions/arazzoWorkflowDependsOnValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoWorkflowDependsOnValidation.ts new file mode 100644 index 000000000..265b1ac22 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoWorkflowDependsOnValidation.ts @@ -0,0 +1,87 @@ +import { IFunctionResult } from '@stoplight/spectral-core'; +import { getAllWorkflows } from './utils/getAllWorkflows'; +import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidation'; +import { ArazzoSpecification } from './types/arazzoTypes'; + +export default function arazzoWorkflowDependsOnValidation( + targetVal: ArazzoSpecification, + _options: null, +): IFunctionResult[] { + const results: IFunctionResult[] = []; + const localWorkflowIds = new Set(); + const sourceDescriptionNames = new Map((targetVal.sourceDescriptions ?? []).map(sd => [sd.name, sd.type])); + + const workflows = targetVal.workflows ?? []; + for (const { workflow } of getAllWorkflows({ workflows })) { + if ('workflowId' in workflow && typeof workflow.workflowId === 'string') { + localWorkflowIds.add(workflow.workflowId); + } + } + + for (const { workflow, path } of getAllWorkflows({ workflows })) { + const seenWorkflows = new Set(); + + if (Array.isArray(workflow.dependsOn)) { + workflow.dependsOn.forEach((dep: string | unknown, depIndex: number) => { + if (typeof dep !== 'string') { + return; // Skip non-string dependencies + } + + // Check for uniqueness + if (seenWorkflows.has(dep)) { + results.push({ + message: `Duplicate workflowId "${dep}" in dependsOn for workflow "${workflow.workflowId}".`, + path: [...path, 'dependsOn', depIndex], + }); + return; + } else { + seenWorkflows.add(dep); + } + + if (dep.startsWith('$')) { + if (!arazzoRuntimeExpressionValidation(dep, targetVal)) { + results.push({ + message: `Runtime expression "${dep}" is invalid.`, + path: [...path, 'dependsOn', depIndex], + }); + } + } + + // Check for runtime expression format + if (dep.startsWith('$sourceDescriptions.')) { + const parts = dep.split('.'); + const sourceName = parts[1]; + const workflowId = parts[2] as string | undefined; + + const sourceType = sourceDescriptionNames.get(sourceName); + if (sourceType == null) { + results.push({ + message: `Source description "${sourceName}" not found for workflowId "${dep}".`, + path: [...path, 'dependsOn', depIndex], + }); + } else if (sourceType !== 'arazzo') { + results.push({ + message: `Source description "${sourceName}" must have a type of "arazzo".`, + path: [...path, 'dependsOn', depIndex], + }); + } else if (workflowId == null) { + results.push({ + message: `WorkflowId part is missing in the expression "${dep}".`, + path: [...path, 'dependsOn', depIndex], + }); + } + } else { + // Check against locally defined workflows + if (!localWorkflowIds.has(dep)) { + results.push({ + message: `WorkflowId "${dep}" not found in local Arazzo workflows "${workflow.workflowId}".`, + path: [...path, 'dependsOn', depIndex], + }); + } + } + }); + } + } + + return results; +} diff --git a/packages/rulesets/src/arazzo/functions/arazzoWorkflowIdUniqueness.ts b/packages/rulesets/src/arazzo/functions/arazzoWorkflowIdUniqueness.ts new file mode 100644 index 000000000..3155ec971 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoWorkflowIdUniqueness.ts @@ -0,0 +1,23 @@ +import { IFunctionResult } from '@stoplight/spectral-core'; +import { getAllWorkflows } from './utils/getAllWorkflows'; +import { ArazzoSpecification } from './types/arazzoTypes'; + +export default function arazzoWorkflowIdUniqueness(targetVal: ArazzoSpecification, _options: null): IFunctionResult[] { + const results: IFunctionResult[] = []; + const workflows = getAllWorkflows(targetVal); + + const seenIds: Set = new Set(); + for (const { path, workflow } of workflows) { + const workflowId = workflow.workflowId; + if (seenIds.has(workflowId)) { + results.push({ + message: `"workflowId" must be unique across all workflows. "${workflowId}" is duplicated.`, + path: [...path, 'workflowId'], + }); + } else { + seenIds.add(workflowId); + } + } + + return results; +} diff --git a/packages/rulesets/src/arazzo/functions/arazzoWorkflowOutputNamesValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoWorkflowOutputNamesValidation.ts new file mode 100644 index 000000000..658f2ac86 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoWorkflowOutputNamesValidation.ts @@ -0,0 +1,76 @@ +import { createRulesetFunction, IFunctionResult } from '@stoplight/spectral-core'; +import type { JsonPath } from '@stoplight/types'; +import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidation'; +import { ArazzoSpecification } from './types/arazzoTypes'; + +const OUTPUT_NAME_PATTERN = /^[a-zA-Z0-9.\-_]+$/; + +export default createRulesetFunction( + { + input: { + type: 'object', + properties: { + workflows: { + type: 'array', + items: { + type: 'object', + properties: { + outputs: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + }, + }, + }, + }, + }, + options: null, + }, + function arazzoWorkflowOutputNamesValidation(targetVal, _opts) { + const results: IFunctionResult[] = []; + + if (Array.isArray(targetVal.workflows)) { + targetVal.workflows.forEach((workflow, workflowIndex) => { + if (workflow.outputs && typeof workflow.outputs === 'object') { + const seenOutputNames = new Set(); + + Object.entries(workflow.outputs).forEach(([outputName, outputValue], outputIndex) => { + // Validate output name + if (!OUTPUT_NAME_PATTERN.test(outputName)) { + results.push({ + message: `"${outputName}" does not match the required pattern "^[a-zA-Z0-9.\\-_]+$".`, + path: ['workflows', workflowIndex, 'outputs', outputName, outputIndex] as JsonPath, + }); + } + + // Check for uniqueness within the workflow + if (seenOutputNames.has(outputName)) { + results.push({ + message: `"${outputName}" must be unique within the workflow outputs.`, + path: ['workflows', workflowIndex, 'outputs', outputName, outputIndex] as JsonPath, + }); + } else { + seenOutputNames.add(outputName); + } + + // Validate runtime expression + if ( + !arazzoRuntimeExpressionValidation( + outputValue, + targetVal as unknown as ArazzoSpecification, + workflowIndex, + ) + ) { + results.push({ + message: `"${outputValue}" is not a valid runtime expression.`, + path: ['workflows', workflowIndex, 'outputs', outputName, outputIndex] as JsonPath, + }); + } + }); + } + }); + } + + return results; + }, +); diff --git a/packages/rulesets/src/arazzo/functions/index.ts b/packages/rulesets/src/arazzo/functions/index.ts new file mode 100644 index 000000000..13c22483d --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/index.ts @@ -0,0 +1,31 @@ +import { default as arazzoDocumentSchema } from './arazzoDocumentSchema'; +import { default as arazzoWorkflowIdUniqueness } from './arazzoWorkflowIdUniqueness'; +import { default as arazzoStepIdUniqueness } from './arazzoStepIdUniqueness'; +import { default as arazzoWorkflowOutputNamesValidation } from './arazzoWorkflowOutputNamesValidation'; +import { default as arazzoStepOutputNamesValidation } from './arazzoStepOutputNamesValidation'; +import { default as arazzoStepParametersValidation } from './arazzoStepParametersValidation'; +import { default as arazzoStepFailureActionsValidation } from './arazzoStepFailureActionsValidation'; +import { default as arazzoStepSuccessActionsValidation } from './arazzoStepSuccessActionsValidation'; +import { default as arazzoRuntimeExpressionValidation } from './arazzoRuntimeExpressionValidation'; +import { default as arazzoWorkflowDependsOnValidation } from './arazzoWorkflowDependsOnValidation'; +import { default as arazzoCriterionValidation } from './arazzoCriterionValidation'; +import { default as arazzoStepSuccessCriteriaValidation } from './arazzoStepSuccessCriteriaValidation'; +import { default as arazzoStepRequestBodyValidation } from './arazzoStepRequestBodyValidation'; +import { default as arazzoStepValidation } from './arazzoStepValidation'; + +export { + arazzoDocumentSchema, + arazzoWorkflowIdUniqueness, + arazzoWorkflowOutputNamesValidation, + arazzoStepIdUniqueness, + arazzoStepOutputNamesValidation, + arazzoStepParametersValidation, + arazzoStepFailureActionsValidation, + arazzoStepSuccessActionsValidation, + arazzoRuntimeExpressionValidation, + arazzoWorkflowDependsOnValidation, + arazzoCriterionValidation, + arazzoStepSuccessCriteriaValidation, + arazzoStepRequestBodyValidation, + arazzoStepValidation, +}; diff --git a/packages/rulesets/src/arazzo/functions/types/arazzoTypes.ts b/packages/rulesets/src/arazzo/functions/types/arazzoTypes.ts new file mode 100644 index 000000000..16774b77a --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/types/arazzoTypes.ts @@ -0,0 +1,92 @@ +export type CriterionExpressionType = { + type: 'jsonpath' | 'xpath'; + version: 'draft-goessner-dispatch-jsonpath-00' | 'xpath-30' | 'xpath-20' | 'xpath-10'; +}; + +export type Criterion = { + context?: string; + condition: string; + type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; +}; + +export type Parameter = { + name: string; + in?: string; + value?: unknown; +}; + +export type FailureAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + retryAfter?: number; + retryLimit?: number; + criteria?: Criterion[]; +}; + +export type SuccessAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + criteria?: Criterion[]; +}; + +export type ReusableObject = { + reference: string; + value?: unknown; +}; + +export type PayloadReplacement = { + target: string; + value: unknown | string; +}; + +export type RequestBody = { + contentType?: string; + payload?: unknown | string; + replacements?: PayloadReplacement[]; +}; + +export type Step = { + stepId: string; + onFailure?: (FailureAction | ReusableObject)[]; + onSuccess?: (SuccessAction | ReusableObject)[]; + parameters?: (Parameter | ReusableObject)[]; + successCriteria?: Criterion[]; + requestBody?: RequestBody; + outputs?: { [key: string]: string }; + workflowId?: string; + operationId?: string; + operationPath?: string; +}; + +export type SourceDescription = { + name: string; + url: string; + type?: 'arazzo' | 'openapi'; +}; + +export type Workflow = { + workflowId: string; + steps: Step[]; + inputs?: Record; + parameters?: (Parameter | ReusableObject)[]; + successActions?: (SuccessAction | ReusableObject)[]; + failureActions?: (FailureAction | ReusableObject)[]; + dependsOn?: string[]; + outputs?: { [key: string]: string }; +}; + +export type ArazzoSpecification = { + workflows: Workflow[]; + sourceDescriptions?: SourceDescription[]; + components?: { + inputs?: Record; + parameters?: Record; + successActions?: Record; + failureActions?: Record; + [key: string]: unknown; + }; +}; diff --git a/packages/rulesets/src/arazzo/functions/utils/getAllFailureActions.ts b/packages/rulesets/src/arazzo/functions/utils/getAllFailureActions.ts new file mode 100644 index 000000000..0bdda3391 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/utils/getAllFailureActions.ts @@ -0,0 +1,87 @@ +import { isPlainObject } from '@stoplight/json'; +import arazzoRuntimeExpressionValidation from '../arazzoRuntimeExpressionValidation'; +import { ArazzoSpecification, Workflow, Step, ReusableObject, FailureAction } from '../types/arazzoTypes'; + +function isFailureAction(action: unknown): action is FailureAction { + return typeof action === 'object' && action !== null && 'name' in action && 'type' in action; +} + +function processReusableAction(action: ReusableObject, arazzoSpec: ArazzoSpecification): FailureAction { + const actionName = action.reference; + + // Ensure the reference starts with $components.failureActions + if (!action.reference.startsWith('$components.failureActions.')) { + return { name: `masked-invalid-reusable-failure-action-reference-${actionName}`, type: '' }; + } + + // Validate the reference right here, ensuring it resolves + if (!arazzoRuntimeExpressionValidation(action.reference, arazzoSpec)) { + return { name: `masked-invalid-reusable-failure-action-reference-${actionName}`, type: '' }; + } + + // Further processing with extracted name + const refPath = action.reference.replace('$components.failureActions.', ''); + const resolvedAction = arazzoSpec.components?.failureActions?.[refPath]; + + if (!resolvedAction) { + return { name: `masked-unresolved-failure-action-reference-${actionName}`, type: '' }; + } + + return resolvedAction; +} + +export default function getAllFailureActions( + step: Step, + workflow: Workflow, + arazzoSpec: ArazzoSpecification, +): FailureAction[] { + const resolvedFailureActions: FailureAction[] = []; + const resolvedStepFailureActions: FailureAction[] = []; + + const resolveActions = (actions: (FailureAction | ReusableObject)[], targetArray: FailureAction[]): void => { + actions.forEach(action => { + let actionToPush: FailureAction; + + if (isPlainObject(action) && 'reference' in action) { + actionToPush = processReusableAction(action, arazzoSpec); + } else { + actionToPush = action; + } + + if (isFailureAction(actionToPush)) { + const isDuplicate = targetArray.some(existingAction => existingAction.name === actionToPush.name); + + if (isDuplicate) { + actionToPush = { + ...actionToPush, + name: `masked-duplicate-${actionToPush.name}`, + }; + } + + targetArray.push(actionToPush); + } + }); + }; + + // Process workflow-level failure actions + if (workflow.failureActions) { + resolveActions(workflow.failureActions, resolvedFailureActions); + } + + // Process step-level failure actions + if (step.onFailure) { + resolveActions(step.onFailure, resolvedStepFailureActions); + } + + // Merge step actions into workflow actions, overriding duplicates + resolvedStepFailureActions.forEach(action => { + const existingActionIndex = resolvedFailureActions.findIndex(a => a.name === action.name); + if (existingActionIndex !== -1) { + resolvedFailureActions[existingActionIndex] = action; // Override workflow action with step action + } else { + resolvedFailureActions.push(action); + } + }); + + return resolvedFailureActions; +} diff --git a/packages/rulesets/src/arazzo/functions/utils/getAllParameters.ts b/packages/rulesets/src/arazzo/functions/utils/getAllParameters.ts new file mode 100644 index 000000000..bc49089cb --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/utils/getAllParameters.ts @@ -0,0 +1,95 @@ +import { isPlainObject } from '@stoplight/json'; +import arazzoRuntimeExpressionValidation from '../arazzoRuntimeExpressionValidation'; +import { ArazzoSpecification, Workflow, Step, ReusableObject, Parameter } from '../types/arazzoTypes'; + +const resolveReusableParameter = ( + reusableObject: ReusableObject, + arazzoSpec: ArazzoSpecification, +): Parameter | undefined => { + const refPath = reusableObject.reference.replace('$components.parameters.', ''); + return arazzoSpec.components?.parameters?.[refPath]; +}; + +function isParameter(param: unknown): param is Parameter { + if (typeof param === 'object' && param !== null) { + const obj = param as Record; + return typeof obj.name === 'string' && (typeof obj.in === 'string' || obj.in === undefined); + } + return false; +} + +export default function getAllParameters(step: Step, workflow: Workflow, arazzoSpec: ArazzoSpecification): Parameter[] { + const resolvedParameters: Parameter[] = []; + const resolvedStepParameters: Parameter[] = []; + + const processReusableParameter = (param: ReusableObject): Parameter => { + const paramName = param.reference; + + if (!arazzoRuntimeExpressionValidation(param.reference, arazzoSpec)) { + return { name: `masked-invalid-reusable-parameter-reference-${paramName}` }; + } + + const resolvedParam = resolveReusableParameter(param, arazzoSpec); + + if (!resolvedParam) { + return { name: `masked-unresolved-parameter-reference-${paramName}` }; + } + + return resolvedParam; + }; + + const resolveParameters = (params: (Parameter | ReusableObject)[], targetArray: Parameter[]): void => { + params.forEach(param => { + let paramToPush: Parameter; + + if (isPlainObject(param) && 'reference' in param) { + paramToPush = processReusableParameter(param); + } else { + paramToPush = param; + } + + if (isParameter(paramToPush)) { + const isDuplicate = targetArray.some( + existingParam => + isParameter(existingParam) && + isParameter(paramToPush) && + existingParam.name === paramToPush.name && + (existingParam.in ?? '') === (paramToPush.in ?? ''), + ); + + if (isDuplicate) { + paramToPush = { + ...paramToPush, + name: `masked-duplicate-${String(paramToPush.name)}`, + }; + } + + targetArray.push(paramToPush); + } + }); + }; + + // Process workflow-level parameters + if (workflow.parameters != null) { + resolveParameters(workflow.parameters, resolvedParameters); + } + + // Process step-level parameters + if (step.parameters != null) { + resolveParameters(step.parameters, resolvedStepParameters); + } + + // Merge step parameters into workflow parameters, overriding duplicates + resolvedStepParameters.forEach(param => { + const existingParamIndex = resolvedParameters.findIndex( + p => isParameter(p) && p.name === param.name && (p.in ?? '') === (param.in ?? ''), + ); + if (existingParamIndex !== -1) { + resolvedParameters[existingParamIndex] = param; + } else { + resolvedParameters.push(param); + } + }); + + return resolvedParameters; +} diff --git a/packages/rulesets/src/arazzo/functions/utils/getAllSuccessActions.ts b/packages/rulesets/src/arazzo/functions/utils/getAllSuccessActions.ts new file mode 100644 index 000000000..dd9d745d8 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/utils/getAllSuccessActions.ts @@ -0,0 +1,86 @@ +import { isPlainObject } from '@stoplight/json'; +import arazzoRuntimeExpressionValidation from '../arazzoRuntimeExpressionValidation'; +import { ArazzoSpecification, Workflow, Step, ReusableObject, SuccessAction } from '../types/arazzoTypes'; + +const resolveReusableSuccessActions = ( + reusableObject: ReusableObject, + arazzoSpec: ArazzoSpecification, +): SuccessAction | undefined => { + const refPath = reusableObject.reference.replace('$components.successActions.', ''); + return arazzoSpec.components?.successActions?.[refPath]; +}; + +function isSuccessAction(action: unknown): action is SuccessAction { + return typeof action === 'object' && action !== null && 'name' in action && 'type' in action; +} + +export default function getAllSuccessActions( + step: Step, + workflow: Workflow, + arazzoSpec: ArazzoSpecification, +): SuccessAction[] { + const resolvedSuccessActions: SuccessAction[] = []; + const resolvedStepSuccessActions: SuccessAction[] = []; + + const processReusableAction = (action: ReusableObject): SuccessAction => { + const actionName = action.reference; + + if (!arazzoRuntimeExpressionValidation(action.reference, arazzoSpec)) { + return { name: `masked-invalid-reusable-success-action-reference-${actionName}`, type: '' }; + } + + const resolvedAction = resolveReusableSuccessActions(action, arazzoSpec); + if (!resolvedAction) { + return { name: `masked-non-existing-success-action-reference-${actionName}`, type: '' }; + } + + return resolvedAction; + }; + + const resolveActions = (actions: (SuccessAction | ReusableObject)[], targetArray: SuccessAction[]): void => { + actions.forEach(action => { + let actionToPush: SuccessAction; + + if (isPlainObject(action) && 'reference' in action) { + actionToPush = processReusableAction(action); + } else { + actionToPush = action; + } + + if (isSuccessAction(actionToPush)) { + const isDuplicate = targetArray.some(existingAction => existingAction.name === actionToPush.name); + + if (isDuplicate) { + actionToPush = { + ...actionToPush, + name: `masked-duplicate-${actionToPush.name}`, + }; + } + + targetArray.push(actionToPush); + } + }); + }; + + // Process workflow-level success actions + if (workflow.successActions) { + resolveActions(workflow.successActions, resolvedSuccessActions); + } + + // Process step-level success actions + if (step.onSuccess) { + resolveActions(step.onSuccess, resolvedStepSuccessActions); + } + + // Merge step actions into workflow actions, overriding duplicates + resolvedStepSuccessActions.forEach(action => { + const existingActionIndex = resolvedSuccessActions.findIndex(a => a.name === action.name); + if (existingActionIndex !== -1) { + resolvedSuccessActions[existingActionIndex] = action; // Override workflow action with step action + } else { + resolvedSuccessActions.push(action); + } + }); + + return resolvedSuccessActions; +} diff --git a/packages/rulesets/src/arazzo/functions/utils/getAllWorkflows.ts b/packages/rulesets/src/arazzo/functions/utils/getAllWorkflows.ts new file mode 100644 index 000000000..3ff3b16d1 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/utils/getAllWorkflows.ts @@ -0,0 +1,23 @@ +import { isPlainObject } from '@stoplight/json'; +import type { JsonPath } from '@stoplight/types'; +import { ArazzoSpecification, Workflow } from '../types/arazzoTypes'; + +type Result = { path: JsonPath; workflow: Workflow }; + +export function* getAllWorkflows(arazzo: ArazzoSpecification): IterableIterator { + const workflows = arazzo?.workflows; + if (!Array.isArray(workflows)) { + return; + } + + for (const [index, workflow] of workflows.entries()) { + if (!isPlainObject(workflow)) { + continue; + } + + yield { + path: ['workflows', index], + workflow, + }; + } +} diff --git a/packages/rulesets/src/arazzo/index.ts b/packages/rulesets/src/arazzo/index.ts new file mode 100644 index 000000000..87e406544 --- /dev/null +++ b/packages/rulesets/src/arazzo/index.ts @@ -0,0 +1,227 @@ +import { arazzo1_0 } from '@stoplight/spectral-formats'; +import { truthy, falsy, pattern } from '@stoplight/spectral-functions'; + +import arazzoDocumentSchema from './functions/arazzoDocumentSchema'; +import arazzoWorkflowIdUniqueness from './functions/arazzoWorkflowIdUniqueness'; +import arazzoStepIdUniqueness from './functions/arazzoStepIdUniqueness'; +import arazzoWorkflowOutputNamesValidation from './functions/arazzoWorkflowOutputNamesValidation'; +import arazzoStepOutputNamesValidation from './functions/arazzoStepOutputNamesValidation'; +import arazzoStepParametersValidation from './functions/arazzoStepParametersValidation'; +import arazzoStepFailureActionsValidation from './functions/arazzoStepFailureActionsValidation'; +import arazzoStepSuccessActionsValidation from './functions/arazzoStepSuccessActionsValidation'; +import arazzoWorkflowDependsOnValidation from './functions/arazzoWorkflowDependsOnValidation'; +import arazzoStepSuccessCriteriaValidation from './functions/arazzoStepSuccessCriteriaValidation'; +import arazzoStepRequestBodyValidation from './functions/arazzoStepRequestBodyValidation'; +import arazzoStepValidation from './functions/arazzoStepValidation'; + +export default { + documentationUrl: 'https://meta.stoplight.io/docs/spectral/docs/reference/arazzo-rules.md', + formats: [arazzo1_0], + rules: { + 'arazzo-document-schema': { + description: 'Arazzo Document must be valid against the Arazzo schema.', + message: '{{error}}', + severity: 0, + given: '$', + then: { + function: arazzoDocumentSchema, + }, + }, + 'arazzo-workflowId-unique': { + description: 'Every workflow must have unique "workflowId".', + message: `{{error}}`, + severity: 0, + given: '$', + then: { + function: arazzoWorkflowIdUniqueness, + }, + }, + 'arazzo-workflow-output-validation': { + description: 'Every workflow output must have unique name and its value must be a valid runtime expression.', + message: `{{error}}`, + severity: 0, + given: '$', + then: { + function: arazzoWorkflowOutputNamesValidation, + }, + }, + 'arazzo-workflow-stepId-unique': { + description: 'Every step must have unique "stepId".', + message: `{{error}}`, + severity: 0, + given: '$.workflows[*]', + then: { + function: arazzoStepIdUniqueness, + }, + }, + 'arazzo-step-output-validation': { + description: 'Every step output must have unique name and its value must be a valid runtime expression.', + message: `{{error}}`, + severity: 0, + given: '$', + then: { + function: arazzoStepOutputNamesValidation, + }, + }, + 'arazzo-step-parameters-validation': { + description: 'Step parameters and workflow parameters must valid.', + message: `{{error}}`, + severity: 0, + given: '$', + then: { + function: arazzoStepParametersValidation, + }, + }, + 'arazzo-step-failure-actions-validation': { + description: + 'Every failure action must have a unique "name", and the fields "workflowId" and "stepId" are mutually exclusive.', + message: `{{error}}`, + severity: 0, + given: '$', + then: { + function: arazzoStepFailureActionsValidation, + }, + }, + 'arazzo-step-success-actions-validation': { + description: + 'Every success action must have a unique "name", and the fields "workflowId" and "stepId" are mutually exclusive.', + message: `{{error}}`, + severity: 0, + given: '$', + then: { + function: arazzoStepSuccessActionsValidation, + }, + }, + 'arazzo-workflow-depends-on-validation': { + description: 'Every workflow dependency must be valid.', + severity: 0, + given: '$', + then: { + function: arazzoWorkflowDependsOnValidation, + }, + }, + 'arazzo-step-success-criteria-validation': { + description: 'Every success criteria must have a valid context, conditions, and types.', + message: `{{error}}`, + severity: 0, + given: '$.workflows[*]', + then: { + function: arazzoStepSuccessCriteriaValidation, + }, + }, + 'arazzo-step-request-body-validation': { + description: 'Every step request body must have a valid `contentType` and use of runtime expressions.', + severity: 0, + given: '$', + then: { + function: arazzoStepRequestBodyValidation, + }, + }, + 'arazzo-step-validation': { + description: + 'Every step must have a valid "stepId" and an valid "operationId" or "operationPath" or "workflowId".', + severity: 0, + given: '$', + then: { + function: arazzoStepValidation, + }, + }, + 'arazzo-no-script-tags-in-markdown': { + description: 'Markdown descriptions must not have "