From 720c76067d219a2885475a6d865e0e1fd8919264 Mon Sep 17 00:00:00 2001 From: Frank Kilcommins Date: Fri, 2 Aug 2024 12:38:13 +0100 Subject: [PATCH 01/28] feat(formats): add arazzo format --- packages/formats/src/__tests__/arazzo.test.ts | 27 +++++++++++++++++++ packages/formats/src/arazzo.ts | 12 +++++++++ packages/formats/src/index.ts | 1 + 3 files changed, 40 insertions(+) create mode 100644 packages/formats/src/__tests__/arazzo.test.ts create mode 100644 packages/formats/src/arazzo.ts diff --git a/packages/formats/src/__tests__/arazzo.test.ts b/packages/formats/src/__tests__/arazzo.test.ts new file mode 100644 index 000000000..f9557463e --- /dev/null +++ b/packages/formats/src/__tests__/arazzo.test.ts @@ -0,0 +1,27 @@ +import { arazzo1_0 } from '../arazzo'; + +describe('Arazzo format', () => { + describe('Arazzo 1.0.x', () => { + it.each(['1.0.0', '1.0', '1.0.1', '1.0.2', '1.0.99'])('recognizes %s version correctly', version => { + expect(arazzo1_0({ arazzo: version }, null)).toBe(true); + }); + + const testCases = [ + { arazzo: '0.1' }, + { arazzo: '1.1.0' }, + { arazzo: '2' }, + { arazzo: '2.0' }, + { arazzo: '2.0.' }, + { arazzo: '2.0.01' }, + { arazzo: 2 }, + { arazzo: null }, + { arazzo: '4.0' }, + {}, + null, + ]; + + it.each(testCases)('does not recognize invalid document %o', document => { + expect(arazzo1_0(document, null)).toBe(false); + }); + }); +}); diff --git a/packages/formats/src/arazzo.ts b/packages/formats/src/arazzo.ts new file mode 100644 index 000000000..787449e5a --- /dev/null +++ b/packages/formats/src/arazzo.ts @@ -0,0 +1,12 @@ +import type { Format } from '@stoplight/spectral-core'; +import { isPlainObject } from '@stoplight/json'; + +type MaybeArazzo = { arazzo: unknown } & Record; + +const arazzo1_0Regex = /^1\.0(?:\.[0-9]*)?$/; + +const isArazzo = (document: unknown): document is { arazzo: string } & Record => + isPlainObject(document) && 'arazzo' in document && arazzo1_0Regex.test(String((document as MaybeArazzo).arazzo)); + +export const arazzo1_0: Format = isArazzo; +arazzo1_0.displayName = 'Arazzo 1.0.x'; diff --git a/packages/formats/src/index.ts b/packages/formats/src/index.ts index 0451e893f..071d1ae46 100644 --- a/packages/formats/src/index.ts +++ b/packages/formats/src/index.ts @@ -1,3 +1,4 @@ export * from './openapi'; export * from './asyncapi'; export * from './jsonSchema'; +export * from './arazzo'; From ea5ab31bedb6835ef1ada2c5fc59ce925ead76bb Mon Sep 17 00:00:00 2001 From: Frank Kilcommins Date: Wed, 14 Aug 2024 20:00:42 +0100 Subject: [PATCH 02/28] feat(rulesets): add initial arazzo rules --- .../src/plugins/__tests__/builtins.spec.ts | 1 + packages/rulesets/scripts/compile-schemas.ts | 2 + .../src/__tests__/__helpers__/tester.ts | 2 + ...arazzoStepFailureActionsValidation.test.ts | 103 ++ .../__tests__/arazzoStepIdUniqueness.test.ts | 49 + .../arazzoStepOutputNamesValidation.test.ts | 91 ++ .../arazzoStepParametersValidation.test.ts | 286 ++++++ ...arazzoStepSuccessActionsValidation.test.ts | 101 ++ .../arazzoWorkflowIdUniqueness.test.ts | 47 + ...razzoWorkflowOutputNamesValidation.test.ts | 91 ++ .../arazzoStepFailureActionsValidation.ts | 78 ++ .../functions/arazzoStepIdUniqueness.ts | 43 + .../arazzoStepOutputNamesValidation.ts | 65 ++ .../arazzoStepParametersValidation.ts | 187 ++++ .../arazzoStepSuccessActionsValidation.ts | 76 ++ .../functions/arazzoWorkflowIdUniqueness.ts | 43 + .../arazzoWorkflowOutputNamesValidation.ts | 64 ++ .../rulesets/src/arazzo/functions/index.ts | 11 + .../functions/utils/getAllFailureActions.ts | 128 +++ .../functions/utils/getAllParameters.ts | 59 ++ .../functions/utils/getAllSuccessActions.ts | 125 +++ .../arazzo/functions/utils/getAllWorkflows.ts | 26 + packages/rulesets/src/arazzo/index.ts | 49 + .../src/arazzo/schemas/arazzo/v1.0/LICENSE | 201 ++++ .../src/arazzo/schemas/arazzo/v1.0/README.md | 1 + .../src/arazzo/schemas/arazzo/v1.0/index.json | 962 ++++++++++++++++++ .../src/arazzo/schemas/json-schema/LICENSE | 21 + .../src/arazzo/schemas/json-schema/README.md | 1 + .../json-schema/draft-2020-12/index.json | 56 + .../json-schema/draft-2020-12/validation.json | 102 ++ packages/rulesets/src/index.ts | 3 +- 31 files changed, 3073 insertions(+), 1 deletion(-) create mode 100644 packages/rulesets/src/arazzo/functions/__tests__/arazzoStepFailureActionsValidation.test.ts create mode 100644 packages/rulesets/src/arazzo/functions/__tests__/arazzoStepIdUniqueness.test.ts create mode 100644 packages/rulesets/src/arazzo/functions/__tests__/arazzoStepOutputNamesValidation.test.ts create mode 100644 packages/rulesets/src/arazzo/functions/__tests__/arazzoStepParametersValidation.test.ts create mode 100644 packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessActionsValidation.test.ts create mode 100644 packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowIdUniqueness.test.ts create mode 100644 packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowOutputNamesValidation.test.ts create mode 100644 packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts create mode 100644 packages/rulesets/src/arazzo/functions/arazzoStepIdUniqueness.ts create mode 100644 packages/rulesets/src/arazzo/functions/arazzoStepOutputNamesValidation.ts create mode 100644 packages/rulesets/src/arazzo/functions/arazzoStepParametersValidation.ts create mode 100644 packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts create mode 100644 packages/rulesets/src/arazzo/functions/arazzoWorkflowIdUniqueness.ts create mode 100644 packages/rulesets/src/arazzo/functions/arazzoWorkflowOutputNamesValidation.ts create mode 100644 packages/rulesets/src/arazzo/functions/index.ts create mode 100644 packages/rulesets/src/arazzo/functions/utils/getAllFailureActions.ts create mode 100644 packages/rulesets/src/arazzo/functions/utils/getAllParameters.ts create mode 100644 packages/rulesets/src/arazzo/functions/utils/getAllSuccessActions.ts create mode 100644 packages/rulesets/src/arazzo/functions/utils/getAllWorkflows.ts create mode 100644 packages/rulesets/src/arazzo/index.ts create mode 100644 packages/rulesets/src/arazzo/schemas/arazzo/v1.0/LICENSE create mode 100644 packages/rulesets/src/arazzo/schemas/arazzo/v1.0/README.md create mode 100644 packages/rulesets/src/arazzo/schemas/arazzo/v1.0/index.json create mode 100644 packages/rulesets/src/arazzo/schemas/json-schema/LICENSE create mode 100644 packages/rulesets/src/arazzo/schemas/json-schema/README.md create mode 100644 packages/rulesets/src/arazzo/schemas/json-schema/draft-2020-12/index.json create mode 100644 packages/rulesets/src/arazzo/schemas/json-schema/draft-2020-12/validation.json 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/rulesets/scripts/compile-schemas.ts b/packages/rulesets/scripts/compile-schemas.ts index 7378ed7c7..cdd3250d0 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') @@ -55,6 +56,7 @@ Promise.all(schemas) 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 = ( 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__/arazzoStepFailureActionsValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepFailureActionsValidation.test.ts new file mode 100644 index 000000000..453b32f7d --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepFailureActionsValidation.test.ts @@ -0,0 +1,103 @@ +import arazzoStepFailureActionsValidation from '../arazzoStepFailureActionsValidation'; +import type { RulesetFunctionContext } from '@stoplight/spectral-core'; + +type FailureAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + retryAfter?: number; + retryLimit?: number; + criteria?: Criterion[]; +}; + +type Criterion = { + condition: string; +}; + +type ReusableObject = { + reference: string; +}; + +type Step = { + onFailure?: (FailureAction | ReusableObject)[]; +}; + +type Workflow = { + steps: Step[]; + onFailure?: (FailureAction | ReusableObject)[]; + components?: { failureActions?: Record }; +}; + +const runRule = (target: Workflow, _contextOverrides: Partial = {}) => { + return arazzoStepFailureActionsValidation(target, null); +}; + +describe('validateFailureActions', () => { + test('should not report any errors for valid and unique failure actions', () => { + const results = runRule({ + steps: [ + { + onFailure: [ + { name: 'action1', type: 'goto', stepId: 'step1' }, + { name: 'action2', type: 'end' }, + ], + }, + ], + components: { failureActions: {} }, + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for duplicate failure actions within the same step', () => { + const results = runRule({ + steps: [ + { + onFailure: [ + { name: 'action1', type: 'goto', stepId: 'step1' }, + { name: 'action1', type: 'end' }, + ], + }, // Duplicate action name + ], + components: { failureActions: {} }, + }); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ + message: `Duplicate action: "action1" must be unique within the combined failure actions.`, + path: ['steps', 0, 'onFailure', 1], + }); + }); + + test('should report an error for mutually exclusive workflowId and stepId', () => { + const results = runRule({ + steps: [ + { + onFailure: [{ name: 'action1', type: 'goto', stepId: 'step1', workflowId: 'workflow1' }], + }, + ], + components: { failureActions: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"workflowId" and "stepId" are mutually exclusive and cannot be specified together.`, + path: ['steps', 0, 'onFailure', 0], + }); + }); + + test('should override workflow level onFailure action with step level onFailure action', () => { + const results = runRule({ + steps: [ + { + onFailure: [{ name: 'action1', type: 'goto', stepId: 'step1' }], + }, + ], + onFailure: [{ name: 'action1', type: 'end' }], + components: { failureActions: {} }, + }); + + expect(results).toHaveLength(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..3592a49e0 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepOutputNamesValidation.test.ts @@ -0,0 +1,91 @@ +import arazzoStepOutputNamesValidation from '../arazzoStepOutputNamesValidation'; +import { DeepPartial } from '@stoplight/types'; +import type { RulesetFunctionContext } from '@stoplight/spectral-core'; + +const runRule = ( + target: { steps: Array<{ outputs?: [string, string][] }> }, + 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({ + steps: [ + { + outputs: [ + ['output1', 'value1'], + ['output2', 'value2'], + ], + }, + { outputs: [['output3', 'value3']] }, + ], + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for invalid output names', () => { + const results = runRule({ + steps: [ + { + outputs: [ + ['invalid name', 'value1'], + ['output2', 'value2'], + ], + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"invalid name" does not match the required pattern "^[a-zA-Z0-9.\\-_]+$".`, + path: ['steps', 0, 'outputs', 'invalid name'], + }); + }); + + test('should report an error for duplicate output names within the same step', () => { + const results = runRule({ + steps: [ + { + outputs: [ + ['output1', 'value1'], + ['output2', 'value2'], + ['output1', 'value3'], + ], + }, // Duplicate key simulated here + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"output1" must be unique within the step outputs.`, + path: ['steps', 0, 'outputs', 'output1'], + }); + }); + + test('should not report an error for duplicate output names across different steps', () => { + const results = runRule({ + steps: [ + { outputs: [['output1', 'value1']] }, + { outputs: [['output1', 'value2']] }, // Duplicate output name across different steps + ], + }); + + expect(results).toHaveLength(0); + }); +}); 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..eab18566f --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepParametersValidation.test.ts @@ -0,0 +1,286 @@ +import { DeepPartial } from '@stoplight/types'; +import arazzoStepParametersValidation from '../arazzoStepParametersValidation'; +import type { RulesetFunctionContext } from '@stoplight/spectral-core'; + +type Parameter = { + name: string; + in?: string; +}; + +type ReusableObject = { + reference: string; +}; + +type Step = { + parameters?: (Parameter | ReusableObject)[]; + workflowId?: string; + operationId?: string; + operationPath?: string; +}; + +const runRule = ( + target: { steps: Step[]; parameters?: Parameter[]; components?: { parameters?: 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 arazzoStepParametersValidation(target, null, context as RulesetFunctionContext); +}; + +describe('arazzoStepParametersValidation', () => { + test('should not report any errors for valid and unique parameters', () => { + const results = runRule({ + steps: [ + { + parameters: [ + { name: 'param1', in: 'query' }, + { name: 'param2', in: 'header' }, + ], + }, + ], + 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({ + steps: [ + { + parameters: [ + { name: 'param1', in: 'query' }, + { name: 'param2', in: 'header' }, + ], + }, + ], + components: { parameters: { param1: { name: 'param3', in: 'cookie' } } }, + }); + + expect(results).toHaveLength(0); + }); + + test('should handle combined parameters from step and workflow without "in" when "workflowId" is specified', () => { + const results = runRule({ + steps: [ + { + workflowId: 'workflow1', + parameters: [{ name: 'param1' }], + }, + { + workflowId: 'workflow1', + parameters: [{ name: 'param2' }], + }, + ], + parameters: [{ name: 'param3' }, { name: 'param4' }], + components: { parameters: {} }, + }); + + expect(results).toHaveLength(0); + }); + + test('should handle combined parameters from step and workflow with "in" when "operationPath" is specified', () => { + const results = runRule({ + steps: [ + { + operationPath: '/path1', + parameters: [{ name: 'param1', in: 'query' }], + }, + { + operationPath: '/path2', + parameters: [{ name: 'param2', in: 'header' }], + }, + ], + parameters: [ + { name: 'param1', in: 'cookie' }, + { name: 'param2', in: 'cookie' }, + ], + components: { parameters: {} }, + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for duplicate parameters within the same step', () => { + const results = runRule({ + steps: [ + { + parameters: [ + { name: 'param1', in: 'query' }, + { name: 'param1', in: 'query' }, + ], + }, // Duplicate parameter + ], + components: { parameters: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"param1" with "in" value "query" must be unique within the combined parameters.`, + path: ['steps', 0, 'parameters', 1], + }); + }); + + test('should report an error for duplicate reusable parameters', () => { + const results = runRule({ + steps: [ + { + parameters: [{ reference: '$components.parameters.param1' }, { reference: '$components.parameters.param1' }], + }, + ], + components: { parameters: { param1: { name: 'param1', in: 'query' } } }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"param1" with "in" value "query" must be unique within the combined parameters.`, + path: ['steps', 0, 'parameters', 0], + }); + }); + + test('should report an error for combined duplicate parameters from step and workflow level', () => { + const results = runRule({ + steps: [ + { + workflowId: 'workflow1', + parameters: [{ name: 'param1' }], + }, + ], + parameters: [{ name: 'param1' }], + components: { parameters: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `Duplicate parameter: "param1" must be unique within the combined parameters.`, + path: ['steps', 0, 'parameters', 1], + }); + }); + + test('should report an error for mixed "in" presence when "workflowId" is present', () => { + const results = runRule({ + steps: [ + { + workflowId: 'workflow1', + parameters: [{ name: 'param1' }, { name: 'param2', in: 'query' }], + }, + ], + components: { parameters: {} }, + }); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ + message: `Parameters must not mix "in" field presence.`, + path: ['steps', 0, 'parameters'], + }); + }); + + test('should report an error for parameters containing "in" when "workflowId" is present', () => { + const results = runRule({ + steps: [ + { + workflowId: 'workflow1', + parameters: [ + { name: 'param1', in: 'header' }, + { name: 'param2', in: 'query' }, + ], + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `Step with "workflowId" must not have parameters with an "in" field.`, + path: ['steps', 0, 'parameters'], + }); + }); + + test('should report an error for parameters missing "in" when "operationId" is present', () => { + const results = runRule({ + steps: [ + { + operationId: 'operation1', + parameters: [{ name: 'param1' }, { name: 'param2' }], + }, + ], + components: { parameters: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `Step with "operationId" or "operationPath" must have parameters with an "in" field.`, + path: ['steps', 0, 'parameters'], + }); + }); + + test('should report an error for combined duplicate parameters from step and workflow with "in" when "operationId" is specified', () => { + const results = runRule({ + steps: [ + { + operationId: 'operation1', + parameters: [{ name: 'param1', in: 'query' }], + }, + ], + parameters: [{ name: 'param1', in: 'query' }], + components: { parameters: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `Duplicate parameter: "param1" must be unique within the combined parameters.`, + path: ['steps', 0, 'parameters', 1], + }); + }); + + test('should handle combined parameters from step and workflow with "in" when "operationId" is specified', () => { + const results = runRule({ + steps: [ + { + operationId: 'operation1', + parameters: [{ name: 'param1', in: 'query' }], + }, + { + operationId: 'operation2', + parameters: [{ name: 'param2', in: 'header' }], + }, + ], + parameters: [ + { name: 'param1', in: 'header' }, + { name: 'param2', in: 'query' }, + ], + components: { parameters: {} }, + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for combined duplicate parameters from step and workflow with "in" when "operationPath" is specified', () => { + const results = runRule({ + steps: [ + { + operationPath: '/path1', + parameters: [{ name: 'param1', in: 'query' }], + }, + ], + parameters: [{ name: 'param1', in: 'query' }], + components: { parameters: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `Duplicate parameter: "param1" must be unique within the combined parameters.`, + path: ['steps', 0, 'parameters', 1], + }); + }); +}); 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..5785064af --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessActionsValidation.test.ts @@ -0,0 +1,101 @@ +import arazzoStepSuccessActionsValidation from '../arazzoStepSuccessActionsValidation'; +import type { RulesetFunctionContext } from '@stoplight/spectral-core'; + +type SuccessAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + criteria?: Criterion[]; +}; + +type Criterion = { + condition: string; +}; + +type ReusableObject = { + reference: string; +}; + +type Step = { + onSuccess?: (SuccessAction | ReusableObject)[]; +}; + +type Workflow = { + steps: Step[]; + successActions?: (SuccessAction | ReusableObject)[]; + components?: { successActions?: Record }; +}; + +const runRule = (target: Workflow, _contextOverrides: Partial = {}) => { + return arazzoStepSuccessActionsValidation(target, null); +}; + +describe('validateSuccessActions', () => { + test('should not report any errors for valid and unique success actions', () => { + const results = runRule({ + steps: [ + { + onSuccess: [ + { name: 'action1', type: 'goto', stepId: 'step1' }, + { name: 'action2', type: 'end' }, + ], + }, + ], + components: { successActions: {} }, + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for duplicate success actions within the same step', () => { + const results = runRule({ + steps: [ + { + onSuccess: [ + { name: 'action1', type: 'goto', stepId: 'step1' }, + { name: 'action1', type: 'end' }, + ], + }, // Duplicate action name + ], + components: { successActions: {} }, + }); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ + message: `Duplicate action: "action1" must be unique within the combined success actions.`, + path: ['steps', 0, 'onSuccess', 1], + }); + }); + + test('should report an error for mutually exclusive workflowId and stepId', () => { + const results = runRule({ + steps: [ + { + onSuccess: [{ name: 'action1', type: 'goto', 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: ['steps', 0, 'onSuccess', 0], + }); + }); + + test('should override workflow level success action with step level success action', () => { + const results = runRule({ + steps: [ + { + onSuccess: [{ name: 'action1', type: 'goto', stepId: 'step1' }], + }, + ], + successActions: [{ name: 'action1', type: 'end' }], + components: { successActions: {} }, + }); + + expect(results).toHaveLength(0); + }); +}); 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..41d51d97f --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowIdUniqueness.test.ts @@ -0,0 +1,47 @@ +import { DeepPartial } from '@stoplight/types'; +import arazzoWorkflowIdUniqueness from '../arazzoWorkflowIdUniqueness'; +import type { RulesetFunctionContext } from '@stoplight/spectral-core'; + +const runRule = (target: { workflows: Record[] }) => { + const context: DeepPartial = { + path: [], + documentInventory: { + graph: {} as any, // Mock the graph property + referencedDocuments: {}, // Mock the referencedDocuments property as a Dictionary + findAssociatedItemForPath: jest.fn(), // Mock the findAssociatedItemForPath function + }, + document: { + formats: new Set(), // Mock the formats property correctly + }, + }; + + return arazzoWorkflowIdUniqueness(target, null, context as RulesetFunctionContext); +}; + +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.`, + 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..be1e622cd --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowOutputNamesValidation.test.ts @@ -0,0 +1,91 @@ +import arazzoWorkflowOutputNamesValidation from '../arazzoWorkflowOutputNamesValidation'; +import { DeepPartial } from '@stoplight/types'; +import type { RulesetFunctionContext } from '@stoplight/spectral-core'; + +const runRule = ( + target: { workflows: Array<{ outputs?: [string, string][] }> }, + 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', 'value1'], + ['output2', 'value2'], + ], + }, + { outputs: [['output3', 'value3']] }, + ], + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for invalid output names', () => { + const results = runRule({ + workflows: [ + { + outputs: [ + ['invalid name', 'value1'], + ['output2', 'value2'], + ], + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"invalid name" does not match the required pattern "^[a-zA-Z0-9.\\-_]+$".`, + path: ['workflows', 0, 'outputs', 'invalid name'], + }); + }); + + test('should report an error for duplicate output names within the same workflow', () => { + const results = runRule({ + workflows: [ + { + outputs: [ + ['output1', 'value1'], + ['output2', 'value2'], + ['output1', 'value3'], + ], + }, // Duplicate key simulated here + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"output1" must be unique within the workflow outputs.`, + path: ['workflows', 0, 'outputs', 'output1'], + }); + }); + + test('should not report an error for duplicate output names across different workflows', () => { + const results = runRule({ + workflows: [ + { outputs: [['output1', 'value1']] }, + { outputs: [['output1', 'value2']] }, // Duplicate output name across different workflows + ], + }); + + expect(results).toHaveLength(0); + }); +}); diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts new file mode 100644 index 000000000..d786ad8c5 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts @@ -0,0 +1,78 @@ +import type { IFunctionResult } from '@stoplight/spectral-core'; +import getAllFailureActions from './utils/getAllFailureActions'; + +type FailureAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + retryAfter?: number; + retryLimit?: number; + criteria?: Criterion[]; +}; + +type Criterion = { + condition: string; +}; + +type ReusableObject = { + reference: string; +}; + +type Step = { + onFailure?: (FailureAction | ReusableObject)[]; + workflowId?: string; + operationId?: string; + operationPath?: string; +}; + +type Workflow = { + steps: Step[]; + onFailure?: (FailureAction | ReusableObject)[]; + components?: { failureActions?: Record }; +}; + +export default function validateFailureActions(target: Workflow, _options: null): IFunctionResult[] { + const results: IFunctionResult[] = []; + const components = target.components?.failureActions ?? {}; + + target.steps.forEach((step, stepIndex) => { + const resolvedActions = getAllFailureActions(step, target, components); + + const seenNames: Set = new Set(); + resolvedActions.forEach((action, actionIndex) => { + if (seenNames.has(action.name)) { + results.push({ + message: `"${action.name}" must be unique within the combined failure actions.`, + path: ['steps', stepIndex, 'onFailure', actionIndex], + }); + } else { + seenNames.add(action.name); + } + + if (action.type === 'goto' || action.type === 'retry') { + if (action.workflowId != null && action.stepId != null) { + results.push({ + message: `"workflowId" and "stepId" are mutually exclusive and cannot be specified together.`, + path: ['steps', stepIndex, 'onFailure', actionIndex], + }); + } + } + + const maskedDuplicates = resolvedActions.filter(action => action.name.startsWith('masked-duplicate-')); + if (maskedDuplicates.length > 0) { + maskedDuplicates.forEach(action => { + results.push({ + message: `Duplicate action: "${action.name.replace( + 'masked-duplicate-', + '', + )}" must be unique within the combined failure actions.`, + path: ['steps', stepIndex, 'onFailure', resolvedActions.indexOf(action)], + }); + }); + } + }); + }); + + 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..3a3cff9b0 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoStepIdUniqueness.ts @@ -0,0 +1,43 @@ +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', + }, + }, + required: ['stepId'], + }, + }, + }, + }, + options: null, + }, + function arazzoStepIdUniqueness(targetVal, _) { + const results: IFunctionResult[] = []; + const stepIds = new Set(); + + targetVal.steps.forEach((step, index) => { + const { stepId } = step; + 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..443fe6d61 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoStepOutputNamesValidation.ts @@ -0,0 +1,65 @@ +import { createRulesetFunction, IFunctionResult } from '@stoplight/spectral-core'; +import type { JsonPath } from '@stoplight/types'; + +const OUTPUT_NAME_PATTERN = /^[a-zA-Z0-9.\-_]+$/; + +export default createRulesetFunction< + { steps: Array<{ outputs?: [string, string][] }> }, // Updated type to accept array of entries + null +>( + { + input: { + type: 'object', + properties: { + steps: { + type: 'array', + items: { + type: 'object', + properties: { + outputs: { + type: 'array', // Updated type to array + items: { + type: 'array', + minItems: 2, + maxItems: 2, + items: [{ type: 'string' }, { type: 'string' }], + }, + }, + }, + }, + }, + }, + }, + options: null, + }, + function arazzoStepOutputNamesValidation(targetVal) { + const results: IFunctionResult[] = []; + + targetVal.steps.forEach((step, stepIndex) => { + if (step.outputs) { + const seenOutputNames = new Set(); + + step.outputs.forEach(([outputName]) => { + // Destructure entries directly + if (!OUTPUT_NAME_PATTERN.test(outputName)) { + results.push({ + message: `"${outputName}" does not match the required pattern "^[a-zA-Z0-9.\\-_]+$".`, + path: ['steps', stepIndex, 'outputs', outputName] as JsonPath, + }); + } + + if (seenOutputNames.has(outputName)) { + results.push({ + message: `"${outputName}" must be unique within the step outputs.`, + path: ['steps', stepIndex, 'outputs', outputName] as JsonPath, + }); + } else { + seenOutputNames.add(outputName); + } + }); + } + }); + + 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..63990af15 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoStepParametersValidation.ts @@ -0,0 +1,187 @@ +import { createRulesetFunction } from '@stoplight/spectral-core'; +import type { IFunctionResult } from '@stoplight/spectral-core'; +import getAllParameters from './utils/getAllParameters'; + +type Parameter = { + name: string; + in?: string; +}; + +type ReusableObject = { + reference: string; +}; + +type Step = { + parameters?: (Parameter | ReusableObject)[]; + workflowId?: string; + operationId?: string; + operationPath?: string; +}; + +export default createRulesetFunction< + { steps: Step[]; parameters?: Parameter[]; components?: { parameters?: Record } }, + null +>( + { + input: { + type: 'object', + properties: { + steps: { + type: 'array', + items: { + type: 'object', + properties: { + parameters: { + type: 'array', + items: { + oneOf: [ + { + type: 'object', + properties: { + name: { + type: 'string', + }, + in: { + type: 'string', + }, + }, + required: ['name'], + }, + { + type: 'object', + properties: { + reference: { + type: 'string', + }, + }, + required: ['reference'], + }, + ], + }, + }, + workflowId: { + type: 'string', + }, + operationId: { + type: 'string', + }, + operationPath: { + type: 'string', + }, + }, + }, + }, + parameters: { + type: 'array', + items: { + type: 'object', + properties: { + name: { + type: 'string', + }, + in: { + type: 'string', + }, + }, + required: ['name'], + }, + }, + components: { + type: 'object', + properties: { + parameters: { + type: 'object', + additionalProperties: { + type: 'object', + properties: { + name: { + type: 'string', + }, + in: { + type: 'string', + }, + }, + required: ['name'], + }, + }, + }, + }, + }, + required: ['steps'], + }, + options: null, + }, + function arazzoStepParametersValidation(targetVal, _) { + const results: IFunctionResult[] = []; + const { steps, parameters = [], components = { parameters: {} } } = targetVal; + + // Convert the workflow parameters to the expected format for getAllParameters + const workflow = { parameters }; + + // Process steps + for (const [stepIndex, step] of steps.entries()) { + if (!step.parameters) continue; + + const { workflowId, operationId, operationPath } = step; + const stepParams = getAllParameters(step, workflow, components.parameters ?? {}); + + // Check for duplicate parameters within the step + const paramSet = new Set(); + for (const param of stepParams) { + const key = `${param.name}-${param.in ?? ''}`; + if (paramSet.has(key)) { + results.push({ + message: `"${param.name}" with "in" value "${ + param.in ?? '' + }" must be unique within the combined parameters.`, + path: ['steps', stepIndex, 'parameters', stepParams.indexOf(param)], + }); + } else { + paramSet.add(key); + } + } + + // Check for masked duplicates + const maskedDuplicates = stepParams.filter(param => param.name.startsWith('masked-duplicate-')); + if (maskedDuplicates.length > 0) { + maskedDuplicates.forEach(param => { + results.push({ + message: `Duplicate parameter: "${param.name.replace( + 'masked-duplicate-', + '', + )}" must be unique within the combined parameters.`, + path: ['steps', stepIndex, 'parameters', stepParams.indexOf(param)], + }); + }); + } + + // Validate no mix of `in` presence + const hasInField = stepParams.some(param => 'in' in param); + const noInField = stepParams.some(param => !('in' in param)); + + if (hasInField && noInField) { + results.push({ + message: `Parameters must not mix "in" field presence.`, + path: ['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: ['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: ['steps', stepIndex, 'parameters'], + }); + } + } + return results; + }, +); diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts new file mode 100644 index 000000000..e366748f7 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts @@ -0,0 +1,76 @@ +import type { IFunctionResult } from '@stoplight/spectral-core'; +import getAllSuccessActions from './utils/getAllSuccessActions'; + +type SuccessAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + criteria?: Criterion[]; +}; + +type Criterion = { + condition: string; +}; + +type ReusableObject = { + reference: string; +}; + +type Step = { + onSuccess?: (SuccessAction | ReusableObject)[]; + workflowId?: string; + operationId?: string; + operationPath?: string; +}; + +type Workflow = { + steps: Step[]; + successActions?: (SuccessAction | ReusableObject)[]; + components?: { successActions?: Record }; +}; + +export default function validateSuccessActions(target: Workflow, _options: null): IFunctionResult[] { + const results: IFunctionResult[] = []; + const components = target.components?.successActions ?? {}; + + target.steps.forEach((step, stepIndex) => { + const resolvedActions = getAllSuccessActions(step, target, components); + + const seenNames: Set = new Set(); + resolvedActions.forEach((action, actionIndex) => { + if (seenNames.has(action.name)) { + results.push({ + message: `"${action.name}" must be unique within the combined success actions.`, + path: ['steps', stepIndex, 'onSuccess', actionIndex], + }); + } else { + seenNames.add(action.name); + } + + if (action.type === 'goto') { + if (action.workflowId != null && action.stepId != null) { + results.push({ + message: `"workflowId" and "stepId" are mutually exclusive and cannot be specified together.`, + path: ['steps', stepIndex, 'onSuccess', actionIndex], + }); + } + } + + const maskedDuplicates = resolvedActions.filter(action => action.name.startsWith('masked-duplicate-')); + if (maskedDuplicates.length > 0) { + maskedDuplicates.forEach(action => { + results.push({ + message: `Duplicate action: "${action.name.replace( + 'masked-duplicate-', + '', + )}" must be unique within the combined success actions.`, + path: ['steps', stepIndex, 'onSuccess', resolvedActions.indexOf(action)], + }); + }); + } + }); + }); + + 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..9e85e1de1 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoWorkflowIdUniqueness.ts @@ -0,0 +1,43 @@ +import { createRulesetFunction, IFunctionResult } from '@stoplight/spectral-core'; +import { getAllWorkflows } from './utils/getAllWorkflows'; + +export default createRulesetFunction<{ workflows: Record[] }, null>( + { + input: { + type: 'object', + properties: { + workflows: { + type: 'array', + items: { + type: 'object', + properties: { + workflowId: { + type: 'string', + }, + }, + }, + }, + }, + }, + options: null, + }, + function arazzoWorkflowIdUniqueness(targetVal, _) { + const results: IFunctionResult[] = []; + const workflows = getAllWorkflows(targetVal); + + const seenIds: Set = new Set(); + for (const { path, workflow } of workflows) { + const workflowId = workflow.workflowId as string; + if (seenIds.has(workflowId)) { + results.push({ + message: `"workflowId" must be unique across all workflows.`, + 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..3af83c2ee --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoWorkflowOutputNamesValidation.ts @@ -0,0 +1,64 @@ +import { createRulesetFunction, IFunctionResult } from '@stoplight/spectral-core'; +import type { JsonPath } from '@stoplight/types'; + +const OUTPUT_NAME_PATTERN = /^[a-zA-Z0-9.\-_]+$/; + +export default createRulesetFunction< + { workflows: Array<{ outputs?: [string, string][] }> }, // Accept array of entries for workflow outputs + null +>( + { + input: { + type: 'object', + properties: { + workflows: { + type: 'array', + items: { + type: 'object', + properties: { + outputs: { + type: 'array', + items: { + type: 'array', + minItems: 2, + maxItems: 2, + items: [{ type: 'string' }, { type: 'string' }], + }, + }, + }, + }, + }, + }, + }, + options: null, + }, + function arazzoWorkflowOutputNamesValidation(targetVal) { + const results: IFunctionResult[] = []; + + targetVal.workflows.forEach((workflow, workflowIndex) => { + if (workflow.outputs) { + const seenOutputNames = new Set(); + + workflow.outputs.forEach(([outputName]) => { + 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] as JsonPath, + }); + } + + if (seenOutputNames.has(outputName)) { + results.push({ + message: `"${outputName}" must be unique within the workflow outputs.`, + path: ['workflows', workflowIndex, 'outputs', outputName] as JsonPath, + }); + } else { + seenOutputNames.add(outputName); + } + }); + } + }); + + 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..f81cf6020 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/index.ts @@ -0,0 +1,11 @@ +import { default as arazzoWorkflowIdUniqueness } from './arazzoWorkflowIdUniqueness'; +import { default as arazzoStepIdUniqueness } from './arazzoStepIdUniqueness'; +import { default as arazzoWorkflowOutputNamesValidation } from './arazzoWorkflowOutputNamesValidation'; +import { default as arazzoStepOutputNamesValidation } from './arazzoStepOutputNamesValidation'; + +export { + arazzoWorkflowIdUniqueness, + arazzoWorkflowOutputNamesValidation, + arazzoStepIdUniqueness, + arazzoStepOutputNamesValidation, +}; 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..f53ba3bca --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/utils/getAllFailureActions.ts @@ -0,0 +1,128 @@ +import { isPlainObject } from '@stoplight/json'; + +type FailureAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + retryAfter?: number; + retryLimit?: number; + criteria?: Criterion[]; +}; + +type Criterion = { + condition: string; +}; + +type ReusableObject = { + reference: string; +}; + +type Step = { + onFailure?: (FailureAction | ReusableObject)[]; +}; + +type Workflow = { + steps: Step[]; + onFailure?: (FailureAction | ReusableObject)[]; + components?: { failureActions?: Record }; +}; + +const resolveReusableFailureActions = ( + reusableObject: ReusableObject, + components: Record, +): FailureAction | undefined => { + const refPath = reusableObject.reference.split('.').slice(1).join('.'); + return components[refPath]; +}; + +function isFailureAction(action: unknown): action is FailureAction { + if (typeof action === 'object' && action !== null) { + const obj = action as Record; + return typeof obj.name === 'string' && typeof obj.type === 'string'; + } + return false; +} +export default function getAllFailureActions( + step: Step, + workflow: Workflow, + components: Record, +): FailureAction[] { + const resolvedFailureActions: FailureAction[] = []; + const resolvedStepFailureActions: FailureAction[] = []; + + if (workflow.onFailure) { + workflow.onFailure.forEach(action => { + let actionToPush = action; + + if (isPlainObject(action) && 'reference' in action) { + const resolvedAction = resolveReusableFailureActions(action, components); + if (resolvedAction) { + actionToPush = resolvedAction; + } + } + + if (isFailureAction(actionToPush)) { + const isDuplicate = resolvedFailureActions.some( + existingAction => + isFailureAction(existingAction) && + isFailureAction(actionToPush) && + existingAction.name === actionToPush.name, + ); + + if (isDuplicate) { + actionToPush = { + ...actionToPush, + name: `masked-duplicate-${actionToPush.name}`, + }; + } + + resolvedFailureActions.push(actionToPush); + } + }); + } + + //now process step onFailure actions into resolvedStepFailureActions and check for duplicates + if (step.onFailure) { + step.onFailure.forEach(action => { + let actionToPush = action; + + if (isPlainObject(action) && 'reference' in action) { + const resolvedAction = resolveReusableFailureActions(action, components); + if (resolvedAction) { + actionToPush = resolvedAction; + } + } + + if (isFailureAction(actionToPush)) { + const isDuplicate = resolvedStepFailureActions.some( + existingAction => + isFailureAction(existingAction) && + isFailureAction(actionToPush) && + existingAction.name === actionToPush.name, + ); + + if (isDuplicate) { + actionToPush = { + ...actionToPush, + name: `masked-duplicate-${actionToPush.name}`, + }; + } + + resolvedStepFailureActions.push(actionToPush); + } + }); + } + + //update below to process the resolvedStepFailureActions and overwrite duplicates in resolvedFailureActions + resolvedStepFailureActions.forEach(action => { + const existingActionIndex = resolvedFailureActions.findIndex(a => a.name === action.name); + if (existingActionIndex !== -1) { + resolvedFailureActions[existingActionIndex] = 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..522f917bb --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/utils/getAllParameters.ts @@ -0,0 +1,59 @@ +import { isPlainObject } from '@stoplight/json'; + +type Parameter = { + name: string; + in?: string; +}; + +type ReusableObject = { + reference: string; +}; + +type Step = { + parameters?: (Parameter | ReusableObject)[]; +}; + +type Workflow = { + parameters?: Parameter[]; +}; + +const resolveReusableParameters = ( + reusableObject: ReusableObject, + components: Record, +): Parameter | undefined => { + const refPath = reusableObject.reference.replace('$components.parameters.', ''); + return components[refPath]; +}; + +export default function getAllParameters( + step: Step, + workflow: Workflow, + components: Record, +): Parameter[] { + const resolvedParameters: Parameter[] = []; + + if (step.parameters) { + step.parameters.forEach(param => { + if (isPlainObject(param) && 'reference' in param) { + const resolvedParam = resolveReusableParameters(param, components); + if (resolvedParam) { + resolvedParameters.push(resolvedParam); + } + } else { + resolvedParameters.push(param); + } + }); + } + + if (workflow.parameters) { + workflow.parameters.forEach(param => { + if (resolvedParameters.some(p => p.name === param.name && p.in === param.in)) { + // Tag duplicate parameter + param.name = `masked-duplicate-${param.name}`; + } + 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..594677f77 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/utils/getAllSuccessActions.ts @@ -0,0 +1,125 @@ +import { isPlainObject } from '@stoplight/json'; + +type SuccessAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + criteria?: Criterion[]; +}; + +type Criterion = { + condition: string; +}; + +type ReusableObject = { + reference: string; +}; + +type Step = { + onSuccess?: (SuccessAction | ReusableObject)[]; +}; + +type Workflow = { + steps: Step[]; + successActions?: (SuccessAction | ReusableObject)[]; + components?: { successActions?: Record }; +}; + +const resolveReusableSuccessActions = ( + reusableObject: ReusableObject, + components: Record, +): SuccessAction | undefined => { + const refPath = reusableObject.reference.split('.').slice(1).join('.'); + return components[refPath]; +}; + +function isSuccessAction(action: unknown): action is SuccessAction { + if (typeof action === 'object' && action !== null) { + const obj = action as Record; + return typeof obj.name === 'string' && typeof obj.type === 'string'; + } + return false; +} + +export default function getAllSuccessActions( + step: Step, + workflow: Workflow, + components: Record, +): SuccessAction[] { + const resolvedSuccessActions: SuccessAction[] = []; + const resolvedStepSuccessActions: SuccessAction[] = []; + + if (workflow.successActions) { + workflow.successActions.forEach(action => { + let actionToPush = action; + + if (isPlainObject(action) && 'reference' in action) { + const resolvedAction = resolveReusableSuccessActions(action, components); + if (resolvedAction) { + actionToPush = resolvedAction; + } + } + + if (isSuccessAction(actionToPush)) { + const isDuplicate = resolvedSuccessActions.some( + existingAction => + isSuccessAction(existingAction) && + isSuccessAction(actionToPush) && + existingAction.name === actionToPush.name, + ); + + if (isDuplicate) { + actionToPush = { + ...actionToPush, + name: `masked-duplicate-${actionToPush.name}`, + }; + } + + resolvedSuccessActions.push(actionToPush); + } + }); + } + + if (step.onSuccess) { + step.onSuccess.forEach(action => { + let actionToPush = action; + + if (isPlainObject(action) && 'reference' in action) { + const resolvedAction = resolveReusableSuccessActions(action, components); + if (resolvedAction) { + actionToPush = resolvedAction; + } + } + + if (isSuccessAction(actionToPush)) { + const isDuplicate = resolvedStepSuccessActions.some( + existingAction => + isSuccessAction(existingAction) && + isSuccessAction(actionToPush) && + existingAction.name === actionToPush.name, + ); + + if (isDuplicate) { + actionToPush = { + ...actionToPush, + name: `masked-duplicate-${actionToPush.name}`, + }; + } + + resolvedStepSuccessActions.push(actionToPush); + } + }); + } + + resolvedStepSuccessActions.forEach(action => { + const existingActionIndex = resolvedSuccessActions.findIndex(a => a.name === action.name); + if (existingActionIndex !== -1) { + resolvedSuccessActions[existingActionIndex] = 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..cace9917b --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/utils/getAllWorkflows.ts @@ -0,0 +1,26 @@ +import { isPlainObject } from '@stoplight/json'; +import type { JsonPath } from '@stoplight/types'; + +type WorkflowObject = Record; +type ArazzoDocument = { + workflows?: WorkflowObject[]; +}; +type Result = { path: JsonPath; workflow: WorkflowObject }; + +export function* getAllWorkflows(arazzo: ArazzoDocument): 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..a89a8c820 --- /dev/null +++ b/packages/rulesets/src/arazzo/index.ts @@ -0,0 +1,49 @@ +import { arazzo1_0 } from '@stoplight/spectral-formats'; + +import arazzoWorkflowIdUniqueness from './functions/arazzoWorkflowIdUniqueness'; +import arazzoStepIdUniqueness from './functions/arazzoStepIdUniqueness'; +import arazzoWorkflowOutputNamesValidation from './functions/arazzoWorkflowOutputNamesValidation'; +import arazzoStepOutputNamesValidation from './functions/arazzoStepOutputNamesValidation'; + +export default { + documentationUrl: 'https://meta.stoplight.io/docs/spectral/docs/reference/arazzo-rules.md', + formats: [arazzo1_0], + rules: { + 'arazzo-workflowId-unique': { + description: 'Every workflow must have unique "workflowId".', + recommended: true, + severity: 0, + given: '$.workflows', + then: { + function: arazzoWorkflowIdUniqueness, + }, + }, + 'arazzo-workflow-output-names-validation': { + description: 'Every workflow output must have unique name.', + recommended: true, + severity: 0, + given: '$.workflows[*].outputs', + then: { + function: arazzoWorkflowOutputNamesValidation, + }, + }, + 'arazzo-workflow-stepId-unique': { + description: 'Every step must have unique "stepId".', + recommended: true, + severity: 0, + given: '$.steps', + then: { + function: arazzoStepIdUniqueness, + }, + }, + 'arazzo-step-output-names-validation': { + description: 'Every step output must have unique name.', + recommended: true, + severity: 0, + given: '$.steps[*].outputs', + then: { + function: arazzoStepOutputNamesValidation, + }, + }, + }, +}; diff --git a/packages/rulesets/src/arazzo/schemas/arazzo/v1.0/LICENSE b/packages/rulesets/src/arazzo/schemas/arazzo/v1.0/LICENSE new file mode 100644 index 000000000..23b34fdff --- /dev/null +++ b/packages/rulesets/src/arazzo/schemas/arazzo/v1.0/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright The Linux Foundation + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/rulesets/src/arazzo/schemas/arazzo/v1.0/README.md b/packages/rulesets/src/arazzo/schemas/arazzo/v1.0/README.md new file mode 100644 index 000000000..e7427b99e --- /dev/null +++ b/packages/rulesets/src/arazzo/schemas/arazzo/v1.0/README.md @@ -0,0 +1 @@ +The schemas here are based on https://github.com/OAI/Arazzo-Specification/blob/main/schemas/ with a few changes to yield more useful validation results. diff --git a/packages/rulesets/src/arazzo/schemas/arazzo/v1.0/index.json b/packages/rulesets/src/arazzo/schemas/arazzo/v1.0/index.json new file mode 100644 index 000000000..b542ddd72 --- /dev/null +++ b/packages/rulesets/src/arazzo/schemas/arazzo/v1.0/index.json @@ -0,0 +1,962 @@ +{ + "$id": "https://spec.openapis.org/arazzo/1.0/schema/2024-08-01", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "The description of OpenAPI Initiative Arazzo v1.0.0 documents\nwithout schema validation, as defined by https://spec.openapis.org/arazzo/v1.0.0", + "type": "object", + "properties": { + "arazzo": { + "description": "The version number of the Arazzo Specification", + "type": "string", + "pattern": "^1\\.0\\.\\d+(-.+)?$" + }, + "info": { + "$ref": "#/$defs/info" + }, + "sourceDescriptions": { + "description": "A list of source descriptions such as Arazzo or OpenAPI", + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { + "$ref": "#/$defs/source-description-object" + } + }, + "workflows": { + "description": "A list of workflows", + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { + "$ref": "#/$defs/workflow-object" + } + }, + "components": { + "$ref": "#/$defs/components-object" + } + }, + "required": [ + "arazzo", + "info", + "sourceDescriptions", + "workflows" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false, + "$defs": { + "info": { + "$comment": "https://spec.openapis.org/arazzo/v1.0.0#info-object", + "description": "Provides metadata about the Arazzo description", + "type": "object", + "properties": { + "title": { + "description": "A human readable title of the Arazzo Description", + "type": "string" + }, + "summary": { + "description": "A short summary of the Arazzo Description", + "type": "string" + }, + "description": { + "description": "A description of the purpose of the workflows defined. CommonMark syntax MAY be used for rich text representation", + "type": "string" + }, + "version": { + "description": "The version identifier of the Arazzo document (which is distinct from the Arazzo Specification version)", + "type": "string" + } + }, + "required": [ + "title", + "version" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "source-description-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0.0#source-description-object", + "description": "Describes a source description (such as an OpenAPI description)\nthat will be referenced by one or more workflows described within\nan Arazzo description", + "type": "object", + "properties": { + "name": { + "description": "A unique name for the source description", + "type": "string", + "pattern": "^[A-Za-z0-9_\\-]+$" + }, + "url": { + "description": "A URL to a source description to be used by a workflow", + "type": "string", + "format": "uri-reference" + }, + "type": { + "description": "The type of source description", + "enum": [ + "arazzo", + "openapi" + ] + } + }, + "required": [ + "name", + "url" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "workflow-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0.0#workflow-object", + "description": "Describes the steps to be taken across one or more APIs to achieve an objective", + "type": "object", + "properties": { + "workflowId": { + "description": "Unique string to represent the workflow", + "$dynamicAnchor": "workflowId", + "type": "string" + }, + "summary": { + "description": "A summary of the purpose or objective of the workflow", + "type": "string" + }, + "description": { + "description": "A description of the workflow. CommonMark syntax MAY be used for rich text representation", + "type": "string" + }, + "inputs": { + "description": "A JSON Schema 2020-12 object representing the input parameters used by this workflow", + "$dynamicRef": "#meta" + }, + "dependsOn": { + "description": "A list of workflows that MUST be completed before this workflow can be processed", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "steps": { + "description": "An ordered list of steps where each step represents a call to an API operation or to another workflow", + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { + "$ref": "#/$defs/step-object" + } + }, + "successActions": { + "description": "A list of success actions that are applicable for all steps described under this workflow", + "type": "array", + "uniqueItems": true, + "items": { + "oneOf": [ + { + "$ref": "#/$defs/success-action-object" + }, + { + "$ref": "#/$defs/reusable-object" + } + ] + } + }, + "failureActions": { + "description": "A list of failure actions that are applicable for all steps described under this workflow", + "type": "array", + "uniqueItems": true, + "items": { + "oneOf": [ + { + "$ref": "#/$defs/failure-action-object" + }, + { + "$ref": "#/$defs/reusable-object" + } + ] + } + }, + "outputs": { + "description": "A map between a friendly name and a dynamic output value", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "type": "string" + } + } + }, + "parameters": { + "description": "A list of parameters that are applicable for all steps described under this workflow", + "type": "array", + "uniqueItems": true, + "items": { + "oneOf": [ + { + "$ref": "#/$defs/parameter-object" + }, + { + "$ref": "#/$defs/reusable-object" + } + ] + } + } + }, + "required": [ + "workflowId", + "steps" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "step-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0.0#step-object'", + "description": "Describes a single workflow step which MAY be a call to an\nAPI operation (OpenAPI Operation Object or another Workflow Object)", + "type": "object", + "properties": { + "stepId": { + "description": "Unique string to represent the step", + "$dynamicAnchor": "stepId", + "type": "string" + }, + "description": { + "description": "A description of the step. CommonMark syntax MAY be used for rich text representation", + "type": "string" + }, + "operationId": { + "description": "The name of an existing, resolvable operation, as defined with a unique operationId and existing within one of the sourceDescriptions", + "type": "string" + }, + "operationPath": { + "description": "A reference to a Source combined with a JSON Pointer to reference an operation", + "type": "string" + }, + "workflowId": { + "description": "The workflowId referencing an existing workflow within the Arazzo description", + "$dynamicRef": "#workflowId" + }, + "parameters": { + "description": "A list of parameters that MUST be passed to an operation or workflow as referenced by operationId, operationPath, or workflowId", + "type": "array", + "uniqueItems": true, + "items": true + }, + "requestBody": { + "$ref": "#/$defs/request-body-object" + }, + "successCriteria": { + "description": "A list of assertions to determine the success of the step", + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { + "$ref": "#/$defs/criterion-object" + } + }, + "onSuccess": { + "description": "An array of success action objects that specify what to do upon step success", + "type": "array", + "uniqueItems": true, + "items": { + "oneOf": [ + { + "$ref": "#/$defs/success-action-object" + }, + { + "$ref": "#/$defs/reusable-object" + } + ] + } + }, + "onFailure": { + "description": "An array of failure action objects that specify what to do upon step failure", + "type": "array", + "uniqueItems": true, + "items": { + "oneOf": [ + { + "$ref": "#/$defs/failure-action-object" + }, + { + "$ref": "#/$defs/reusable-object" + } + ] + } + }, + "outputs": { + "description": "A map between a friendly name and a dynamic output value defined using a runtime expression", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "type": "string" + } + } + } + }, + "required": [ + "stepId" + ], + "oneOf": [ + { + "required": [ + "operationId" + ] + }, + { + "required": [ + "operationPath" + ] + }, + { + "required": [ + "workflowId" + ] + } + ], + "allOf": [ + { + "if": { + "required": [ + "operationId" + ] + }, + "then": { + "properties": { + "parameters": { + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "in": { + "description": "The named location of the parameter", + "enum": [ + "path", + "query", + "header", + "cookie", + "body" + ] + }, + "value": { + "description": "The value to pass in the parameter", + "type": [ + "string", + "boolean", + "object", + "array", + "number", + "null" + ] + } + }, + "required": [ + "name", + "value", + "in" + ] + }, + { + "type": "object", + "properties": { + "reference": { + "type": "string" + }, + "value": { + "description": "The value to pass in the parameter", + "type": [ + "string", + "boolean", + "object", + "array", + "number", + "null" + ] + } + }, + "required": [ + "reference" + ] + } + ] + } + } + } + } + }, + { + "if": { + "required": [ + "operationPath" + ] + }, + "then": { + "properties": { + "parameters": { + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "in": { + "description": "The named location of the parameter", + "enum": [ + "path", + "query", + "header", + "cookie", + "body" + ] + }, + "value": { + "description": "The value to pass in the parameter", + "type": [ + "string", + "boolean", + "object", + "array", + "number", + "null" + ] + } + }, + "required": [ + "name", + "value", + "in" + ] + }, + { + "type": "object", + "properties": { + "reference": { + "type": "string" + }, + "value": { + "description": "The value to pass in the parameter", + "type": [ + "string", + "boolean", + "object", + "array", + "number", + "null" + ] + } + }, + "required": [ + "reference" + ] + } + ] + } + } + } + } + }, + { + "if": { + "required": [ + "workflowId" + ] + }, + "then": { + "properties": { + "parameters": { + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "description": "The value to pass in the parameter", + "type": [ + "string", + "boolean", + "object", + "array", + "number", + "null" + ] + } + }, + "required": [ + "name", + "value" + ] + }, + { + "type": "object", + "properties": { + "reference": { + "type": "string" + }, + "value": { + "description": "The value to pass in the parameter", + "type": [ + "string", + "boolean", + "object", + "array", + "number", + "null" + ] + } + }, + "required": [ + "reference" + ] + } + ] + } + } + } + } + } + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "request-body-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0.0#request-body-object", + "description": "The request body to pass to an operation as referenced by operationId or operationPath", + "type": "object", + "properties": { + "contentType": { + "description": "The Content-Type for the request content", + "type": "string" + }, + "payload": true, + "replacements": { + "description": "A list of locations and values to set within a payload", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/payload-replacement-object" + } + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "criterion-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0.0#criterion-object", + "description": "An object used to specify the context, conditions, and condition types\nthat can be used to prove or satisfy assertions specified in Step Object successCriteria,\nSuccess Action Object criteria, and Failure Action Object criteria", + "type": "object", + "properties": { + "context": { + "description": "A runtime expression used to set the context for the condition to be applied on", + "type": "string" + }, + "condition": { + "description": "The condition to apply", + "type": "string" + } + }, + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "description": "The type of condition to be applied", + "enum": [ + "simple", + "regex", + "jsonpath", + "xpath" + ], + "default": "simple" + } + } + }, + { + "$ref": "#/$defs/criterion-expression-type-object" + } + ], + "required": [ + "condition" + ], + "dependentRequired": { + "type": [ + "context" + ] + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "criterion-expression-type-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0.0#criterion-expression-type-object", + "description": "An object used to describe the type and version of an expression used within a Criterion Object", + "type": "object", + "properties": { + "type": { + "description": "The type of condition to be applied", + "enum": [ + "jsonpath", + "xpath" + ] + }, + "version": { + "description": "A short hand string representing the version of the expression type", + "type": "string" + } + }, + "required": [ + "type", + "version" + ], + "allOf": [ + { + "if": { + "required": [ + "type" + ], + "properties": { + "type": { + "const": "jsonpath" + } + } + }, + "then": { + "properties": { + "version": { + "const": "draft-goessner-dispatch-jsonpath-00" + } + } + } + }, + { + "if": { + "required": [ + "type" + ], + "properties": { + "type": { + "const": "xpath" + } + } + }, + "then": { + "properties": { + "version": { + "enum": [ + "xpath-10", + "xpath-20", + "xpath-30" + ] + } + } + } + } + ], + "$ref": "#/$defs/specification-extensions" + }, + "success-action-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0.0#success-action-object", + "description": "A single success action which describes an action to take upon success of a workflow step", + "type": "object", + "properties": { + "name": { + "description": "The name of the success action", + "type": "string" + }, + "type": { + "description": "The type of action to take", + "enum": [ + "end", + "goto" + ] + }, + "workflowId": { + "description": "The workflowId referencing an existing workflow within the Arazzo description to transfer to upon success of the step", + "$dynamicRef": "#workflowId" + }, + "stepId": { + "description": "The stepId to transfer to upon success of the step", + "$dynamicRef": "#stepId" + }, + "criteria": { + "description": "A list of assertions to determine if this action SHALL be executed", + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { + "$ref": "#/$defs/criterion-object" + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "goto" + } + } + }, + "then": { + "oneOf": [ + { + "required": [ + "workflowId" + ] + }, + { + "required": [ + "stepId" + ] + } + ] + } + } + ], + "required": [ + "name", + "type" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "failure-action-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0.0#failure-action-object", + "description": "A single failure action which describes an action to take upon failure of a workflow step", + "type": "object", + "properties": { + "name": { + "description": "The name of the failure action", + "type": "string" + }, + "type": { + "description": "The type of action to take", + "enum": [ + "end", + "goto", + "retry" + ] + }, + "workflowId": { + "description": "The workflowId referencing an existing workflow within the Arazzo description to transfer to upon failure of the step", + "$dynamicRef": "#workflowId" + }, + "stepId": { + "description": "The stepId to transfer to upon failure of the step", + "$dynamicRef": "#stepId" + }, + "retryAfter": { + "description": "A non-negative decimal indicating the seconds to delay after the step failure before another attempt SHALL be made", + "type": "number", + "minimum": 0 + }, + "retryLimit": { + "description": "A non-negative integer indicating how many attempts to retry the step MAY be attempted before failing the overall step", + "type": "integer", + "minimum": 0 + }, + "criteria": { + "description": "A list of assertions to determine if this action SHALL be executed", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/criterion-object" + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "enum": [ + "goto", + "retry" + ] + } + } + }, + "then": { + "oneOf": [ + { + "required": [ + "workflowId" + ] + }, + { + "required": [ + "stepId" + ] + } + ] + } + }, + { + "if": { + "properties": { + "type": { + "const": "retry" + } + } + }, + "then": { + "required": [ + "retryAfter" + ] + } + } + ], + "required": [ + "name", + "type" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "reusable-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0.0#reusable-object", + "description": "A simple object to allow referencing of objects contained within the Components Object", + "type": "object", + "properties": { + "reference": { + "description": "A runtime expression used to reference the desired object", + "type": "string" + }, + "value": { + "description": "Sets a value of the referenced parameter", + "type": [ + "string", + "boolean", + "object", + "array", + "number", + "null" + ] + } + }, + "required": [ + "reference" + ], + "unevaluatedProperties": false + }, + "parameter-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0.0#parameter-object", + "description": "Describes a single step parameter", + "type": "object", + "properties": { + "name": { + "description": "The name of the parameter", + "type": "string" + }, + "in": { + "description": "The named location of the parameter", + "enum": [ + "path", + "query", + "header", + "cookie", + "body" + ] + }, + "value": { + "description": "The value to pass in the parameter", + "type": [ + "string", + "boolean", + "object", + "array", + "number", + "null" + ] + } + }, + "required": [ + "name", + "value" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "payload-replacement-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0.0#payload-replacement-object", + "description": "Describes a location within a payload (e.g., a request body) and a value to set within the location", + "type": "object", + "properties": { + "target": { + "description": "A JSON Pointer or XPath Expression which MUST be resolved against the request body", + "type": "string" + }, + "value": { + "description": "The value set within the target location", + "type": "string" + } + }, + "required": [ + "target", + "value" + ], + "unevaluatedProperties": false, + "$ref": "#/$defs/specification-extensions" + }, + "components-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0.0#components-object", + "description": "Holds a set of reusable objects for different aspects of the Arazzo Specification", + "type": "object", + "properties": { + "inputs": { + "description": "An object to hold reusable JSON Schema 2020-12 schemas to be referenced from workflow inputs", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "$dynamicRef": "#meta" + } + } + }, + "parameters": { + "description": "An object to hold reusable Parameter Objects", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "$ref": "#/$defs/parameter-object" + } + } + }, + "successActions": { + "description": "An object to hold reusable Success Actions Objects", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "$ref": "#/$defs/success-action-object" + } + } + }, + "failureActions": { + "description": "An object to hold reusable Failure Actions Objects", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "$ref": "#/$defs/failure-action-object" + } + } + } + }, + "unevaluatedProperties": false, + "$ref": "#/$defs/specification-extensions" + }, + "specification-extensions": { + "$comment": "https://spec.openapis.org/arazzo/v1.0.0#specification-extensions", + "description": "While the Arazzo Specification tries to accommodate most use cases, additional data can be added to extend the specification at certain points", + "patternProperties": { + "^x-": true + } + }, + "schema": { + "$comment": "https://spec.openapis.org/arazzo/v1.0.0#schema-object", + "$dynamicAnchor": "meta", + "type": [ + "object", + "boolean" + ] + } + } +} \ No newline at end of file diff --git a/packages/rulesets/src/arazzo/schemas/json-schema/LICENSE b/packages/rulesets/src/arazzo/schemas/json-schema/LICENSE new file mode 100644 index 000000000..397909a84 --- /dev/null +++ b/packages/rulesets/src/arazzo/schemas/json-schema/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-2021 Evgeny Poberezkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/rulesets/src/arazzo/schemas/json-schema/README.md b/packages/rulesets/src/arazzo/schemas/json-schema/README.md new file mode 100644 index 000000000..9d1da6381 --- /dev/null +++ b/packages/rulesets/src/arazzo/schemas/json-schema/README.md @@ -0,0 +1 @@ +The schemas here are based on https://github.com/ajv-validator/ajv with one change related to the validation of the "type" property to yield more useful validation results. diff --git a/packages/rulesets/src/arazzo/schemas/json-schema/draft-2020-12/index.json b/packages/rulesets/src/arazzo/schemas/json-schema/draft-2020-12/index.json new file mode 100644 index 000000000..fc4b1b5b4 --- /dev/null +++ b/packages/rulesets/src/arazzo/schemas/json-schema/draft-2020-12/index.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stoplight.io/json-schema/draft/2020-12", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/content": true + }, + "$dynamicAnchor": "meta", + + "title": "Core and Validation specifications meta-schema", + + "if": { + "type": "object" + }, + "then": { + "allOf": [ + { "$ref": "https://json-schema.org/draft/2020-12/meta/core" }, + { "$ref": "https://json-schema.org/draft/2020-12/meta/applicator" }, + { "$ref": "https://json-schema.org/draft/2020-12/meta/unevaluated" }, + { "$ref": "https://stoplight.io/json-schema/draft/2020-12/meta/validation" }, + { "$ref": "https://json-schema.org/draft/2020-12/meta/meta-data" }, + { "$ref": "https://json-schema.org/draft/2020-12/meta/format-annotation" }, + { "$ref": "https://json-schema.org/draft/2020-12/meta/content" } + ], + "properties": { + "definitions": { + "$ref": "https://json-schema.org/draft/2020-12/schema#/properties/definitions" + }, + "dependencies": { + "$ref": "https://json-schema.org/draft/2020-12/schema#/properties/dependencies" + }, + "$recursiveAnchor": { + "$ref": "https://json-schema.org/draft/2020-12/schema#/properties/%24recursiveAnchor" + }, + "$recursiveRef": { + "$ref": "https://json-schema.org/draft/2020-12/schema#/properties/%24recursiveRef" + } + } + }, + "else": { + "if": { + "type": "boolean" + }, + "then": true, + "else": { + "not": true, + "errorMessage": "\"{{property}}\" property must be a valid Schema Object" + } + }, + "$comment": "This meta-schema also defines keywords that have appeared in previous drafts in order to prevent incompatible extensions as they remain in common use." +} diff --git a/packages/rulesets/src/arazzo/schemas/json-schema/draft-2020-12/validation.json b/packages/rulesets/src/arazzo/schemas/json-schema/draft-2020-12/validation.json new file mode 100644 index 000000000..29c4e927e --- /dev/null +++ b/packages/rulesets/src/arazzo/schemas/json-schema/draft-2020-12/validation.json @@ -0,0 +1,102 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stoplight.io/json-schema/draft/2020-12/meta/validation", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/validation": true + }, + "$dynamicAnchor": "meta", + + "title": "Validation vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "type": { + "if": { + "type": "string" + }, + "then": { + "$ref": "#/$defs/simpleTypes" + }, + "else": { + "if": { + "type": "array" + }, + "then": { + "type": "array", + "items": { "$ref": "#/$defs/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + }, + "else": { + "not": true, + "errorMessage": "\"type\" property must be either a string or an array of strings" + } + } + }, + "const": true, + "enum": { + "type": "array", + "items": true + }, + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "maxLength": { "$ref": "#/$defs/nonNegativeInteger" }, + "minLength": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "pattern": { + "type": "string", + "format": "regex" + }, + "maxItems": { "$ref": "#/$defs/nonNegativeInteger" }, + "minItems": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "maxContains": { "$ref": "#/$defs/nonNegativeInteger" }, + "minContains": { + "$ref": "#/$defs/nonNegativeInteger", + "default": 1 + }, + "maxProperties": { "$ref": "#/$defs/nonNegativeInteger" }, + "minProperties": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "required": { "$ref": "#/$defs/stringArray" }, + "dependentRequired": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/stringArray" + } + } + }, + "$defs": { + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "$ref": "#/$defs/nonNegativeInteger", + "default": 0 + }, + "simpleTypes": { + "enum": ["array", "boolean", "integer", "null", "number", "object", "string"] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "default": [] + } + } +} diff --git a/packages/rulesets/src/index.ts b/packages/rulesets/src/index.ts index 8f5b53792..230b7e8d4 100644 --- a/packages/rulesets/src/index.ts +++ b/packages/rulesets/src/index.ts @@ -1,4 +1,5 @@ import { default as oas } from './oas'; import { default as asyncapi } from './asyncapi'; +import { default as arazzo } from './arazzo'; -export { oas, asyncapi }; +export { oas, asyncapi, arazzo }; From 636c0311717dca91d459e76158f09cea64860c1d Mon Sep 17 00:00:00 2001 From: Frank Kilcommins Date: Wed, 14 Aug 2024 22:42:02 +0100 Subject: [PATCH 03/28] chore(rulesets): adjust Parameter Validation --- .../arazzoStepParametersValidation.test.ts | 30 ++----- .../functions/utils/getAllParameters.ts | 86 ++++++++++++++++--- 2 files changed, 83 insertions(+), 33 deletions(-) diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepParametersValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepParametersValidation.test.ts index eab18566f..f16f65a92 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepParametersValidation.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepParametersValidation.test.ts @@ -127,7 +127,7 @@ describe('arazzoStepParametersValidation', () => { expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ - message: `"param1" with "in" value "query" must be unique within the combined parameters.`, + message: `Duplicate parameter: "param1" must be unique within the combined parameters.`, path: ['steps', 0, 'parameters', 1], }); }); @@ -144,12 +144,12 @@ describe('arazzoStepParametersValidation', () => { expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ - message: `"param1" with "in" value "query" must be unique within the combined parameters.`, - path: ['steps', 0, 'parameters', 0], + message: `Duplicate parameter: "param1" must be unique within the combined parameters.`, + path: ['steps', 0, 'parameters', 1], }); }); - test('should report an error for combined duplicate parameters from step and workflow level', () => { + test('should handle combined duplicate parameters from step and workflow level (override scenario)', () => { const results = runRule({ steps: [ { @@ -161,11 +161,7 @@ describe('arazzoStepParametersValidation', () => { components: { parameters: {} }, }); - expect(results).toHaveLength(1); - expect(results[0]).toMatchObject({ - message: `Duplicate parameter: "param1" must be unique within the combined parameters.`, - path: ['steps', 0, 'parameters', 1], - }); + expect(results).toHaveLength(0); }); test('should report an error for mixed "in" presence when "workflowId" is present', () => { @@ -224,7 +220,7 @@ describe('arazzoStepParametersValidation', () => { }); }); - test('should report an error for combined duplicate parameters from step and workflow with "in" when "operationId" is specified', () => { + test('should handle combined duplicate parameters from step and workflow with "in" when "operationId" is specified (override scenario)', () => { const results = runRule({ steps: [ { @@ -236,11 +232,7 @@ describe('arazzoStepParametersValidation', () => { components: { parameters: {} }, }); - expect(results).toHaveLength(1); - expect(results[0]).toMatchObject({ - message: `Duplicate parameter: "param1" must be unique within the combined parameters.`, - path: ['steps', 0, 'parameters', 1], - }); + expect(results).toHaveLength(0); }); test('should handle combined parameters from step and workflow with "in" when "operationId" is specified', () => { @@ -265,7 +257,7 @@ describe('arazzoStepParametersValidation', () => { expect(results).toHaveLength(0); }); - test('should report an error for combined duplicate parameters from step and workflow with "in" when "operationPath" is specified', () => { + test('should handle combined duplicate parameters from step and workflow with "in" when "operationPath" is specified (override scenario)', () => { const results = runRule({ steps: [ { @@ -277,10 +269,6 @@ describe('arazzoStepParametersValidation', () => { components: { parameters: {} }, }); - expect(results).toHaveLength(1); - expect(results[0]).toMatchObject({ - message: `Duplicate parameter: "param1" must be unique within the combined parameters.`, - path: ['steps', 0, 'parameters', 1], - }); + expect(results).toHaveLength(0); }); }); diff --git a/packages/rulesets/src/arazzo/functions/utils/getAllParameters.ts b/packages/rulesets/src/arazzo/functions/utils/getAllParameters.ts index 522f917bb..e16df8742 100644 --- a/packages/rulesets/src/arazzo/functions/utils/getAllParameters.ts +++ b/packages/rulesets/src/arazzo/functions/utils/getAllParameters.ts @@ -14,7 +14,8 @@ type Step = { }; type Workflow = { - parameters?: Parameter[]; + parameters?: (Parameter | ReusableObject)[]; + components?: { parameters?: Record }; }; const resolveReusableParameters = ( @@ -25,35 +26,96 @@ const resolveReusableParameters = ( return components[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, components: Record, ): Parameter[] { const resolvedParameters: Parameter[] = []; + const resolvedStepParameters: Parameter[] = []; + + if (workflow.parameters) { + workflow.parameters.forEach(param => { + let paramToPush = param; - if (step.parameters) { - step.parameters.forEach(param => { if (isPlainObject(param) && 'reference' in param) { const resolvedParam = resolveReusableParameters(param, components); if (resolvedParam) { - resolvedParameters.push(resolvedParam); + paramToPush = resolvedParam; + } + } + + if (isParameter(paramToPush)) { + const isDuplicate = resolvedParameters.some( + existingParam => + isParameter(existingParam) && + isParameter(paramToPush) && + existingParam.name === paramToPush.name && + (existingParam.in ?? '') === (paramToPush.in ?? ''), + ); + + if (isDuplicate) { + paramToPush = { + ...paramToPush, + name: `masked-duplicate-${String(paramToPush.name)}`, + }; } - } else { - resolvedParameters.push(param); + + resolvedParameters.push(paramToPush); } }); } - if (workflow.parameters) { - workflow.parameters.forEach(param => { - if (resolvedParameters.some(p => p.name === param.name && p.in === param.in)) { - // Tag duplicate parameter - param.name = `masked-duplicate-${param.name}`; + if (step.parameters) { + step.parameters.forEach(param => { + let paramToPush = param; + + if (isPlainObject(param) && 'reference' in param) { + const resolvedParam = resolveReusableParameters(param, components); + if (resolvedParam) { + paramToPush = resolvedParam; + } + } + + if (isParameter(paramToPush)) { + const isDuplicate = resolvedStepParameters.some( + existingParam => + isParameter(existingParam) && + isParameter(paramToPush) && + existingParam.name === paramToPush.name && + (existingParam.in ?? '') === (paramToPush.in ?? ''), + ); + + if (isDuplicate) { + paramToPush = { + ...paramToPush, + name: `masked-duplicate-${String(paramToPush.name)}`, + }; + } + + resolvedStepParameters.push(paramToPush); } - resolvedParameters.push(param); }); } + 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; } From 1803581bbaec42ea54bc1a0b1e639c6c6a0dbf7c Mon Sep 17 00:00:00 2001 From: Frank Kilcommins Date: Fri, 16 Aug 2024 09:38:29 +0100 Subject: [PATCH 04/28] feat(rulesets): add initial arazzo runtime expression validation --- .../arazzoStepOutputNamesValidation.test.ts | 70 ++++++++++++++++--- .../arazzoRuntimeExpressionValidation.ts | 38 ++++++++++ .../arazzoStepFailureActionsValidation.ts | 2 +- .../arazzoStepOutputNamesValidation.ts | 25 ++++++- .../arazzoStepSuccessActionsValidation.ts | 2 +- .../rulesets/src/arazzo/functions/index.ts | 8 +++ packages/rulesets/src/arazzo/index.ts | 30 ++++++++ 7 files changed, 160 insertions(+), 15 deletions(-) create mode 100644 packages/rulesets/src/arazzo/functions/arazzoRuntimeExpressionValidation.ts diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepOutputNamesValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepOutputNamesValidation.test.ts index 3592a49e0..15e04ea0b 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepOutputNamesValidation.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepOutputNamesValidation.test.ts @@ -28,11 +28,11 @@ describe('arazzoStepOutputNamesValidation', () => { steps: [ { outputs: [ - ['output1', 'value1'], - ['output2', 'value2'], + ['output1', '$url'], + ['output2', '$response.body#/status'], ], }, - { outputs: [['output3', 'value3']] }, + { outputs: [['output3', '$steps.foo.outputs.bar']] }, ], }); @@ -44,8 +44,8 @@ describe('arazzoStepOutputNamesValidation', () => { steps: [ { outputs: [ - ['invalid name', 'value1'], - ['output2', 'value2'], + ['invalid name', '$url'], + ['output2', '$statusCode'], ], }, ], @@ -63,9 +63,9 @@ describe('arazzoStepOutputNamesValidation', () => { steps: [ { outputs: [ - ['output1', 'value1'], - ['output2', 'value2'], - ['output1', 'value3'], + ['output1', '$statusCode'], + ['output2', '$url'], + ['output1', '$statusCode'], ], }, // Duplicate key simulated here ], @@ -81,11 +81,61 @@ describe('arazzoStepOutputNamesValidation', () => { test('should not report an error for duplicate output names across different steps', () => { const results = runRule({ steps: [ - { outputs: [['output1', 'value1']] }, - { outputs: [['output1', 'value2']] }, // Duplicate output name across different steps + { outputs: [['output1', '$response.body']] }, + { outputs: [['output1', '$response.body']] }, // Duplicate output name across different steps ], }); expect(results).toHaveLength(0); }); + + test('should not report any errors for valid runtime expressions', () => { + const results = runRule({ + steps: [ + { + outputs: [ + ['output1', '$response.body#/status'], + ['output2', '$steps.step1.outputs.value'], + ], + }, + ], + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for invalid runtime expressions', () => { + const results = runRule({ + steps: [ + { + outputs: [['output1', 'invalid expression']], + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"invalid expression" is not a valid runtime expression.`, + path: ['steps', 0, 'outputs', 'output1'], + }); + }); + + test('should handle valid and invalid expressions mixed', () => { + const results = runRule({ + steps: [ + { + outputs: [ + ['validOutput', '$response.body#/status'], + ['invalidOutput', 'invalid expression'], + ], + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"invalid expression" is not a valid runtime expression.`, + path: ['steps', 0, 'outputs', 'invalidOutput'], + }); + }); }); diff --git a/packages/rulesets/src/arazzo/functions/arazzoRuntimeExpressionValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoRuntimeExpressionValidation.ts new file mode 100644 index 000000000..3bb473457 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoRuntimeExpressionValidation.ts @@ -0,0 +1,38 @@ +type Workflow = { + steps: Step[]; +}; + +type Step = { + stepId: string; + outputs?: { [key: string]: string }; +}; + +function arazzoRuntimeExpressionValidation(expression: string, _workflow: Workflow): boolean { + const validPrefixes = [ + '$url', + '$method', + '$statusCode', + '$request.', + '$response.', + '$message.', + '$inputs.', + '$outputs.', + '$steps.', + '$workflows.', + '$sourceDescriptions.', + '$components.', + '$components.parameters.', + ]; + const isValidPrefix = validPrefixes.some(prefix => expression.startsWith(prefix)); + + if (!isValidPrefix) { + return false; + } + + // ToDo: Advanced validation logic can be added here + // For example, validate $steps.foo.outputs.bar references + + return true; +} + +export default arazzoRuntimeExpressionValidation; diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts index d786ad8c5..cbdadc8f4 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts @@ -32,7 +32,7 @@ type Workflow = { components?: { failureActions?: Record }; }; -export default function validateFailureActions(target: Workflow, _options: null): IFunctionResult[] { +export default function arazzoStepFailureActionsValidation(target: Workflow, _options: null): IFunctionResult[] { const results: IFunctionResult[] = []; const components = target.components?.failureActions ?? {}; diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepOutputNamesValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepOutputNamesValidation.ts index 443fe6d61..539da013b 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoStepOutputNamesValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoStepOutputNamesValidation.ts @@ -1,8 +1,18 @@ import { createRulesetFunction, IFunctionResult } from '@stoplight/spectral-core'; import type { JsonPath } from '@stoplight/types'; +import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidation'; const OUTPUT_NAME_PATTERN = /^[a-zA-Z0-9.\-_]+$/; +type Workflow = { + steps: Step[]; +}; + +type Step = { + stepId: string; + outputs?: { [key: string]: string }; +}; + export default createRulesetFunction< { steps: Array<{ outputs?: [string, string][] }> }, // Updated type to accept array of entries null @@ -32,15 +42,15 @@ export default createRulesetFunction< }, options: null, }, - function arazzoStepOutputNamesValidation(targetVal) { + function arazzoStepOutputNamesValidation(targetVal, _opts, context) { const results: IFunctionResult[] = []; targetVal.steps.forEach((step, stepIndex) => { if (step.outputs) { const seenOutputNames = new Set(); - step.outputs.forEach(([outputName]) => { - // Destructure entries directly + step.outputs.forEach(([outputName, outputValue]) => { + // Validate output name if (!OUTPUT_NAME_PATTERN.test(outputName)) { results.push({ message: `"${outputName}" does not match the required pattern "^[a-zA-Z0-9.\\-_]+$".`, @@ -48,6 +58,7 @@ export default createRulesetFunction< }); } + // Check for uniqueness within the step if (seenOutputNames.has(outputName)) { results.push({ message: `"${outputName}" must be unique within the step outputs.`, @@ -56,6 +67,14 @@ export default createRulesetFunction< } else { seenOutputNames.add(outputName); } + + // Validate runtime expression + if (!arazzoRuntimeExpressionValidation(outputValue, context.document as unknown as Workflow)) { + results.push({ + message: `"${outputValue}" is not a valid runtime expression.`, + path: ['steps', stepIndex, 'outputs', outputName] as JsonPath, + }); + } }); } }); diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts index e366748f7..f8fcdf62b 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts @@ -30,7 +30,7 @@ type Workflow = { components?: { successActions?: Record }; }; -export default function validateSuccessActions(target: Workflow, _options: null): IFunctionResult[] { +export default function arazzoStepSuccessActionsValidation(target: Workflow, _options: null): IFunctionResult[] { const results: IFunctionResult[] = []; const components = target.components?.successActions ?? {}; diff --git a/packages/rulesets/src/arazzo/functions/index.ts b/packages/rulesets/src/arazzo/functions/index.ts index f81cf6020..46743290b 100644 --- a/packages/rulesets/src/arazzo/functions/index.ts +++ b/packages/rulesets/src/arazzo/functions/index.ts @@ -2,10 +2,18 @@ import { default as arazzoWorkflowIdUniqueness } from './arazzoWorkflowIdUniquen 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'; export { arazzoWorkflowIdUniqueness, arazzoWorkflowOutputNamesValidation, arazzoStepIdUniqueness, arazzoStepOutputNamesValidation, + arazzoStepParametersValidation, + arazzoStepFailureActionsValidation, + arazzoStepSuccessActionsValidation, + arazzoRuntimeExpressionValidation, }; diff --git a/packages/rulesets/src/arazzo/index.ts b/packages/rulesets/src/arazzo/index.ts index a89a8c820..e59bbac85 100644 --- a/packages/rulesets/src/arazzo/index.ts +++ b/packages/rulesets/src/arazzo/index.ts @@ -4,6 +4,9 @@ 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'; export default { documentationUrl: 'https://meta.stoplight.io/docs/spectral/docs/reference/arazzo-rules.md', @@ -45,5 +48,32 @@ export default { function: arazzoStepOutputNamesValidation, }, }, + 'arazzo-step-parameters-validation': { + description: 'Step parameters and workflow parameters must be independently unique.', + recommended: true, + severity: 0, + given: '$.workflow[*]', + then: { + function: arazzoStepParametersValidation, + }, + }, + 'arazzo-step-failure-actions-validation': { + description: 'Every failure action must have a unique name and "workflowId" and "stepId" are mutually exclusive.', + recommended: true, + severity: 0, + given: '$.workflows[*]', + then: { + function: arazzoStepFailureActionsValidation, + }, + }, + 'arazzo-step-success-actions-validation': { + description: 'Every success action must have a unique name and "workflowId" and "stepId" are mutually exclusive.', + recommended: true, + severity: 0, + given: '$.workflows[*]', + then: { + function: arazzoStepSuccessActionsValidation, + }, + }, }, }; From 3a0a46a669893ff963a80761b7b94708b21672ec Mon Sep 17 00:00:00 2001 From: Frank Kilcommins Date: Fri, 16 Aug 2024 11:07:34 +0100 Subject: [PATCH 05/28] feat(rulesets): add workflow dependsOn validation for Arazzo --- ...arazzoWorkflowsDependsOnValidation.test.ts | 104 ++++++++++++++++++ .../arazzoWorkflowDependsOnValidation.ts | 88 +++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowsDependsOnValidation.test.ts create mode 100644 packages/rulesets/src/arazzo/functions/arazzoWorkflowDependsOnValidation.ts 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..9fc79d076 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowsDependsOnValidation.test.ts @@ -0,0 +1,104 @@ +import arazzoWorkflowDependsOnValidation from '../arazzoWorkflowDependsOnValidation'; +import { IFunctionResult } from '@stoplight/spectral-core'; + +type SourceDescription = { + name: string; + url: string; + type?: string; +}; + +type Workflow = { + workflowId: string; + dependsOn?: string[]; +}; + +type Document = { + workflows: Workflow[]; + sourceDescriptions: SourceDescription[]; +}; + +const runRule = (target: Document): IFunctionResult[] => { + return arazzoWorkflowDependsOnValidation(target, null); +}; + +describe('arazzoWorkflowDependsOnValidation', () => { + test('should not report any errors for valid dependsOn references', () => { + const results = runRule({ + workflows: [{ workflowId: 'workflow1' }, { workflowId: 'workflow2', dependsOn: ['workflow1'] }], + 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' }, { workflowId: 'workflow2', dependsOn: ['workflow1', 'workflow1'] }], + 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' }, { workflowId: 'workflow2', dependsOn: ['workflow3'] }], + 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' }, + { workflowId: 'workflow2', dependsOn: ['$sourceDescriptions.nonExistent.workflow3'] }, + ], + 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' }, { workflowId: 'workflow2', dependsOn: ['$sourceDescriptions.source1'] }], + 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' }, + { workflowId: 'workflow2', dependsOn: ['$sourceDescriptions.source1.workflow3'] }, + ], + 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], + }); + }); +}); diff --git a/packages/rulesets/src/arazzo/functions/arazzoWorkflowDependsOnValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoWorkflowDependsOnValidation.ts new file mode 100644 index 000000000..3f6c1c3ee --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoWorkflowDependsOnValidation.ts @@ -0,0 +1,88 @@ +import { IFunctionResult } from '@stoplight/spectral-core'; +import { getAllWorkflows } from './utils/getAllWorkflows'; + +type SourceDescription = { + name: string; + url: string; + type?: string; +}; + +type Workflow = { + workflowId: string; + dependsOn?: string[]; +}; + +type Document = { + workflows: Workflow[]; + sourceDescriptions: SourceDescription[]; +}; + +export default function arazzoWorkflowDependsOnValidation(targetVal: Document, _options: null): IFunctionResult[] { + const results: IFunctionResult[] = []; + const localWorkflowIds = new Set(); + const sourceDescriptionNames = new Map(targetVal.sourceDescriptions.map(sd => [sd.name, sd.type])); + + for (const { workflow } of getAllWorkflows(targetVal)) { + if ('workflowId' in workflow && typeof workflow.workflowId === 'string') { + localWorkflowIds.add(workflow.workflowId); + } + } + + for (const { workflow, path } of getAllWorkflows(targetVal)) { + const seenWorkflows = new Set(); + + if (Boolean(workflow.dependsOn) && 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 as string}".`, + path: [...path, 'dependsOn', depIndex], + }); + return; + } else { + seenWorkflows.add(dep); + } + + // 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) { + 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 as string}".`, + path: [...path, 'dependsOn', depIndex], + }); + } + } + }); + } + } + + return results; +} From 337b23f35a568c2427c5af2ff124c35727faef0d Mon Sep 17 00:00:00 2001 From: Frank Kilcommins Date: Fri, 16 Aug 2024 12:49:56 +0100 Subject: [PATCH 06/28] chore(rulesets): adjust null check --- .../src/arazzo/functions/arazzoWorkflowDependsOnValidation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rulesets/src/arazzo/functions/arazzoWorkflowDependsOnValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoWorkflowDependsOnValidation.ts index 3f6c1c3ee..f47963226 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoWorkflowDependsOnValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoWorkflowDependsOnValidation.ts @@ -55,7 +55,7 @@ export default function arazzoWorkflowDependsOnValidation(targetVal: Document, _ const workflowId = parts[2] as string | undefined; const sourceType = sourceDescriptionNames.get(sourceName); - if (!sourceType) { + if (sourceType == null) { results.push({ message: `Source description "${sourceName}" not found for workflowId "${dep}".`, path: [...path, 'dependsOn', depIndex], From 2b121c73e48820ece68f997ecbb7dff1cbf81ee2 Mon Sep 17 00:00:00 2001 From: Frank Kilcommins Date: Fri, 16 Aug 2024 15:10:48 +0100 Subject: [PATCH 07/28] feat(rulesets): add Criterion Validation for Arazzo --- ...razzoStepSuccessCriteriaValidation.test.ts | 75 +++++++++++++++++ .../functions/arazzoCriterionValidation.ts | 80 +++++++++++++++++++ .../arazzoStepSuccessCriteriaValidation.ts | 41 ++++++++++ .../rulesets/src/arazzo/functions/index.ts | 6 ++ packages/rulesets/src/arazzo/index.ts | 20 +++++ 5 files changed, 222 insertions(+) create mode 100644 packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessCriteriaValidation.test.ts create mode 100644 packages/rulesets/src/arazzo/functions/arazzoCriterionValidation.ts create mode 100644 packages/rulesets/src/arazzo/functions/arazzoStepSuccessCriteriaValidation.ts 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..5a2336f46 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessCriteriaValidation.test.ts @@ -0,0 +1,75 @@ +import arazzoStepSuccessCriteriaValidation from '../arazzoStepSuccessCriteriaValidation'; +import type { RulesetFunctionContext } from '@stoplight/spectral-core'; + +type CriterionExpressionType = { + type: 'jsonpath' | 'xpath'; + version: 'draft-goessner-dispatch-jsonpath-00' | 'xpath-30' | 'xpath-20' | 'xpath-10'; +}; + +type Criterion = { + context?: string; + condition: string; + type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; +}; + +type Step = { + stepId: string; + successCriteria?: Criterion[]; +}; + +type Workflow = { + steps: Step[]; +}; + +const runRule = (target: Workflow, _contextOverrides: Partial = {}) => { + return arazzoStepSuccessCriteriaValidation(target, null); +}; + +describe('arazzoStepSuccessCriteriaValidation', () => { + test('should not report any errors for valid success criteria', () => { + const results = runRule({ + 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({ + 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: ['steps', 0, 'successCriteria', 0, 'context'], + }); + }); + + test('should report an error for missing condition in success criteria', () => { + const results = runRule({ + 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: ['steps', 0, 'successCriteria', 0, 'condition'], + }); + }); +}); diff --git a/packages/rulesets/src/arazzo/functions/arazzoCriterionValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoCriterionValidation.ts new file mode 100644 index 000000000..388bed8fd --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoCriterionValidation.ts @@ -0,0 +1,80 @@ +import { IFunctionResult } from '@stoplight/spectral-core'; +import validateRuntimeExpression from './arazzoRuntimeExpressionValidation'; + +type CriterionExpressionType = { + type: 'jsonpath' | 'xpath'; + version: 'draft-goessner-dispatch-jsonpath-00' | 'xpath-30' | 'xpath-20' | 'xpath-10'; +}; + +type Criterion = { + context?: string; + condition: string; + type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; +}; + +type Step = { + stepId: string; + successCriteria?: Criterion[]; +}; + +type Workflow = { + steps: Step[]; +}; + +export default function arazzoCriterionValidation( + criterion: Criterion, + contextPath: (string | number)[], + workflow: Workflow, // Assuming you have access to the Workflow or document object +): 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, workflow)) { + 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/arazzoStepSuccessCriteriaValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepSuccessCriteriaValidation.ts new file mode 100644 index 000000000..70a73acd7 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoStepSuccessCriteriaValidation.ts @@ -0,0 +1,41 @@ +import { IFunctionResult } from '@stoplight/spectral-core'; +import arazzoCriterionValidation from './arazzoCriterionValidation'; + +type CriterionExpressionType = { + type: 'jsonpath' | 'xpath'; + version: 'draft-goessner-dispatch-jsonpath-00' | 'xpath-30' | 'xpath-20' | 'xpath-10'; +}; + +type Criterion = { + context?: string; + condition: string; + type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; +}; + +type Step = { + stepId: string; + successCriteria?: Criterion[]; +}; + +type Workflow = { + steps: Step[]; +}; + +export default function validateSuccessCriteria(targetVal: Workflow, _options: null): IFunctionResult[] { + const results: IFunctionResult[] = []; + + targetVal.steps.forEach((step, stepIndex) => { + if (step.successCriteria) { + step.successCriteria.forEach((criterion, criterionIndex) => { + const criterionResults = arazzoCriterionValidation( + criterion, + ['steps', stepIndex, 'successCriteria', criterionIndex], + targetVal, + ); + results.push(...criterionResults); + }); + } + }); + + return results; +} diff --git a/packages/rulesets/src/arazzo/functions/index.ts b/packages/rulesets/src/arazzo/functions/index.ts index 46743290b..b36c8f5ea 100644 --- a/packages/rulesets/src/arazzo/functions/index.ts +++ b/packages/rulesets/src/arazzo/functions/index.ts @@ -6,6 +6,9 @@ import { default as arazzoStepParametersValidation } from './arazzoStepParameter 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'; export { arazzoWorkflowIdUniqueness, @@ -16,4 +19,7 @@ export { arazzoStepFailureActionsValidation, arazzoStepSuccessActionsValidation, arazzoRuntimeExpressionValidation, + arazzoWorkflowDependsOnValidation, + arazzoCriterionValidation, + arazzoStepSuccessCriteriaValidation, }; diff --git a/packages/rulesets/src/arazzo/index.ts b/packages/rulesets/src/arazzo/index.ts index e59bbac85..a7b914dbf 100644 --- a/packages/rulesets/src/arazzo/index.ts +++ b/packages/rulesets/src/arazzo/index.ts @@ -7,6 +7,8 @@ import arazzoStepOutputNamesValidation from './functions/arazzoStepOutputNamesVa 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'; export default { documentationUrl: 'https://meta.stoplight.io/docs/spectral/docs/reference/arazzo-rules.md', @@ -75,5 +77,23 @@ export default { function: arazzoStepSuccessActionsValidation, }, }, + 'arazzo-workflow-depends-on-validation': { + description: 'Every workflow dependency must be valid.', + recommended: true, + severity: 0, + given: '$.workflows[*]', + then: { + function: arazzoWorkflowDependsOnValidation, + }, + }, + 'arazzo-step-success-criteria-validation': { + description: 'Every success criteria must have a valid context, conditions, and types.', + recommended: true, + severity: 0, + given: '$.workflows[*]', + then: { + function: arazzoStepSuccessCriteriaValidation, + }, + }, }, }; From ceea177caf6b2d74bea68d588a729a6d1ebbe27f Mon Sep 17 00:00:00 2001 From: Frank Kilcommins Date: Fri, 16 Aug 2024 16:19:23 +0100 Subject: [PATCH 08/28] feat(rulesets): add Step Request Body Validation for Arazzo --- .../arazzoStepRequestBodyValidation.test.ts | 143 ++++++++++++++++++ .../arazzoRuntimeExpressionValidation.ts | 2 +- .../arazzoStepRequestBodyValidation.ts | 116 ++++++++++++++ 3 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 packages/rulesets/src/arazzo/functions/__tests__/arazzoStepRequestBodyValidation.test.ts create mode 100644 packages/rulesets/src/arazzo/functions/arazzoStepRequestBodyValidation.ts 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..fe0ad8b80 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepRequestBodyValidation.test.ts @@ -0,0 +1,143 @@ +import validateRequestBody from '../arazzoStepRequestBodyValidation'; +import { DeepPartial } from '@stoplight/types'; +import type { RulesetFunctionContext } from '@stoplight/spectral-core'; + +const runRule = ( + target: { + steps: Array<{ + requestBody?: { + contentType?: string; + payload?: unknown; + replacements?: Array<{ target: string; value: unknown }>; + }; + }>; + }, + 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 validateRequestBody(target, null, context as RulesetFunctionContext); +}; + +describe('validateRequestBody', () => { + test('should not report any errors for valid requestBody', () => { + const results = runRule({ + steps: [ + { + requestBody: { + contentType: 'application/json', + payload: { key: 'value' }, + replacements: [{ target: '/key', value: 'newValue' }], + }, + }, + ], + }); + + expect(results).toHaveLength(0); + }); + + test('should report an error for invalid MIME type in contentType', () => { + const results = runRule({ + steps: [ + { + requestBody: { + contentType: 'invalid/type', + payload: { key: 'value' }, + }, + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: 'Invalid MIME type in contentType: invalid/type', + path: ['steps', 0, 'requestBody', 'contentType'], + }); + }); + + test('should report an error for invalid runtime expression in payload', () => { + const results = runRule({ + steps: [ + { + requestBody: { + contentType: 'application/json', + payload: '$invalid.runtime.expression', + }, + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: 'Invalid runtime expression in payload: $invalid.runtime.expression', + path: ['steps', 0, 'requestBody', 'payload'], + }); + }); + + test('should report an error for missing target in Payload Replacement', () => { + const results = runRule({ + steps: [ + { + requestBody: { + contentType: 'application/json', + payload: { key: 'value' }, + replacements: [{ target: '', value: 'newValue' }], + }, + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: '"target" is required in Payload Replacement.', + path: ['steps', 0, 'requestBody', 'replacements', 0, 'target'], + }); + }); + + test('should report an error for invalid runtime expression in replacement value', () => { + const results = runRule({ + steps: [ + { + requestBody: { + contentType: 'application/json', + payload: { key: 'value' }, + replacements: [{ target: '/key', value: '$invalid.runtime.expression' }], + }, + }, + ], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: 'Invalid runtime expression in replacement value: $invalid.runtime.expression', + path: ['steps', 0, 'requestBody', 'replacements', 0, 'value'], + }); + }); + + test('should not report any errors for valid runtime expressions in payload and replacements', () => { + const results = runRule({ + steps: [ + { + requestBody: { + contentType: 'application/json', + payload: '$inputs.validExpression', + replacements: [{ target: '/key', value: '$outputs.someOutput' }], + }, + }, + ], + }); + + expect(results).toHaveLength(0); + }); +}); diff --git a/packages/rulesets/src/arazzo/functions/arazzoRuntimeExpressionValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoRuntimeExpressionValidation.ts index 3bb473457..d0a299999 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoRuntimeExpressionValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoRuntimeExpressionValidation.ts @@ -7,7 +7,7 @@ type Step = { outputs?: { [key: string]: string }; }; -function arazzoRuntimeExpressionValidation(expression: string, _workflow: Workflow): boolean { +function arazzoRuntimeExpressionValidation(expression: string, _workflow?: Workflow): boolean { const validPrefixes = [ '$url', '$method', diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepRequestBodyValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepRequestBodyValidation.ts new file mode 100644 index 000000000..231c94aae --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoStepRequestBodyValidation.ts @@ -0,0 +1,116 @@ +import { IFunctionResult } from '@stoplight/spectral-core'; +import { createRulesetFunction } from '@stoplight/spectral-core'; +import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidation'; + +type PayloadReplacement = { + target: string; + value: unknown | string; +}; + +type RequestBody = { + contentType?: string; + payload?: unknown | string; + replacements?: PayloadReplacement[]; +}; + +const MIME_TYPE_REGEX = + /^(application|audio|font|example|image|message|model|multipart|text|video)\/[a-zA-Z0-9!#$&^_.+-]{1,127}$/; + +export default createRulesetFunction<{ steps: Array<{ requestBody?: RequestBody }> }, null>( + { + input: { + type: 'object', + properties: { + steps: { + type: 'array', + items: { + type: 'object', + properties: { + requestBody: { + type: 'object', + properties: { + contentType: { type: 'string' }, + payload: { type: ['object', 'string', 'number', 'boolean', 'array', 'null'] }, + replacements: { + type: 'array', + items: { + type: 'object', + properties: { + target: { type: 'string' }, + value: { type: ['object', 'string', 'number', 'boolean', 'array', 'null'] }, + }, + required: ['target', 'value'], + }, + }, + }, + }, + }, + }, + }, + }, + }, + options: null, + }, + function validateRequestBody(targetVal) { + const results: IFunctionResult[] = []; + + //const ctx: RulesetFunctionContext = context as unknown as RulesetFunctionContext; + + if (!Array.isArray(targetVal.steps)) { + return results; // No steps to validate + } + + targetVal.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: ['steps', stepIndex, 'requestBody', 'contentType'], + }); + } + + // Validate payload + if (Boolean(requestBody.payload) && typeof requestBody.payload === 'string') { + if (!arazzoRuntimeExpressionValidation(requestBody.payload)) { + results.push({ + message: `Invalid runtime expression in payload: ${requestBody.payload}`, + path: ['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: ['steps', stepIndex, 'requestBody', 'replacements', replacementIndex, 'target'], + }); + } + + if ( + Boolean(replacement.value) && + typeof replacement.value === 'string' && + replacement.value.startsWith('$') + ) { + if (!arazzoRuntimeExpressionValidation(replacement.value)) { + results.push({ + message: `Invalid runtime expression in replacement value: ${replacement.value}`, + path: ['steps', stepIndex, 'requestBody', 'replacements', replacementIndex, 'value'], + }); + } + } + }); + } + }); + + return results; + }, +); From 2d10bb3047bca771ab2eb8d726bfccbe0edc2af1 Mon Sep 17 00:00:00 2001 From: Frank Kilcommins Date: Fri, 16 Aug 2024 16:42:48 +0100 Subject: [PATCH 09/28] chore(rulesets): improve runtime validation for dependsOn --- .../arazzoWorkflowsDependsOnValidation.test.ts | 13 +++++++++++++ .../functions/arazzoWorkflowDependsOnValidation.ts | 10 ++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowsDependsOnValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowsDependsOnValidation.test.ts index 9fc79d076..c8edd1c3b 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowsDependsOnValidation.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowsDependsOnValidation.test.ts @@ -101,4 +101,17 @@ describe('arazzoWorkflowDependsOnValidation', () => { path: ['workflows', 1, 'dependsOn', 0], }); }); + + test('should report an error for invalid runtime expression in dependsOn', () => { + const results = runRule({ + workflows: [{ workflowId: 'workflow1' }, { workflowId: 'workflow2', dependsOn: ['$invalid.source1.expression'] }], + 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/arazzoWorkflowDependsOnValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoWorkflowDependsOnValidation.ts index f47963226..d5875be5c 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoWorkflowDependsOnValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoWorkflowDependsOnValidation.ts @@ -1,5 +1,6 @@ import { IFunctionResult } from '@stoplight/spectral-core'; import { getAllWorkflows } from './utils/getAllWorkflows'; +import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidation'; type SourceDescription = { name: string; @@ -48,6 +49,15 @@ export default function arazzoWorkflowDependsOnValidation(targetVal: Document, _ seenWorkflows.add(dep); } + if (dep.startsWith('$')) { + if (!arazzoRuntimeExpressionValidation(dep)) { + 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('.'); From f9bd5adbeba34d6409aee54422c513894368686c Mon Sep 17 00:00:00 2001 From: Frank Kilcommins Date: Fri, 16 Aug 2024 17:17:35 +0100 Subject: [PATCH 10/28] feat(rulesets): add criteria validation to success and failure actions for Arazzo --- ...arazzoStepFailureActionsValidation.test.ts | 92 +++++++++++++++++++ ...arazzoStepSuccessActionsValidation.test.ts | 92 +++++++++++++++++++ .../arazzoStepFailureActionsValidation.ts | 28 +++++- .../arazzoStepSuccessActionsValidation.ts | 28 +++++- 4 files changed, 232 insertions(+), 8 deletions(-) diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepFailureActionsValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepFailureActionsValidation.test.ts index 453b32f7d..ffda9a867 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepFailureActionsValidation.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepFailureActionsValidation.test.ts @@ -11,8 +11,15 @@ type FailureAction = { criteria?: Criterion[]; }; +type CriterionExpressionType = { + type: 'jsonpath' | 'xpath'; + version: 'draft-goessner-dispatch-jsonpath-00' | 'xpath-30' | 'xpath-20' | 'xpath-10'; +}; + type Criterion = { + context?: string; condition: string; + type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; }; type ReusableObject = { @@ -20,6 +27,7 @@ type ReusableObject = { }; type Step = { + stepId: string; onFailure?: (FailureAction | ReusableObject)[]; }; @@ -42,6 +50,7 @@ describe('validateFailureActions', () => { { name: 'action1', type: 'goto', stepId: 'step1' }, { name: 'action2', type: 'end' }, ], + stepId: 'step1', }, ], components: { failureActions: {} }, @@ -58,6 +67,7 @@ describe('validateFailureActions', () => { { name: 'action1', type: 'goto', stepId: 'step1' }, { name: 'action1', type: 'end' }, ], + stepId: 'step1', }, // Duplicate action name ], components: { failureActions: {} }, @@ -75,6 +85,7 @@ describe('validateFailureActions', () => { steps: [ { onFailure: [{ name: 'action1', type: 'goto', stepId: 'step1', workflowId: 'workflow1' }], + stepId: 'step1', }, ], components: { failureActions: {} }, @@ -92,6 +103,7 @@ describe('validateFailureActions', () => { steps: [ { onFailure: [{ name: 'action1', type: 'goto', stepId: 'step1' }], + stepId: 'step1', }, ], onFailure: [{ name: 'action1', type: 'end' }], @@ -100,4 +112,84 @@ describe('validateFailureActions', () => { expect(results).toHaveLength(0); }); + + test('should report an error for missing condition in Criterion', () => { + const results = runRule({ + steps: [ + { + onFailure: [ + { + name: 'action1', + type: 'goto', + stepId: 'step1', + criteria: [ + { + context: '$response.body', + condition: '', + }, + ], // Missing condition + }, + ], + stepId: 'step1', + }, + ], + components: { failureActions: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `Missing or invalid "condition" in Criterion Object.`, + path: ['steps', 0, 'onFailure', 0, 'criteria', 0, 'condition'], + }); + }); + + test('should report an error for invalid regex pattern in Criterion condition', () => { + const results = runRule({ + steps: [ + { + onFailure: [ + { + name: 'action1', + type: 'goto', + stepId: 'step1', + criteria: [{ context: '$statusCode', condition: '^(200$', type: 'regex' }], // Invalid regex + }, + ], + stepId: 'step1', + }, + ], + components: { failureActions: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"condition" contains an invalid regex pattern.`, + path: ['steps', 0, 'onFailure', 0, 'criteria', 0, 'condition'], + }); + }); + + test('should report an error for missing context when type is specified in Criterion', () => { + const results = runRule({ + steps: [ + { + onFailure: [ + { + name: 'action1', + type: 'goto', + stepId: 'step1', + criteria: [{ condition: '$response.body', type: 'jsonpath' }], // Missing context + }, + ], + stepId: 'step1', + }, + ], + components: { failureActions: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `A "context" must be specified for a Criterion Object with type "jsonpath".`, + path: ['steps', 0, 'onFailure', 0, 'criteria', 0, 'context'], + }); + }); }); diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessActionsValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessActionsValidation.test.ts index 5785064af..21bfe0c6b 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessActionsValidation.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessActionsValidation.test.ts @@ -9,8 +9,15 @@ type SuccessAction = { criteria?: Criterion[]; }; +type CriterionExpressionType = { + type: 'jsonpath' | 'xpath'; + version: 'draft-goessner-dispatch-jsonpath-00' | 'xpath-30' | 'xpath-20' | 'xpath-10'; +}; + type Criterion = { + context?: string; condition: string; + type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; }; type ReusableObject = { @@ -18,6 +25,7 @@ type ReusableObject = { }; type Step = { + stepId: string; onSuccess?: (SuccessAction | ReusableObject)[]; }; @@ -40,6 +48,7 @@ describe('validateSuccessActions', () => { { name: 'action1', type: 'goto', stepId: 'step1' }, { name: 'action2', type: 'end' }, ], + stepId: 'step1', }, ], components: { successActions: {} }, @@ -56,6 +65,7 @@ describe('validateSuccessActions', () => { { name: 'action1', type: 'goto', stepId: 'step1' }, { name: 'action1', type: 'end' }, ], + stepId: 'step1', }, // Duplicate action name ], components: { successActions: {} }, @@ -73,6 +83,7 @@ describe('validateSuccessActions', () => { steps: [ { onSuccess: [{ name: 'action1', type: 'goto', stepId: 'step1', workflowId: 'workflow1' }], + stepId: 'step1', }, ], components: { successActions: {} }, @@ -90,6 +101,7 @@ describe('validateSuccessActions', () => { steps: [ { onSuccess: [{ name: 'action1', type: 'goto', stepId: 'step1' }], + stepId: 'step1', }, ], successActions: [{ name: 'action1', type: 'end' }], @@ -98,4 +110,84 @@ describe('validateSuccessActions', () => { expect(results).toHaveLength(0); }); + + test('should report an error for missing condition in Criterion', () => { + const results = runRule({ + steps: [ + { + onSuccess: [ + { + name: 'action1', + type: 'goto', + stepId: 'step1', + criteria: [ + { + context: '$response.body', + condition: '', + }, + ], // Missing condition + }, + ], + stepId: 'step1', + }, + ], + components: { successActions: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `Missing or invalid "condition" in Criterion Object.`, + path: ['steps', 0, 'onSuccess', 0, 'criteria', 0, 'condition'], + }); + }); + + test('should report an error for invalid regex pattern in Criterion condition', () => { + const results = runRule({ + steps: [ + { + onSuccess: [ + { + name: 'action1', + type: 'goto', + stepId: 'step1', + criteria: [{ context: '$statusCode', condition: '^(200$', type: 'regex' }], // Invalid regex + }, + ], + stepId: 'step1', + }, + ], + components: { successActions: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `"condition" contains an invalid regex pattern.`, + path: ['steps', 0, 'onSuccess', 0, 'criteria', 0, 'condition'], + }); + }); + + test('should report an error for missing context when type is specified in Criterion', () => { + const results = runRule({ + steps: [ + { + onSuccess: [ + { + name: 'action1', + type: 'goto', + stepId: 'step1', + criteria: [{ condition: '$response.body', type: 'jsonpath' }], // Missing context + }, + ], + stepId: 'step1', + }, + ], + components: { successActions: {} }, + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + message: `A "context" must be specified for a Criterion Object with type "jsonpath".`, + path: ['steps', 0, 'onSuccess', 0, 'criteria', 0, 'context'], + }); + }); }); diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts index cbdadc8f4..5182c2f71 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts @@ -1,5 +1,17 @@ import type { IFunctionResult } from '@stoplight/spectral-core'; import getAllFailureActions from './utils/getAllFailureActions'; +import arazzoCriterionValidation from './arazzoCriterionValidation'; + +type CriterionExpressionType = { + type: 'jsonpath' | 'xpath'; + version: 'draft-goessner-dispatch-jsonpath-00' | 'xpath-30' | 'xpath-20' | 'xpath-10'; +}; + +type Criterion = { + context?: string; + condition: string; + type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; +}; type FailureAction = { name: string; @@ -11,15 +23,12 @@ type FailureAction = { criteria?: Criterion[]; }; -type Criterion = { - condition: string; -}; - type ReusableObject = { reference: string; }; type Step = { + stepId: string; onFailure?: (FailureAction | ReusableObject)[]; workflowId?: string; operationId?: string; @@ -59,6 +68,17 @@ export default function arazzoStepFailureActionsValidation(target: Workflow, _op } } + if (action.criteria) { + action.criteria.forEach((criterion, criterionIndex) => { + const criterionResults = arazzoCriterionValidation( + criterion, + ['steps', stepIndex, 'onFailure', actionIndex, 'criteria', criterionIndex], + target, + ); + results.push(...criterionResults); + }); + } + const maskedDuplicates = resolvedActions.filter(action => action.name.startsWith('masked-duplicate-')); if (maskedDuplicates.length > 0) { maskedDuplicates.forEach(action => { diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts index f8fcdf62b..0b6ba4b78 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts @@ -1,5 +1,17 @@ import type { IFunctionResult } from '@stoplight/spectral-core'; import getAllSuccessActions from './utils/getAllSuccessActions'; +import arazzoCriterionValidation from './arazzoCriterionValidation'; + +type CriterionExpressionType = { + type: 'jsonpath' | 'xpath'; + version: 'draft-goessner-dispatch-jsonpath-00' | 'xpath-30' | 'xpath-20' | 'xpath-10'; +}; + +type Criterion = { + context?: string; + condition: string; + type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; +}; type SuccessAction = { name: string; @@ -9,15 +21,12 @@ type SuccessAction = { criteria?: Criterion[]; }; -type Criterion = { - condition: string; -}; - type ReusableObject = { reference: string; }; type Step = { + stepId: string; onSuccess?: (SuccessAction | ReusableObject)[]; workflowId?: string; operationId?: string; @@ -57,6 +66,17 @@ export default function arazzoStepSuccessActionsValidation(target: Workflow, _op } } + if (action.criteria) { + action.criteria.forEach((criterion, criterionIndex) => { + const criterionResults = arazzoCriterionValidation( + criterion, + ['steps', stepIndex, 'onSuccess', actionIndex, 'criteria', criterionIndex], + target, + ); + results.push(...criterionResults); + }); + } + const maskedDuplicates = resolvedActions.filter(action => action.name.startsWith('masked-duplicate-')); if (maskedDuplicates.length > 0) { maskedDuplicates.forEach(action => { From c5e9ab5af8eec48e615a6af40ab7b47e37d30687 Mon Sep 17 00:00:00 2001 From: Frank Kilcommins Date: Fri, 16 Aug 2024 17:59:58 +0100 Subject: [PATCH 11/28] feat(rulesets): add Step Validation for Arazzo --- .../__tests__/arazzoStepValidation.test.ts | 122 ++++++++++++++++++ .../arazzo/functions/arazzoStepValidation.ts | 96 ++++++++++++++ .../rulesets/src/arazzo/functions/index.ts | 4 + packages/rulesets/src/arazzo/index.ts | 20 +++ 4 files changed, 242 insertions(+) create mode 100644 packages/rulesets/src/arazzo/functions/__tests__/arazzoStepValidation.test.ts create mode 100644 packages/rulesets/src/arazzo/functions/arazzoStepValidation.ts 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..c370877a3 --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepValidation.test.ts @@ -0,0 +1,122 @@ +import arazzoStepValidation from '../arazzoStepValidation'; +import type { IFunctionResult } from '@stoplight/spectral-core'; + +type SourceDescription = { + name: string; + url: string; + type?: string; +}; + +type Step = { + stepId: string; + operationId?: string; + operationPath?: string; + workflowId?: string; +}; + +type Workflow = { + steps: Step[]; + sourceDescriptions: SourceDescription[]; +}; + +const runRule = (target: Workflow): IFunctionResult[] => { + return arazzoStepValidation(target, null); +}; + +describe('arazzoStepValidation', () => { + test('should not report any errors for valid operationId, operationPath, and workflowId', () => { + const results = runRule({ + steps: [ + { + stepId: 'step1', + operationId: '$sourceDescriptions.validSource.operationId', + }, + { + stepId: 'step2', + operationPath: '{$sourceDescriptions.validSource.url}', + }, + { + 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({ + 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: ['steps', 0, 'operationId'], + }); + }); + + test('should report an error for invalid operationPath format', () => { + const results = runRule({ + 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: ['steps', 0, 'operationPath'], + }); + }); + + test('should report an error for invalid workflowId runtime expression', () => { + const results = runRule({ + 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: ['steps', 0, 'workflowId'], + }); + }); + + test('should report an error for missing source description in operationPath', () => { + const results = runRule({ + steps: [ + { + stepId: 'step1', + operationPath: '{$sourceDescriptions.missingSource.url}', + }, + ], + 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}" in step "step1".', + path: ['steps', 0, 'operationPath'], + }); + }); +}); diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepValidation.ts new file mode 100644 index 000000000..0c758f28c --- /dev/null +++ b/packages/rulesets/src/arazzo/functions/arazzoStepValidation.ts @@ -0,0 +1,96 @@ +import { IFunctionResult } from '@stoplight/spectral-core'; +import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidation'; + +type SourceDescription = { + name: string; + url: string; + type?: string; +}; + +type Step = { + stepId: string; + operationId?: string; + operationPath?: string; + workflowId?: string; +}; + +type Workflow = { + steps: Step[]; + sourceDescriptions: SourceDescription[]; +}; + +const OPERATION_PATH_REGEX = /^\{\$sourceDescriptions\.[a-zA-Z0-9_-]+\.(url)\}$/; + +export default function arazzoStepValidation(targetVal: Workflow, _options: null): IFunctionResult[] { + const results: IFunctionResult[] = []; + const sourceDescriptionNames = new Set(targetVal.sourceDescriptions.map(sd => sd.name)); + + targetVal.steps.forEach((step, stepIndex) => { + const { operationId, operationPath, workflowId } = step; + + // Validate operationId + if (operationId != null) { + if (operationId.startsWith('$')) { + if (!arazzoRuntimeExpressionValidation(operationId)) { + results.push({ + message: `Runtime expression "${operationId}" is invalid in step "${step.stepId}".`, + path: ['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: ['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: ['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: ['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: ['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: ['steps', stepIndex, 'workflowId'], + }); + } + } + } + }); + + return results; +} diff --git a/packages/rulesets/src/arazzo/functions/index.ts b/packages/rulesets/src/arazzo/functions/index.ts index b36c8f5ea..e220e94e6 100644 --- a/packages/rulesets/src/arazzo/functions/index.ts +++ b/packages/rulesets/src/arazzo/functions/index.ts @@ -9,6 +9,8 @@ import { default as arazzoRuntimeExpressionValidation } from './arazzoRuntimeExp 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 { arazzoWorkflowIdUniqueness, @@ -22,4 +24,6 @@ export { arazzoWorkflowDependsOnValidation, arazzoCriterionValidation, arazzoStepSuccessCriteriaValidation, + arazzoStepRequestBodyValidation, + arazzoStepValidation, }; diff --git a/packages/rulesets/src/arazzo/index.ts b/packages/rulesets/src/arazzo/index.ts index a7b914dbf..0a94d0b5b 100644 --- a/packages/rulesets/src/arazzo/index.ts +++ b/packages/rulesets/src/arazzo/index.ts @@ -9,6 +9,8 @@ import arazzoStepFailureActionsValidation from './functions/arazzoStepFailureAct 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', @@ -95,5 +97,23 @@ export default { function: arazzoStepSuccessCriteriaValidation, }, }, + 'arazzo-step-request-body-validation': { + description: 'Every step request body must have a valid context, conditions, and types.', + recommended: true, + severity: 0, + given: '$.workflows[*].steps[*]', + then: { + function: arazzoStepRequestBodyValidation, + }, + }, + 'arazzo-step-validation': { + description: 'Every step must have a valid "stepId", "operationId", "operationPath", and "onSuccess" and "onFailure" actions.', + recommended: true, + severity: 0, + given: '$.workflows[*]', + then: { + function: arazzoStepValidation, + }, + }, }, }; From e6ccf21fe2b2feff83df069516137e4feb1da4c5 Mon Sep 17 00:00:00 2001 From: Frank Kilcommins Date: Mon, 19 Aug 2024 10:31:59 +0100 Subject: [PATCH 12/28] chore(formats): bump package version --- packages/formats/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From bd8d2f7e99f105d08604fdf7b1e9939500924593 Mon Sep 17 00:00:00 2001 From: Frank Kilcommins Date: Mon, 19 Aug 2024 10:49:22 +0100 Subject: [PATCH 13/28] chore(rulesets): bump package version --- packages/rulesets/package.json | 4 ++-- yarn.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/rulesets/package.json b/packages/rulesets/package.json index 0b5dfd5de..062b86236 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.0", "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/yarn.lock b/yarn.lock index 40a769a4e..b7833027f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2709,7 +2709,7 @@ __metadata: languageName: unknown linkType: soft -"@stoplight/spectral-formats@*, @stoplight/spectral-formats@>=1, @stoplight/spectral-formats@^1.0.0, @stoplight/spectral-formats@^1.5.0, @stoplight/spectral-formats@workspace:packages/formats": +"@stoplight/spectral-formats@*, @stoplight/spectral-formats@>=1, @stoplight/spectral-formats@^1.0.0, @stoplight/spectral-formats@^1.7.0, @stoplight/spectral-formats@workspace:packages/formats": version: 0.0.0-use.local resolution: "@stoplight/spectral-formats@workspace:packages/formats" dependencies: @@ -2848,7 +2848,7 @@ __metadata: "@stoplight/json": ^3.17.0 "@stoplight/path": ^1.3.2 "@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-parsers": "*" "@stoplight/spectral-ref-resolver": ^1.0.4 From 613ca65ef76e597718db418e1659b226fe976603 Mon Sep 17 00:00:00 2001 From: Frank Kilcommins Date: Fri, 23 Aug 2024 11:44:44 +0100 Subject: [PATCH 14/28] feat(rulesets): add Arazzo schema, criterion, expression, outputs,and action validation --- package.json | 6 +- packages/cli/package.json | 6 +- packages/ruleset-bundler/package.json | 6 +- packages/rulesets/package.json | 2 +- packages/rulesets/scripts/compile-schemas.ts | 11 + .../__tests__/arazzoDocumentSchema.test.ts | 284 ++++++++++++ ...arazzoStepFailureActionsValidation.test.ts | 420 +++++++++++++++--- .../arazzoStepOutputNamesValidation.test.ts | 127 ++++-- .../arazzoStepParametersValidation.test.ts | 76 ++-- ...arazzoStepSuccessActionsValidation.test.ts | 268 +++++++++-- ...razzoStepSuccessCriteriaValidation.test.ts | 45 +- .../__tests__/arazzoStepValidation.test.ts | 139 ++++-- ...razzoWorkflowOutputNamesValidation.test.ts | 326 ++++++++++++-- ...arazzoWorkflowsDependsOnValidation.test.ts | 126 +++++- .../functions/arazzoCriterionValidation.ts | 14 +- .../arazzo/functions/arazzoDocumentSchema.ts | 126 ++++++ .../arazzoRuntimeExpressionValidation.ts | 202 ++++++++- .../arazzoStepFailureActionsValidation.ts | 170 +++++-- .../functions/arazzoStepIdUniqueness.ts | 19 +- .../arazzoStepOutputNamesValidation.ts | 141 ++++-- .../arazzoStepParametersValidation.ts | 6 +- .../arazzoStepSuccessActionsValidation.ts | 168 +++++-- .../arazzoStepSuccessCriteriaValidation.ts | 41 +- .../arazzo/functions/arazzoStepValidation.ts | 176 +++++--- .../arazzoWorkflowDependsOnValidation.ts | 59 ++- .../arazzoWorkflowOutputNamesValidation.ts | 132 ++++-- .../rulesets/src/arazzo/functions/index.ts | 2 + .../functions/utils/getAllFailureActions.ts | 132 +++--- .../functions/utils/getAllSuccessActions.ts | 118 ++--- packages/rulesets/src/arazzo/index.ts | 167 ++++++- .../src/arazzo/schemas/arazzo/v1.0/index.json | 39 +- .../rulesets/src/arazzo/schemas/validators.ts | 2 + yarn.lock | 14 +- 33 files changed, 2926 insertions(+), 644 deletions(-) create mode 100644 packages/rulesets/src/arazzo/functions/__tests__/arazzoDocumentSchema.test.ts create mode 100644 packages/rulesets/src/arazzo/functions/arazzoDocumentSchema.ts create mode 100644 packages/rulesets/src/arazzo/schemas/validators.ts diff --git a/package.json b/package.json index d99759294..a610df44b 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", @@ -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..1bcc271b9 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-bundler": "^1.5.4", "@stoplight/spectral-ruleset-migrator": "^1.9.5", - "@stoplight/spectral-rulesets": ">=1", + "@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/ruleset-bundler/package.json b/packages/ruleset-bundler/package.json index b224a348d..140e4c924 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-rulesets": "^1.20.1", "@stoplight/spectral-runtime": "^1.1.0", "@stoplight/types": "^13.6.0", "@types/node": "*", diff --git a/packages/rulesets/package.json b/packages/rulesets/package.json index 062b86236..0fc2aa927 100644 --- a/packages/rulesets/package.json +++ b/packages/rulesets/package.json @@ -1,6 +1,6 @@ { "name": "@stoplight/spectral-rulesets", - "version": "1.20.0", + "version": "1.20.2", "homepage": "https://github.com/stoplightio/spectral", "bugs": "https://github.com/stoplightio/spectral/issues", "author": "Stoplight ", diff --git a/packages/rulesets/scripts/compile-schemas.ts b/packages/rulesets/scripts/compile-schemas.ts index cdd3250d0..e1b154cdf 100644 --- a/packages/rulesets/scripts/compile-schemas.ts +++ b/packages/rulesets/scripts/compile-schemas.ts @@ -51,6 +51,7 @@ 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', @@ -81,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/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 index ffda9a867..18bde30da 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepFailureActionsValidation.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepFailureActionsValidation.test.ts @@ -11,6 +11,12 @@ type FailureAction = { criteria?: Criterion[]; }; +type SourceDescription = { + name: string; + url: string; + type?: string; +}; + type CriterionExpressionType = { type: 'jsonpath' | 'xpath'; version: 'draft-goessner-dispatch-jsonpath-00' | 'xpath-30' | 'xpath-20' | 'xpath-10'; @@ -32,28 +38,46 @@ type Step = { }; type Workflow = { + workflowId: string; steps: Step[]; - onFailure?: (FailureAction | ReusableObject)[]; + failureActions?: (FailureAction | ReusableObject)[]; +}; + +type ArazzoSpecification = { + sourceDescriptions?: SourceDescription[]; + workflows: Workflow[]; components?: { failureActions?: Record }; }; -const runRule = (target: Workflow, _contextOverrides: Partial = {}) => { +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({ - steps: [ + workflows: [ { - onFailure: [ - { name: 'action1', type: 'goto', stepId: 'step1' }, - { name: 'action2', type: 'end' }, + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + onFailure: [ + { name: 'action1', type: 'goto', stepId: 'step1' }, + { name: 'action2', type: 'end' }, + ], + }, ], - stepId: 'step1', }, ], - components: { failureActions: {} }, + components: { + failureActions: { + allDone: { + name: 'finishWorkflow', + type: 'end', + }, + }, + }, }); expect(results).toHaveLength(0); @@ -61,76 +85,98 @@ describe('validateFailureActions', () => { test('should report an error for duplicate failure actions within the same step', () => { const results = runRule({ - steps: [ + workflows: [ { - onFailure: [ - { name: 'action1', type: 'goto', stepId: 'step1' }, - { name: 'action1', type: 'end' }, + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + onFailure: [ + { name: 'action1', type: 'goto', stepId: 'step1' }, + { name: 'action1', type: 'end' }, + ], + }, ], - stepId: 'step1', - }, // Duplicate action name + }, ], - components: { failureActions: {} }, + components: { + failureActions: {}, + }, }); expect(results).toHaveLength(2); expect(results[0]).toMatchObject({ - message: `Duplicate action: "action1" must be unique within the combined failure actions.`, - path: ['steps', 0, 'onFailure', 1], + 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({ - steps: [ + workflows: [ { - onFailure: [{ name: 'action1', type: 'goto', stepId: 'step1', workflowId: 'workflow1' }], - stepId: 'step1', + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + onFailure: [{ name: 'action1', type: 'goto', stepId: 'step1', workflowId: 'workflow1' }], + }, + ], }, ], components: { failureActions: {} }, }); - expect(results).toHaveLength(1); + 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: ['steps', 0, 'onFailure', 0], + path: ['workflows', 0, 'steps', 0, 'onFailure', 0], }); }); test('should override workflow level onFailure action with step level onFailure action', () => { const results = runRule({ - steps: [ + workflows: [ { - onFailure: [{ name: 'action1', type: 'goto', stepId: 'step1' }], - stepId: 'step1', + workflowId: 'workflow1', + failureActions: [{ name: 'action1', type: 'end' }], + steps: [ + { + stepId: 'step1', + onFailure: [{ name: 'action1', type: 'goto', stepId: 'step1' }], + }, + ], }, ], - onFailure: [{ name: 'action1', type: 'end' }], components: { failureActions: {} }, }); - expect(results).toHaveLength(0); + 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({ - steps: [ + workflows: [ { - onFailure: [ + workflowId: 'workflow1', + steps: [ { - name: 'action1', - type: 'goto', stepId: 'step1', - criteria: [ + onFailure: [ { - context: '$response.body', - condition: '', + name: 'action1', + type: 'goto', + stepId: 'step1', + criteria: [ + { + context: '$response.body', + condition: '', + }, + ], }, - ], // Missing condition + ], }, ], - stepId: 'step1', }, ], components: { failureActions: {} }, @@ -139,23 +185,28 @@ describe('validateFailureActions', () => { expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ message: `Missing or invalid "condition" in Criterion Object.`, - path: ['steps', 0, 'onFailure', 0, 'criteria', 0, 'condition'], + 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({ - steps: [ + workflows: [ { - onFailure: [ + workflowId: 'workflow1', + steps: [ { - name: 'action1', - type: 'goto', stepId: 'step1', - criteria: [{ context: '$statusCode', condition: '^(200$', type: 'regex' }], // Invalid regex + onFailure: [ + { + name: 'action1', + type: 'goto', + stepId: 'step1', + criteria: [{ context: '$statusCode', condition: '^(200$', type: 'regex' }], + }, + ], }, ], - stepId: 'step1', }, ], components: { failureActions: {} }, @@ -164,23 +215,28 @@ describe('validateFailureActions', () => { expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ message: `"condition" contains an invalid regex pattern.`, - path: ['steps', 0, 'onFailure', 0, 'criteria', 0, 'condition'], + 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({ - steps: [ + workflows: [ { - onFailure: [ + workflowId: 'workflow1', + steps: [ { - name: 'action1', - type: 'goto', stepId: 'step1', - criteria: [{ condition: '$response.body', type: 'jsonpath' }], // Missing context + onFailure: [ + { + name: 'action1', + type: 'goto', + stepId: 'step1', + criteria: [{ condition: '$response.body', type: 'jsonpath' }], + }, + ], }, ], - stepId: 'step1', }, ], components: { failureActions: {} }, @@ -189,7 +245,269 @@ describe('validateFailureActions', () => { expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ message: `A "context" must be specified for a Criterion Object with type "jsonpath".`, - path: ['steps', 0, 'onFailure', 0, 'criteria', 0, 'context'], + 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__/arazzoStepOutputNamesValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepOutputNamesValidation.test.ts index 15e04ea0b..062d64402 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepOutputNamesValidation.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepOutputNamesValidation.test.ts @@ -3,7 +3,16 @@ import { DeepPartial } from '@stoplight/types'; import type { RulesetFunctionContext } from '@stoplight/spectral-core'; const runRule = ( - target: { steps: Array<{ outputs?: [string, string][] }> }, + target: { + workflows: Array<{ + workflowId: string; + steps: Array<{ + stepId: string; + outputs?: { [key: string]: string }; + }>; + }>; + components?: Record; + }, contextOverrides: Partial = {}, ) => { const context: DeepPartial = { @@ -25,14 +34,23 @@ const runRule = ( describe('arazzoStepOutputNamesValidation', () => { test('should not report any errors for valid and unique output names', () => { const results = runRule({ - steps: [ + workflows: [ { - outputs: [ - ['output1', '$url'], - ['output2', '$response.body#/status'], + workflowId: 'workflow1', + steps: [ + { + outputs: { + output1: '$url', + output2: '$response.body#/status', + }, + stepId: 'step1', + }, + { + outputs: { output3: '$steps.step1.outputs.output1' }, + stepId: 'step2', + }, ], }, - { outputs: [['output3', '$steps.foo.outputs.bar']] }, ], }); @@ -41,11 +59,17 @@ describe('arazzoStepOutputNamesValidation', () => { test('should report an error for invalid output names', () => { const results = runRule({ - steps: [ + workflows: [ { - outputs: [ - ['invalid name', '$url'], - ['output2', '$statusCode'], + workflowId: 'workflow1', + steps: [ + { + outputs: { + 'invalid name': '$url', + output2: '$statusCode', + }, + stepId: 'step1', + }, ], }, ], @@ -54,35 +78,50 @@ describe('arazzoStepOutputNamesValidation', () => { expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ message: `"invalid name" does not match the required pattern "^[a-zA-Z0-9.\\-_]+$".`, - path: ['steps', 0, 'outputs', 'invalid name'], + path: ['workflows', 0, 'steps', 0, 'outputs', 'invalid name', 0], }); }); - test('should report an error for duplicate output names within the same step', () => { + test('should report an error for invalid step name in output expression', () => { const results = runRule({ - steps: [ + workflows: [ { - outputs: [ - ['output1', '$statusCode'], - ['output2', '$url'], - ['output1', '$statusCode'], + workflowId: 'workflow1', + steps: [ + { + outputs: { + output1: '$statusCode', + }, + stepId: 'step1', + }, + { + outputs: { + foo: '$steps.non-existing-step.outputs.output1', + }, + stepId: 'step2', + }, ], - }, // Duplicate key simulated here + }, ], }); expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ - message: `"output1" must be unique within the step outputs.`, - path: ['steps', 0, 'outputs', 'output1'], + 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({ - steps: [ - { outputs: [['output1', '$response.body']] }, - { outputs: [['output1', '$response.body']] }, // Duplicate output name across different steps + workflows: [ + { + workflowId: 'workflow1', + steps: [ + { outputs: { output1: '$response.body' }, stepId: 'step1' }, + { outputs: { output1: '$response.body' }, stepId: 'step2' }, // Duplicate output name across different steps + ], + }, ], }); @@ -91,11 +130,17 @@ describe('arazzoStepOutputNamesValidation', () => { test('should not report any errors for valid runtime expressions', () => { const results = runRule({ - steps: [ + workflows: [ { - outputs: [ - ['output1', '$response.body#/status'], - ['output2', '$steps.step1.outputs.value'], + workflowId: 'workflow1', + steps: [ + { + outputs: { + output1: '$response.body#/status', + output2: '$steps.step1.outputs.value', + }, + stepId: 'step1', + }, ], }, ], @@ -106,9 +151,15 @@ describe('arazzoStepOutputNamesValidation', () => { test('should report an error for invalid runtime expressions', () => { const results = runRule({ - steps: [ + workflows: [ { - outputs: [['output1', 'invalid expression']], + workflowId: 'workflow1', + steps: [ + { + outputs: { output1: 'invalid expression' }, + stepId: 'step1', + }, + ], }, ], }); @@ -116,17 +167,23 @@ describe('arazzoStepOutputNamesValidation', () => { expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ message: `"invalid expression" is not a valid runtime expression.`, - path: ['steps', 0, 'outputs', 'output1'], + path: ['workflows', 0, 'steps', 0, 'outputs', 'output1', 0], }); }); test('should handle valid and invalid expressions mixed', () => { const results = runRule({ - steps: [ + workflows: [ { - outputs: [ - ['validOutput', '$response.body#/status'], - ['invalidOutput', 'invalid expression'], + workflowId: 'workflow1', + steps: [ + { + outputs: { + validOutput: '$response.body#/status', + invalidOutput: 'invalid expression', + }, + stepId: 'step1', + }, ], }, ], @@ -135,7 +192,7 @@ describe('arazzoStepOutputNamesValidation', () => { expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ message: `"invalid expression" is not a valid runtime expression.`, - path: ['steps', 0, 'outputs', 'invalidOutput'], + 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 index f16f65a92..546328af8 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepParametersValidation.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepParametersValidation.test.ts @@ -5,6 +5,7 @@ import type { RulesetFunctionContext } from '@stoplight/spectral-core'; type Parameter = { name: string; in?: string; + value: string; }; type ReusableObject = { @@ -44,8 +45,8 @@ describe('arazzoStepParametersValidation', () => { steps: [ { parameters: [ - { name: 'param1', in: 'query' }, - { name: 'param2', in: 'header' }, + { name: 'param1', in: 'query', value: 'value1' }, + { name: 'param2', in: 'header', value: 'value2' }, ], }, ], @@ -60,12 +61,12 @@ describe('arazzoStepParametersValidation', () => { steps: [ { parameters: [ - { name: 'param1', in: 'query' }, - { name: 'param2', in: 'header' }, + { name: 'param1', in: 'query', value: 'value1' }, + { name: 'param2', in: 'header', value: 'value2' }, ], }, ], - components: { parameters: { param1: { name: 'param3', in: 'cookie' } } }, + components: { parameters: { param1: { name: 'param3', in: 'cookie', value: 'value3' } } }, }); expect(results).toHaveLength(0); @@ -76,14 +77,17 @@ describe('arazzoStepParametersValidation', () => { steps: [ { workflowId: 'workflow1', - parameters: [{ name: 'param1' }], + parameters: [{ name: 'param1', value: 'value1' }], }, { workflowId: 'workflow1', - parameters: [{ name: 'param2' }], + parameters: [{ name: 'param2', value: 'value2' }], }, ], - parameters: [{ name: 'param3' }, { name: 'param4' }], + parameters: [ + { name: 'param3', value: 'value3' }, + { name: 'param4', value: 'value4' }, + ], components: { parameters: {} }, }); @@ -95,16 +99,16 @@ describe('arazzoStepParametersValidation', () => { steps: [ { operationPath: '/path1', - parameters: [{ name: 'param1', in: 'query' }], + parameters: [{ name: 'param1', in: 'query', value: 'value1' }], }, { operationPath: '/path2', - parameters: [{ name: 'param2', in: 'header' }], + parameters: [{ name: 'param2', in: 'header', value: 'value2' }], }, ], parameters: [ - { name: 'param1', in: 'cookie' }, - { name: 'param2', in: 'cookie' }, + { name: 'param1', in: 'cookie', value: 'value3' }, + { name: 'param2', in: 'cookie', value: 'value4' }, ], components: { parameters: {} }, }); @@ -117,8 +121,8 @@ describe('arazzoStepParametersValidation', () => { steps: [ { parameters: [ - { name: 'param1', in: 'query' }, - { name: 'param1', in: 'query' }, + { name: 'param1', in: 'query', value: 'value1' }, + { name: 'param1', in: 'query', value: 'value2' }, ], }, // Duplicate parameter ], @@ -139,7 +143,15 @@ describe('arazzoStepParametersValidation', () => { parameters: [{ reference: '$components.parameters.param1' }, { reference: '$components.parameters.param1' }], }, ], - components: { parameters: { param1: { name: 'param1', in: 'query' } } }, + components: { + parameters: { + param1: { + name: 'param1', + in: 'query', + value: 'value1', + }, + }, + }, }); expect(results).toHaveLength(1); @@ -154,10 +166,10 @@ describe('arazzoStepParametersValidation', () => { steps: [ { workflowId: 'workflow1', - parameters: [{ name: 'param1' }], + parameters: [{ name: 'param1', value: 'value1' }], }, ], - parameters: [{ name: 'param1' }], + parameters: [{ name: 'param1', value: 'value2' }], components: { parameters: {} }, }); @@ -169,7 +181,10 @@ describe('arazzoStepParametersValidation', () => { steps: [ { workflowId: 'workflow1', - parameters: [{ name: 'param1' }, { name: 'param2', in: 'query' }], + parameters: [ + { name: 'param1', value: 'value1' }, + { name: 'param2', in: 'query', value: 'value2' }, + ], }, ], components: { parameters: {} }, @@ -188,8 +203,8 @@ describe('arazzoStepParametersValidation', () => { { workflowId: 'workflow1', parameters: [ - { name: 'param1', in: 'header' }, - { name: 'param2', in: 'query' }, + { name: 'param1', in: 'header', value: 'value1' }, + { name: 'param2', in: 'query', value: 'value2' }, ], }, ], @@ -207,7 +222,10 @@ describe('arazzoStepParametersValidation', () => { steps: [ { operationId: 'operation1', - parameters: [{ name: 'param1' }, { name: 'param2' }], + parameters: [ + { name: 'param1', value: 'value1' }, + { name: 'param2', value: 'value2' }, + ], }, ], components: { parameters: {} }, @@ -225,10 +243,10 @@ describe('arazzoStepParametersValidation', () => { steps: [ { operationId: 'operation1', - parameters: [{ name: 'param1', in: 'query' }], + parameters: [{ name: 'param1', in: 'query', value: 'value1' }], }, ], - parameters: [{ name: 'param1', in: 'query' }], + parameters: [{ name: 'param1', in: 'query', value: 'value2' }], components: { parameters: {} }, }); @@ -240,16 +258,16 @@ describe('arazzoStepParametersValidation', () => { steps: [ { operationId: 'operation1', - parameters: [{ name: 'param1', in: 'query' }], + parameters: [{ name: 'param1', in: 'query', value: 'value1' }], }, { operationId: 'operation2', - parameters: [{ name: 'param2', in: 'header' }], + parameters: [{ name: 'param2', in: 'header', value: 'value2' }], }, ], parameters: [ - { name: 'param1', in: 'header' }, - { name: 'param2', in: 'query' }, + { name: 'param1', in: 'header', value: 'value3' }, + { name: 'param2', in: 'query', value: 'value4' }, ], components: { parameters: {} }, }); @@ -262,10 +280,10 @@ describe('arazzoStepParametersValidation', () => { steps: [ { operationPath: '/path1', - parameters: [{ name: 'param1', in: 'query' }], + parameters: [{ name: 'param1', in: 'query', value: 'value1' }], }, ], - parameters: [{ name: 'param1', in: 'query' }], + parameters: [{ name: 'param1', in: 'query', value: 'value2' }], components: { parameters: {} }, }); diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessActionsValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessActionsValidation.test.ts index 21bfe0c6b..03f2ccc12 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessActionsValidation.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessActionsValidation.test.ts @@ -9,6 +9,12 @@ type SuccessAction = { criteria?: Criterion[]; }; +type SourceDescription = { + name: string; + url: string; + type?: string; +}; + type CriterionExpressionType = { type: 'jsonpath' | 'xpath'; version: 'draft-goessner-dispatch-jsonpath-00' | 'xpath-30' | 'xpath-20' | 'xpath-10'; @@ -30,25 +36,36 @@ type Step = { }; type Workflow = { + workflowId: string; steps: Step[]; successActions?: (SuccessAction | ReusableObject)[]; +}; + +type ArazzoSpecification = { + workflows: Workflow[]; + sourceDescriptions?: SourceDescription[]; components?: { successActions?: Record }; }; -const runRule = (target: Workflow, _contextOverrides: Partial = {}) => { +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({ - steps: [ + workflows: [ { - onSuccess: [ - { name: 'action1', type: 'goto', stepId: 'step1' }, - { name: 'action2', type: 'end' }, + steps: [ + { + onSuccess: [ + { name: 'action1', type: 'goto', stepId: 'step1' }, + { name: 'action2', type: 'end' }, + ], + stepId: 'step1', + }, ], - stepId: 'step1', + workflowId: 'workflow1', }, ], components: { successActions: {} }, @@ -59,31 +76,41 @@ describe('validateSuccessActions', () => { test('should report an error for duplicate success actions within the same step', () => { const results = runRule({ - steps: [ + workflows: [ { - onSuccess: [ - { name: 'action1', type: 'goto', stepId: 'step1' }, - { name: 'action1', type: 'end' }, + steps: [ + { + onSuccess: [ + { name: 'action1', type: 'goto', stepId: 'step1' }, + { name: 'action1', type: 'end' }, + ], + stepId: 'step1', + }, // Duplicate action name ], - stepId: 'step1', - }, // Duplicate action name + workflowId: 'workflow1', + }, ], components: { successActions: {} }, }); expect(results).toHaveLength(2); expect(results[0]).toMatchObject({ - message: `Duplicate action: "action1" must be unique within the combined success actions.`, - path: ['steps', 0, 'onSuccess', 1], + 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({ - steps: [ + workflows: [ { - onSuccess: [{ name: 'action1', type: 'goto', stepId: 'step1', workflowId: 'workflow1' }], - stepId: 'step1', + steps: [ + { + onSuccess: [{ name: 'action1', type: 'goto', stepId: 'step1', workflowId: 'workflow1' }], + stepId: 'step1', + }, + ], + workflowId: 'workflow1', }, ], components: { successActions: {} }, @@ -92,43 +119,114 @@ describe('validateSuccessActions', () => { expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ message: `"workflowId" and "stepId" are mutually exclusive and cannot be specified together.`, - path: ['steps', 0, 'onSuccess', 0], + path: ['workflows', 0, 'steps', 0, 'onSuccess', 0], }); }); test('should override workflow level success action with step level success action', () => { const results = runRule({ - steps: [ + workflows: [ { - onSuccess: [{ name: 'action1', type: 'goto', stepId: 'step1' }], - stepId: 'step1', + steps: [ + { + onSuccess: [{ name: 'action1', type: 'goto', stepId: 'step1' }], + stepId: 'step1', + }, + ], + successActions: [{ name: 'action1', type: 'end' }], + workflowId: 'workflow1', }, ], - successActions: [{ name: 'action1', type: 'end' }], components: { successActions: {} }, }); expect(results).toHaveLength(0); }); - test('should report an error for missing condition in Criterion', () => { + 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({ - steps: [ + workflows: [ { - onSuccess: [ + steps: [ { - name: 'action1', - type: 'goto', + onSuccess: [{ reference: '$components.successActions.nonExistingAction' }], stepId: 'step1', - criteria: [ + }, + ], + 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: [ { - context: '$response.body', - condition: '', + name: 'action1', + type: 'goto', + stepId: 'step1', + criteria: [ + { + context: '$response.body', + condition: '', + }, + ], // Missing condition }, - ], // Missing condition + ], + stepId: 'step1', }, ], - stepId: 'step1', + workflowId: 'workflow1', }, ], components: { successActions: {} }, @@ -137,23 +235,28 @@ describe('validateSuccessActions', () => { expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ message: `Missing or invalid "condition" in Criterion Object.`, - path: ['steps', 0, 'onSuccess', 0, 'criteria', 0, 'condition'], + 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({ - steps: [ + workflows: [ { - onSuccess: [ + steps: [ { - name: 'action1', - type: 'goto', + onSuccess: [ + { + name: 'action1', + type: 'goto', + stepId: 'step1', + criteria: [{ context: '$statusCode', condition: '^(200$', type: 'regex' }], // Invalid regex + }, + ], stepId: 'step1', - criteria: [{ context: '$statusCode', condition: '^(200$', type: 'regex' }], // Invalid regex }, ], - stepId: 'step1', + workflowId: 'workflow1', }, ], components: { successActions: {} }, @@ -162,23 +265,28 @@ describe('validateSuccessActions', () => { expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ message: `"condition" contains an invalid regex pattern.`, - path: ['steps', 0, 'onSuccess', 0, 'criteria', 0, 'condition'], + 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({ - steps: [ + workflows: [ { - onSuccess: [ + steps: [ { - name: 'action1', - type: 'goto', + onSuccess: [ + { + name: 'action1', + type: 'goto', + stepId: 'step1', + criteria: [{ condition: '$response.body', type: 'jsonpath' }], // Missing context + }, + ], stepId: 'step1', - criteria: [{ condition: '$response.body', type: 'jsonpath' }], // Missing context }, ], - stepId: 'step1', + workflowId: 'workflow1', }, ], components: { successActions: {} }, @@ -187,7 +295,73 @@ describe('validateSuccessActions', () => { expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ message: `A "context" must be specified for a Criterion Object with type "jsonpath".`, - path: ['steps', 0, 'onSuccess', 0, 'criteria', 0, 'context'], + 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 index 5a2336f46..316a5b6d2 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessCriteriaValidation.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessCriteriaValidation.test.ts @@ -18,20 +18,31 @@ type Step = { }; type Workflow = { + workflowId: string; steps: Step[]; }; -const runRule = (target: Workflow, _contextOverrides: Partial = {}) => { +type ArazzoSpecification = { + workflows: Workflow[]; + components?: object; +}; + +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({ - steps: [ + workflows: [ { - stepId: 'step1', - successCriteria: [{ condition: '$statusCode == 200' }], + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + successCriteria: [{ condition: '$statusCode == 200' }], + }, + ], }, ], }); @@ -41,10 +52,15 @@ describe('arazzoStepSuccessCriteriaValidation', () => { test('should report an error for invalid context in success criteria', () => { const results = runRule({ - steps: [ + workflows: [ { - stepId: 'step1', - successCriteria: [{ context: 'invalidContext', condition: '$statusCode == 200' }], + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + successCriteria: [{ context: 'invalidContext', condition: '$statusCode == 200' }], + }, + ], }, ], }); @@ -52,16 +68,21 @@ describe('arazzoStepSuccessCriteriaValidation', () => { expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ message: `"context" contains an invalid runtime expression.`, - path: ['steps', 0, 'successCriteria', 0, 'context'], + path: ['workflows', 0, 'steps', 0, 'successCriteria', 0, 'context'], }); }); test('should report an error for missing condition in success criteria', () => { const results = runRule({ - steps: [ + workflows: [ { - stepId: 'step1', - successCriteria: [{ context: '$response.body', condition: '' }], + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + successCriteria: [{ context: '$response.body', condition: '' }], + }, + ], }, ], }); @@ -69,7 +90,7 @@ describe('arazzoStepSuccessCriteriaValidation', () => { expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ message: `Missing or invalid "condition" in Criterion Object.`, - path: ['steps', 0, 'successCriteria', 0, 'condition'], + 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 index c370877a3..6ba994d14 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepValidation.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepValidation.test.ts @@ -7,37 +7,90 @@ type SourceDescription = { type?: string; }; +type ArazzoSpecification = { + workflows: Workflow[]; + sourceDescriptions?: SourceDescription[]; + components?: { + parameters?: Record; + successActions?: Record; + failureActions?: Record; + [key: string]: unknown; + }; +}; + +type Workflow = { + workflowId: string; + steps: Step[]; + successActions?: (SuccessAction | ReusableObject)[]; + failureActions?: (FailureAction | ReusableObject)[]; + outputs?: Record; +}; + type Step = { stepId: string; operationId?: string; operationPath?: string; workflowId?: string; + outputs?: Record; + onSuccess?: (SuccessAction | ReusableObject)[]; + onFailure?: (FailureAction | ReusableObject)[]; }; -type Workflow = { - steps: Step[]; - sourceDescriptions: SourceDescription[]; +type SuccessAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + criteria?: Criterion[]; +}; + +type FailureAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + criteria?: Criterion[]; +}; + +type Criterion = { + context?: string; + condition: string; + type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; +}; + +type CriterionExpressionType = { + type: 'jsonpath' | 'xpath'; + version: string; }; -const runRule = (target: Workflow): IFunctionResult[] => { +type ReusableObject = { + reference: string; +}; + +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({ - steps: [ - { - stepId: 'step1', - operationId: '$sourceDescriptions.validSource.operationId', - }, - { - stepId: 'step2', - operationPath: '{$sourceDescriptions.validSource.url}', - }, + workflows: [ { - stepId: 'step3', - workflowId: '$sourceDescriptions.validSource.workflowId', + 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' }], @@ -48,10 +101,15 @@ describe('arazzoStepValidation', () => { test('should report an error for invalid operationId runtime expression', () => { const results = runRule({ - steps: [ + workflows: [ { - stepId: 'step1', - operationId: '$invalidSourceDescription.operationId', + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + operationId: '$invalidSourceDescription.operationId', + }, + ], }, ], sourceDescriptions: [{ name: 'validSource', url: 'http://example.com', type: 'arazzo' }], @@ -60,16 +118,21 @@ describe('arazzoStepValidation', () => { expect(results).toHaveLength(2); expect(results[0]).toMatchObject({ message: 'Runtime expression "$invalidSourceDescription.operationId" is invalid in step "step1".', - path: ['steps', 0, 'operationId'], + path: ['workflows', 0, 'steps', 0, 'operationId'], }); }); test('should report an error for invalid operationPath format', () => { const results = runRule({ - steps: [ + workflows: [ { - stepId: 'step1', - operationPath: 'invalidOperationPathFormat', + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + operationPath: 'invalidOperationPathFormat', + }, + ], }, ], sourceDescriptions: [{ name: 'validSource', url: 'http://example.com', type: 'arazzo' }], @@ -78,17 +141,22 @@ describe('arazzoStepValidation', () => { expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ message: - 'OperationPath "invalidOperationPathFormat" must be a valid runtime expression following the format "{$sourceDescriptions..url}".', - path: ['steps', 0, 'operationPath'], + '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({ - steps: [ + workflows: [ { - stepId: 'step1', - workflowId: '$invalidSourceDescription.workflowId', + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + workflowId: '$invalidSourceDescription.workflowId', + }, + ], }, ], sourceDescriptions: [{ name: 'validSource', url: 'http://example.com', type: 'arazzo' }], @@ -97,16 +165,21 @@ describe('arazzoStepValidation', () => { expect(results).toHaveLength(2); expect(results[0]).toMatchObject({ message: 'Runtime expression "$invalidSourceDescription.workflowId" is invalid in step "step1".', - path: ['steps', 0, 'workflowId'], + path: ['workflows', 0, 'steps', 0, 'workflowId'], }); }); test('should report an error for missing source description in operationPath', () => { const results = runRule({ - steps: [ + workflows: [ { - stepId: 'step1', - operationPath: '{$sourceDescriptions.missingSource.url}', + workflowId: 'workflow1', + steps: [ + { + stepId: 'step1', + operationPath: '{$sourceDescriptions.missingSource.url}#foo', + }, + ], }, ], sourceDescriptions: [{ name: 'validSource', url: 'http://example.com', type: 'arazzo' }], @@ -115,8 +188,8 @@ describe('arazzoStepValidation', () => { expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ message: - 'Source description "missingSource" not found for operationPath "{$sourceDescriptions.missingSource.url}" in step "step1".', - path: ['steps', 0, 'operationPath'], + '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__/arazzoWorkflowOutputNamesValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowOutputNamesValidation.test.ts index be1e622cd..3a119d8fd 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowOutputNamesValidation.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowOutputNamesValidation.test.ts @@ -2,10 +2,65 @@ import arazzoWorkflowOutputNamesValidation from '../arazzoWorkflowOutputNamesVal import { DeepPartial } from '@stoplight/types'; import type { RulesetFunctionContext } from '@stoplight/spectral-core'; -const runRule = ( - target: { workflows: Array<{ outputs?: [string, string][] }> }, - contextOverrides: Partial = {}, -) => { +type ArazzoSpecification = { + workflows: Workflow[]; + sourceDescriptions?: SourceDescription[]; + components?: { + successActions?: Record; + failureActions?: Record; + [key: string]: unknown; + }; +}; + +type SourceDescription = { + name: string; + url: string; + type?: string; +}; + +type SuccessAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + criteria?: Criterion[]; +}; + +type FailureAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + criteria?: Criterion[]; +}; + +type Workflow = { + workflowId: string; + steps: Step[]; + outputs?: { [key: string]: string }; +}; + +type Step = { + stepId: string; + operationId?: string; + workflowId?: string; + operationPath?: string; + parameters?: Record; + outputs?: { [key: string]: string }; +}; + +type Criterion = { + context?: string; + condition: string; + type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; +}; + +type CriterionExpressionType = { + type: 'jsonpath' | 'xpath'; + version: string; +}; + +const runRule = (target: ArazzoSpecification, contextOverrides: Partial = {}) => { const context: DeepPartial = { path: [], documentInventory: { @@ -27,12 +82,18 @@ describe('arazzoWorkflowOutputNamesValidation', () => { const results = runRule({ workflows: [ { - outputs: [ - ['output1', 'value1'], - ['output2', 'value2'], - ], + outputs: { + output1: '$url', + output2: '$statusCode', + }, + workflowId: 'workflow§', + steps: [], + }, + { + outputs: { output3: '$statusCode' }, + workflowId: 'workflow2', + steps: [], }, - { outputs: [['output3', 'value3']] }, ], }); @@ -43,49 +104,262 @@ describe('arazzoWorkflowOutputNamesValidation', () => { const results = runRule({ workflows: [ { - outputs: [ - ['invalid name', 'value1'], - ['output2', 'value2'], - ], + outputs: { + 'invalid name': 'value1', + output2: 'value2', + }, + workflowId: 'workflow1', + steps: [], }, ], }); - expect(results).toHaveLength(1); + 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'], + 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 report an error for duplicate output names within the same workflow', () => { + test('should handle runtime expression referencing a step within a different workflow', () => { const results = runRule({ workflows: [ { - outputs: [ - ['output1', 'value1'], - ['output2', 'value2'], - ['output1', 'value3'], + workflowId: 'place-order1', + steps: [ + { + stepId: 'place-order', + operationId: 'placeOrder', + outputs: { step_order_id: '$statusCode' }, + }, ], - }, // Duplicate key simulated here + 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: `"output1" must be unique within the workflow outputs.`, - path: ['workflows', 0, 'outputs', 'output1'], + message: `"$workflows.non-existing-workflow.steps.foo.outputs.output1" is not a valid runtime expression.`, + path: ['workflows', 1, 'outputs', 'output1', 0], }); }); - test('should not report an error for duplicate output names across different workflows', () => { + test('should report an error for runtime expression referencing a separate existing workflow but with non-existing step', () => { const results = runRule({ workflows: [ - { outputs: [['output1', 'value1']] }, - { outputs: [['output1', 'value2']] }, // Duplicate output name across different 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 index c8edd1c3b..032506e7c 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowsDependsOnValidation.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowsDependsOnValidation.test.ts @@ -9,22 +9,70 @@ type SourceDescription = { type Workflow = { workflowId: string; + steps: Step[]; dependsOn?: string[]; }; -type Document = { +type Step = { + stepId: string; + outputs?: { [key: string]: string }; +}; + +type ArazzoSpecification = { + sourceDescriptions?: SourceDescription[]; workflows: Workflow[]; - sourceDescriptions: SourceDescription[]; + components?: { + parameters?: Record; + successActions?: Record; + failureActions?: Record; + [key: string]: unknown; + }; +}; + +type SuccessAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + criteria?: Criterion[]; }; -const runRule = (target: Document): IFunctionResult[] => { +type FailureAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + criteria?: Criterion[]; +}; + +type Criterion = { + context?: string; + condition: string; + type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; +}; + +type CriterionExpressionType = { + type: 'jsonpath' | 'xpath'; + version: string; +}; +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' }, { workflowId: 'workflow2', dependsOn: ['workflow1'] }], + workflows: [ + { + workflowId: 'workflow1', + steps: [], + }, + { + workflowId: 'workflow2', + dependsOn: ['workflow1'], + steps: [], + }, + ], sourceDescriptions: [{ name: 'source1', url: 'http://example.com', type: 'arazzo' }], }); @@ -33,7 +81,17 @@ describe('arazzoWorkflowDependsOnValidation', () => { test('should report an error for duplicate workflowId in dependsOn', () => { const results = runRule({ - workflows: [{ workflowId: 'workflow1' }, { workflowId: 'workflow2', dependsOn: ['workflow1', 'workflow1'] }], + workflows: [ + { + workflowId: 'workflow1', + steps: [], + }, + { + workflowId: 'workflow2', + dependsOn: ['workflow1', 'workflow1'], + steps: [], + }, + ], sourceDescriptions: [{ name: 'source1', url: 'http://example.com', type: 'arazzo' }], }); @@ -46,7 +104,17 @@ describe('arazzoWorkflowDependsOnValidation', () => { test('should report an error for non-existent local workflowId in dependsOn', () => { const results = runRule({ - workflows: [{ workflowId: 'workflow1' }, { workflowId: 'workflow2', dependsOn: ['workflow3'] }], + workflows: [ + { + workflowId: 'workflow1', + steps: [], + }, + { + workflowId: 'workflow2', + dependsOn: ['workflow3'], + steps: [], + }, + ], sourceDescriptions: [{ name: 'source1', url: 'http://example.com', type: 'arazzo' }], }); @@ -60,8 +128,15 @@ describe('arazzoWorkflowDependsOnValidation', () => { test('should report an error for non-existent source description in dependsOn', () => { const results = runRule({ workflows: [ - { workflowId: 'workflow1' }, - { workflowId: 'workflow2', dependsOn: ['$sourceDescriptions.nonExistent.workflow3'] }, + { + workflowId: 'workflow1', + steps: [], + }, + { + workflowId: 'workflow2', + dependsOn: ['$sourceDescriptions.nonExistent.workflow3'], + steps: [], + }, ], sourceDescriptions: [{ name: 'source1', url: 'http://example.com', type: 'arazzo' }], }); @@ -75,7 +150,17 @@ describe('arazzoWorkflowDependsOnValidation', () => { test('should report an error for missing workflowId part in runtime expression', () => { const results = runRule({ - workflows: [{ workflowId: 'workflow1' }, { workflowId: 'workflow2', dependsOn: ['$sourceDescriptions.source1'] }], + workflows: [ + { + workflowId: 'workflow1', + steps: [], + }, + { + workflowId: 'workflow2', + dependsOn: ['$sourceDescriptions.source1'], + steps: [], + }, + ], sourceDescriptions: [{ name: 'source1', url: 'http://example.com', type: 'arazzo' }], }); @@ -89,8 +174,15 @@ describe('arazzoWorkflowDependsOnValidation', () => { test('should report an error for non-arazzo type in source description', () => { const results = runRule({ workflows: [ - { workflowId: 'workflow1' }, - { workflowId: 'workflow2', dependsOn: ['$sourceDescriptions.source1.workflow3'] }, + { + workflowId: 'workflow1', + steps: [], + }, + { + workflowId: 'workflow2', + dependsOn: ['$sourceDescriptions.source1.workflow3'], + steps: [], + }, ], sourceDescriptions: [{ name: 'source1', url: 'http://example.com', type: 'openapi' }], }); @@ -104,7 +196,17 @@ describe('arazzoWorkflowDependsOnValidation', () => { test('should report an error for invalid runtime expression in dependsOn', () => { const results = runRule({ - workflows: [{ workflowId: 'workflow1' }, { workflowId: 'workflow2', dependsOn: ['$invalid.source1.expression'] }], + workflows: [ + { + workflowId: 'workflow1', + steps: [], + }, + { + workflowId: 'workflow2', + dependsOn: ['$invalid.source1.expression'], + steps: [], + }, + ], sourceDescriptions: [{ name: 'source1', url: 'http://example.com', type: 'arazzo' }], }); diff --git a/packages/rulesets/src/arazzo/functions/arazzoCriterionValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoCriterionValidation.ts index 388bed8fd..fa365e470 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoCriterionValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoCriterionValidation.ts @@ -14,17 +14,24 @@ type Criterion = { type Step = { stepId: string; - successCriteria?: Criterion[]; + outputs?: { [key: string]: string }; }; type Workflow = { + workflowId: string; steps: Step[]; + outputs?: { [key: string]: string }; +}; + +type ArazzoSpecification = { + workflows: Workflow[]; + components?: { [key: string]: any }; }; export default function arazzoCriterionValidation( criterion: Criterion, contextPath: (string | number)[], - workflow: Workflow, // Assuming you have access to the Workflow or document object + arazzoSpec: ArazzoSpecification, // Updated from Workflow to ArazzoSpecification ): IFunctionResult[] { const results: IFunctionResult[] = []; @@ -54,6 +61,7 @@ export default function arazzoCriterionValidation( }); } } + // Validate regex pattern if (criterion.type === 'regex') { try { @@ -67,7 +75,7 @@ export default function arazzoCriterionValidation( } // Validate context using arazzoRuntimeExpressionValidation - if (criterion.context != null && !validateRuntimeExpression(criterion.context, workflow)) { + if (criterion.context != null && !validateRuntimeExpression(criterion.context, arazzoSpec)) { results.push({ message: `"context" contains an invalid runtime expression.`, path: [...contextPath, 'context'], 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 index d0a299999..1df5eef0a 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoRuntimeExpressionValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoRuntimeExpressionValidation.ts @@ -1,13 +1,182 @@ +type ArazzoSpecification = { + workflows: Workflow[]; + sourceDescriptions?: SourceDescription[]; + components?: { + parameters?: Record; + successActions?: Record; + failureActions?: Record; + [key: string]: unknown; + }; +}; + +type SourceDescription = { + name: string; + url: string; + type?: string; +}; + type Workflow = { + workflowId: string; steps: Step[]; + successActions?: (SuccessAction | ReusableObject)[]; + failureActions?: (FailureAction | ReusableObject)[]; + outputs?: Record; }; type Step = { stepId: string; - outputs?: { [key: string]: string }; + outputs?: Record; + onSuccess?: (SuccessAction | ReusableObject)[]; + onFailure?: (FailureAction | ReusableObject)[]; +}; + +type SuccessAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + criteria?: Criterion[]; +}; + +type FailureAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + criteria?: Criterion[]; +}; + +type Criterion = { + context?: string; + condition: string; + type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; +}; + +type CriterionExpressionType = { + type: 'jsonpath' | 'xpath'; + version: string; +}; + +type ReusableObject = { + reference: string; }; -function arazzoRuntimeExpressionValidation(expression: string, _workflow?: Workflow): boolean { +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; + + // Ensure that arazzoSpec and its workflows are defined and not null + if (arazzoSpec == null || !Array.isArray(arazzoSpec.workflows) || arazzoSpec.workflows.length === 0) { + return false; // The ArazzoSpecification or workflows are not properly defined + } + + // Get the relevant steps to search in the current workflow or all workflows + 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; // No steps available to search + } + + const step = stepsToSearch.find(step => step.stepId === stepId); + if (!step) { + return false; // The step does not exist + } + + return true; // The path resolves correctly +} + +function validateWorkflowsExpression(workflowsExpression: string, arazzoSpec: ArazzoSpecification): boolean { + const workflowsRegex = /^\$workflows\.([A-Za-z0-9_\\-]+)\.(.*)$/; + const match = workflowsRegex.exec(workflowsExpression); + + if (!match) { + return false; // The expression didn't match the expected pattern + } + + const [, workflowId, remainingPath] = match; + + // Ensure that arazzoSpec and its workflows are defined and not null + if (arazzoSpec == null || !Array.isArray(arazzoSpec.workflows) || arazzoSpec.workflows.length === 0) { + return false; + } + + // Find the specified workflow + const workflowIndex = arazzoSpec.workflows.findIndex(workflow => workflow.workflowId === workflowId); + if (workflowIndex === -1) { + return false; + } + + // If the remaining path refers to steps, validate the steps expression + if (remainingPath.startsWith('steps.')) { + return validateStepsExpression(`$steps.${remainingPath.slice(6)}`, arazzoSpec, workflowIndex); + } + + // If the remaining path is empty or does not refer to steps, consider it valid + return true; +} + +function validateReusableSuccessActionExpression(expression: string, arazzoSpec: ArazzoSpecification): boolean { + const successActionsRegex = /^\$components\.successActions\.([A-Za-z0-9_\\-]+)$/; + const match = successActionsRegex.exec(expression); + + if (!match) { + return false; // The expression didn't match the expected pattern + } + + const [, actionName] = match; + + if (arazzoSpec.components?.successActions && actionName in arazzoSpec.components.successActions) { + return true; // The action exists in the components.successActions + } + + return false; // The action does not exist +} + +function validateReusableFailureActionExpression(expression: string, arazzoSpec: ArazzoSpecification): boolean { + const failureActionsRegex = /^\$components\.failureActions\.([A-Za-z0-9_\\-]+)$/; + const match = failureActionsRegex.exec(expression); + + if (!match) { + return false; // The expression didn't match the expected pattern + } + + const [, actionName] = match; + + if (arazzoSpec.components?.failureActions && actionName in arazzoSpec.components.failureActions) { + return true; // The action exists in the components.failureActions + } + + return false; // The action does not exist +} + +function arazzoRuntimeExpressionValidation( + expression: string, + arazzoSpec?: ArazzoSpecification, + currentWorkflowIndex?: number, +): boolean { + if (!expression && !arazzoSpec) { + return false; + } + const validPrefixes = [ '$url', '$method', @@ -20,17 +189,40 @@ function arazzoRuntimeExpressionValidation(expression: string, _workflow?: Workf '$steps.', '$workflows.', '$sourceDescriptions.', - '$components.', + '$components.inputs.', '$components.parameters.', + '$components.successActions.', + '$components.failureActions.', ]; + const isValidPrefix = validPrefixes.some(prefix => expression.startsWith(prefix)); + // Early return if no valid prefix found if (!isValidPrefix) { return false; } - // ToDo: Advanced validation logic can be added here - // For example, validate $steps.foo.outputs.bar references + // Basic validation of $steps expressions + if (expression.startsWith('$steps.') && arazzoSpec) { + return validateStepsExpression(expression, arazzoSpec, currentWorkflowIndex); + } + + // Basic validation for $workflows expressions + if (expression.startsWith('$workflows.') && arazzoSpec) { + return validateWorkflowsExpression(expression, arazzoSpec); + } + + // Basic validation for $components.failureActions expressions + if (expression.startsWith('$components.failureActions.') && arazzoSpec) { + return validateReusableFailureActionExpression(expression, arazzoSpec); + } + + // Basic validation for $components.successActions expressions + if (expression.startsWith('$components.successActions.') && arazzoSpec) { + return validateReusableSuccessActionExpression(expression, arazzoSpec); + } + + // ToDo: Add any other advanced validation here return true; } diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts index 5182c2f71..86e95023b 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts @@ -1,6 +1,7 @@ import type { IFunctionResult } from '@stoplight/spectral-core'; import getAllFailureActions from './utils/getAllFailureActions'; import arazzoCriterionValidation from './arazzoCriterionValidation'; +import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidation'; type CriterionExpressionType = { type: 'jsonpath' | 'xpath'; @@ -35,64 +36,145 @@ type Step = { operationPath?: string; }; +type SourceDescription = { + name: string; + url: string; + type?: string; +}; + type Workflow = { + workflowId: string; steps: Step[]; - onFailure?: (FailureAction | ReusableObject)[]; + failureActions?: (FailureAction | ReusableObject)[]; +}; + +type ArazzoSpecification = { + sourceDescriptions?: SourceDescription[]; + workflows: Workflow[]; components?: { failureActions?: Record }; }; -export default function arazzoStepFailureActionsValidation(target: Workflow, _options: null): IFunctionResult[] { +export default function arazzoStepFailureActionsValidation( + target: ArazzoSpecification, + _options: null, +): IFunctionResult[] { const results: IFunctionResult[] = []; - const components = target.components?.failureActions ?? {}; - target.steps.forEach((step, stepIndex) => { - const resolvedActions = getAllFailureActions(step, target, components); + 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); - const seenNames: Set = new Set(); - resolvedActions.forEach((action, actionIndex) => { - if (seenNames.has(action.name)) { - results.push({ - message: `"${action.name}" must be unique within the combined failure actions.`, - path: ['steps', stepIndex, 'onFailure', actionIndex], - }); - } else { - seenNames.add(action.name); - } + 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 (action.type === 'goto' || action.type === 'retry') { - if (action.workflowId != null && action.stepId != null) { - results.push({ - message: `"workflowId" and "stepId" are mutually exclusive and cannot be specified together.`, - path: ['steps', stepIndex, 'onFailure', actionIndex], - }); - } - } + 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.criteria) { - action.criteria.forEach((criterion, criterionIndex) => { - const criterionResults = arazzoCriterionValidation( - criterion, - ['steps', stepIndex, 'onFailure', actionIndex, 'criteria', criterionIndex], - target, - ); - results.push(...criterionResults); - }); - } + 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) { + 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], + }); + } + } - const maskedDuplicates = resolvedActions.filter(action => action.name.startsWith('masked-duplicate-')); - if (maskedDuplicates.length > 0) { - maskedDuplicates.forEach(action => { - results.push({ - message: `Duplicate action: "${action.name.replace( - 'masked-duplicate-', - '', - )}" must be unique within the combined failure actions.`, - path: ['steps', stepIndex, 'onFailure', resolvedActions.indexOf(action)], - }); + 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 index 3a3cff9b0..9b6d42a9f 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoStepIdUniqueness.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoStepIdUniqueness.ts @@ -1,7 +1,7 @@ import { createRulesetFunction, IFunctionResult } from '@stoplight/spectral-core'; import type { JsonPath } from '@stoplight/types'; -export default createRulesetFunction<{ steps: Array<{ stepId: string }> }, null>( +export default createRulesetFunction<{ steps: Array<{ stepId?: string }> }, null>( { input: { type: 'object', @@ -15,19 +15,32 @@ export default createRulesetFunction<{ steps: Array<{ stepId: string }> }, null> type: 'string', }, }, - required: ['stepId'], }, }, }, }, options: null, }, - function arazzoStepIdUniqueness(targetVal, _) { + 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.`, diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepOutputNamesValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepOutputNamesValidation.ts index 539da013b..30206967d 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoStepOutputNamesValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoStepOutputNamesValidation.ts @@ -4,35 +4,80 @@ import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidati const OUTPUT_NAME_PATTERN = /^[a-zA-Z0-9.\-_]+$/; +type ArazzoSpecification = { + workflows: Workflow[]; + sourceDescriptions?: SourceDescription[]; + components?: { + parameters?: Record; + successActions?: Record; + failureActions?: Record; + [key: string]: unknown; + }; +}; + +type SourceDescription = { + name: string; + url: string; + type?: string; +}; + +type SuccessAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + criteria?: Criterion[]; +}; + +type FailureAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + criteria?: Criterion[]; +}; type Workflow = { + workflowId: string; steps: Step[]; + outputs?: Record; }; type Step = { stepId: string; - outputs?: { [key: string]: string }; + outputs?: Record; +}; + +type Criterion = { + context?: string; + condition: string; + type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; }; -export default createRulesetFunction< - { steps: Array<{ outputs?: [string, string][] }> }, // Updated type to accept array of entries - null ->( +type CriterionExpressionType = { + type: 'jsonpath' | 'xpath'; + version: string; +}; + +export default createRulesetFunction( { input: { type: 'object', properties: { - steps: { + workflows: { type: 'array', items: { type: 'object', properties: { - outputs: { - type: 'array', // Updated type to array + steps: { + type: 'array', items: { - type: 'array', - minItems: 2, - maxItems: 2, - items: [{ type: 'string' }, { type: 'string' }], + type: 'object', + properties: { + outputs: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + }, }, }, }, @@ -42,41 +87,53 @@ export default createRulesetFunction< }, options: null, }, - function arazzoStepOutputNamesValidation(targetVal, _opts, context) { + function arazzoStepOutputNamesValidation(targetVal, _opts) { const results: IFunctionResult[] = []; - targetVal.steps.forEach((step, stepIndex) => { - if (step.outputs) { - const seenOutputNames = new Set(); + 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(); - step.outputs.forEach(([outputName, outputValue]) => { - // Validate output name - if (!OUTPUT_NAME_PATTERN.test(outputName)) { - results.push({ - message: `"${outputName}" does not match the required pattern "^[a-zA-Z0-9.\\-_]+$".`, - path: ['steps', stepIndex, 'outputs', outputName] as JsonPath, - }); - } + 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: ['steps', stepIndex, 'outputs', outputName] as JsonPath, - }); - } else { - seenOutputNames.add(outputName); - } + // 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, context.document as unknown as Workflow)) { - results.push({ - message: `"${outputValue}" is not a valid runtime expression.`, - path: ['steps', stepIndex, 'outputs', outputName] as JsonPath, - }); - } - }); - } + // 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 index 63990af15..83cb61aed 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoStepParametersValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoStepParametersValidation.ts @@ -126,15 +126,15 @@ export default createRulesetFunction< const stepParams = getAllParameters(step, workflow, components.parameters ?? {}); // Check for duplicate parameters within the step - const paramSet = new Set(); - for (const param of stepParams) { + const paramSet = new Set(); + for (const [paramIndex, param] of stepParams.entries()) { const key = `${param.name}-${param.in ?? ''}`; if (paramSet.has(key)) { results.push({ message: `"${param.name}" with "in" value "${ param.in ?? '' }" must be unique within the combined parameters.`, - path: ['steps', stepIndex, 'parameters', stepParams.indexOf(param)], + path: ['steps', stepIndex, 'parameters', paramIndex], }); } else { paramSet.add(key); diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts index 0b6ba4b78..f82dbd519 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts @@ -1,6 +1,7 @@ import type { IFunctionResult } from '@stoplight/spectral-core'; import getAllSuccessActions from './utils/getAllSuccessActions'; import arazzoCriterionValidation from './arazzoCriterionValidation'; +import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidation'; type CriterionExpressionType = { type: 'jsonpath' | 'xpath'; @@ -34,63 +35,144 @@ type Step = { }; type Workflow = { + workflowId: string; steps: Step[]; successActions?: (SuccessAction | ReusableObject)[]; +}; + +type SourceDescription = { + name: string; + url: string; + type?: string; +}; + +type ArazzoSpecification = { + sourceDescriptions?: SourceDescription[]; + workflows: Workflow[]; components?: { successActions?: Record }; }; -export default function arazzoStepSuccessActionsValidation(target: Workflow, _options: null): IFunctionResult[] { +export default function arazzoStepSuccessActionsValidation( + target: ArazzoSpecification, + _options: null, +): IFunctionResult[] { const results: IFunctionResult[] = []; - const components = target.components?.successActions ?? {}; - target.steps.forEach((step, stepIndex) => { - const resolvedActions = getAllSuccessActions(step, target, components); + 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); - const seenNames: Set = new Set(); - resolvedActions.forEach((action, actionIndex) => { - if (seenNames.has(action.name)) { - results.push({ - message: `"${action.name}" must be unique within the combined success actions.`, - path: ['steps', stepIndex, 'onSuccess', actionIndex], - }); - } else { - seenNames.add(action.name); - } + 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 (action.type === 'goto') { - if (action.workflowId != null && action.stepId != null) { - results.push({ - message: `"workflowId" and "stepId" are mutually exclusive and cannot be specified together.`, - path: ['steps', stepIndex, 'onSuccess', actionIndex], - }); - } - } + 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.criteria) { - action.criteria.forEach((criterion, criterionIndex) => { - const criterionResults = arazzoCriterionValidation( - criterion, - ['steps', stepIndex, 'onSuccess', actionIndex, 'criteria', criterionIndex], - target, - ); - results.push(...criterionResults); - }); - } + 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], + }); + } + } - const maskedDuplicates = resolvedActions.filter(action => action.name.startsWith('masked-duplicate-')); - if (maskedDuplicates.length > 0) { - maskedDuplicates.forEach(action => { - results.push({ - message: `Duplicate action: "${action.name.replace( - 'masked-duplicate-', - '', - )}" must be unique within the combined success actions.`, - path: ['steps', stepIndex, 'onSuccess', resolvedActions.indexOf(action)], - }); + 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 index 70a73acd7..a1359af68 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoStepSuccessCriteriaValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoStepSuccessCriteriaValidation.ts @@ -18,24 +18,39 @@ type Step = { }; type Workflow = { + workflowId: string; steps: Step[]; }; -export default function validateSuccessCriteria(targetVal: Workflow, _options: null): IFunctionResult[] { +type ArazzoSpecification = { + workflows: Workflow[]; + components?: object; +}; + +export default function arazzoStepSuccessCriteriaValidation( + targetVal: ArazzoSpecification, + _options: null, +): IFunctionResult[] { const results: IFunctionResult[] = []; - targetVal.steps.forEach((step, stepIndex) => { - if (step.successCriteria) { - step.successCriteria.forEach((criterion, criterionIndex) => { - const criterionResults = arazzoCriterionValidation( - criterion, - ['steps', stepIndex, 'successCriteria', criterionIndex], - targetVal, - ); - results.push(...criterionResults); - }); - } - }); + 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 index 0c758f28c..5c4671bca 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoStepValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoStepValidation.ts @@ -1,4 +1,4 @@ -import { IFunctionResult } from '@stoplight/spectral-core'; +import type { IFunctionResult } from '@stoplight/spectral-core'; import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidation'; type SourceDescription = { @@ -7,89 +7,153 @@ type SourceDescription = { type?: string; }; +type ArazzoSpecification = { + workflows: Workflow[]; + sourceDescriptions?: SourceDescription[]; + components?: { + parameters?: Record; + successActions?: Record; + failureActions?: Record; + [key: string]: unknown; + }; +}; + +type Workflow = { + workflowId: string; + steps: Step[]; + successActions?: (SuccessAction | ReusableObject)[]; + failureActions?: (FailureAction | ReusableObject)[]; + outputs?: Record; +}; + type Step = { stepId: string; operationId?: string; operationPath?: string; workflowId?: string; + outputs?: Record; + onSuccess?: (SuccessAction | ReusableObject)[]; + onFailure?: (FailureAction | ReusableObject)[]; }; -type Workflow = { - steps: Step[]; - sourceDescriptions: SourceDescription[]; +type SuccessAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + criteria?: Criterion[]; +}; + +type FailureAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + criteria?: Criterion[]; +}; + +type Criterion = { + context?: string; + condition: string; + type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; +}; + +type CriterionExpressionType = { + type: 'jsonpath' | 'xpath'; + version: string; +}; + +type ReusableObject = { + reference: string; }; -const OPERATION_PATH_REGEX = /^\{\$sourceDescriptions\.[a-zA-Z0-9_-]+\.(url)\}$/; +const OPERATION_PATH_REGEX = /^\{\$sourceDescriptions\.[a-zA-Z0-9_-]+\.(url)\}#.+$/; -export default function arazzoStepValidation(targetVal: Workflow, _options: null): IFunctionResult[] { +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.steps.forEach((step, stepIndex) => { - const { operationId, operationPath, workflowId } = step; + 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; + } - // Validate operationId - if (operationId != null) { - if (operationId.startsWith('$')) { - if (!arazzoRuntimeExpressionValidation(operationId)) { - results.push({ - message: `Runtime expression "${operationId}" is invalid in step "${step.stepId}".`, - path: ['steps', stepIndex, 'operationId'], - }); - } + workflow.steps.forEach((step, stepIndex) => { + const { operationId, operationPath, workflowId } = step; - const parts = operationId.split('.'); - const sourceName = parts[1]; + // 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'], + }); + } - if (!sourceDescriptionNames.has(sourceName)) { - results.push({ - message: `Source description "${sourceName}" not found for operationId "${operationId}" in step "${step.stepId}".`, - path: ['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: ['steps', stepIndex, 'operationPath'], - }); - } else { - const sourceName = operationPath.split('.')[1]; - - if (!sourceDescriptionNames.has(sourceName)) { + // Validate operationPath as JSON Pointer with correct format + if (operationPath != null) { + if (!OPERATION_PATH_REGEX.test(operationPath)) { results.push({ - message: `Source description "${sourceName}" not found for operationPath "${operationPath}" in step "${step.stepId}".`, - path: ['steps', stepIndex, 'operationPath'], + 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: ['steps', stepIndex, 'workflowId'], - }); - } + // 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]; + 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: ['steps', stepIndex, 'workflowId'], - }); + 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 index d5875be5c..1beed4028 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoWorkflowDependsOnValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoWorkflowDependsOnValidation.ts @@ -10,29 +10,72 @@ type SourceDescription = { type Workflow = { workflowId: string; + steps: Step[]; dependsOn?: string[]; }; -type Document = { +type Step = { + stepId: string; + outputs?: { [key: string]: string }; +}; + +type ArazzoSpecification = { workflows: Workflow[]; - sourceDescriptions: SourceDescription[]; + sourceDescriptions?: SourceDescription[]; + components?: { + parameters?: Record; + successActions?: Record; + failureActions?: Record; + [key: string]: unknown; + }; +}; + +type SuccessAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + criteria?: Criterion[]; +}; + +type FailureAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + criteria?: Criterion[]; +}; + +type Criterion = { + context?: string; + condition: string; + type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; +}; + +type CriterionExpressionType = { + type: 'jsonpath' | 'xpath'; + version: string; }; -export default function arazzoWorkflowDependsOnValidation(targetVal: Document, _options: null): IFunctionResult[] { +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 sourceDescriptionNames = new Map((targetVal.sourceDescriptions ?? []).map(sd => [sd.name, sd.type])); - for (const { workflow } of getAllWorkflows(targetVal)) { + 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(targetVal)) { + for (const { workflow, path } of getAllWorkflows({ workflows })) { const seenWorkflows = new Set(); - if (Boolean(workflow.dependsOn) && Array.isArray(workflow.dependsOn)) { + if (Array.isArray(workflow.dependsOn)) { workflow.dependsOn.forEach((dep: string | unknown, depIndex: number) => { if (typeof dep !== 'string') { return; // Skip non-string dependencies @@ -50,7 +93,7 @@ export default function arazzoWorkflowDependsOnValidation(targetVal: Document, _ } if (dep.startsWith('$')) { - if (!arazzoRuntimeExpressionValidation(dep)) { + if (!arazzoRuntimeExpressionValidation(dep, targetVal)) { results.push({ message: `Runtime expression "${dep}" is invalid.`, path: [...path, 'dependsOn', depIndex], diff --git a/packages/rulesets/src/arazzo/functions/arazzoWorkflowOutputNamesValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoWorkflowOutputNamesValidation.ts index 3af83c2ee..2d44740c3 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoWorkflowOutputNamesValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoWorkflowOutputNamesValidation.ts @@ -1,12 +1,65 @@ import { createRulesetFunction, IFunctionResult } from '@stoplight/spectral-core'; import type { JsonPath } from '@stoplight/types'; +import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidation'; const OUTPUT_NAME_PATTERN = /^[a-zA-Z0-9.\-_]+$/; -export default createRulesetFunction< - { workflows: Array<{ outputs?: [string, string][] }> }, // Accept array of entries for workflow outputs - null ->( +type ArazzoSpecification = { + workflows: Workflow[]; + sourceDescriptions?: SourceDescription[]; + components?: { + parameters?: Record; + successActions?: Record; + failureActions?: Record; + [key: string]: unknown; + }; +}; + +type SourceDescription = { + name: string; + url: string; + type?: string; +}; + +type SuccessAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + criteria?: Criterion[]; +}; + +type FailureAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + criteria?: Criterion[]; +}; + +type Workflow = { + workflowId: string; + steps: Step[]; + outputs?: { [key: string]: string }; +}; + +type Step = { + stepId: string; + outputs?: { [key: string]: string }; +}; + +type Criterion = { + context?: string; + condition: string; + type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; +}; + +type CriterionExpressionType = { + type: 'jsonpath' | 'xpath'; + version: string; +}; + +export default createRulesetFunction( { input: { type: 'object', @@ -17,13 +70,8 @@ export default createRulesetFunction< type: 'object', properties: { outputs: { - type: 'array', - items: { - type: 'array', - minItems: 2, - maxItems: 2, - items: [{ type: 'string' }, { type: 'string' }], - }, + type: 'object', + additionalProperties: { type: 'string' }, }, }, }, @@ -32,32 +80,50 @@ export default createRulesetFunction< }, options: null, }, - function arazzoWorkflowOutputNamesValidation(targetVal) { + function arazzoWorkflowOutputNamesValidation(targetVal, _opts) { const results: IFunctionResult[] = []; - targetVal.workflows.forEach((workflow, workflowIndex) => { - if (workflow.outputs) { - const seenOutputNames = new Set(); + 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, + }); + } - workflow.outputs.forEach(([outputName]) => { - 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] 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); + } - if (seenOutputNames.has(outputName)) { - results.push({ - message: `"${outputName}" must be unique within the workflow outputs.`, - path: ['workflows', workflowIndex, 'outputs', outputName] 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 index e220e94e6..13c22483d 100644 --- a/packages/rulesets/src/arazzo/functions/index.ts +++ b/packages/rulesets/src/arazzo/functions/index.ts @@ -1,3 +1,4 @@ +import { default as arazzoDocumentSchema } from './arazzoDocumentSchema'; import { default as arazzoWorkflowIdUniqueness } from './arazzoWorkflowIdUniqueness'; import { default as arazzoStepIdUniqueness } from './arazzoStepIdUniqueness'; import { default as arazzoWorkflowOutputNamesValidation } from './arazzoWorkflowOutputNamesValidation'; @@ -13,6 +14,7 @@ import { default as arazzoStepRequestBodyValidation } from './arazzoStepRequestB import { default as arazzoStepValidation } from './arazzoStepValidation'; export { + arazzoDocumentSchema, arazzoWorkflowIdUniqueness, arazzoWorkflowOutputNamesValidation, arazzoStepIdUniqueness, diff --git a/packages/rulesets/src/arazzo/functions/utils/getAllFailureActions.ts b/packages/rulesets/src/arazzo/functions/utils/getAllFailureActions.ts index f53ba3bca..1080cfee3 100644 --- a/packages/rulesets/src/arazzo/functions/utils/getAllFailureActions.ts +++ b/packages/rulesets/src/arazzo/functions/utils/getAllFailureActions.ts @@ -1,4 +1,22 @@ import { isPlainObject } from '@stoplight/json'; +import arazzoRuntimeExpressionValidation from '../arazzoRuntimeExpressionValidation'; + +type ArazzoSpecification = { + workflows: Workflow[]; + sourceDescriptions?: SourceDescription[]; + components?: { + parameters?: Record; + successActions?: Record; + failureActions?: Record; + [key: string]: unknown; + }; +}; + +type SourceDescription = { + name: string; + url: string; + type?: string; +}; type FailureAction = { name: string; @@ -10,6 +28,14 @@ type FailureAction = { criteria?: Criterion[]; }; +type SuccessAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + criteria?: Criterion[]; +}; + type Criterion = { condition: string; }; @@ -19,56 +45,64 @@ type ReusableObject = { }; type Step = { + stepId: string; onFailure?: (FailureAction | ReusableObject)[]; }; type Workflow = { + workflowId: string; steps: Step[]; - onFailure?: (FailureAction | ReusableObject)[]; - components?: { failureActions?: Record }; -}; - -const resolveReusableFailureActions = ( - reusableObject: ReusableObject, - components: Record, -): FailureAction | undefined => { - const refPath = reusableObject.reference.split('.').slice(1).join('.'); - return components[refPath]; + failureActions?: (FailureAction | ReusableObject)[]; }; function isFailureAction(action: unknown): action is FailureAction { - if (typeof action === 'object' && action !== null) { - const obj = action as Record; - return typeof obj.name === 'string' && typeof obj.type === 'string'; + 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 false; + + return resolvedAction; } + export default function getAllFailureActions( step: Step, workflow: Workflow, - components: Record, + arazzoSpec: ArazzoSpecification, ): FailureAction[] { const resolvedFailureActions: FailureAction[] = []; const resolvedStepFailureActions: FailureAction[] = []; - if (workflow.onFailure) { - workflow.onFailure.forEach(action => { - let actionToPush = action; + const resolveActions = (actions: (FailureAction | ReusableObject)[], targetArray: FailureAction[]): void => { + actions.forEach(action => { + let actionToPush: FailureAction; if (isPlainObject(action) && 'reference' in action) { - const resolvedAction = resolveReusableFailureActions(action, components); - if (resolvedAction) { - actionToPush = resolvedAction; - } + actionToPush = processReusableAction(action, arazzoSpec); + } else { + actionToPush = action; } if (isFailureAction(actionToPush)) { - const isDuplicate = resolvedFailureActions.some( - existingAction => - isFailureAction(existingAction) && - isFailureAction(actionToPush) && - existingAction.name === actionToPush.name, - ); + const isDuplicate = targetArray.some(existingAction => existingAction.name === actionToPush.name); if (isDuplicate) { actionToPush = { @@ -77,48 +111,26 @@ export default function getAllFailureActions( }; } - resolvedFailureActions.push(actionToPush); + targetArray.push(actionToPush); } }); + }; + + // Process workflow-level failure actions + if (workflow.failureActions) { + resolveActions(workflow.failureActions, resolvedFailureActions); } - //now process step onFailure actions into resolvedStepFailureActions and check for duplicates + // Process step-level failure actions if (step.onFailure) { - step.onFailure.forEach(action => { - let actionToPush = action; - - if (isPlainObject(action) && 'reference' in action) { - const resolvedAction = resolveReusableFailureActions(action, components); - if (resolvedAction) { - actionToPush = resolvedAction; - } - } - - if (isFailureAction(actionToPush)) { - const isDuplicate = resolvedStepFailureActions.some( - existingAction => - isFailureAction(existingAction) && - isFailureAction(actionToPush) && - existingAction.name === actionToPush.name, - ); - - if (isDuplicate) { - actionToPush = { - ...actionToPush, - name: `masked-duplicate-${actionToPush.name}`, - }; - } - - resolvedStepFailureActions.push(actionToPush); - } - }); + resolveActions(step.onFailure, resolvedStepFailureActions); } - //update below to process the resolvedStepFailureActions and overwrite duplicates in resolvedFailureActions + // 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; + resolvedFailureActions[existingActionIndex] = action; // Override workflow action with step action } else { resolvedFailureActions.push(action); } diff --git a/packages/rulesets/src/arazzo/functions/utils/getAllSuccessActions.ts b/packages/rulesets/src/arazzo/functions/utils/getAllSuccessActions.ts index 594677f77..89e949b5a 100644 --- a/packages/rulesets/src/arazzo/functions/utils/getAllSuccessActions.ts +++ b/packages/rulesets/src/arazzo/functions/utils/getAllSuccessActions.ts @@ -1,4 +1,22 @@ import { isPlainObject } from '@stoplight/json'; +import arazzoRuntimeExpressionValidation from '../arazzoRuntimeExpressionValidation'; + +type ArazzoSpecification = { + workflows: Workflow[]; + sourceDescriptions?: SourceDescription[]; + components?: { + parameters?: Record; + successActions?: Record; + failureActions?: Record; + [key: string]: unknown; + }; +}; + +type SourceDescription = { + name: string; + url: string; + type?: string; +}; type SuccessAction = { name: string; @@ -8,6 +26,16 @@ type SuccessAction = { criteria?: Criterion[]; }; +type FailureAction = { + name: string; + type: string; + workflowId?: string; + stepId?: string; + retryAfter?: number; + retryLimit?: number; + criteria?: Criterion[]; +}; + type Criterion = { condition: string; }; @@ -17,88 +45,63 @@ type ReusableObject = { }; type Step = { + stepId: string; onSuccess?: (SuccessAction | ReusableObject)[]; }; type Workflow = { + workflowId: string; steps: Step[]; successActions?: (SuccessAction | ReusableObject)[]; - components?: { successActions?: Record }; }; const resolveReusableSuccessActions = ( reusableObject: ReusableObject, - components: Record, + arazzoSpec: ArazzoSpecification, ): SuccessAction | undefined => { - const refPath = reusableObject.reference.split('.').slice(1).join('.'); - return components[refPath]; + const refPath = reusableObject.reference.replace('$components.successActions.', ''); + return arazzoSpec.components?.successActions?.[refPath]; }; function isSuccessAction(action: unknown): action is SuccessAction { - if (typeof action === 'object' && action !== null) { - const obj = action as Record; - return typeof obj.name === 'string' && typeof obj.type === 'string'; - } - return false; + return typeof action === 'object' && action !== null && 'name' in action && 'type' in action; } export default function getAllSuccessActions( step: Step, workflow: Workflow, - components: Record, + arazzoSpec: ArazzoSpecification, ): SuccessAction[] { const resolvedSuccessActions: SuccessAction[] = []; const resolvedStepSuccessActions: SuccessAction[] = []; - if (workflow.successActions) { - workflow.successActions.forEach(action => { - let actionToPush = action; + const processReusableAction = (action: ReusableObject): SuccessAction => { + const actionName = action.reference; - if (isPlainObject(action) && 'reference' in action) { - const resolvedAction = resolveReusableSuccessActions(action, components); - if (resolvedAction) { - actionToPush = resolvedAction; - } - } - - if (isSuccessAction(actionToPush)) { - const isDuplicate = resolvedSuccessActions.some( - existingAction => - isSuccessAction(existingAction) && - isSuccessAction(actionToPush) && - existingAction.name === actionToPush.name, - ); + if (!arazzoRuntimeExpressionValidation(action.reference, arazzoSpec)) { + return { name: `masked-invalid-reusable-success-action-reference-${actionName}`, type: '' }; + } - if (isDuplicate) { - actionToPush = { - ...actionToPush, - name: `masked-duplicate-${actionToPush.name}`, - }; - } + const resolvedAction = resolveReusableSuccessActions(action, arazzoSpec); + if (!resolvedAction) { + return { name: `masked-non-existing-success-action-reference-${actionName}`, type: '' }; + } - resolvedSuccessActions.push(actionToPush); - } - }); - } + return resolvedAction; + }; - if (step.onSuccess) { - step.onSuccess.forEach(action => { - let actionToPush = action; + const resolveActions = (actions: (SuccessAction | ReusableObject)[], targetArray: SuccessAction[]): void => { + actions.forEach(action => { + let actionToPush: SuccessAction; if (isPlainObject(action) && 'reference' in action) { - const resolvedAction = resolveReusableSuccessActions(action, components); - if (resolvedAction) { - actionToPush = resolvedAction; - } + actionToPush = processReusableAction(action); + } else { + actionToPush = action; } if (isSuccessAction(actionToPush)) { - const isDuplicate = resolvedStepSuccessActions.some( - existingAction => - isSuccessAction(existingAction) && - isSuccessAction(actionToPush) && - existingAction.name === actionToPush.name, - ); + const isDuplicate = targetArray.some(existingAction => existingAction.name === actionToPush.name); if (isDuplicate) { actionToPush = { @@ -107,15 +110,26 @@ export default function getAllSuccessActions( }; } - resolvedStepSuccessActions.push(actionToPush); + 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; + resolvedSuccessActions[existingActionIndex] = action; // Override workflow action with step action } else { resolvedSuccessActions.push(action); } diff --git a/packages/rulesets/src/arazzo/index.ts b/packages/rulesets/src/arazzo/index.ts index 0a94d0b5b..a10fa4933 100644 --- a/packages/rulesets/src/arazzo/index.ts +++ b/packages/rulesets/src/arazzo/index.ts @@ -1,5 +1,7 @@ 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'; @@ -16,65 +18,83 @@ 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}}', + recommended: true, + severity: 0, + given: '$', + then: { + function: arazzoDocumentSchema, + }, + }, 'arazzo-workflowId-unique': { description: 'Every workflow must have unique "workflowId".', recommended: true, severity: 0, - given: '$.workflows', + given: '$', then: { function: arazzoWorkflowIdUniqueness, }, }, 'arazzo-workflow-output-names-validation': { - description: 'Every workflow output must have unique name.', + description: 'Every workflow output must have unique name and its value must be a valid runtime expression.', + message: `{{error}}`, recommended: true, severity: 0, - given: '$.workflows[*].outputs', + given: '$', then: { function: arazzoWorkflowOutputNamesValidation, }, }, 'arazzo-workflow-stepId-unique': { description: 'Every step must have unique "stepId".', + message: `{{error}}`, recommended: true, severity: 0, - given: '$.steps', + given: '$.workflows[*]', then: { function: arazzoStepIdUniqueness, }, }, 'arazzo-step-output-names-validation': { - description: 'Every step output must have unique name.', + description: 'Every step output must have unique name and its value must be a valid runtime expression.', + message: `{{error}}`, recommended: true, severity: 0, - given: '$.steps[*].outputs', + given: '$', then: { function: arazzoStepOutputNamesValidation, }, }, 'arazzo-step-parameters-validation': { description: 'Step parameters and workflow parameters must be independently unique.', + message: `{{error}}`, recommended: true, severity: 0, - given: '$.workflow[*]', + given: '$.workflows[*]', then: { function: arazzoStepParametersValidation, }, }, 'arazzo-step-failure-actions-validation': { - description: 'Every failure action must have a unique name and "workflowId" and "stepId" are mutually exclusive.', + description: + 'Every failure action must have a unique "name", and the fields "workflowId" and "stepId" are mutually exclusive.', + message: `{{error}}`, recommended: true, severity: 0, - given: '$.workflows[*]', + given: '$', then: { function: arazzoStepFailureActionsValidation, }, }, 'arazzo-step-success-actions-validation': { - description: 'Every success action must have a unique name and "workflowId" and "stepId" are mutually exclusive.', + description: + 'Every success action must have a unique "name", and the fields "workflowId" and "stepId" are mutually exclusive.', + message: `{{error}}`, recommended: true, severity: 0, - given: '$.workflows[*]', + given: '$', then: { function: arazzoStepSuccessActionsValidation, }, @@ -83,13 +103,14 @@ export default { description: 'Every workflow dependency must be valid.', recommended: true, severity: 0, - given: '$.workflows[*]', + given: '$', then: { function: arazzoWorkflowDependsOnValidation, }, }, 'arazzo-step-success-criteria-validation': { description: 'Every success criteria must have a valid context, conditions, and types.', + message: `{{error}}`, recommended: true, severity: 0, given: '$.workflows[*]', @@ -107,13 +128,131 @@ export default { }, }, 'arazzo-step-validation': { - description: 'Every step must have a valid "stepId", "operationId", "operationPath", and "onSuccess" and "onFailure" actions.', + description: + 'Every step must have a valid "stepId" and an valid "operationId" or "operationPath" or "workflowId".', recommended: true, severity: 0, - given: '$.workflows[*]', + given: '$', then: { function: arazzoStepValidation, }, }, + 'arazzo-no-script-tags-in-markdown': { + description: 'Markdown descriptions must not have "', +``` + +### 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 + + + + + + + From 6a3fd3d5e312f4fe267bb8d70669991719cbaad9 Mon Sep 17 00:00:00 2001 From: Frank Kilcommins Date: Tue, 27 Aug 2024 13:41:04 +0100 Subject: [PATCH 20/28] chore(rulesets): modified yarn.lock --- yarn.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/yarn.lock b/yarn.lock index 9367db26a..ef66b1a85 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2651,7 +2651,7 @@ __metadata: "@stoplight/spectral-ref-resolver": ^1.0.4 "@stoplight/spectral-ruleset-bundler": ^1.5.4 "@stoplight/spectral-ruleset-migrator": ^1.9.5 - "@stoplight/spectral-rulesets": ^1.20.1 + "@stoplight/spectral-rulesets": 1.20.2 "@stoplight/spectral-runtime": ^1.1.2 "@stoplight/types": ^13.6.0 "@types/es-aggregate-error": ^1.0.2 @@ -2681,7 +2681,7 @@ __metadata: "@stoplight/better-ajv-errors": 1.0.3 "@stoplight/json": ~3.21.0 "@stoplight/path": 1.3.2 - "@stoplight/spectral-formats": "*" + "@stoplight/spectral-formats": ^1.7.0 "@stoplight/spectral-functions": "*" "@stoplight/spectral-parsers": "*" "@stoplight/spectral-ref-resolver": ^1.0.4 @@ -2709,7 +2709,7 @@ __metadata: languageName: unknown linkType: soft -"@stoplight/spectral-formats@*, @stoplight/spectral-formats@^1.0.0, @stoplight/spectral-formats@^1.7.0, @stoplight/spectral-formats@workspace:packages/formats": +"@stoplight/spectral-formats@^1.7.0, @stoplight/spectral-formats@workspace:packages/formats": version: 0.0.0-use.local resolution: "@stoplight/spectral-formats@workspace:packages/formats" dependencies: @@ -2750,7 +2750,7 @@ __metadata: "@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-parsers": "*" "@stoplight/spectral-runtime": ^1.1.0 ajv: ^8.17.1 @@ -2839,7 +2839,7 @@ __metadata: languageName: unknown linkType: soft -"@stoplight/spectral-rulesets@*, @stoplight/spectral-rulesets@^1.20.1, @stoplight/spectral-rulesets@workspace:packages/rulesets": +"@stoplight/spectral-rulesets@*, @stoplight/spectral-rulesets@1.20.2, @stoplight/spectral-rulesets@^1.20.1, @stoplight/spectral-rulesets@workspace:packages/rulesets": version: 0.0.0-use.local resolution: "@stoplight/spectral-rulesets@workspace:packages/rulesets" dependencies: From 98fcfe34cb6d07fed2b7b2f4780fdfc620e6ce41 Mon Sep 17 00:00:00 2001 From: Frank Kilcommins Date: Tue, 27 Aug 2024 23:28:14 +0100 Subject: [PATCH 21/28] chore(rulesets): add arazzoTypes and refactor validations and tests --- ...arazzoStepFailureActionsValidation.test.ts | 49 +--------- .../arazzoStepParametersValidation.test.ts | 40 +------- .../arazzoStepRequestBodyValidation.test.ts | 52 +---------- ...arazzoStepSuccessActionsValidation.test.ts | 47 +--------- ...razzoStepSuccessCriteriaValidation.test.ts | 27 +----- .../__tests__/arazzoStepValidation.test.ts | 73 +-------------- .../arazzoWorkflowIdUniqueness.test.ts | 20 +--- ...razzoWorkflowOutputNamesValidation.test.ts | 59 +----------- ...arazzoWorkflowsDependsOnValidation.test.ts | 61 +----------- .../functions/arazzoCriterionValidation.ts | 28 +----- .../arazzoRuntimeExpressionValidation.ts | 72 +-------------- .../arazzoStepFailureActionsValidation.ts | 52 +---------- .../arazzoStepOutputNamesValidation.ts | 61 +----------- .../arazzoStepParametersValidation.ts | 37 +------- .../arazzoStepRequestBodyValidation.ts | 52 +---------- .../arazzoStepSuccessActionsValidation.ts | 50 +--------- .../arazzoStepSuccessCriteriaValidation.ts | 27 +----- .../arazzo/functions/arazzoStepValidation.ts | 75 +-------------- .../arazzoWorkflowDependsOnValidation.ts | 62 +------------ .../functions/arazzoWorkflowIdUniqueness.ts | 56 ++++------- .../arazzoWorkflowOutputNamesValidation.ts | 62 +------------ .../src/arazzo/functions/types/arazzoTypes.ts | 92 +++++++++++++++++++ .../functions/utils/getAllFailureActions.ts | 61 +----------- .../functions/utils/getAllParameters.ts | 63 +------------ .../functions/utils/getAllSuccessActions.ts | 61 +----------- .../arazzo/functions/utils/getAllWorkflows.ts | 9 +- 26 files changed, 139 insertions(+), 1209 deletions(-) create mode 100644 packages/rulesets/src/arazzo/functions/types/arazzoTypes.ts diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepFailureActionsValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepFailureActionsValidation.test.ts index 18bde30da..79d6ea8f0 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepFailureActionsValidation.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepFailureActionsValidation.test.ts @@ -1,53 +1,6 @@ import arazzoStepFailureActionsValidation from '../arazzoStepFailureActionsValidation'; import type { RulesetFunctionContext } from '@stoplight/spectral-core'; - -type FailureAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - retryAfter?: number; - retryLimit?: number; - criteria?: Criterion[]; -}; - -type SourceDescription = { - name: string; - url: string; - type?: string; -}; - -type CriterionExpressionType = { - type: 'jsonpath' | 'xpath'; - version: 'draft-goessner-dispatch-jsonpath-00' | 'xpath-30' | 'xpath-20' | 'xpath-10'; -}; - -type Criterion = { - context?: string; - condition: string; - type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; -}; - -type ReusableObject = { - reference: string; -}; - -type Step = { - stepId: string; - onFailure?: (FailureAction | ReusableObject)[]; -}; - -type Workflow = { - workflowId: string; - steps: Step[]; - failureActions?: (FailureAction | ReusableObject)[]; -}; - -type ArazzoSpecification = { - sourceDescriptions?: SourceDescription[]; - workflows: Workflow[]; - components?: { failureActions?: Record }; -}; +import { ArazzoSpecification } from '../types/arazzoTypes'; const runRule = (target: ArazzoSpecification, _contextOverrides: Partial = {}) => { return arazzoStepFailureActionsValidation(target, null); diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepParametersValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepParametersValidation.test.ts index 4d6ec84a5..00efb026c 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepParametersValidation.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepParametersValidation.test.ts @@ -1,44 +1,6 @@ import arazzoStepParametersValidation from '../arazzoStepParametersValidation'; import type { RulesetFunctionContext } from '@stoplight/spectral-core'; - -type Parameter = { - name: string; - in?: string; - value: string; -}; - -type ReusableObject = { - reference: string; -}; - -type Step = { - stepId: string; - parameters?: (Parameter | ReusableObject)[]; - workflowId?: string; - operationId?: string; - operationPath?: string; -}; - -type Workflow = { - workflowId: string; - steps: Step[]; - inputs?: Record; -}; - -type SourceDescription = { - name: string; - url: string; - type?: string; -}; - -type ArazzoSpecification = { - workflows: Workflow[]; - components?: { - parameters?: Record; - inputs?: Record; - }; - sourceDescriptions?: SourceDescription[]; -}; +import { ArazzoSpecification } from '../types/arazzoTypes'; const runRule = (target: ArazzoSpecification, _contextOverrides: Partial = {}) => { return arazzoStepParametersValidation(target, null); diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepRequestBodyValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepRequestBodyValidation.test.ts index 159b2fc33..8a2e0f702 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepRequestBodyValidation.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepRequestBodyValidation.test.ts @@ -1,56 +1,6 @@ import arazzoStepRequestBodyValidation from '../arazzoStepRequestBodyValidation'; import type { RulesetFunctionContext } from '@stoplight/spectral-core'; - -type PayloadReplacement = { - target: string; - value: unknown | string; -}; - -type RequestBody = { - contentType?: string; - payload?: unknown | string; - replacements?: PayloadReplacement[]; -}; - -type Parameter = { - name: string; - in?: string; - value?: unknown; -}; - -type Step = { - stepId: string; - outputs?: Record; - requestBody?: RequestBody; - parameters?: (Parameter | ReusableObject)[]; -}; - -type ArazzoSpecification = { - workflows: Workflow[]; - sourceDescriptions?: SourceDescription[]; - components?: { - parameters?: Record; - [key: string]: unknown; - }; -}; - -type ReusableObject = { - reference: string; -}; - -type SourceDescription = { - name: string; - url: string; - type?: string; -}; - -type Workflow = { - workflowId: string; - steps: Step[]; - inputs?: Record; - parameters?: (Parameter | ReusableObject)[]; - outputs?: Record; -}; +import { ArazzoSpecification } from '../types/arazzoTypes'; const runRule = (target: ArazzoSpecification, _contextOverrides: Partial = {}) => { return arazzoStepRequestBodyValidation(target, null); diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessActionsValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessActionsValidation.test.ts index 03f2ccc12..85058a124 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessActionsValidation.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessActionsValidation.test.ts @@ -1,51 +1,6 @@ import arazzoStepSuccessActionsValidation from '../arazzoStepSuccessActionsValidation'; import type { RulesetFunctionContext } from '@stoplight/spectral-core'; - -type SuccessAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - criteria?: Criterion[]; -}; - -type SourceDescription = { - name: string; - url: string; - type?: string; -}; - -type CriterionExpressionType = { - type: 'jsonpath' | 'xpath'; - version: 'draft-goessner-dispatch-jsonpath-00' | 'xpath-30' | 'xpath-20' | 'xpath-10'; -}; - -type Criterion = { - context?: string; - condition: string; - type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; -}; - -type ReusableObject = { - reference: string; -}; - -type Step = { - stepId: string; - onSuccess?: (SuccessAction | ReusableObject)[]; -}; - -type Workflow = { - workflowId: string; - steps: Step[]; - successActions?: (SuccessAction | ReusableObject)[]; -}; - -type ArazzoSpecification = { - workflows: Workflow[]; - sourceDescriptions?: SourceDescription[]; - components?: { successActions?: Record }; -}; +import { ArazzoSpecification } from '../types/arazzoTypes'; const runRule = (target: ArazzoSpecification, _contextOverrides: Partial = {}) => { return arazzoStepSuccessActionsValidation(target, null); diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessCriteriaValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessCriteriaValidation.test.ts index 316a5b6d2..b2c724cc6 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessCriteriaValidation.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepSuccessCriteriaValidation.test.ts @@ -1,31 +1,6 @@ import arazzoStepSuccessCriteriaValidation from '../arazzoStepSuccessCriteriaValidation'; import type { RulesetFunctionContext } from '@stoplight/spectral-core'; - -type CriterionExpressionType = { - type: 'jsonpath' | 'xpath'; - version: 'draft-goessner-dispatch-jsonpath-00' | 'xpath-30' | 'xpath-20' | 'xpath-10'; -}; - -type Criterion = { - context?: string; - condition: string; - type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; -}; - -type Step = { - stepId: string; - successCriteria?: Criterion[]; -}; - -type Workflow = { - workflowId: string; - steps: Step[]; -}; - -type ArazzoSpecification = { - workflows: Workflow[]; - components?: object; -}; +import { ArazzoSpecification } from '../types/arazzoTypes'; const runRule = (target: ArazzoSpecification, _contextOverrides: Partial = {}) => { return arazzoStepSuccessCriteriaValidation(target, null); diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepValidation.test.ts index 1fcf1876e..90e8b025f 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepValidation.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoStepValidation.test.ts @@ -1,77 +1,6 @@ import arazzoStepValidation from '../arazzoStepValidation'; import type { IFunctionResult } from '@stoplight/spectral-core'; - -type SourceDescription = { - name: string; - url: string; - type?: string; -}; - -type ArazzoSpecification = { - workflows: Workflow[]; - sourceDescriptions?: SourceDescription[]; - components?: { - parameters?: Record; - successActions?: Record; - failureActions?: Record; - [key: string]: unknown; - }; -}; - -type Parameter = { - name: string; - in?: string; - value?: unknown; -}; - -type Workflow = { - workflowId: string; - steps: Step[]; - successActions?: (SuccessAction | ReusableObject)[]; - failureActions?: (FailureAction | ReusableObject)[]; - outputs?: Record; -}; - -type Step = { - stepId: string; - operationId?: string; - operationPath?: string; - workflowId?: string; - outputs?: Record; - onSuccess?: (SuccessAction | ReusableObject)[]; - onFailure?: (FailureAction | ReusableObject)[]; -}; - -type SuccessAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - criteria?: Criterion[]; -}; - -type FailureAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - criteria?: Criterion[]; -}; - -type Criterion = { - context?: string; - condition: string; - type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; -}; - -type CriterionExpressionType = { - type: 'jsonpath' | 'xpath'; - version: string; -}; - -type ReusableObject = { - reference: string; -}; +import { ArazzoSpecification } from '../types/arazzoTypes'; const runRule = (target: ArazzoSpecification): IFunctionResult[] => { return arazzoStepValidation(target, null); diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowIdUniqueness.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowIdUniqueness.test.ts index 41d51d97f..08888ddac 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowIdUniqueness.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowIdUniqueness.test.ts @@ -1,21 +1,9 @@ -import { DeepPartial } from '@stoplight/types'; +import { IFunctionResult } from '@stoplight/spectral-core'; import arazzoWorkflowIdUniqueness from '../arazzoWorkflowIdUniqueness'; -import type { RulesetFunctionContext } from '@stoplight/spectral-core'; +import { ArazzoSpecification } from '../types/arazzoTypes'; -const runRule = (target: { workflows: Record[] }) => { - const context: DeepPartial = { - path: [], - documentInventory: { - graph: {} as any, // Mock the graph property - referencedDocuments: {}, // Mock the referencedDocuments property as a Dictionary - findAssociatedItemForPath: jest.fn(), // Mock the findAssociatedItemForPath function - }, - document: { - formats: new Set(), // Mock the formats property correctly - }, - }; - - return arazzoWorkflowIdUniqueness(target, null, context as RulesetFunctionContext); +const runRule = (target: ArazzoSpecification): IFunctionResult[] => { + return arazzoWorkflowIdUniqueness(target, null); }; describe('arazzoWorkflowIdUniqueness', () => { diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowOutputNamesValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowOutputNamesValidation.test.ts index 3a119d8fd..2dfae2e38 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowOutputNamesValidation.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowOutputNamesValidation.test.ts @@ -1,64 +1,7 @@ import arazzoWorkflowOutputNamesValidation from '../arazzoWorkflowOutputNamesValidation'; import { DeepPartial } from '@stoplight/types'; import type { RulesetFunctionContext } from '@stoplight/spectral-core'; - -type ArazzoSpecification = { - workflows: Workflow[]; - sourceDescriptions?: SourceDescription[]; - components?: { - successActions?: Record; - failureActions?: Record; - [key: string]: unknown; - }; -}; - -type SourceDescription = { - name: string; - url: string; - type?: string; -}; - -type SuccessAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - criteria?: Criterion[]; -}; - -type FailureAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - criteria?: Criterion[]; -}; - -type Workflow = { - workflowId: string; - steps: Step[]; - outputs?: { [key: string]: string }; -}; - -type Step = { - stepId: string; - operationId?: string; - workflowId?: string; - operationPath?: string; - parameters?: Record; - outputs?: { [key: string]: string }; -}; - -type Criterion = { - context?: string; - condition: string; - type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; -}; - -type CriterionExpressionType = { - type: 'jsonpath' | 'xpath'; - version: string; -}; +import { ArazzoSpecification } from '../types/arazzoTypes'; const runRule = (target: ArazzoSpecification, contextOverrides: Partial = {}) => { const context: DeepPartial = { diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowsDependsOnValidation.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowsDependsOnValidation.test.ts index 7c06407c0..e1d88757c 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowsDependsOnValidation.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowsDependsOnValidation.test.ts @@ -1,66 +1,7 @@ import arazzoWorkflowDependsOnValidation from '../arazzoWorkflowDependsOnValidation'; import { IFunctionResult } from '@stoplight/spectral-core'; +import { ArazzoSpecification } from '../types/arazzoTypes'; -type SourceDescription = { - name: string; - url: string; - type?: string; -}; - -type Workflow = { - workflowId: string; - steps: Step[]; - dependsOn?: string[]; -}; - -type Step = { - stepId: string; - outputs?: { [key: string]: string }; -}; - -type Parameter = { - name: string; - in?: string; - value?: unknown; -}; - -type ArazzoSpecification = { - sourceDescriptions?: SourceDescription[]; - workflows: Workflow[]; - components?: { - parameters?: Record; - successActions?: Record; - failureActions?: Record; - [key: string]: unknown; - }; -}; - -type SuccessAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - criteria?: Criterion[]; -}; - -type FailureAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - criteria?: Criterion[]; -}; - -type Criterion = { - context?: string; - condition: string; - type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; -}; - -type CriterionExpressionType = { - type: 'jsonpath' | 'xpath'; - version: string; -}; const runRule = (target: ArazzoSpecification): IFunctionResult[] => { return arazzoWorkflowDependsOnValidation(target, null); }; diff --git a/packages/rulesets/src/arazzo/functions/arazzoCriterionValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoCriterionValidation.ts index fa365e470..1debce61e 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoCriterionValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoCriterionValidation.ts @@ -1,32 +1,6 @@ import { IFunctionResult } from '@stoplight/spectral-core'; import validateRuntimeExpression from './arazzoRuntimeExpressionValidation'; - -type CriterionExpressionType = { - type: 'jsonpath' | 'xpath'; - version: 'draft-goessner-dispatch-jsonpath-00' | 'xpath-30' | 'xpath-20' | 'xpath-10'; -}; - -type Criterion = { - context?: string; - condition: string; - type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; -}; - -type Step = { - stepId: string; - outputs?: { [key: string]: string }; -}; - -type Workflow = { - workflowId: string; - steps: Step[]; - outputs?: { [key: string]: string }; -}; - -type ArazzoSpecification = { - workflows: Workflow[]; - components?: { [key: string]: any }; -}; +import { Criterion, ArazzoSpecification } from './types/arazzoTypes'; export default function arazzoCriterionValidation( criterion: Criterion, diff --git a/packages/rulesets/src/arazzo/functions/arazzoRuntimeExpressionValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoRuntimeExpressionValidation.ts index 8b8053962..e8e014e25 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoRuntimeExpressionValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoRuntimeExpressionValidation.ts @@ -1,74 +1,4 @@ -type ArazzoSpecification = { - workflows: Workflow[]; - sourceDescriptions?: SourceDescription[]; - components?: { - parameters?: Record; - successActions?: Record; - failureActions?: Record; - [key: string]: unknown; - }; -}; - -type SourceDescription = { - name: string; - url: string; - type?: string; -}; - -type Workflow = { - workflowId: string; - steps: Step[]; - inputs?: Record; - parameters?: (Parameter | ReusableObject)[]; - successActions?: (SuccessAction | ReusableObject)[]; - failureActions?: (FailureAction | ReusableObject)[]; - outputs?: Record; -}; - -type Parameter = { - name: string; - in?: string; - value?: unknown; -}; - -type Step = { - stepId: string; - outputs?: Record; - parameters?: (Parameter | ReusableObject)[]; - onSuccess?: (SuccessAction | ReusableObject)[]; - onFailure?: (FailureAction | ReusableObject)[]; -}; - -type SuccessAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - criteria?: Criterion[]; -}; - -type FailureAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - criteria?: Criterion[]; -}; - -type Criterion = { - context?: string; - condition: string; - type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; -}; - -type CriterionExpressionType = { - type: 'jsonpath' | 'xpath'; - version: string; -}; - -type ReusableObject = { - reference: string; -}; +import { ArazzoSpecification, Step } from './types/arazzoTypes'; function isNonNullObject(value: unknown): value is Record { return value !== null && typeof value === 'object'; diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts index 29a296abc..2e14caf46 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoStepFailureActionsValidation.ts @@ -2,57 +2,7 @@ import type { IFunctionResult } from '@stoplight/spectral-core'; import getAllFailureActions from './utils/getAllFailureActions'; import arazzoCriterionValidation from './arazzoCriterionValidation'; import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidation'; - -type CriterionExpressionType = { - type: 'jsonpath' | 'xpath'; - version: 'draft-goessner-dispatch-jsonpath-00' | 'xpath-30' | 'xpath-20' | 'xpath-10'; -}; - -type Criterion = { - context?: string; - condition: string; - type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; -}; - -type FailureAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - retryAfter?: number; - retryLimit?: number; - criteria?: Criterion[]; -}; - -type ReusableObject = { - reference: string; -}; - -type Step = { - stepId: string; - onFailure?: (FailureAction | ReusableObject)[]; - workflowId?: string; - operationId?: string; - operationPath?: string; -}; - -type SourceDescription = { - name: string; - url: string; - type?: string; -}; - -type Workflow = { - workflowId: string; - steps: Step[]; - failureActions?: (FailureAction | ReusableObject)[]; -}; - -type ArazzoSpecification = { - sourceDescriptions?: SourceDescription[]; - workflows: Workflow[]; - components?: { failureActions?: Record }; -}; +import { ArazzoSpecification } from './types/arazzoTypes'; export default function arazzoStepFailureActionsValidation( target: ArazzoSpecification, diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepOutputNamesValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepOutputNamesValidation.ts index 9be07d2fb..6fef6615d 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoStepOutputNamesValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoStepOutputNamesValidation.ts @@ -1,69 +1,10 @@ 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.\-_]+$/; -type ArazzoSpecification = { - workflows: Workflow[]; - sourceDescriptions?: SourceDescription[]; - components?: { - parameters?: Record; - successActions?: Record; - failureActions?: Record; - [key: string]: unknown; - }; -}; - -type SourceDescription = { - name: string; - url: string; - type?: string; -}; - -type Parameter = { - name: string; - in?: string; - value?: unknown; -}; - -type SuccessAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - criteria?: Criterion[]; -}; - -type FailureAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - criteria?: Criterion[]; -}; -type Workflow = { - workflowId: string; - steps: Step[]; - outputs?: Record; -}; - -type Step = { - stepId: string; - outputs?: Record; -}; - -type Criterion = { - context?: string; - condition: string; - type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; -}; - -type CriterionExpressionType = { - type: 'jsonpath' | 'xpath'; - version: string; -}; - export default createRulesetFunction( { input: { diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepParametersValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepParametersValidation.ts index c6a223841..c55be5e3d 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoStepParametersValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoStepParametersValidation.ts @@ -1,42 +1,7 @@ import type { IFunctionResult } from '@stoplight/spectral-core'; import getAllParameters from './utils/getAllParameters'; import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidation'; - -type Parameter = { - name: string; - in?: string; - value?: unknown; -}; - -type ReusableObject = { - reference: string; -}; - -type Step = { - stepId: string; - parameters?: (Parameter | ReusableObject)[]; - workflowId?: string; - operationId?: string; - operationPath?: string; -}; - -type SourceDescription = { - name: string; - url: string; - type?: string; -}; - -type Workflow = { - workflowId: string; - steps: Step[]; - parameters?: (Parameter | ReusableObject)[]; -}; - -type ArazzoSpecification = { - sourceDescriptions?: SourceDescription[]; - workflows: Workflow[]; - components?: { parameters?: Record }; -}; +import { ArazzoSpecification } from './types/arazzoTypes'; export default function arazzoStepParametersValidation(target: ArazzoSpecification, _options: null): IFunctionResult[] { const results: IFunctionResult[] = []; diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepRequestBodyValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepRequestBodyValidation.ts index 38474eb85..64879aa76 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoStepRequestBodyValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoStepRequestBodyValidation.ts @@ -1,56 +1,6 @@ import { IFunctionResult } from '@stoplight/spectral-core'; import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidation'; - -type PayloadReplacement = { - target: string; - value: unknown | string; -}; - -type RequestBody = { - contentType?: string; - payload?: unknown | string; - replacements?: PayloadReplacement[]; -}; - -type Parameter = { - name: string; - in?: string; - value?: unknown; -}; - -type Step = { - stepId: string; - outputs?: Record; - requestBody?: RequestBody; - parameters?: (Parameter | ReusableObject)[]; -}; - -type ArazzoSpecification = { - workflows: Workflow[]; - sourceDescriptions?: SourceDescription[]; - components?: { - parameters?: Record; - [key: string]: unknown; - }; -}; - -type ReusableObject = { - reference: string; -}; - -type SourceDescription = { - name: string; - url: string; - type?: string; -}; - -type Workflow = { - workflowId: string; - steps: Step[]; - inputs?: Record; - parameters?: (Parameter | ReusableObject)[]; - outputs?: Record; -}; +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}$/; diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts index f82dbd519..f2c074a57 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoStepSuccessActionsValidation.ts @@ -2,55 +2,7 @@ import type { IFunctionResult } from '@stoplight/spectral-core'; import getAllSuccessActions from './utils/getAllSuccessActions'; import arazzoCriterionValidation from './arazzoCriterionValidation'; import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidation'; - -type CriterionExpressionType = { - type: 'jsonpath' | 'xpath'; - version: 'draft-goessner-dispatch-jsonpath-00' | 'xpath-30' | 'xpath-20' | 'xpath-10'; -}; - -type Criterion = { - context?: string; - condition: string; - type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; -}; - -type SuccessAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - criteria?: Criterion[]; -}; - -type ReusableObject = { - reference: string; -}; - -type Step = { - stepId: string; - onSuccess?: (SuccessAction | ReusableObject)[]; - workflowId?: string; - operationId?: string; - operationPath?: string; -}; - -type Workflow = { - workflowId: string; - steps: Step[]; - successActions?: (SuccessAction | ReusableObject)[]; -}; - -type SourceDescription = { - name: string; - url: string; - type?: string; -}; - -type ArazzoSpecification = { - sourceDescriptions?: SourceDescription[]; - workflows: Workflow[]; - components?: { successActions?: Record }; -}; +import { ArazzoSpecification } from './types/arazzoTypes'; export default function arazzoStepSuccessActionsValidation( target: ArazzoSpecification, diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepSuccessCriteriaValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepSuccessCriteriaValidation.ts index a1359af68..513a3043b 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoStepSuccessCriteriaValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoStepSuccessCriteriaValidation.ts @@ -1,31 +1,6 @@ import { IFunctionResult } from '@stoplight/spectral-core'; import arazzoCriterionValidation from './arazzoCriterionValidation'; - -type CriterionExpressionType = { - type: 'jsonpath' | 'xpath'; - version: 'draft-goessner-dispatch-jsonpath-00' | 'xpath-30' | 'xpath-20' | 'xpath-10'; -}; - -type Criterion = { - context?: string; - condition: string; - type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; -}; - -type Step = { - stepId: string; - successCriteria?: Criterion[]; -}; - -type Workflow = { - workflowId: string; - steps: Step[]; -}; - -type ArazzoSpecification = { - workflows: Workflow[]; - components?: object; -}; +import { ArazzoSpecification } from './types/arazzoTypes'; export default function arazzoStepSuccessCriteriaValidation( targetVal: ArazzoSpecification, diff --git a/packages/rulesets/src/arazzo/functions/arazzoStepValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoStepValidation.ts index a30378225..466e261b8 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoStepValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoStepValidation.ts @@ -1,79 +1,6 @@ import type { IFunctionResult } from '@stoplight/spectral-core'; import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidation'; - -type SourceDescription = { - name: string; - url: string; - type?: string; -}; - -type ArazzoSpecification = { - workflows: Workflow[]; - sourceDescriptions?: SourceDescription[]; - components?: { - parameters?: Record; - successActions?: Record; - failureActions?: Record; - [key: string]: unknown; - }; -}; - -type Workflow = { - workflowId: string; - steps: Step[]; - parameters?: (Parameter | ReusableObject)[]; - successActions?: (SuccessAction | ReusableObject)[]; - failureActions?: (FailureAction | ReusableObject)[]; - outputs?: Record; -}; - -type Step = { - stepId: string; - operationId?: string; - operationPath?: string; - workflowId?: string; - outputs?: Record; - parameters?: (Parameter | ReusableObject)[]; - onSuccess?: (SuccessAction | ReusableObject)[]; - onFailure?: (FailureAction | ReusableObject)[]; -}; - -type Parameter = { - name: string; - in?: string; - value?: unknown; -}; - -type SuccessAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - criteria?: Criterion[]; -}; - -type FailureAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - criteria?: Criterion[]; -}; - -type Criterion = { - context?: string; - condition: string; - type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; -}; - -type CriterionExpressionType = { - type: 'jsonpath' | 'xpath'; - version: string; -}; - -type ReusableObject = { - reference: string; -}; +import { ArazzoSpecification } from './types/arazzoTypes'; const OPERATION_PATH_REGEX = /^\{\$sourceDescriptions\.[a-zA-Z0-9_-]+\.(url)\}#.+$/; diff --git a/packages/rulesets/src/arazzo/functions/arazzoWorkflowDependsOnValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoWorkflowDependsOnValidation.ts index eb1aea05b..d3cee8b65 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoWorkflowDependsOnValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoWorkflowDependsOnValidation.ts @@ -1,67 +1,7 @@ import { IFunctionResult } from '@stoplight/spectral-core'; import { getAllWorkflows } from './utils/getAllWorkflows'; import arazzoRuntimeExpressionValidation from './arazzoRuntimeExpressionValidation'; - -type SourceDescription = { - name: string; - url: string; - type?: string; -}; - -type Workflow = { - workflowId: string; - steps: Step[]; - dependsOn?: string[]; -}; - -type Step = { - stepId: string; - outputs?: { [key: string]: string }; -}; - -type Parameter = { - name: string; - in?: string; - value?: unknown; -}; - -type ArazzoSpecification = { - workflows: Workflow[]; - sourceDescriptions?: SourceDescription[]; - components?: { - parameters?: Record; - successActions?: Record; - failureActions?: Record; - [key: string]: unknown; - }; -}; - -type SuccessAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - criteria?: Criterion[]; -}; - -type FailureAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - criteria?: Criterion[]; -}; - -type Criterion = { - context?: string; - condition: string; - type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; -}; - -type CriterionExpressionType = { - type: 'jsonpath' | 'xpath'; - version: string; -}; +import { ArazzoSpecification } from './types/arazzoTypes'; export default function arazzoWorkflowDependsOnValidation( targetVal: ArazzoSpecification, diff --git a/packages/rulesets/src/arazzo/functions/arazzoWorkflowIdUniqueness.ts b/packages/rulesets/src/arazzo/functions/arazzoWorkflowIdUniqueness.ts index 9e85e1de1..95347aa1a 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoWorkflowIdUniqueness.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoWorkflowIdUniqueness.ts @@ -1,43 +1,23 @@ -import { createRulesetFunction, IFunctionResult } from '@stoplight/spectral-core'; +import { IFunctionResult } from '@stoplight/spectral-core'; import { getAllWorkflows } from './utils/getAllWorkflows'; +import { ArazzoSpecification } from './types/arazzoTypes'; -export default createRulesetFunction<{ workflows: Record[] }, null>( - { - input: { - type: 'object', - properties: { - workflows: { - type: 'array', - items: { - type: 'object', - properties: { - workflowId: { - type: 'string', - }, - }, - }, - }, - }, - }, - options: null, - }, - function arazzoWorkflowIdUniqueness(targetVal, _) { - const results: IFunctionResult[] = []; - const workflows = getAllWorkflows(targetVal); +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 as string; - if (seenIds.has(workflowId)) { - results.push({ - message: `"workflowId" must be unique across all workflows.`, - path: [...path, 'workflowId'], - }); - } else { - seenIds.add(workflowId); - } + 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.`, + path: [...path, 'workflowId'], + }); + } else { + seenIds.add(workflowId); } + } - return results; - }, -); + return results; +} diff --git a/packages/rulesets/src/arazzo/functions/arazzoWorkflowOutputNamesValidation.ts b/packages/rulesets/src/arazzo/functions/arazzoWorkflowOutputNamesValidation.ts index 22a7bbe2a..658f2ac86 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoWorkflowOutputNamesValidation.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoWorkflowOutputNamesValidation.ts @@ -1,70 +1,10 @@ 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.\-_]+$/; -type ArazzoSpecification = { - workflows: Workflow[]; - sourceDescriptions?: SourceDescription[]; - components?: { - parameters?: Record; - successActions?: Record; - failureActions?: Record; - [key: string]: unknown; - }; -}; - -type SourceDescription = { - name: string; - url: string; - type?: string; -}; - -type Parameter = { - name: string; - in?: string; - value?: unknown; -}; - -type SuccessAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - criteria?: Criterion[]; -}; - -type FailureAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - criteria?: Criterion[]; -}; - -type Workflow = { - workflowId: string; - steps: Step[]; - outputs?: { [key: string]: string }; -}; - -type Step = { - stepId: string; - outputs?: { [key: string]: string }; -}; - -type Criterion = { - context?: string; - condition: string; - type?: 'simple' | 'regex' | 'jsonpath' | 'xpath' | CriterionExpressionType; -}; - -type CriterionExpressionType = { - type: 'jsonpath' | 'xpath'; - version: string; -}; - export default createRulesetFunction( { input: { 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 index 73b4743eb..0bdda3391 100644 --- a/packages/rulesets/src/arazzo/functions/utils/getAllFailureActions.ts +++ b/packages/rulesets/src/arazzo/functions/utils/getAllFailureActions.ts @@ -1,65 +1,6 @@ import { isPlainObject } from '@stoplight/json'; import arazzoRuntimeExpressionValidation from '../arazzoRuntimeExpressionValidation'; - -type ArazzoSpecification = { - workflows: Workflow[]; - sourceDescriptions?: SourceDescription[]; - components?: { - parameters?: Record; - successActions?: Record; - failureActions?: Record; - [key: string]: unknown; - }; -}; - -type Parameter = { - name: string; - in?: string; - value?: unknown; -}; - -type SourceDescription = { - name: string; - url: string; - type?: string; -}; - -type FailureAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - retryAfter?: number; - retryLimit?: number; - criteria?: Criterion[]; -}; - -type SuccessAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - criteria?: Criterion[]; -}; - -type Criterion = { - condition: string; -}; - -type ReusableObject = { - reference: string; -}; - -type Step = { - stepId: string; - onFailure?: (FailureAction | ReusableObject)[]; -}; - -type Workflow = { - workflowId: string; - steps: Step[]; - failureActions?: (FailureAction | ReusableObject)[]; -}; +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; diff --git a/packages/rulesets/src/arazzo/functions/utils/getAllParameters.ts b/packages/rulesets/src/arazzo/functions/utils/getAllParameters.ts index 4c7875db3..bc49089cb 100644 --- a/packages/rulesets/src/arazzo/functions/utils/getAllParameters.ts +++ b/packages/rulesets/src/arazzo/functions/utils/getAllParameters.ts @@ -1,67 +1,6 @@ import { isPlainObject } from '@stoplight/json'; import arazzoRuntimeExpressionValidation from '../arazzoRuntimeExpressionValidation'; - -type Parameter = { - name: string; - in?: string; - value?: unknown; -}; - -type Workflow = { - workflowId: string; - steps: Step[]; - parameters?: (Parameter | ReusableObject)[]; - components?: { parameters?: Record }; -}; - -type ArazzoSpecification = { - workflows: Workflow[]; - sourceDescriptions?: SourceDescription[]; - components?: { - parameters?: Record; - successActions?: Record; - failureActions?: Record; - [key: string]: unknown; - }; -}; - -type SourceDescription = { - name: string; - url: string; - type?: string; -}; - -type FailureAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - retryAfter?: number; - retryLimit?: number; - criteria?: Criterion[]; -}; - -type SuccessAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - criteria?: Criterion[]; -}; - -type Criterion = { - condition: string; -}; - -type ReusableObject = { - reference: string; -}; - -type Step = { - stepId: string; - parameters?: (Parameter | ReusableObject)[]; - onFailure?: (FailureAction | ReusableObject)[]; -}; +import { ArazzoSpecification, Workflow, Step, ReusableObject, Parameter } from '../types/arazzoTypes'; const resolveReusableParameter = ( reusableObject: ReusableObject, diff --git a/packages/rulesets/src/arazzo/functions/utils/getAllSuccessActions.ts b/packages/rulesets/src/arazzo/functions/utils/getAllSuccessActions.ts index ad726a13e..dd9d745d8 100644 --- a/packages/rulesets/src/arazzo/functions/utils/getAllSuccessActions.ts +++ b/packages/rulesets/src/arazzo/functions/utils/getAllSuccessActions.ts @@ -1,65 +1,6 @@ import { isPlainObject } from '@stoplight/json'; import arazzoRuntimeExpressionValidation from '../arazzoRuntimeExpressionValidation'; - -type ArazzoSpecification = { - workflows: Workflow[]; - sourceDescriptions?: SourceDescription[]; - components?: { - parameters?: Record; - successActions?: Record; - failureActions?: Record; - [key: string]: unknown; - }; -}; - -type Parameter = { - name: string; - in?: string; - value?: unknown; -}; - -type SourceDescription = { - name: string; - url: string; - type?: string; -}; - -type SuccessAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - criteria?: Criterion[]; -}; - -type FailureAction = { - name: string; - type: string; - workflowId?: string; - stepId?: string; - retryAfter?: number; - retryLimit?: number; - criteria?: Criterion[]; -}; - -type Criterion = { - condition: string; -}; - -type ReusableObject = { - reference: string; -}; - -type Step = { - stepId: string; - onSuccess?: (SuccessAction | ReusableObject)[]; -}; - -type Workflow = { - workflowId: string; - steps: Step[]; - successActions?: (SuccessAction | ReusableObject)[]; -}; +import { ArazzoSpecification, Workflow, Step, ReusableObject, SuccessAction } from '../types/arazzoTypes'; const resolveReusableSuccessActions = ( reusableObject: ReusableObject, diff --git a/packages/rulesets/src/arazzo/functions/utils/getAllWorkflows.ts b/packages/rulesets/src/arazzo/functions/utils/getAllWorkflows.ts index cace9917b..3ff3b16d1 100644 --- a/packages/rulesets/src/arazzo/functions/utils/getAllWorkflows.ts +++ b/packages/rulesets/src/arazzo/functions/utils/getAllWorkflows.ts @@ -1,13 +1,10 @@ import { isPlainObject } from '@stoplight/json'; import type { JsonPath } from '@stoplight/types'; +import { ArazzoSpecification, Workflow } from '../types/arazzoTypes'; -type WorkflowObject = Record; -type ArazzoDocument = { - workflows?: WorkflowObject[]; -}; -type Result = { path: JsonPath; workflow: WorkflowObject }; +type Result = { path: JsonPath; workflow: Workflow }; -export function* getAllWorkflows(arazzo: ArazzoDocument): IterableIterator { +export function* getAllWorkflows(arazzo: ArazzoSpecification): IterableIterator { const workflows = arazzo?.workflows; if (!Array.isArray(workflows)) { return; From ef86ef491567db413a0ba182985fcdf2a6405372 Mon Sep 17 00:00:00 2001 From: Frank Kilcommins Date: Wed, 28 Aug 2024 14:36:26 +0100 Subject: [PATCH 22/28] feat(ruleset-migrator): add arazzo support --- packages/cli/package.json | 2 +- packages/ruleset-bundler/package.json | 2 +- packages/ruleset-migrator/package.json | 2 +- packages/ruleset-migrator/src/transformers/extends.ts | 1 + packages/ruleset-migrator/src/transformers/formats.ts | 2 ++ yarn.lock | 6 +++--- 6 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index fca32558d..c01579858 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -41,7 +41,7 @@ "@stoplight/spectral-parsers": "^1.0.3", "@stoplight/spectral-ref-resolver": "^1.0.4", "@stoplight/spectral-ruleset-bundler": "^1.5.4", - "@stoplight/spectral-ruleset-migrator": "^1.9.5", + "@stoplight/spectral-ruleset-migrator": "^1.9.6", "@stoplight/spectral-rulesets": "1.20.2", "@stoplight/spectral-runtime": "^1.1.2", "@stoplight/types": "^13.6.0", diff --git a/packages/ruleset-bundler/package.json b/packages/ruleset-bundler/package.json index 140e4c924..f756be946 100644 --- a/packages/ruleset-bundler/package.json +++ b/packages/ruleset-bundler/package.json @@ -42,7 +42,7 @@ "@stoplight/spectral-functions": ">=1", "@stoplight/spectral-parsers": ">=1", "@stoplight/spectral-ref-resolver": "^1.0.4", - "@stoplight/spectral-ruleset-migrator": "^1.7.4", + "@stoplight/spectral-ruleset-migrator": "^1.9.6", "@stoplight/spectral-rulesets": "^1.20.1", "@stoplight/spectral-runtime": "^1.1.0", "@stoplight/types": "^13.6.0", 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/yarn.lock b/yarn.lock index ef66b1a85..616375224 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2650,7 +2650,7 @@ __metadata: "@stoplight/spectral-parsers": ^1.0.3 "@stoplight/spectral-ref-resolver": ^1.0.4 "@stoplight/spectral-ruleset-bundler": ^1.5.4 - "@stoplight/spectral-ruleset-migrator": ^1.9.5 + "@stoplight/spectral-ruleset-migrator": ^1.9.6 "@stoplight/spectral-rulesets": 1.20.2 "@stoplight/spectral-runtime": ^1.1.2 "@stoplight/types": ^13.6.0 @@ -2796,7 +2796,7 @@ __metadata: "@stoplight/spectral-functions": ">=1" "@stoplight/spectral-parsers": ">=1" "@stoplight/spectral-ref-resolver": ^1.0.4 - "@stoplight/spectral-ruleset-migrator": ^1.7.4 + "@stoplight/spectral-ruleset-migrator": ^1.9.6 "@stoplight/spectral-rulesets": ^1.20.1 "@stoplight/spectral-runtime": ^1.1.0 "@stoplight/types": ^13.6.0 @@ -2812,7 +2812,7 @@ __metadata: languageName: unknown linkType: soft -"@stoplight/spectral-ruleset-migrator@^1.7.4, @stoplight/spectral-ruleset-migrator@^1.9.5, @stoplight/spectral-ruleset-migrator@workspace:packages/ruleset-migrator": +"@stoplight/spectral-ruleset-migrator@^1.9.6, @stoplight/spectral-ruleset-migrator@workspace:packages/ruleset-migrator": version: 0.0.0-use.local resolution: "@stoplight/spectral-ruleset-migrator@workspace:packages/ruleset-migrator" dependencies: From 717103a3996c53c976e3163f5ddbf6947fb6425c Mon Sep 17 00:00:00 2001 From: Frank Kilcommins Date: Wed, 11 Sep 2024 12:39:58 +0100 Subject: [PATCH 23/28] chore(rulesets): address PR review comments --- README.md | 2 +- .../arazzoWorkflowIdUniqueness.test.ts | 2 +- .../functions/arazzoWorkflowIdUniqueness.ts | 2 +- packages/rulesets/src/arazzo/index.ts | 28 ++----------------- 4 files changed, 6 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index b8d8c66ab..9a2a3cb71 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ 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:arazzo"]' > .spectral.yaml diff --git a/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowIdUniqueness.test.ts b/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowIdUniqueness.test.ts index 08888ddac..45c4e6cfb 100644 --- a/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowIdUniqueness.test.ts +++ b/packages/rulesets/src/arazzo/functions/__tests__/arazzoWorkflowIdUniqueness.test.ts @@ -28,7 +28,7 @@ describe('arazzoWorkflowIdUniqueness', () => { expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ - message: `"workflowId" must be unique across all workflows.`, + message: `"workflowId" must be unique across all workflows. "workflow1" is duplicated.`, path: ['workflows', 1, 'workflowId'], }); }); diff --git a/packages/rulesets/src/arazzo/functions/arazzoWorkflowIdUniqueness.ts b/packages/rulesets/src/arazzo/functions/arazzoWorkflowIdUniqueness.ts index 95347aa1a..3155ec971 100644 --- a/packages/rulesets/src/arazzo/functions/arazzoWorkflowIdUniqueness.ts +++ b/packages/rulesets/src/arazzo/functions/arazzoWorkflowIdUniqueness.ts @@ -11,7 +11,7 @@ export default function arazzoWorkflowIdUniqueness(targetVal: ArazzoSpecificatio const workflowId = workflow.workflowId; if (seenIds.has(workflowId)) { results.push({ - message: `"workflowId" must be unique across all workflows.`, + message: `"workflowId" must be unique across all workflows. "${workflowId}" is duplicated.`, path: [...path, 'workflowId'], }); } else { diff --git a/packages/rulesets/src/arazzo/index.ts b/packages/rulesets/src/arazzo/index.ts index bf790d3fc..07420a8fc 100644 --- a/packages/rulesets/src/arazzo/index.ts +++ b/packages/rulesets/src/arazzo/index.ts @@ -21,7 +21,6 @@ export default { 'arazzo-document-schema': { description: 'Arazzo Document must be valid against the Arazzo schema.', message: '{{error}}', - recommended: true, severity: 0, given: '$', then: { @@ -30,7 +29,7 @@ export default { }, 'arazzo-workflowId-unique': { description: 'Every workflow must have unique "workflowId".', - recommended: true, + message: `{{error}}`, severity: 0, given: '$', then: { @@ -40,7 +39,6 @@ export default { 'arazzo-workflow-output-validation': { description: 'Every workflow output must have unique name and its value must be a valid runtime expression.', message: `{{error}}`, - recommended: true, severity: 0, given: '$', then: { @@ -50,7 +48,6 @@ export default { 'arazzo-workflow-stepId-unique': { description: 'Every step must have unique "stepId".', message: `{{error}}`, - recommended: true, severity: 0, given: '$.workflows[*]', then: { @@ -60,7 +57,6 @@ export default { 'arazzo-step-output-validation': { description: 'Every step output must have unique name and its value must be a valid runtime expression.', message: `{{error}}`, - recommended: true, severity: 0, given: '$', then: { @@ -70,7 +66,6 @@ export default { 'arazzo-step-parameters-validation': { description: 'Step parameters and workflow parameters must valid.', message: `{{error}}`, - recommended: true, severity: 0, given: '$', then: { @@ -81,7 +76,6 @@ export default { description: 'Every failure action must have a unique "name", and the fields "workflowId" and "stepId" are mutually exclusive.', message: `{{error}}`, - recommended: true, severity: 0, given: '$', then: { @@ -92,7 +86,6 @@ export default { description: 'Every success action must have a unique "name", and the fields "workflowId" and "stepId" are mutually exclusive.', message: `{{error}}`, - recommended: true, severity: 0, given: '$', then: { @@ -101,7 +94,6 @@ export default { }, 'arazzo-workflow-depends-on-validation': { description: 'Every workflow dependency must be valid.', - recommended: true, severity: 0, given: '$', then: { @@ -111,7 +103,6 @@ export default { 'arazzo-step-success-criteria-validation': { description: 'Every success criteria must have a valid context, conditions, and types.', message: `{{error}}`, - recommended: true, severity: 0, given: '$.workflows[*]', then: { @@ -120,7 +111,6 @@ export default { }, 'arazzo-step-request-body-validation': { description: 'Every step request body must have a valid `contentType` and use of runtime expressions.', - recommended: true, severity: 0, given: '$', then: { @@ -130,7 +120,6 @@ export default { 'arazzo-step-validation': { description: 'Every step must have a valid "stepId" and an valid "operationId" or "operationPath" or "workflowId".', - recommended: true, severity: 0, given: '$', then: { @@ -139,7 +128,6 @@ export default { }, 'arazzo-no-script-tags-in-markdown': { description: 'Markdown descriptions must not have "