@usearazzo/runner 0.0.0-stage → 1.0.1-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +20 -0
- package/README.md +784 -2
- package/dist/arazzo-runner.browser.js +111247 -0
- package/dist/arazzo-runner.browser.min.js +1 -0
- package/package.json +88 -4
- package/src/action/ActionResolver.cjs +160 -0
- package/src/action/ActionResolver.mjs +150 -0
- package/src/assembler/OpenAPI2DocumentAssembler.cjs +111 -0
- package/src/assembler/OpenAPI2DocumentAssembler.mjs +104 -0
- package/src/assembler/OpenAPI30DocumentAssembler.cjs +110 -0
- package/src/assembler/OpenAPI30DocumentAssembler.mjs +103 -0
- package/src/assembler/OpenAPI31DocumentAssembler.cjs +111 -0
- package/src/assembler/OpenAPI31DocumentAssembler.mjs +104 -0
- package/src/assembler/OpenAPIDocumentAssembler.cjs +58 -0
- package/src/assembler/OpenAPIDocumentAssembler.mjs +50 -0
- package/src/client/HTTPClient.cjs +5 -0
- package/src/client/HTTPClient.mjs +1 -0
- package/src/client/HTTPClientFetch.cjs +16 -0
- package/src/client/HTTPClientFetch.mjs +10 -0
- package/src/client/OpenAPIOperationRequest.cjs +5 -0
- package/src/client/OpenAPIOperationRequest.mjs +1 -0
- package/src/client/OpenAPIOperationResponse.cjs +42 -0
- package/src/client/OpenAPIOperationResponse.mjs +36 -0
- package/src/client/OpenAPIOperationResponseNormalizer.cjs +48 -0
- package/src/client/OpenAPIOperationResponseNormalizer.mjs +40 -0
- package/src/client/ParameterDelivery.cjs +119 -0
- package/src/client/ParameterDelivery.mjs +113 -0
- package/src/constants.cjs +9 -0
- package/src/constants.mjs +3 -0
- package/src/criterion/CriterionEvaluator.cjs +205 -0
- package/src/criterion/CriterionEvaluator.mjs +196 -0
- package/src/criterion/JSONPathCriterionEvaluator.cjs +47 -0
- package/src/criterion/JSONPathCriterionEvaluator.mjs +40 -0
- package/src/criterion/RegexCriterionEvaluator.cjs +67 -0
- package/src/criterion/RegexCriterionEvaluator.mjs +60 -0
- package/src/criterion/SimpleCriterionEvaluator.cjs +50 -0
- package/src/criterion/SimpleCriterionEvaluator.mjs +43 -0
- package/src/criterion/XPathCriterionEvaluator.cjs +117 -0
- package/src/criterion/XPathCriterionEvaluator.mjs +109 -0
- package/src/document/APIDocument.cjs +35 -0
- package/src/document/APIDocument.mjs +30 -0
- package/src/document/ArazzoDocument.cjs +81 -0
- package/src/document/ArazzoDocument.mjs +74 -0
- package/src/document/ArazzoSourceDescriptionIndex.cjs +26 -0
- package/src/document/ArazzoSourceDescriptionIndex.mjs +20 -0
- package/src/document/ArazzoStepIndex.cjs +25 -0
- package/src/document/ArazzoStepIndex.mjs +19 -0
- package/src/document/ArazzoWorkflowIndex.cjs +17 -0
- package/src/document/ArazzoWorkflowIndex.mjs +11 -0
- package/src/document/OpenAPIDocument.cjs +28 -0
- package/src/document/OpenAPIDocument.mjs +21 -0
- package/src/document/OpenAPIOperationIndex.cjs +24 -0
- package/src/document/OpenAPIOperationIndex.mjs +18 -0
- package/src/document/ParameterIdentity.cjs +93 -0
- package/src/document/ParameterIdentity.mjs +88 -0
- package/src/document/openapi-types.cjs +5 -0
- package/src/document/openapi-types.mjs +1 -0
- package/src/errors/ArazzoRunnerError.cjs +10 -0
- package/src/errors/ArazzoRunnerError.mjs +5 -0
- package/src/errors/AssemblerError.cjs +11 -0
- package/src/errors/AssemblerError.mjs +4 -0
- package/src/errors/ClientError.cjs +11 -0
- package/src/errors/ClientError.mjs +4 -0
- package/src/errors/CriterionError.cjs +11 -0
- package/src/errors/CriterionError.mjs +4 -0
- package/src/errors/ExecutionError.cjs +11 -0
- package/src/errors/ExecutionError.mjs +4 -0
- package/src/errors/ExtractionError.cjs +11 -0
- package/src/errors/ExtractionError.mjs +4 -0
- package/src/errors/InvalidEntryDocumentError.cjs +11 -0
- package/src/errors/InvalidEntryDocumentError.mjs +4 -0
- package/src/errors/NormalizationError.cjs +11 -0
- package/src/errors/NormalizationError.mjs +4 -0
- package/src/errors/ResolverError.cjs +11 -0
- package/src/errors/ResolverError.mjs +4 -0
- package/src/errors/RuntimeExpressionError.cjs +11 -0
- package/src/errors/RuntimeExpressionError.mjs +4 -0
- package/src/errors/UnmatchedProviderError.cjs +11 -0
- package/src/errors/UnmatchedProviderError.mjs +4 -0
- package/src/executor/ArazzoWorkflowLocatorNormalizer.cjs +147 -0
- package/src/executor/ArazzoWorkflowLocatorNormalizer.mjs +137 -0
- package/src/executor/OpenAPIOperationExecutor.cjs +457 -0
- package/src/executor/OpenAPIOperationExecutor.mjs +446 -0
- package/src/executor/OpenAPIOperationLocatorNormalizer.cjs +197 -0
- package/src/executor/OpenAPIOperationLocatorNormalizer.mjs +189 -0
- package/src/executor/SourceDescriptionsLocatorNormalizer.cjs +44 -0
- package/src/executor/SourceDescriptionsLocatorNormalizer.mjs +38 -0
- package/src/executor/StepExecutor.cjs +333 -0
- package/src/executor/StepExecutor.mjs +326 -0
- package/src/executor/StepRetryRunner.cjs +226 -0
- package/src/executor/StepRetryRunner.mjs +215 -0
- package/src/executor/StepTransitionInterpreter.cjs +159 -0
- package/src/executor/StepTransitionInterpreter.mjs +151 -0
- package/src/executor/WorkflowCallStack.cjs +116 -0
- package/src/executor/WorkflowCallStack.mjs +106 -0
- package/src/executor/WorkflowExecutor.cjs +1091 -0
- package/src/executor/WorkflowExecutor.mjs +1077 -0
- package/src/executor/abort.cjs +70 -0
- package/src/executor/abort.mjs +60 -0
- package/src/expression/RuntimeExpressionContext.cjs +5 -0
- package/src/expression/RuntimeExpressionContext.mjs +1 -0
- package/src/expression/RuntimeExpressionEvaluator.cjs +337 -0
- package/src/expression/RuntimeExpressionEvaluator.mjs +328 -0
- package/src/extractor/ArazzoStepExtractor.cjs +48 -0
- package/src/extractor/ArazzoStepExtractor.mjs +41 -0
- package/src/extractor/ArazzoWorkflowExtractor.cjs +50 -0
- package/src/extractor/ArazzoWorkflowExtractor.mjs +43 -0
- package/src/extractor/OpenAPIOperationExtractor.cjs +84 -0
- package/src/extractor/OpenAPIOperationExtractor.mjs +77 -0
- package/src/index.cjs +419 -0
- package/src/index.mjs +73 -0
- package/src/normalizer/ArazzoStepNormalizer.cjs +23 -0
- package/src/normalizer/ArazzoStepNormalizer.mjs +17 -0
- package/src/normalizer/ArazzoWorkflowNormalizer.cjs +214 -0
- package/src/normalizer/ArazzoWorkflowNormalizer.mjs +206 -0
- package/src/normalizer/OpenAPI2OperationNormalizer.cjs +128 -0
- package/src/normalizer/OpenAPI2OperationNormalizer.mjs +120 -0
- package/src/normalizer/OpenAPI30OperationNormalizer.cjs +129 -0
- package/src/normalizer/OpenAPI30OperationNormalizer.mjs +121 -0
- package/src/normalizer/OpenAPI31OperationNormalizer.cjs +131 -0
- package/src/normalizer/OpenAPI31OperationNormalizer.mjs +123 -0
- package/src/normalizer/OpenAPIOperationNormalizer.cjs +58 -0
- package/src/normalizer/OpenAPIOperationNormalizer.mjs +50 -0
- package/src/registry/DocumentRegistry.cjs +160 -0
- package/src/registry/DocumentRegistry.mjs +151 -0
- package/src/registry/providers/ArazzoDocumentRegistryProvider.cjs +154 -0
- package/src/registry/providers/ArazzoDocumentRegistryProvider.mjs +146 -0
- package/src/registry/providers/DocumentRegistryProvider.cjs +31 -0
- package/src/registry/providers/DocumentRegistryProvider.mjs +25 -0
- package/src/registry/providers/OpenAPIDocumentRegistryProvider.cjs +122 -0
- package/src/registry/providers/OpenAPIDocumentRegistryProvider.mjs +114 -0
- package/src/resolver/ArazzoValueResolver.cjs +42 -0
- package/src/resolver/ArazzoValueResolver.mjs +37 -0
- package/src/resolver/OpenAPIOperationParameterResolver.cjs +117 -0
- package/src/resolver/OpenAPIOperationParameterResolver.mjs +110 -0
- package/src/resolver/OutputResolver.cjs +71 -0
- package/src/resolver/OutputResolver.mjs +64 -0
- package/src/resolver/RequestBodyResolver.cjs +227 -0
- package/src/resolver/RequestBodyResolver.mjs +220 -0
- package/src/resolver/ResolverScope.cjs +5 -0
- package/src/resolver/ResolverScope.mjs +1 -0
- package/src/resolver/StepParameterResolver.cjs +22 -0
- package/src/resolver/StepParameterResolver.mjs +15 -0
- package/src/resolver/WorkflowParameterResolver.cjs +73 -0
- package/src/resolver/WorkflowParameterResolver.mjs +66 -0
- package/src/state/WorkflowExecutionState.cjs +99 -0
- package/src/state/WorkflowExecutionState.mjs +93 -0
- package/src/vendor/swagger-client.cjs +1 -0
- package/src/vendor/swagger-client.mjs +1 -0
- package/types/runner.d.ts +2341 -0
package/README.md
CHANGED
|
@@ -1,3 +1,785 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @usearazzo/runner
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> [!NOTE]
|
|
4
|
+
> This package is published under the `alpha` channel: APIs may still change before 1.0.0.
|
|
5
|
+
|
|
6
|
+
`@usearazzo/runner` executes [Arazzo Specification](https://spec.openapis.org/arazzo/latest.html)
|
|
7
|
+
workflows against live APIs described by
|
|
8
|
+
[OpenAPI Specification](https://spec.openapis.org/oas/latest.html) source descriptions. It builds on
|
|
9
|
+
[SpecLynx ApiDOM](https://github.com/speclynx/apidom) data models and reuses the loading,
|
|
10
|
+
resolution, and normalization primitives shared across
|
|
11
|
+
[`@usearazzo/parser`](https://www.npmjs.com/package/@usearazzo/parser) and
|
|
12
|
+
[`@usearazzo/resolver`](https://www.npmjs.com/package/@usearazzo/resolver).
|
|
13
|
+
|
|
14
|
+
**Supported Arazzo versions:**
|
|
15
|
+
|
|
16
|
+
- [Arazzo 1.0.0](https://spec.openapis.org/arazzo/v1.0.0)
|
|
17
|
+
- [Arazzo 1.0.1](https://spec.openapis.org/arazzo/v1.0.1)
|
|
18
|
+
- [Arazzo 1.1.0](https://spec.openapis.org/arazzo/v1.1.0)
|
|
19
|
+
|
|
20
|
+
**Supported OpenAPI versions (for source descriptions):**
|
|
21
|
+
|
|
22
|
+
- [OpenAPI 2.0](https://spec.openapis.org/oas/v2.0)
|
|
23
|
+
- [OpenAPI 3.0.x](https://spec.openapis.org/oas/v3.0.4)
|
|
24
|
+
- [OpenAPI 3.1.x](https://spec.openapis.org/oas/v3.1.2)
|
|
25
|
+
|
|
26
|
+
## Architecture
|
|
27
|
+
|
|
28
|
+
Running an Arazzo workflow is a pipeline of small, single-responsibility building blocks organized
|
|
29
|
+
into four main components:
|
|
30
|
+
|
|
31
|
+
- **`DocumentRegistry`**: loads and caches the Arazzo entry document and its OpenAPI source
|
|
32
|
+
descriptions, so each is fetched and parsed once.
|
|
33
|
+
- **`WorkflowExecutor`**: iterates a workflow's steps, owns the run state, and interprets
|
|
34
|
+
control-flow actions (`goto`, `retry`, `end`).
|
|
35
|
+
- **`StepExecutor`**: runs a single operation-shaped Arazzo step (`operationId` /
|
|
36
|
+
`operationPath`): locates its operation, resolves inputs, evaluates criteria and outputs, and
|
|
37
|
+
selects the next action. A `workflowId` step is run by `WorkflowExecutor` instead, which borrows
|
|
38
|
+
this component's criteria evaluation and action selection for it.
|
|
39
|
+
- **`OpenAPIOperationExecutor`**: runs a single OpenAPI operation: builds the HTTP request
|
|
40
|
+
(parameter serialization, securities, server resolution), sends it through the pluggable
|
|
41
|
+
`HTTPClient` transport, and normalizes the raw response into the runner's response model.
|
|
42
|
+
|
|
43
|
+
```mermaid
|
|
44
|
+
flowchart TD
|
|
45
|
+
Registry[("DocumentRegistry<br/><i>load & cache documents</i>")]
|
|
46
|
+
|
|
47
|
+
WF["WorkflowExecutor<br/>iterate steps · own state · control flow"]
|
|
48
|
+
Step["StepExecutor<br/>run one Arazzo step"]
|
|
49
|
+
Op["OpenAPIOperationExecutor<br/>build request · normalize response"]
|
|
50
|
+
HTTP["HTTPClient<br/>send the built request (fetch by default)"]
|
|
51
|
+
|
|
52
|
+
WF -->|"execute step"| Step
|
|
53
|
+
Step -->|"execute OpenAPI operation"| Op
|
|
54
|
+
Op -->|"send HTTP request"| HTTP
|
|
55
|
+
|
|
56
|
+
Registry -.->|documents| WF
|
|
57
|
+
Registry -.->|documents| Step
|
|
58
|
+
Registry -.->|documents| Op
|
|
59
|
+
|
|
60
|
+
%% brand colors: Arazzo green, OpenAPI green, neutral registry grey
|
|
61
|
+
classDef arazzo fill:#94C83D,stroke:#6BA543,stroke-width:1px,color:#231F20;
|
|
62
|
+
classDef openapi fill:#6BA543,stroke:#4D5A31,color:#fff;
|
|
63
|
+
classDef neutral fill:#424143,stroke:#231F20,color:#fff;
|
|
64
|
+
|
|
65
|
+
class WF,Step arazzo;
|
|
66
|
+
class Op,HTTP openapi;
|
|
67
|
+
class Registry neutral;
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Each layer reads run state but never mutates it. The `WorkflowExecutor` is the single writer that
|
|
71
|
+
records outputs and interprets the returned control-flow action.
|
|
72
|
+
|
|
73
|
+
`$sourceDescriptions.{name}.{reference}` locators are resolved by two normalizers that extend the
|
|
74
|
+
abstract `SourceDescriptionsLocatorNormalizer`: `OpenAPIOperationLocatorNormalizer` resolves a
|
|
75
|
+
step's `operationId` / `operationPath` to an OpenAPI operation, and
|
|
76
|
+
`ArazzoWorkflowLocatorNormalizer` resolves a step's `workflowId` to an Arazzo workflow.
|
|
77
|
+
|
|
78
|
+
## `DocumentRegistry`
|
|
79
|
+
|
|
80
|
+
Loads and caches Arazzo and OpenAPI documents, so a source description referenced by many steps is
|
|
81
|
+
fetched and parsed once.
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
import { DocumentRegistry } from '@usearazzo/runner';
|
|
85
|
+
|
|
86
|
+
const registry = new DocumentRegistry();
|
|
87
|
+
|
|
88
|
+
// the entry Arazzo document.
|
|
89
|
+
const arazzoDoc = await registry.acquireEntryDocument(
|
|
90
|
+
'https://ui.usearazzo.com/petstore-order-workflow.arazzo.yaml',
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// a source description, resolved by name to an absolute URI, then acquired.
|
|
94
|
+
const uri = arazzoDoc.resolveSourceDescriptionURI('petstoreAPI');
|
|
95
|
+
const openapiDoc = await registry.acquire(uri);
|
|
96
|
+
|
|
97
|
+
registry.clear(); // drop cached documents to reclaim memory
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## `WorkflowExecutor`
|
|
101
|
+
|
|
102
|
+
`WorkflowExecutor` is the stateful orchestrator that runs a whole workflow. It iterates a workflow's
|
|
103
|
+
steps in list order, calling `StepExecutor` per step, and owns the run state (a
|
|
104
|
+
`WorkflowExecutionState`) that accumulates each step's outputs so later steps can read
|
|
105
|
+
`$steps.*.outputs`. It interprets the control-flow actions `StepExecutor` only _selects_ (advancing
|
|
106
|
+
to the next step, jumping via `goto`, transferring one-way to another workflow via a `goto` naming a
|
|
107
|
+
`workflowId`, or stopping on `end` or the failure break-default), and resolves the workflow's
|
|
108
|
+
`outputs` against the final state — unless the run transferred, in which case it never reaches its
|
|
109
|
+
own end and `outputs` is always `{}`.
|
|
110
|
+
|
|
111
|
+
Give it the entry document, registry, and a `StepExecutor` once; call `execute` per run with a
|
|
112
|
+
`workflowId` and its `inputs`:
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
import {
|
|
116
|
+
DocumentRegistry,
|
|
117
|
+
OpenAPIOperationExecutor,
|
|
118
|
+
StepExecutor,
|
|
119
|
+
WorkflowExecutor,
|
|
120
|
+
} from '@usearazzo/runner';
|
|
121
|
+
|
|
122
|
+
const registry = new DocumentRegistry();
|
|
123
|
+
const arazzoDoc = await registry.acquireEntryDocument(
|
|
124
|
+
'https://ui.usearazzo.com/petstore-order-workflow.arazzo.yaml',
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// compose the engines bottom-up: operation executor → step executor → workflow
|
|
128
|
+
// executor. Each takes its collaborator rather than building one, so a stub
|
|
129
|
+
// drops in for tests at any layer. The operation executor sends requests
|
|
130
|
+
// through global fetch by default; pass { httpClient } to swap the transport.
|
|
131
|
+
const operationExecutor = new OpenAPIOperationExecutor();
|
|
132
|
+
const stepExecutor = new StepExecutor({ document: arazzoDoc, registry, operationExecutor });
|
|
133
|
+
const executor = new WorkflowExecutor({ document: arazzoDoc, registry, stepExecutor });
|
|
134
|
+
|
|
135
|
+
const result = await executor.execute('authenticateAndOrderPet', {
|
|
136
|
+
inputs: {
|
|
137
|
+
username: 'user1',
|
|
138
|
+
password: 'secret',
|
|
139
|
+
preferredPetStatus: 'available',
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
console.log(result.status); // 'completed' | 'ended' | 'failed' | 'transferred'
|
|
144
|
+
console.log(result.settledStatus); // 'completed' | 'ended' | 'failed' — the actual verdict; see Control flow
|
|
145
|
+
console.log(result.outputs); // workflow $outputs, resolved against final state
|
|
146
|
+
console.log(result.steps); // trace: each step's id, success, action, attempts, durationMs
|
|
147
|
+
console.log(result.durationMs); // elapsed time for the whole run
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`execute` takes the workflowId and an options bag:
|
|
151
|
+
|
|
152
|
+
| option | meaning |
|
|
153
|
+
| ------------------ | ------------------------------------------------------------------------------------ |
|
|
154
|
+
| `inputs` | the workflow's inputs, read via `$inputs` |
|
|
155
|
+
| `executeOptions` | opaque bag forwarded to every step's operation (e.g. `server`, `requestInterceptor`) |
|
|
156
|
+
| `dependencyInputs` | inputs for workflows run to satisfy `dependsOn`, keyed by the entry as written |
|
|
157
|
+
| `runDependencies` | run this workflow's `dependsOn` workflows first (default `true`) |
|
|
158
|
+
| `signal` | an `AbortSignal` cancelling the run (see [Cancellation](#cancellation)) |
|
|
159
|
+
|
|
160
|
+
The run state is created fresh per `execute` call and owned internally; the returned `result` is
|
|
161
|
+
read-only. Every layer takes its collaborator rather than building one: `WorkflowExecutor` takes a
|
|
162
|
+
`StepExecutor`, which takes an `OpenAPIOperationExecutor`, which takes the `httpClient` transport.
|
|
163
|
+
Each therefore stays agnostic to how the layer beneath it reaches the live API, and a deterministic
|
|
164
|
+
stub drops in for tests at any level.
|
|
165
|
+
|
|
166
|
+
### Control flow
|
|
167
|
+
|
|
168
|
+
After each step, the selected `onSuccess` / `onFailure` action determines what happens next:
|
|
169
|
+
|
|
170
|
+
- **no matching action**: success falls through to the next step; failure _breaks and returns_
|
|
171
|
+
(`status: 'failed'`);
|
|
172
|
+
- **`end`**: stops the run early with `status: 'ended'`, returning the outputs accumulated so far;
|
|
173
|
+
- **`goto` a `stepId`**: jumps to that step within the current workflow;
|
|
174
|
+
- **`goto` a `workflowId`**: transfers control to another workflow — see below;
|
|
175
|
+
- **`retry`**: re-runs the step's operation up to the action's `retryLimit` (default `1`), waiting
|
|
176
|
+
`retryAfter` seconds between attempts. Per spec, `retryLimit` is exhausted _before_ subsequent
|
|
177
|
+
failure actions run, so an exhausted `retry` falls through to the next matching failure action,
|
|
178
|
+
which may be another `retry` with its own independent budget or a terminal `end` or `goto`. If
|
|
179
|
+
none remains, the break-default applies. Each step's `attempts` count is surfaced in the result
|
|
180
|
+
trace.
|
|
181
|
+
|
|
182
|
+
A `retry` action may also carry a `stepId` or `workflowId` **reference** — a repair to run before
|
|
183
|
+
the next attempt (re-authenticate, reset a fixture). It fires once per attempt the action grants,
|
|
184
|
+
not once for the whole retry chain: after the `retryAfter` wait, before the step is re-run. A
|
|
185
|
+
`stepId` reference re-runs that step of the current workflow, charged against the same step budget
|
|
186
|
+
as any attempt; a `workflowId` reference runs that workflow to completion, with no inputs — the
|
|
187
|
+
specification gives a retry reference no input-mapping mechanism of its own, the same gap
|
|
188
|
+
`dependsOn` has — recording its outputs under `$workflows.<id>` the way a sub-workflow step does.
|
|
189
|
+
Neither kind's own success/failure actions are followed, and a failed reference does not break the
|
|
190
|
+
retry chain: the specification speaks of the reference's _completion_, not its success, and a
|
|
191
|
+
futile retry is still bounded by `retryLimit`. Reference runs are surfaced on the step's trace as
|
|
192
|
+
`retryReferences`, one entry per firing.
|
|
193
|
+
|
|
194
|
+
A `goto` naming a `workflowId` **transfers control one-way** to that workflow: unlike `end`, control
|
|
195
|
+
does not return. The specification calls `goto` "a one-way transfer of workflow control", and the
|
|
196
|
+
`stepId`/`workflowId` field descriptions condition "context transfers back upon completion" on
|
|
197
|
+
`retry` specifically — the scoping is the signal that a `goto`'s transfer does not return. The
|
|
198
|
+
calling workflow's own run ends at the `goto`, so its `outputs` declaration is never evaluated
|
|
199
|
+
(`result.outputs` is always `{}` on a transferred result) and `result.steps` keeps only the partial
|
|
200
|
+
trace up to the transfer. The target's own result is nested at `result.transferredTo`, with
|
|
201
|
+
`result.status` set to `'transferred'`; if the target itself transfers onward, the chain reads back
|
|
202
|
+
via `transferredTo.transferredTo`, recursively. Like a retry's `workflowId` reference, a transfer's
|
|
203
|
+
target runs with no inputs — a `goto` action carries no `parameters`, and the specification gives it
|
|
204
|
+
no input-mapping mechanism (the same gap issue `#62` tracks for retry references). A transfer joins
|
|
205
|
+
the same guarded call tree as everything else below — a self-transfer or a transfer cycle throws
|
|
206
|
+
`workflow-cycle`, and a legitimate transfer chain is bounded by `maxWorkflowDepth` — but, unlike a
|
|
207
|
+
retry's reference, entering the target is **not** charged against the step budget: a `goto` fires
|
|
208
|
+
once per step evaluation, with no way to re-fire from the same frame the way `retryLimit` lets a
|
|
209
|
+
reference do.
|
|
210
|
+
|
|
211
|
+
> [!NOTE]
|
|
212
|
+
> A transfer target is entered on the *transferring* workflow's own call-stack frame, which is why
|
|
213
|
+
> the cycle/depth rules above apply to it at all. This means a transfer chain does not behave like a
|
|
214
|
+
> `goto stepId` loop: since the transferring workflow's frame stays on the stack (there is no return
|
|
215
|
+
> to pop it), a workflow that transfers back to a workflow already on that chain — even indirectly,
|
|
216
|
+
> even once the loop's actual exit condition would have been met — throws `workflow-cycle` rather
|
|
217
|
+
> than looping, and a long acyclic chain of distinct workflows throws `workflow-depth` past
|
|
218
|
+
> `maxWorkflowDepth` (default `32`) rather than running indefinitely. A `goto stepId` loop bounded by
|
|
219
|
+
> ordinary data (poll until a status flips, say) has no such ceiling — only the shared step budget. A
|
|
220
|
+
> state machine expressed as workflows chained by `goto workflowId` should therefore be modeled as a
|
|
221
|
+
> bounded sequence, not a cycle that revisits a workflow already in progress.
|
|
222
|
+
|
|
223
|
+
Because a transferred result is not itself a verdict about success — only a record of where control
|
|
224
|
+
went — every `WorkflowExecutionResult` carries a second field, `settledStatus`, alongside `status`:
|
|
225
|
+
`'completed' | 'ended' | 'failed'`, computed eagerly (when the result is built, not when read) by
|
|
226
|
+
following a `'transferred'` result's `transferredTo` chain to its terminal, non-`transferred` result;
|
|
227
|
+
for a result that isn't `'transferred'`, it simply repeats `status`. Anywhere this library reads a
|
|
228
|
+
nested run's outcome — a `dependsOn` prerequisite, a sub-workflow step's own success, a retry
|
|
229
|
+
reference's recorded `successful` — it compares `settledStatus`, not `status`: a dependency,
|
|
230
|
+
sub-workflow step, or reference whose transfer chain settles `'completed'` or `'ended'` is treated as
|
|
231
|
+
satisfied; one that settles `'failed'` is treated as failed. See
|
|
232
|
+
[Composing workflows](#composing-workflows) for how this interacts with `$workflows.<id>.outputs`.
|
|
233
|
+
|
|
234
|
+
> [!IMPORTANT]
|
|
235
|
+
> `settledStatus` matters on the **top-level** result `execute()` returns, too, not only on nested
|
|
236
|
+
> ones. A root run that transfers reports `result.status === 'transferred'`, not `'failed'`, even when
|
|
237
|
+
> its target's own chain fails — `result.settledStatus === 'failed'` is the check that reflects where
|
|
238
|
+
> the run actually landed:
|
|
239
|
+
>
|
|
240
|
+
> ```js
|
|
241
|
+
> const result = await executor.execute('myWorkflow');
|
|
242
|
+
> if (result.settledStatus === 'failed') {
|
|
243
|
+
> // handles both an ordinary failed run and one that transferred into a failing chain
|
|
244
|
+
> }
|
|
245
|
+
> ```
|
|
246
|
+
>
|
|
247
|
+
> A bare `result.status === 'failed'` check silently misses the latter case. `settledStatus` is
|
|
248
|
+
> always present — comparing it is exactly as cheap as comparing `status`, no extra call required.
|
|
249
|
+
>
|
|
250
|
+
> For the terminal *result itself* — its `outputs`, `steps`, `workflowId` — rather than just its
|
|
251
|
+
> status, the exported `settledResult(result)` helper follows the same chain and returns the object it
|
|
252
|
+
> ends on (itself, unchanged, when `result.status` isn't `'transferred'`):
|
|
253
|
+
>
|
|
254
|
+
> ```js
|
|
255
|
+
> import { settledResult } from '@usearazzo/runner';
|
|
256
|
+
>
|
|
257
|
+
> const landedOn = settledResult(result);
|
|
258
|
+
> console.log(landedOn.workflowId, landedOn.outputs);
|
|
259
|
+
> ```
|
|
260
|
+
|
|
261
|
+
A runaway `goto` loop, a runaway `retry`, **or** a runaway tree of sub-workflow calls is bounded by
|
|
262
|
+
`maxSteps` (default `1000`), which counts every step attempt and throws `ExecutionError`
|
|
263
|
+
(`reason: 'step-budget'`) when exceeded. The budget is shared by the whole call tree rather than
|
|
264
|
+
granted afresh per workflow, so a sub-workflow cannot escape the ceiling the caller set; the error
|
|
265
|
+
carries the offending `path` (the chain of workflowIds in progress). The `retryAfter` delay uses an
|
|
266
|
+
injectable `sleep` (`WorkflowExecutorOptions.sleep`, default a real timer) so tests can run without
|
|
267
|
+
waiting, and `durationMs` uses an injectable `now` (default the monotonic `performance.now`) so
|
|
268
|
+
they can assert timings.
|
|
269
|
+
|
|
270
|
+
### Composing workflows
|
|
271
|
+
|
|
272
|
+
A step targeting a `workflowId` calls another workflow. Its `parameters` become that workflow's
|
|
273
|
+
inputs (mapped by `name`, and including any the calling workflow passed down), the sub-run is
|
|
274
|
+
recorded under
|
|
275
|
+
`$workflows.<id>`, and the step's own `outputs` and `successCriteria` are then resolved against that
|
|
276
|
+
updated state. A sub-run that ends `failed` puts the step on its failure path, so `onFailure` —
|
|
277
|
+
including `retry`, which re-runs the whole sub-workflow — applies exactly as for an operation step.
|
|
278
|
+
|
|
279
|
+
The called workflow is the step's result. The specification does not say what `$outputs` or the
|
|
280
|
+
HTTP-exchange expressions mean in such a step, and the runner reads them as follows:
|
|
281
|
+
|
|
282
|
+
- **`$outputs.<name>` names the called workflow's outputs** in the step's `successCriteria`, `outputs`
|
|
283
|
+
and action `criteria` — the reading every specification example that uses it relies on. The
|
|
284
|
+
enclosing workflow's own outputs do not exist until it ends, so that reading could never resolve
|
|
285
|
+
inside a step. Elsewhere `$outputs` keeps its usual meaning, and `$workflows.<id>.outputs.<name>`
|
|
286
|
+
works everywhere.
|
|
287
|
+
- **`$statusCode`, `$url`, `$method`, `$request.*` and `$response.*` throw** a
|
|
288
|
+
`RuntimeExpressionError` (`reason: 'unavailable-expression'`) naming the expression and the step
|
|
289
|
+
when used in its `successCriteria` or `outputs`: they describe one HTTP exchange, and a step that
|
|
290
|
+
calls a workflow made none. In an `onSuccess` / `onFailure` action's `criteria` they make the
|
|
291
|
+
action **not match** instead — actions are shared (inherited from the workflow's
|
|
292
|
+
`successActions` / `failureActions`, reused from `components`), so a default written for the
|
|
293
|
+
operation steps must not abort the run at a step that calls a workflow. The step's `parameters`
|
|
294
|
+
are resolved before the call, so there these expressions stay ordinary unresolved references.
|
|
295
|
+
|
|
296
|
+
```yaml
|
|
297
|
+
- stepId: place-order
|
|
298
|
+
workflowId: place-order
|
|
299
|
+
successCriteria:
|
|
300
|
+
- condition: $outputs.workflow_order_id != null # not $statusCode == 200
|
|
301
|
+
outputs:
|
|
302
|
+
my_order_id: $outputs.workflow_order_id
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
A workflow's `dependsOn` workflows are run to completion before its own steps. Each one's outputs
|
|
306
|
+
become readable as `$workflows.<id>.outputs`; they are not merged into the dependent's own outputs.
|
|
307
|
+
A dependency already completed in the same run is not run again, so a diamond does not duplicate its
|
|
308
|
+
side effects. Because the specification gives `dependsOn` no input-mapping mechanism, a dependency
|
|
309
|
+
that needs inputs takes them from the caller's `dependencyInputs` map; pass `runDependencies: false`
|
|
310
|
+
to assert they were already satisfied out-of-band. That assertion covers the workflow you name and
|
|
311
|
+
no more — a sub-workflow it calls still runs its own prerequisites, which the caller may not know
|
|
312
|
+
exist. A dependency that fails makes the run `failed` without executing any of its own steps.
|
|
313
|
+
|
|
314
|
+
Both recurse through one guarded call tree. Re-entering a workflow already in progress throws
|
|
315
|
+
(`reason: 'workflow-cycle'`, or `'dependsOn-cycle'` when every edge closing the loop is a dependency
|
|
316
|
+
edge), carrying the offending `path`; nesting past `maxWorkflowDepth` (default `32`, counting the
|
|
317
|
+
workflow `execute` was called with) throws `reason: 'workflow-depth'`. A diamond is not a cycle: a
|
|
318
|
+
workflow that completed and unwound may be entered again.
|
|
319
|
+
|
|
320
|
+
The result mirrors this structure. A step record carries `subWorkflows` — one nested
|
|
321
|
+
`WorkflowExecutionResult` per attempt, so a retried sub-workflow step keeps every attempt's trace —
|
|
322
|
+
and a result carries `dependencies`, the prerequisite runs in declaration order. A result whose
|
|
323
|
+
`status` is `'transferred'` (see [Control flow](#control-flow)) additionally carries `transferredTo`,
|
|
324
|
+
nesting the transfer target's own result — a workflow that ran `dependsOn` prerequisites before
|
|
325
|
+
transferring still reports them under `dependencies`; the two fields are independent, not exclusive.
|
|
326
|
+
|
|
327
|
+
A nested run reached this way — a sub-workflow step's target, a `dependsOn` prerequisite, a retry
|
|
328
|
+
reference — is judged by its `settledStatus`, not by a raw `'transferred'` status: a sub-workflow
|
|
329
|
+
step that targets a workflow which transfers onward to a completed run still takes
|
|
330
|
+
the success path, and a `dependsOn` prerequisite that transfers onward to a failed run still fails
|
|
331
|
+
the dependent. Either way, the nested run's own recorded identity and `$workflows.<id>.outputs`
|
|
332
|
+
reflect the workflow that was actually *called*, not the one control ended up in — a transferred
|
|
333
|
+
call always records `outputs: {}` there, for the same reason its own top-level `outputs` is `{}`
|
|
334
|
+
(see [Control flow](#control-flow)). The terminal outputs of a transfer chain are reachable, just
|
|
335
|
+
not under that key: follow `transferredTo.outputs` (or `transferredTo.transferredTo.outputs`, for a
|
|
336
|
+
chain) on the nested result itself, found on the calling step's `subWorkflows` entry or the result's
|
|
337
|
+
`dependencies` entry.
|
|
338
|
+
|
|
339
|
+
### Cross-document workflow references
|
|
340
|
+
|
|
341
|
+
Anywhere a workflow is referenced — a sub-workflow step's `workflowId`, a `dependsOn` entry, a
|
|
342
|
+
`retry` action's `workflowId` reference, a `goto`'s transfer target — the reference may name a
|
|
343
|
+
workflow of **another Arazzo document**, written as the runtime expression
|
|
344
|
+
`$sourceDescriptions.<name>.<workflowId>`. The named source description must resolve to an Arazzo
|
|
345
|
+
document; its declared `type` is not trusted — the source is classified by what it actually parses
|
|
346
|
+
to, the same way operation sources are. Only that dotted spec form is a workflow reference: the
|
|
347
|
+
resolver package's `$sourceDescriptions.<name>#/json/pointer` form is a `$ref`-time construct, not
|
|
348
|
+
a runtime expression, and is rejected (`reason: 'invalid-workflow-reference'`).
|
|
349
|
+
|
|
350
|
+
A foreign workflow runs **against its own document**: its plain `operationId`s resolve against
|
|
351
|
+
*that* document's source descriptions, its `$components` / `$sourceDescriptions` expressions
|
|
352
|
+
against that document, and its own plain workflow references (a nested `workflowId` step, its own
|
|
353
|
+
`dependsOn`) within that document. Source description *names* are likewise scoped to the document
|
|
354
|
+
they are written in — two documents may both name a source `petstoreAPI` and mean different
|
|
355
|
+
documents.
|
|
356
|
+
|
|
357
|
+
Two keying rules follow from the runtime-expression grammar and are worth knowing:
|
|
358
|
+
|
|
359
|
+
- **`$workflows.<id>` state stays keyed by the bare workflowId** — the only form an expression can
|
|
360
|
+
read it back by. If the entry document and a foreign document both define a workflow of one id
|
|
361
|
+
and one run records both, the later write wins under that key (the same last-write-wins two
|
|
362
|
+
same-document calls of one workflow already have). The cycle/depth guards, dependency
|
|
363
|
+
memoization, and caches are *not* affected — internally every workflow is identified by its
|
|
364
|
+
document URI together with its id (which is also how a foreign workflow displays in an error
|
|
365
|
+
`path`: `<documentURI>#<workflowId>`).
|
|
366
|
+
- **`dependencyInputs` is keyed by the `dependsOn` entry as written** — the bare id for a
|
|
367
|
+
same-document dependency, the whole `$sourceDescriptions.<name>.<workflowId>` expression for a
|
|
368
|
+
cross-document one.
|
|
369
|
+
|
|
370
|
+
A bad reference throws `ExecutionError`: `invalid-workflow-reference` (a `$`-prefixed reference
|
|
371
|
+
that is not a `$sourceDescriptions` expression), `source-description-not-found` (the named source
|
|
372
|
+
is absent), `source-description-not-arazzo` (it resolves to a non-Arazzo document — e.g. a workflow
|
|
373
|
+
reference into an OpenAPI source), or `workflow-not-found` (the document does not define the id;
|
|
374
|
+
the message names which document was searched).
|
|
375
|
+
|
|
376
|
+
### Workflow-level default actions
|
|
377
|
+
|
|
378
|
+
A workflow's `successActions` / `failureActions` apply to every step as a **default**. A step that
|
|
379
|
+
declares its own `onSuccess` / `onFailure` **overrides** the corresponding workflow list wholesale.
|
|
380
|
+
There is no per-action merge, and success and failure fall back independently (a step may override
|
|
381
|
+
only its failure actions and still inherit the workflow's success actions).
|
|
382
|
+
|
|
383
|
+
Like the parameters below, this is inheritance rather than dispatch, and happens in
|
|
384
|
+
`ArazzoWorkflowNormalizer`: a normalized workflow's steps already carry the defaults they inherit,
|
|
385
|
+
so the executors only ever read a step's own `onSuccess` / `onFailure`. Presence is what the
|
|
386
|
+
override keys off, not content — a step declaring `onSuccess: []` has overridden the default with an
|
|
387
|
+
empty set of actions and inherits nothing, whereas a step omitting the key entirely inherits.
|
|
388
|
+
|
|
389
|
+
Both kinds of step take the defaults, including one targeting a `workflowId`.
|
|
390
|
+
|
|
391
|
+
### Workflow-level parameters
|
|
392
|
+
|
|
393
|
+
A workflow's `parameters` apply to every step it contains. Unlike the actions above these **merge**
|
|
394
|
+
rather than replace: the specification lets a step override an inherited parameter but says it "can
|
|
395
|
+
never remove" one, so each step ends up with the union of the two lists, its own declaration
|
|
396
|
+
winning.
|
|
397
|
+
|
|
398
|
+
This happens in `ArazzoWorkflowNormalizer` too — a normalized workflow's steps already carry what
|
|
399
|
+
they inherit, exactly as a normalized OpenAPI operation already carries the parameters it inherits
|
|
400
|
+
from its Path Item. Neither executor knows about it.
|
|
401
|
+
|
|
402
|
+
A parameter's identity is the **`(name, in)` pair**, not `name` alone — the rule ApiDOM applies for
|
|
403
|
+
that OpenAPI-side inheritance ("a unique parameter is defined by a combination of a name and
|
|
404
|
+
location"). So a step declaring a `trace` query parameter overrides an inherited `trace` query
|
|
405
|
+
parameter but leaves an inherited `trace` *header* in place; they are two parameters bound for two
|
|
406
|
+
places. Names are case-sensitive, per the specification. A step's own parameters lead the merged
|
|
407
|
+
list, again matching the OpenAPI side.
|
|
408
|
+
|
|
409
|
+
Inheritance copies the **declarations**, not resolved values, so a workflow-level `value` that is a
|
|
410
|
+
runtime expression is still evaluated **once per step, in that step's own context**. A workflow
|
|
411
|
+
parameter reading `$steps.login.outputs.token` therefore means what it looks like it means: each
|
|
412
|
+
step sees the state as it was entered, not a value frozen when the workflow began.
|
|
413
|
+
|
|
414
|
+
A step targeting a `workflowId` inherits them too. Such a step's parameters are workflow inputs
|
|
415
|
+
rather than parts of a request and carry no `in`, so an absent `in` forms its own identity and name
|
|
416
|
+
alone decides what overrides what. An inherited parameter that *does* carry an `in` still applies
|
|
417
|
+
there, per the specification's "when the step in context specifies a `workflowId`, then all
|
|
418
|
+
parameters map to workflow inputs" — it arrives as an input under its bare name.
|
|
419
|
+
|
|
420
|
+
> [!NOTE]
|
|
421
|
+
> Resolved values are keyed by `name` alone when they are handed to the client, so two parameters
|
|
422
|
+
> differing only in `in` collapse into one at that boundary — the **step's own** survives, since the
|
|
423
|
+
> merged list is read most-specific-first. That collapse predates workflow-level parameters (a
|
|
424
|
+
> single step can declare both) and is tracked separately; what matters here is that a step can
|
|
425
|
+
> never lose to something it inherited.
|
|
426
|
+
|
|
427
|
+
### Cancellation
|
|
428
|
+
|
|
429
|
+
Pass an `AbortSignal` as `signal` to cancel a run:
|
|
430
|
+
|
|
431
|
+
```js
|
|
432
|
+
const controller = new AbortController();
|
|
433
|
+
setTimeout(() => controller.abort(), 5_000);
|
|
434
|
+
|
|
435
|
+
const result = await executor.execute('authenticateAndOrderPet', {
|
|
436
|
+
inputs: { username: 'user1', password: 'secret' },
|
|
437
|
+
signal: controller.signal,
|
|
438
|
+
});
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
The signal is observed at every boundary in the call tree — before each step, before each **retry
|
|
442
|
+
attempt** of a step, before entering a sub-workflow or a `dependsOn` prerequisite — and during the
|
|
443
|
+
`retryAfter` waits between attempts, which the default timer cuts short rather than sitting out. It
|
|
444
|
+
is also forwarded to the transport, so the request in flight when the abort lands is cancelled
|
|
445
|
+
rather than merely awaited (the `HTTPClient` contract asks every transport to honor
|
|
446
|
+
`request.signal` — global fetch does it for free, since the request doubles as fetch init; an axios
|
|
447
|
+
or undici client passes `request.signal` through in one line). A transport that ignores it costs
|
|
448
|
+
one in-flight request: the run still stops at the next boundary.
|
|
449
|
+
|
|
450
|
+
An aborted run throws `ExecutionError` with `reason: 'aborted'`, naming the boundary it stopped at
|
|
451
|
+
(`workflowId`, `stepId`, and the `path` of workflows in progress) and carrying the signal's own
|
|
452
|
+
`reason` as the error's `cause`. One shape covers every case: a transport that honors the signal
|
|
453
|
+
rejects the request it was told to drop, and that rejection is reported as the cancellation it is
|
|
454
|
+
rather than as the `ClientError` a request failing on its own terms would raise — so a run
|
|
455
|
+
cancelled mid-flight reads the same as one cancelled between two steps, whichever transport is
|
|
456
|
+
plugged in. It does **not** resolve as a `failed` result: cancellation is not
|
|
457
|
+
something the steps did, and the steps that never ran were not decided against by any criteria.
|
|
458
|
+
|
|
459
|
+
A `signal` passed through `executeOptions` instead — the only channel before this option existed —
|
|
460
|
+
cancels the run just the same; the first-class option wins when both are given. `StepExecutor`
|
|
461
|
+
honors a `signal` in its execute options when used on its own too, throwing `aborted` instead of
|
|
462
|
+
dispatching a request that would be cancelled on the wire.
|
|
463
|
+
|
|
464
|
+
### Authoring errors vs. failed runs
|
|
465
|
+
|
|
466
|
+
Same split as `StepExecutor`: a step whose `successCriteria` go unmet with no redirecting action is
|
|
467
|
+
a normal `status: 'failed'` result, **not** a throw — as is a run whose `dependsOn` workflow failed.
|
|
468
|
+
Authoring errors throw `ExecutionError` — as does a cancelled run (`aborted`, above): an unknown
|
|
469
|
+
`workflowId` (`workflow-not-found`), a `goto` to a step that does not exist
|
|
470
|
+
(`goto-target-not-found`), a `goto` naming neither `stepId` nor `workflowId`
|
|
471
|
+
(`goto-target-missing`), a `retry` reference to a `stepId` this workflow does not declare
|
|
472
|
+
(`retry-target-not-found`), an action of an unknown `type` (`unknown-action-type`), a
|
|
473
|
+
present but malformed `steps` or `dependsOn` (`malformed-steps`, `malformed-dependsOn`), a step, a
|
|
474
|
+
`goto`, or a `retry` reference naming more than one target (`ambiguous-target`), a cycle or
|
|
475
|
+
over-deep nesting (`workflow-cycle`, `dependsOn-cycle`, `workflow-depth`), the step-budget
|
|
476
|
+
overflow above (`step-budget`), or a bad cross-document reference (`invalid-workflow-reference`,
|
|
477
|
+
`source-description-not-found`, `source-description-not-arazzo` — see
|
|
478
|
+
[Cross-document workflow references](#cross-document-workflow-references)).
|
|
479
|
+
|
|
480
|
+
A workflow's own `steps` and `dependsOn` lists are validated before any of its prerequisites run, so
|
|
481
|
+
those two mistakes never fire live requests on the way to throwing. Errors belonging to an
|
|
482
|
+
individual step — a step naming two targets, a `goto` to a step that does not exist, an unknown
|
|
483
|
+
action `type` — are raised when the run reaches that step, which means earlier steps have already
|
|
484
|
+
executed. That is deliberate: a bad step the run never reaches should not fail an otherwise valid
|
|
485
|
+
run.
|
|
486
|
+
|
|
487
|
+
## `StepExecutor`
|
|
488
|
+
|
|
489
|
+
Executes a single operation-shaped Arazzo step — one that invokes an OpenAPI operation — returning
|
|
490
|
+
its outcome. It orchestrates the full per-step pipeline:
|
|
491
|
+
|
|
492
|
+
1. locate the step's operation (`operationId` or `operationPath`);
|
|
493
|
+
2. resolve the step's `parameters` and `requestBody` against the pre-request context (`$inputs`,
|
|
494
|
+
`$steps.*.outputs`, …);
|
|
495
|
+
3. delegate the call to `OpenAPIOperationExecutor`;
|
|
496
|
+
4. evaluate `successCriteria`, resolve `outputs`, and select the `onSuccess` / `onFailure` action
|
|
497
|
+
against the post-request context (`$statusCode`, `$response.*`, `$request.*`, `$url`, `$method`).
|
|
498
|
+
|
|
499
|
+
`StepExecutor` **reads run state and mutates nothing**: it returns the resolved outputs and the
|
|
500
|
+
selected action for the caller to record and interpret. A step targeting a `workflowId` (a
|
|
501
|
+
sub-workflow) is not an operation step and throws; running sub-workflows is the `WorkflowExecutor`'s
|
|
502
|
+
concern — which still evaluates such a step's `successCriteria` and selects its actions through
|
|
503
|
+
this component's public `evaluateCriteria` / `selectActions`, so step semantics live in one place.
|
|
504
|
+
|
|
505
|
+
A step executor is bound to the document its steps belong to (the `document` option): plain
|
|
506
|
+
`operationId`s resolve against that document's source descriptions, and expressions resolve their
|
|
507
|
+
`$components` / `$sourceDescriptions` against it. `forDocument(document)` derives a sibling
|
|
508
|
+
executor bound to another document, sharing the registry and operation executor — how
|
|
509
|
+
`WorkflowExecutor` runs the steps of a
|
|
510
|
+
[cross-document workflow](#cross-document-workflow-references).
|
|
511
|
+
|
|
512
|
+
It delegates the located operation to an injected `OpenAPIOperationExecutor` (below) rather than
|
|
513
|
+
building one, so it stays agnostic to the operation pipeline and HTTP stack:
|
|
514
|
+
|
|
515
|
+
```js
|
|
516
|
+
import {
|
|
517
|
+
DocumentRegistry,
|
|
518
|
+
ArazzoWorkflowExtractor,
|
|
519
|
+
ArazzoStepExtractor,
|
|
520
|
+
WorkflowExecutionState,
|
|
521
|
+
OpenAPIOperationExecutor,
|
|
522
|
+
StepExecutor,
|
|
523
|
+
} from '@usearazzo/runner';
|
|
524
|
+
|
|
525
|
+
const registry = new DocumentRegistry();
|
|
526
|
+
const arazzoDoc = await registry.acquireEntryDocument(
|
|
527
|
+
'https://ui.usearazzo.com/petstore-order-workflow.arazzo.yaml',
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
// pull a step out of the loaded Arazzo document by workflow + step id.
|
|
531
|
+
const workflow = new ArazzoWorkflowExtractor().extract(arazzoDoc, 'authenticateAndOrderPet');
|
|
532
|
+
const step = new ArazzoStepExtractor().extract(workflow, 'findAvailablePets');
|
|
533
|
+
|
|
534
|
+
const operationExecutor = new OpenAPIOperationExecutor();
|
|
535
|
+
const executor = new StepExecutor({ document: arazzoDoc, registry, operationExecutor });
|
|
536
|
+
|
|
537
|
+
// run state carries $inputs and accumulates $steps.*.outputs across a run.
|
|
538
|
+
const state = new WorkflowExecutionState({ inputs: { preferredPetStatus: 'available' } });
|
|
539
|
+
|
|
540
|
+
const outcome = await executor.execute(step, state);
|
|
541
|
+
|
|
542
|
+
console.log(outcome.successful); // true when every successCriterion passed
|
|
543
|
+
console.log(outcome.outputs); // resolved step outputs, keyed by name
|
|
544
|
+
console.log(outcome.action); // the selected onSuccess / onFailure action, or undefined
|
|
545
|
+
|
|
546
|
+
// a caller records the outputs so a later step can read $steps.findAvailablePets.outputs.*
|
|
547
|
+
state.setStepOutputs(outcome.stepId, outcome.outputs);
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
### Request body payloads
|
|
551
|
+
|
|
552
|
+
A parameter `value` or an `outputs` entry is either a literal or one whole runtime expression. A
|
|
553
|
+
`requestBody` payload is different: it "can contain" runtime expressions, so it is walked. Every
|
|
554
|
+
string in it — the payload itself, or any value nested in its objects and arrays — resolves by one
|
|
555
|
+
rule:
|
|
556
|
+
|
|
557
|
+
- a whole runtime expression (`$inputs.petId`) resolves to its **typed** value;
|
|
558
|
+
- any other string has its embedded `{expression}` occurrences interpolated into a **string**
|
|
559
|
+
(`"Order for {$inputs.name}"`, `"{$inputs.petId}"` → `"7"`). Interpolation is tolerant, so
|
|
560
|
+
literal braces stay as written — a JSON or XML template string works as-is.
|
|
561
|
+
|
|
562
|
+
Object keys are never evaluated, and neither are resolved values: a value read from `$inputs` that
|
|
563
|
+
happens to contain `{$...}` is data, not a template. Interpolated values are inserted raw — escaping
|
|
564
|
+
them for the payload's content type is up to the author — with `null` rendered as an empty string
|
|
565
|
+
and objects and arrays as JSON text. A `replacements` entry's `value` is not part of the walk: like a
|
|
566
|
+
parameter value, it is a literal or one whole expression, so `'{$inputs.petId}'` there stays literal.
|
|
567
|
+
|
|
568
|
+
```yaml
|
|
569
|
+
requestBody:
|
|
570
|
+
contentType: application/json
|
|
571
|
+
payload:
|
|
572
|
+
petId: $inputs.pet_id # typed: 10
|
|
573
|
+
couponCode: $inputs.coupon_code # omitted when coupon_code is not provided
|
|
574
|
+
note: 'Order for {$inputs.name}' # string
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
An expression that does not resolve reads as **not provided**. Where leaving it out is possible it
|
|
578
|
+
is left out: an object member is omitted, and a `replacements` entry whose `value` does not resolve
|
|
579
|
+
is skipped. Where it is not — a whole-expression payload, an array element (dropping it would shift
|
|
580
|
+
the indices), or an embedded expression (it would render as an empty string) — resolution throws a
|
|
581
|
+
`ResolverError` with `reason: 'unresolved-expression'` and the location as `target`
|
|
582
|
+
(`requestBody.payload#/order/note`). A `{$` always starts an expression attempt, so one that does
|
|
583
|
+
not parse — `{$inputs.}`, `{$foo.bar}`, an unterminated `{$url` — throws with
|
|
584
|
+
`reason: 'invalid-expression'` rather than being sent as text. A value that resolves to `null` is
|
|
585
|
+
provided and is kept.
|
|
586
|
+
|
|
587
|
+
### Authoring errors vs. failed steps
|
|
588
|
+
|
|
589
|
+
The two are deliberately distinct:
|
|
590
|
+
|
|
591
|
+
- A **received response with unmet criteria** is a normal outcome: `successful: false`, no throw.
|
|
592
|
+
- **Malformed input** throws an `ExecutionError` (a step with no operation target, more than one
|
|
593
|
+
mutually-exclusive target, a `workflowId` step, or an operation that cannot be located).
|
|
594
|
+
- A **field the specification types as `{expression}`** must hold a valid runtime expression, or the
|
|
595
|
+
step throws instead of carrying the text into later requests: a step or workflow `outputs` value
|
|
596
|
+
that is not one (`$response.body.id` is not Arazzo syntax; `$response.body#/id` is) raises a
|
|
597
|
+
`ResolverError` with `reason: 'invalid-expression'`, and a criterion's `context` that is not one,
|
|
598
|
+
whatever the criterion `type`, raises a `CriterionError` with `reason: 'invalid-context'`.
|
|
599
|
+
Literals stay valid where the specification types a field `Any` (parameter and payload values).
|
|
600
|
+
- A **cancelled run** throws too (`reason: 'aborted'`): an `AbortSignal` passed as `signal` in the
|
|
601
|
+
execute options is checked before the request is dispatched, so an already-aborted run does not
|
|
602
|
+
issue a request nobody is waiting for — and a request the transport drops mid-flight is reported
|
|
603
|
+
as the cancellation rather than as the `ClientError` a request failing on its own terms raises.
|
|
604
|
+
|
|
605
|
+
## `OpenAPIOperationExecutor`
|
|
606
|
+
|
|
607
|
+
Executes a single OpenAPI operation and returns its normalized response. It is **Arazzo-agnostic**:
|
|
608
|
+
it neither locates the operation nor resolves runtime expressions. Given a canonical locator
|
|
609
|
+
(`{ document, jsonPointer }`) it runs a three-stage pipeline:
|
|
610
|
+
|
|
611
|
+
1. **build**: extracts the operation from its owning document, normalizes it, assembles a minimal
|
|
612
|
+
standalone OpenAPI document containing just that operation, and builds the HTTP request from it:
|
|
613
|
+
parameter serialization (style/explode), security application, and server resolution, across
|
|
614
|
+
OpenAPI 2.0 through 3.1;
|
|
615
|
+
2. **send**: hands the built request to the pluggable `httpClient` transport (global fetch by
|
|
616
|
+
default);
|
|
617
|
+
3. **normalize**: deserializes the raw WHATWG `Response` by content type into the runner's
|
|
618
|
+
`OpenAPIOperationResponse`, the model runtime expressions and criteria see. Header values stay
|
|
619
|
+
strings as the server sent them (`$response.header.Date` is `Thu, 22 Oct 2026 11:45:22 GMT`),
|
|
620
|
+
and a repeated field, `Set-Cookie` included, is combined into one `", "`-joined string, as
|
|
621
|
+
`Headers.get` returns it.
|
|
622
|
+
|
|
623
|
+
Because the executor builds the request explicitly and holds it, the response carries the request
|
|
624
|
+
**as actually sent** (`response.request`): final URL, method, headers, and serialized body. That way
|
|
625
|
+
`$url` / `$method` / `$request.*` are evaluated against reality, not intent.
|
|
626
|
+
|
|
627
|
+
A step's `requestBody` works the same against every supported version, including Swagger 2.0, which
|
|
628
|
+
has no request-body concept and carries the payload as a declared `body` or `formData` parameter
|
|
629
|
+
instead. Its `contentType` is matched ignoring case and media-type parameters, and a declared range
|
|
630
|
+
counts as covering a concrete type, so `application/json; charset=utf-8` names a declared
|
|
631
|
+
`application/json` and either names a declared `*/*`. The form media types are the exception, since
|
|
632
|
+
they decide how the body is encoded and so must be declared exactly. A `contentType` matching
|
|
633
|
+
nothing raises a `ClientError` rather than sending an empty or wrongly encoded body.
|
|
634
|
+
|
|
635
|
+
### Choosing the server
|
|
636
|
+
|
|
637
|
+
By default the request goes to the first server the operation declares (for Swagger 2.0, the
|
|
638
|
+
document's `schemes` + `host` + `basePath`), with a relative server resolved against the URL the
|
|
639
|
+
document itself was loaded from. That covers the common case with no options at all.
|
|
640
|
+
|
|
641
|
+
**`server`** changes where it goes, and does double duty:
|
|
642
|
+
|
|
643
|
+
```js
|
|
644
|
+
// selection: the value matches a declared server, so its variable defaults
|
|
645
|
+
// come along and only the ones you name are overridden
|
|
646
|
+
await executor.execute(locator, {
|
|
647
|
+
server: 'https://{region}.example.com/{basePath}', // the raw template, as declared
|
|
648
|
+
serverVariables: { region: 'us' }, // basePath keeps its declared default
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
// override: the value matches nothing declared, so it simply is the base URL
|
|
652
|
+
await executor.execute(locator, { server: 'https://staging.internal.test/api' });
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
There is deliberately **no silent fallback**: a `server` matching nothing declared is never quietly
|
|
656
|
+
swapped for the first declared one. The URL you name is the URL that gets called, so a typo fails
|
|
657
|
+
loudly at the network rather than succeeding against the wrong host. An override replaces the
|
|
658
|
+
declared base path along with the host, so include it (`…/api/v3`) if the API expects one.
|
|
659
|
+
Overriding works on Swagger 2.0 too; selection and `serverVariables` are 3.x-only, since 2.0 has no
|
|
660
|
+
server list and no URL templates.
|
|
661
|
+
|
|
662
|
+
Relative servers resolve against the URL the document was loaded from, which the executor already
|
|
663
|
+
knows, so a document fetched from `https://petstore3.swagger.io/api/v3/openapi.json` declaring
|
|
664
|
+
`servers: [{ url: '/api/v3' }]` needs no configuration at all. For a document loaded from disk, give
|
|
665
|
+
`server` an absolute URL.
|
|
666
|
+
|
|
667
|
+
### The `HTTPClient` seam
|
|
668
|
+
|
|
669
|
+
The transport is the extension point, and its contract is deliberately minimal: a function from the
|
|
670
|
+
built request to a WHATWG `Response`. The default, exported as `httpClientFetch`, is global fetch in
|
|
671
|
+
one line (`(request) => fetch(request.url, request)`) and is used when no `httpClient` is given:
|
|
672
|
+
|
|
673
|
+
```js
|
|
674
|
+
import { fetch, Agent } from 'undici';
|
|
675
|
+
import { OpenAPIOperationExecutor } from '@usearazzo/runner';
|
|
676
|
+
|
|
677
|
+
// the default transport, no configuration needed
|
|
678
|
+
const executor = new OpenAPIOperationExecutor();
|
|
679
|
+
|
|
680
|
+
// any HTTP stack drops in with a few lines: here undici with a dispatcher the
|
|
681
|
+
// global fetch cannot take (connection-pool tuning, a proxy, client certificates)
|
|
682
|
+
const dispatcher = new Agent({ connections: 128, keepAliveTimeout: 60_000 });
|
|
683
|
+
|
|
684
|
+
const pooledExecutor = new OpenAPIOperationExecutor({
|
|
685
|
+
httpClient: (request) => fetch(request.url, { ...request, dispatcher }),
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// or serve canned responses in a test: no network, no interception hooks
|
|
689
|
+
const offlineExecutor = new OpenAPIOperationExecutor({
|
|
690
|
+
httpClient: async () =>
|
|
691
|
+
new Response('[{"id":1,"status":"available"}]', {
|
|
692
|
+
status: 200,
|
|
693
|
+
headers: { 'content-type': 'application/json' },
|
|
694
|
+
}),
|
|
695
|
+
});
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
`httpClientFetch` is exported so a transport that only wants to add behavior around the network call
|
|
699
|
+
can delegate to it rather than re-implement fetch.
|
|
700
|
+
|
|
701
|
+
The contract:
|
|
702
|
+
|
|
703
|
+
- resolve with a `Response` for **every** HTTP status: a non-2xx is a valid Arazzo outcome judged by
|
|
704
|
+
a step's `successCriteria`, never an error;
|
|
705
|
+
- throw only on genuine transport failure: the executor wraps it as a `ClientError` carrying the
|
|
706
|
+
original error as `cause`;
|
|
707
|
+
- honor `request.signal` when present.
|
|
708
|
+
|
|
709
|
+
Response normalization runs on the executor's side of the seam for every client, so `$response.body`
|
|
710
|
+
semantics stay identical no matter which transport is plugged in.
|
|
711
|
+
|
|
712
|
+
### Request decoration (and authentication)
|
|
713
|
+
|
|
714
|
+
Arazzo is auth-agnostic (a workflow document says nothing about credentials), so the runner does not
|
|
715
|
+
model authentication either. Credentials are applied by decorating the outgoing request with a
|
|
716
|
+
`requestInterceptor`, which runs after the request is built and before it is sent, may be
|
|
717
|
+
synchronous or asynchronous, and may mutate the request or return (or resolve with) a replacement:
|
|
718
|
+
|
|
719
|
+
```js
|
|
720
|
+
const executor = new OpenAPIOperationExecutor({
|
|
721
|
+
requestInterceptor: async (request) => {
|
|
722
|
+
request.headers.Authorization = `Bearer ${await acquireToken()}`;
|
|
723
|
+
},
|
|
724
|
+
});
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
A per-call `requestInterceptor` can also ride in the execute options bag; it runs after the
|
|
728
|
+
executor-level one.
|
|
729
|
+
|
|
730
|
+
Because the interceptor sets headers directly, it works regardless of how (or whether) the OpenAPI
|
|
731
|
+
document declares its security schemes. If you'd rather have credentials applied _from_ those
|
|
732
|
+
declarations, placed in the header, query, or cookie each scheme prescribes, `buildRequest`'s
|
|
733
|
+
`securities` option still passes through the execute options bag untouched; it is simply not part of
|
|
734
|
+
the typed surface.
|
|
735
|
+
|
|
736
|
+
Decoration via the interceptor, rather than inside a custom `httpClient`, is what makes the edits
|
|
737
|
+
part of the request record: the executor captures `response.request` (which feeds `$request.*` and
|
|
738
|
+
the trace) after the interceptors run and before the transport is called, so transport-side changes
|
|
739
|
+
are deliberately invisible to it.
|
|
740
|
+
|
|
741
|
+
### Standalone use
|
|
742
|
+
|
|
743
|
+
Because it is Arazzo-agnostic, the executor can be used **standalone**: with only an OpenAPI
|
|
744
|
+
document and an `operationId`, no Arazzo workflow involved. The operation index on the loaded
|
|
745
|
+
document maps an `operationId` to its JSON Pointer, which is all a locator needs:
|
|
746
|
+
|
|
747
|
+
```js
|
|
748
|
+
import { DocumentRegistry, OpenAPIOperationExecutor } from '@usearazzo/runner';
|
|
749
|
+
|
|
750
|
+
const registry = new DocumentRegistry();
|
|
751
|
+
const openapiDoc = await registry.acquire('https://petstore3.swagger.io/api/v3/openapi.json');
|
|
752
|
+
|
|
753
|
+
// build a canonical { document, jsonPointer } locator straight from the OpenAPI
|
|
754
|
+
// document; the operation index resolves an operationId to its JSON Pointer.
|
|
755
|
+
const locator = {
|
|
756
|
+
document: openapiDoc,
|
|
757
|
+
jsonPointer: openapiDoc.operationIndex.get('findPetsByStatus'),
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
const executor = new OpenAPIOperationExecutor();
|
|
761
|
+
|
|
762
|
+
// the operation's relative server resolves against the URL the document was
|
|
763
|
+
// loaded from, so there is nothing to configure.
|
|
764
|
+
const response = await executor.execute(locator, { parameters: { status: 'available' } });
|
|
765
|
+
|
|
766
|
+
console.log(response.status, response.body);
|
|
767
|
+
console.log(response.request.url); // the URL as actually sent
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
A non-2xx response is returned as data, not thrown; whether it counts as success is judged one level
|
|
771
|
+
up, by a step's `successCriteria`. Everything else throws with a named reason: malformed input (an
|
|
772
|
+
unlocatable operation, an unsupported OpenAPI version), a request that cannot be built (e.g. a
|
|
773
|
+
missing required parameter), and transport failures. The latter two arrive as `ClientError`.
|
|
774
|
+
|
|
775
|
+
## End-to-end tests
|
|
776
|
+
|
|
777
|
+
`npm run test:e2e` runs `authenticateAndOrderPet` (the toolkit's own petstore workflow) and
|
|
778
|
+
`loginUserRetrievePet` (the Arazzo Specification's `LoginAndRetrievePets` example) over real HTTP
|
|
779
|
+
against a live petstore. It is separate from `npm test`, which stays deterministic and makes no
|
|
780
|
+
network calls.
|
|
781
|
+
|
|
782
|
+
By default the suite starts a `swaggerapi/petstore3:1.0.28` container with Docker, on a free port,
|
|
783
|
+
and stops it afterwards, so Docker must be available. To run against an existing petstore instead,
|
|
784
|
+
set `PETSTORE_URL` to its API base, e.g. `PETSTORE_URL=http://localhost:8080/api/v3 npm run test:e2e`;
|
|
785
|
+
the suite then starts no container of its own.
|