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/README.md b/README.md index cd990c5cd..9a2a3cb71 100644 --- a/README.md +++ b/README.md @@ -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. 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/reference/arazzo-rules.md b/docs/reference/arazzo-rules.md new file mode 100644 index 000000000..80a6b58ae --- /dev/null +++ b/docs/reference/arazzo-rules.md @@ -0,0 +1,307 @@ +# 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-summary + +In order to improve consumer experience, Step `summary` 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/package.json b/packages/cli/package.json index f559873f6..c01579858 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.12.0", "homepage": "https://github.com/stoplightio/spectral", "bugs": "https://github.com/stoplightio/spectral/issues", "author": "Stoplight ", @@ -40,9 +40,9 @@ "@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-rulesets": ">=1", + "@stoplight/spectral-ruleset-bundler": "^1.5.4", + "@stoplight/spectral-ruleset-migrator": "^1.9.6", + "@stoplight/spectral-rulesets": "1.20.2", "@stoplight/spectral-runtime": "^1.1.2", "@stoplight/types": "^13.6.0", "chalk": "4.1.2", diff --git a/packages/core/package.json b/packages/core/package.json index 6b1307d6f..adcf622a4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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/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/functions/package.json b/packages/functions/package.json index 706f6e2b0..ec91352ea 100644 --- a/packages/functions/package.json +++ b/packages/functions/package.json @@ -22,7 +22,7 @@ "@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.17.1", "ajv-draft-04": "~1.0.0", diff --git a/packages/ruleset-bundler/package.json b/packages/ruleset-bundler/package.json index b224a348d..f756be946 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.5.4", "homepage": "https://github.com/stoplightio/spectral", "bugs": "https://github.com/stoplightio/spectral/issues", "author": "Stoplight ", @@ -38,12 +38,12 @@ "@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-rulesets": ">=1", + "@stoplight/spectral-ruleset-migrator": "^1.9.6", + "@stoplight/spectral-rulesets": "^1.20.1", "@stoplight/spectral-runtime": "^1.1.0", "@stoplight/types": "^13.6.0", "@types/node": "*", 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/package.json b/packages/ruleset-migrator/package.json index 5552e71cd..07f911258 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.9.6", "homepage": "https://github.com/stoplightio/spectral", "bugs": "https://github.com/stoplightio/spectral/issues", "author": "Stoplight ", 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/package.json b/packages/rulesets/package.json index 0b5dfd5de..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,7 +22,7 @@ "@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", 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..07420a8fc --- /dev/null +++ b/packages/rulesets/src/arazzo/index.ts @@ -0,0 +1,236 @@ +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 "