@usearazzo/runner executes an Arazzo workflow. It sends real HTTP requests to the APIs the workflow’s OpenAPI source descriptions describe, one step at a time. It evaluates each step’s success criteria, follows the control flow the document declares (goto, retry, end), runs sub-workflows, and hands back a trace of every step.
Today it is a library. ArazzoRunner loads a document and runs its workflows in two calls, and the four building blocks it wires stay public for when you need to swap one. A CLI command to run workflows comes next.
Installation
npm install @usearazzo/runner
The package ships ESM and CommonJS builds with TypeScript declarations, and requires Node.js 20.10 or newer. It is pre-1.0: the version documented here is an alpha, and the API may still change before the stable release.
Run a workflow
Let’s run a real workflow end to end. petstore-order.arazzo.yaml logs a user in, finds an available pet, orders it, and checks the order. Its source description is the Swagger Petstore’s OpenAPI document.
The public Petstore at petstore3.swagger.io answers every order with a 500, so run your own copy. It is the same image the toolkit’s CI runs this workflow against:
docker run --rm -p 8080:8080 swaggerapi/petstore3:1.0.28
Save this script as run.mjs and run it with node run.mjs. It loads the workflow straight from this site:
import { ArazzoRunner } from '@usearazzo/runner';
const runner = await ArazzoRunner.load(
'https://usearazzo.com/assets/reference/runner/petstore-order.arazzo.yaml',
);
const result = await runner.execute('authenticateAndOrderPet', {
inputs: {
username: 'theUser',
password: '12345',
preferredPetStatus: 'available',
orderQuantity: 1,
shipDate: '2026-11-01T10:00:00.000Z',
},
executeOptions: { server: 'http://localhost:8080/api/v3' },
});
console.log(result.status);
console.log(result.outputs);
for (const step of result.steps) {
console.log(step.stepId, step.successful, step.attempts, `${Math.round(step.durationMs)} ms`);
}
completed
{
petName: 'Cat 1',
petCategory: 'Cats',
petId: 1,
orderId: 0,
orderStatus: 'placed',
orderQuantity: 1,
orderShipDate: '2026-11-01T10:00:00.000+00:00'
}
loginUser true 1 746 ms
findAvailablePets true 1 17 ms
placeOrder true 1 16 ms
retrieveOrderedPetDetails true 1 8 ms
verifyOrderConfirmation true 1 7 ms
Five requests went out, each judged by its step’s successCriteria, and the workflow’s outputs were resolved from what came back. The server option points every step at your local Petstore instead of the server the OpenAPI document declares (see Choosing the server).
Notice that every input is passed in. The runner does not fill in default values from the workflow’s inputs schema, so pass every input a step reads.
Coming next
Today you run workflows from your own code. Next, in this order:
- A command in the CLI to run workflows. It will print each step as it passes or fails, along with the result of each success criterion.
- Live progress. An event stream for following a run as it happens, for logging and OpenTelemetry.
Exports
These are the main exports:
| Export | Kind | What it is |
|---|---|---|
ArazzoRunner |
class | Loads an Arazzo document and runs its workflows. Start here. |
DocumentRegistry |
class | Loads and caches the Arazzo document and its source descriptions. |
WorkflowExecutor |
class | Runs a whole workflow and returns its result. |
StepExecutor |
class | Runs one step that calls an OpenAPI operation. |
OpenAPIOperationExecutor |
class | Builds, sends, and reads back one OpenAPI operation. |
httpClientFetch |
function | The default HTTP client: global fetch. |
settledResult |
function | Follows a transferred result to the run it ended in. |
WorkflowExecutionState |
class | Run state, for calling StepExecutor on its own. |
ArazzoWorkflowExtractor, ArazzoStepExtractor |
class | Pull a workflow or a step out of a loaded document by id. |
ArazzoRunnerError |
class | Base class of every error the runner throws. |
ExecutionError, ClientError, ResolverError, CriterionError, RuntimeExpressionError, InvalidEntryDocumentError |
class | The errors you are most likely to catch. See Errors. |
Every type on this page is exported too: ArazzoRunnerLoadOptions, ArazzoRunnerOptions, WorkflowExecutionResult, StepRunRecord, WorkflowExecuteOptions, HTTPClient, RequestInterceptor, and the rest. The package also exports the lower-level pieces the four building blocks are made of (normalizers, resolvers, criterion evaluators, document providers). Among them, OpenAPIOperationLocatorNormalizer resolves a step’s operationId or operationPath to an operation, and ArazzoWorkflowLocatorNormalizer resolves a workflowId to a workflow, both through $sourceDescriptions and both extending SourceDescriptionsLocatorNormalizer. Those are for replacing one part of the pipeline; the TypeScript declarations document them.
ArazzoRunner
ArazzoRunner.load(uri: string, options?: ArazzoRunnerLoadOptions): Promise<ArazzoRunner>
runner.execute(workflowId: string, options?: WorkflowExecuteOptions): Promise<WorkflowExecutionResult>
The way to run a workflow. load reads the Arazzo document at uri (a path or a URL) and wires the four building blocks with their defaults. execute runs one workflow of that document:
import { ArazzoRunner } from '@usearazzo/runner';
const runner = await ArazzoRunner.load('./petstore-order.arazzo.yaml');
const result = await runner.execute('authenticateAndOrderPet', { inputs });
execute is WorkflowExecutor.execute: it takes the same execute options and returns the same result. One runner can run any workflow of its document, as many times as you like; each run gets fresh state.
Load options
Every option is a seam of a building block the runner wires:
| Option | Goes to | Meaning |
|---|---|---|
registry |
DocumentRegistry |
The registry to load into. A new one by default. |
httpClient |
OpenAPIOperationExecutor |
Sends the requests. See The HTTP client. |
requestInterceptor |
OpenAPIOperationExecutor |
Changes every request before it is sent. See Authentication. |
maxSteps, maxWorkflowDepth, sleep, now |
WorkflowExecutor |
See Constructor options. |
const runner = await ArazzoRunner.load(uri, {
requestInterceptor: async (request) => {
request.headers.Authorization = `Bearer ${await acquireToken()}`;
},
});
runner.document is the loaded Arazzo document, and runner.registry the registry holding it and its source descriptions.
One registry, one document
A registry backs one entry document. Loading the same document into a registry again is fine; loading a different one throws InvalidEntryDocumentError:
const registry = new DocumentRegistry();
await ArazzoRunner.load('./petstore-order.arazzo.yaml', { registry });
await ArazzoRunner.load('./adopt-a-pet.arazzo.yaml', { registry });
// InvalidEntryDocumentError: Document at "./adopt-a-pet.arazzo.yaml" is not the entry document of the given registry
To bind a runner to a document you already loaded, use the constructor. It takes the load options above, with document and registry required, and throws InvalidEntryDocumentError unless document is the entry document of registry:
const registry = new DocumentRegistry();
const document = await registry.acquireEntryDocument('./petstore-order.arazzo.yaml');
const runner = new ArazzoRunner({ document, registry });
A document that is not Arazzo throws InvalidEntryDocumentError from load, as it does from acquireEntryDocument.
Four building blocks
ArazzoRunner wires these for you. Use them directly when you need to swap one, or to run a single step or operation on its own. Running a workflow is a pipeline of four parts, each with one job:
| Building block | Job |
|---|---|
DocumentRegistry |
Loads and caches the entry Arazzo document and its OpenAPI source descriptions, so each is fetched and parsed once. |
WorkflowExecutor |
Runs a workflow’s steps in order, owns the run state, and acts on the control flow (goto, retry, end). |
StepExecutor |
Runs one step that calls an operation: finds the operation, resolves the step’s inputs, evaluates its criteria and outputs, and picks the next action. |
OpenAPIOperationExecutor |
Runs one OpenAPI operation: builds the HTTP request, sends it through the HTTP client, and reads the response. |
HTTPClient is drawn dashed because it is the seam you are meant to replace.
You compose them bottom-up: the operation executor goes into the step executor, and the step executor goes into the workflow executor. Each takes its collaborator rather than building one, so you can drop a stub in at any level in tests.
Only WorkflowExecutor writes to the run state. The layers beneath it read the state and return what they found.
DocumentRegistry
new DocumentRegistry(options?: { providers?: DocumentRegistryProvider[] })
Loads and caches Arazzo and OpenAPI documents. A source description that many steps use is fetched and parsed once.
import { DocumentRegistry } from '@usearazzo/runner';
const registry = new DocumentRegistry();
// the entry Arazzo document: a path, a file: URL, or an http(s) URL
const document = await registry.acquireEntryDocument(
'https://usearazzo.com/assets/reference/runner/petstore-order.arazzo.yaml',
);
// a source description, by name, as an absolute URI
const uri = document.resolveSourceDescriptionURI('petstoreAPI');
const openapi = await registry.acquire(uri);
registry.clear(); // drop every cached document
| Member | What it does |
|---|---|
acquireEntryDocument(uri) |
Loads the Arazzo document a run starts from. Throws InvalidEntryDocumentError when the document is not Arazzo. |
acquire(uri) |
Loads any Arazzo or OpenAPI document, or returns the cached one. |
get(uri) |
Returns a document already loaded, or undefined. Never loads. |
entryDocument |
The entry document, once acquired. |
clear() |
Drops every cached document. |
A relative path resolves against the working directory. The executors load each source description when a step first needs it, so you never have to acquire them yourself.
An Arazzo document from the registry also answers resolveSourceDescriptionURI(name), resolveSourceDescriptionField(name, field), and sourceDescriptionURIs(). An OpenAPI document carries an operationIndex, which maps an operationId to its JSON Pointer (see Without a workflow). Both serialize with toJSON() and toYAML().
WorkflowExecutor
new WorkflowExecutor(options: WorkflowExecutorOptions)
executor.execute(workflowId: string, options?: WorkflowExecuteOptions): Promise<WorkflowExecutionResult>
Runs a whole workflow. You give it the entry document, the registry, and a step executor once. Then you call execute once per run, with a workflowId and its inputs.
All run state is local to one execute call, so concurrent runs on one executor do not interfere. The result is read-only.
Constructor options
| Option | Default | Meaning |
|---|---|---|
document |
required | The entry Arazzo document. |
registry |
required | The DocumentRegistry the document came from. |
stepExecutor |
required | The StepExecutor every step goes to. |
maxSteps |
1000 |
The most step attempts one run may make. See Budgets. |
maxWorkflowDepth |
32 |
How deeply workflows may nest, counting the one you called. |
sleep |
a real timer | (ms, signal) => Promise<void>, awaited between retry attempts. Pass a no-op in tests. |
now |
performance.now |
The clock behind every durationMs. |
Execute options
| Option | Meaning |
|---|---|
inputs |
The workflow’s inputs, read through $inputs. |
executeOptions |
Passed to every step’s operation, for example a server or a requestInterceptor. |
dependencyInputs |
Inputs for the workflows run to satisfy dependsOn, keyed by the entry as written. See dependsOn. |
runDependencies |
Run the dependsOn workflows first. Defaults to true. |
signal |
An AbortSignal that cancels the run. See Cancellation. |
The result
execute resolves with a WorkflowExecutionResult:
| Field | Meaning |
|---|---|
workflowId |
The workflow that was called. |
status |
'completed', 'ended', 'failed', or 'transferred'. |
settledStatus |
'completed', 'ended', or 'failed': where the run actually landed. See settledStatus. |
outputs |
The workflow’s outputs, resolved against the final state. Always {} on a transferred run. |
steps |
One StepRunRecord per step that ran, in run order. |
durationMs |
How long the run took, including dependencies, sub-workflows, and retry waits. |
dependencies |
The dependsOn runs, in declaration order. Present only when there were any. |
transferredTo |
The result of the workflow control was handed to. Present only when status is 'transferred'. |
The four statuses mean:
completed: the steps ran to the end of the list.ended: anendaction stopped the run early.failed: a step failed and noonFailureaction redirected it, or adependsOnworkflow failed.transferred: agotohanded control to another workflow for good.
Each StepRunRecord in steps carries:
| Field | Meaning |
|---|---|
stepId |
The step. |
successful |
Whether its successCriteria passed, on the last attempt. |
action |
The onSuccess or onFailure action that was selected, or undefined when none matched. |
attempts |
How many times the step ran: 1, or more after retries. |
durationMs |
Time across every attempt and the waits between them. |
subWorkflows |
One result per attempt, on a step that calls a workflow. |
retryReferences |
One record per retry reference that fired. |
A step that fails its criteria is not an error. It makes a failed result, which you read like any other. Only a broken document, a failed transport, or a cancelled run throws (see Errors).
Control flow
After each step, the first matching onSuccess or onFailure action decides what happens next:
| Action | What happens |
|---|---|
| none matched | Success moves on to the next step. Failure stops the run with status: 'failed'. |
end |
Stops the run with status: 'ended'. |
goto a stepId |
Jumps to that step in the current workflow. |
goto a workflowId |
Hands control to that workflow and does not come back. See Transfer. |
retry |
Runs the step again. See Retries. |
Retries
A retry action runs the step again up to its retryLimit (default 1), waiting retryAfter seconds before each attempt. A retryLimit of 3 therefore means up to four attempts in all. The trace’s attempts field counts them.
Per the specification, the retries run out before later failure actions are considered. An exhausted retry falls through to the next matching failure action. That may be another retry with its own budget, or an end or a goto. When none is left, the run fails.
A retry may also name a stepId or a workflowId to run before each attempt, for example a step that logs in again. The reference runs once per attempt, after the wait. A failed reference does not stop the retries: the specification speaks of the reference completing, not succeeding. A workflowId reference runs with no inputs, because the specification gives it no way to pass any. Its outputs are recorded under $workflows.<id>, as for a sub-workflow step. A stepId reference counts against the step budget like any other attempt. The reference’s own success and failure actions are not followed.
Transfer to another workflow
A goto that names a workflowId hands control over for good. The specification calls goto “a one-way transfer of workflow control”. The calling workflow stops there:
- its
statusis'transferred'; - its
outputsare{}, since it never reaches its own end; - its
stepshold the trace up to thegoto; transferredToholds the target’s own result. A chain of transfers nests:transferredTo.transferredTo.
The target runs with no inputs, because a goto carries no parameters. Entering it does not count against the step budget: a goto fires once per step, so it cannot spin the way a retry can.
A transfer stays on the caller’s call stack. A workflow that transfers back to one already on the chain throws workflow-cycle, even when the loop would have ended on its own. A long chain of distinct workflows throws workflow-depth past maxWorkflowDepth. So model a state machine of workflows as a bounded sequence. A loop that polls until something changes belongs in a goto to a stepId, which has no such limit.
settledStatus
'transferred' says where control went, not whether the run succeeded. settledStatus gives you the verdict. It follows the transferredTo chain to the run that did not transfer and repeats its status. On a run that did not transfer, it is the same as status.
So check settledStatus, not status, when you ask whether a run failed:
const result = await executor.execute('myWorkflow');
if (result.settledStatus === 'failed') {
// an ordinary failed run, or one that transferred into a failing chain
}
A plain result.status === 'failed' check misses the second case. For the result the chain ended in, with its outputs and steps, use settledResult:
import { settledResult } from '@usearazzo/runner';
const landedOn = settledResult(result);
console.log(landedOn.workflowId, landedOn.outputs);
The runner judges nested runs the same way. A sub-workflow step, a dependsOn workflow, or a retry reference whose chain settles 'completed' or 'ended' counts as a success.
Budgets
maxSteps (default 1000) bounds a runaway goto loop, a runaway retry, and a runaway tree of sub-workflow calls alike. Every attempt of every step counts. The budget is shared by the whole call tree, so a sub-workflow cannot escape the limit the caller set. Going over it throws ExecutionError with reason: 'step-budget' and the chain of workflows in progress as path.
Composing workflows
Sub-workflow steps
A step with a workflowId calls another workflow. Its parameters become that workflow’s inputs, matched by name. The run is recorded under $workflows.<id>, and the step’s own successCriteria and outputs are then evaluated.
Parameters the calling workflow passed down reach it too. A called workflow that fails puts the step on its failure path. onFailure then applies exactly as for an operation step, and a retry runs the whole sub-workflow again.
The specification does not say what the HTTP expressions mean in such a step, since it sent no request. The runner reads them this way:
$outputs.<name>is the called workflow’s output in the step’ssuccessCriteria,outputs, and actioncriteria. Every example in the specification that uses it relies on this reading.$workflows.<id>.outputs.<name>works everywhere.$statusCode,$url,$method,$request.*, and$response.*throw aRuntimeExpressionErrorwithreason: 'unavailable-expression'in the step’ssuccessCriteriaoroutputs. In an action’scriteriathey make the action not match instead, so a workflow-wide default written for operation steps does not stop the run.
- stepId: place-order
workflowId: place-order
successCriteria:
- condition: $outputs.workflow_order_id != null # not $statusCode == 200
outputs:
my_order_id: $outputs.workflow_order_id
dependsOn
A workflow’s dependsOn workflows run to completion before its own steps. Each one’s outputs are readable as $workflows.<id>.outputs. A dependency that already completed in the same run is not run again, so a diamond does not repeat its side effects. A dependency that fails makes the run failed before any of its own steps run.
The specification gives dependsOn no way to pass inputs. A dependency that needs them takes them from dependencyInputs, keyed by the dependsOn entry as written:
await executor.execute('placeOrder', {
inputs: { petId: 1 },
dependencyInputs: { login: { username: 'theUser', password: '12345' } },
});
Pass runDependencies: false when the dependencies were already satisfied some other way. That covers the workflow you name and no more. A sub-workflow it calls still runs its own dependencies, which you may not even know about.
A nested run records the workflow that was called, not the one control ended up in. So when a called workflow transfers onward, $workflows.<id>.outputs is {}, for the same reason its own outputs are. The final outputs are on the nested result itself: follow transferredTo on the step’s subWorkflows entry or the result’s dependencies entry. A result may carry both dependencies and transferredTo; a workflow that ran its prerequisites and then transferred reports both.
Re-entering a workflow already in progress throws ExecutionError with reason: 'workflow-cycle' (or 'dependsOn-cycle' when every edge of the loop is a dependsOn edge). A diamond is not a cycle: a workflow that finished may run again.
Other Arazzo documents
Anywhere a workflow is named (a step’s workflowId, a dependsOn entry, a retry reference, a goto), it may be a workflow of another Arazzo document:
- stepId: order
workflowId: $sourceDescriptions.petstoreFlows.authenticateAndOrderPet
The named source description has to be an Arazzo document. The runner checks what the document actually is rather than trusting its type.
A workflow from another document runs against that document. Its operationIds, its expressions, and its own workflow references resolve there. Source description names belong to the document they are written in, so two documents may each have a petstoreAPI that means different APIs.
Two details follow from the runtime expression grammar:
$workflows.<id>uses the bare workflow id. If two documents both have a workflowloginand one run calls both, the later run wins under that key. Cycle detection and caching tell the two apart; an errorpathshows a foreign workflow as<documentURI>#<workflowId>.dependencyInputsuses the entry as written: the bare id for the same document, the whole$sourceDescriptions.<name>.<workflowId>expression for another one.
What steps inherit
A workflow hands its steps two things, and they behave in opposite ways.
Default actions replace. A workflow’s successActions and failureActions apply to every step that declares none of its own. A step that declares onSuccess or onFailure replaces the workflow’s list entirely; there is no merging. Success and failure fall back separately. Declaring the key is what counts: onFailure: [] means no failure actions, not the workflow’s.
Parameters merge. A workflow’s parameters reach every step. The specification lets a step override an inherited parameter but never remove one, so each step gets both lists, its own declaration winning. A parameter is identified by its name and in together: a step’s trace query parameter overrides an inherited trace query parameter, and leaves an inherited trace header alone.
Inheritance copies declarations, not values. A workflow parameter with the value $steps.login.outputs.token is evaluated for each step when that step runs, so every step sees the token as it is at that moment.
Steps that call a workflow inherit both too. Their parameters are inputs, with no in, so the name alone decides what overrides what. An inherited parameter that does carry an in arrives as an input under its bare name.
One limit: resolved values are handed to the request builder keyed by name alone. Two parameters that differ only in in collapse into one there, and the step’s own wins. A step can never lose to something it inherited.
Cancellation
Pass an AbortSignal as signal to cancel a run:
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
const result = await executor.execute('authenticateAndOrderPet', {
inputs,
signal: controller.signal,
});
The runner checks the signal before each step, before each retry attempt, and before entering a sub-workflow or a dependsOn workflow. A retry wait is cut short. The signal also reaches the HTTP client, so the request in flight is cancelled rather than awaited.
A cancelled run throws ExecutionError with reason: 'aborted'. The error names where the run stopped (workflowId, stepId, path), and its cause is the signal’s reason. It does not resolve as a failed result: the steps that never ran did not fail. A request the HTTP client dropped because of the signal is reported the same way, as aborted, not as the ClientError a request failing on its own would raise.
A client that ignores the signal costs one request in flight; the run still stops at the next boundary. A signal passed in executeOptions cancels the run too, and the signal option wins when both are given. StepExecutor honours a signal in its execute options on its own: an aborted signal throws aborted before any request goes out.
StepExecutor
new StepExecutor(options: { document: ArazzoDocument; registry: DocumentRegistry; operationExecutor: OpenAPIOperationExecutor })
stepExecutor.execute(step, state, executeOptions?): Promise<StepExecutionResult>
Runs one step that calls an OpenAPI operation. It does four things in order:
- finds the step’s operation (
operationIdoroperationPath); - resolves the step’s
parametersandrequestBodyagainst$inputs,$steps.*.outputs, and the rest of the state; - sends the operation through the
OpenAPIOperationExecutor; - evaluates
successCriteria, resolvesoutputs, and selects theonSuccessoronFailureaction against the response.
WorkflowExecutor calls it for you. You call it directly to run one step on its own:
import {
DocumentRegistry,
ArazzoWorkflowExtractor,
ArazzoStepExtractor,
WorkflowExecutionState,
OpenAPIOperationExecutor,
StepExecutor,
} from '@usearazzo/runner';
const registry = new DocumentRegistry();
const document = await registry.acquireEntryDocument(
'https://usearazzo.com/assets/reference/runner/petstore-order.arazzo.yaml',
);
const workflow = new ArazzoWorkflowExtractor().extract(document, 'authenticateAndOrderPet');
const step = new ArazzoStepExtractor().extract(workflow, 'findAvailablePets');
const stepExecutor = new StepExecutor({
document,
registry,
operationExecutor: new OpenAPIOperationExecutor(),
});
const state = new WorkflowExecutionState({ inputs: { preferredPetStatus: 'available' } });
const outcome = await stepExecutor.execute(step, state, { server: 'http://localhost:8080/api/v3' });
outcome.successful; // true when every successCriterion passed
outcome.outputs; // the step's outputs, by name
outcome.action; // the selected onSuccess / onFailure action, or undefined
outcome.response; // the response, with the request as sent
// record the outputs so a later step can read $steps.findAvailablePets.outputs
state.setStepOutputs(outcome.stepId, outcome.outputs);
StepExecutor changes nothing in the state. It returns the outputs and the action for the caller to record and act on. matchedActions lists every action that matched, in order; action is the first.
A step whose criteria go unmet is a normal outcome, successful: false, not a throw. These throw:
- a step with no operation, more than one target, or an operation that cannot be found:
ExecutionError; - an
outputsvalue that is not a runtime expression:ResolverErrorwithreason: 'invalid-expression'.$response.body.idis not Arazzo syntax;$response.body#/idis; - a criterion
contextthat is not a runtime expression, whatever the criteriontype:CriterionErrorwithreason: 'invalid-context'.
Parameter and payload values may be literals, since the specification types them Any.
A step that calls a workflow is not an operation step, and execute throws on it. WorkflowExecutor runs those itself, through evaluateCriteria and selectActions, which are public for that reason. forDocument(document) returns a sibling executor bound to another Arazzo document.
Request body payloads
A parameter value or an outputs entry is either a literal or one whole runtime expression. A requestBody payload is different: the specification says it can contain expressions, so the runner walks it. Every string inside it follows one rule:
- a whole expression (
$inputs.petId) becomes its value, with its type; - any other string has its
{expression}parts filled in, as a string ("Order for {$inputs.name}"). Braces that are not an expression stay as written, so a JSON or XML template works as is.
requestBody:
contentType: application/json
payload:
petId: $inputs.pet_id # the number 10
couponCode: $inputs.coupon_code # left out when no coupon_code was given
note: 'Order for {$inputs.name}' # a string
Object keys are never evaluated, and neither are values read from inputs. Filled-in values go in as they are, with null as an empty string and objects as JSON text. Escaping them for the content type is up to you. A replacements entry’s value is not walked: like a parameter value, it is a literal or one whole expression, so '{$inputs.petId}' there stays as written.
An expression that does not resolve counts as not provided. An object member is left out, and so is a replacements entry. Where leaving it out is impossible (a whole-expression payload, an array element, a {...} inside a string), the step throws ResolverError with reason: 'unresolved-expression' and the location as target. A {$ that does not parse, like {$inputs.}, throws with reason: 'invalid-expression' rather than going out as text. A value that resolves to null is provided and kept.
OpenAPIOperationExecutor
new OpenAPIOperationExecutor(options?: OpenAPIOperationExecutorOptions)
executor.execute(locator: { document; jsonPointer }, options?): Promise<OpenAPIOperationResponse>
Runs one OpenAPI operation and returns its response. It knows nothing about Arazzo: it does not find operations or resolve expressions. Given the operation’s document and JSON Pointer, it:
- builds the HTTP request: parameter serialization (
style,explode), security, and the server, for OpenAPI 2.0 through 3.1; - sends it through the HTTP client (global
fetchby default); - reads the
Responseby its content type into anOpenAPIOperationResponse, which is what$response.*and criteria see.
| Constructor option | Meaning |
|---|---|
httpClient |
Sends the request. See The HTTP client. |
requestInterceptor |
Changes every request before it is sent. See Authentication. |
responseNormalizer, operationExtractor, operationNormalizer, documentAssembler |
Replace one stage of the pipeline. Rarely needed. |
| Execute option | Meaning |
|---|---|
parameters |
Parameter values, by name. |
requestBody, requestContentType |
The body and its media type. |
responseContentType |
Sent as Accept. Defaults to the media types of the operation’s 2xx responses. |
server, serverVariables |
Where the request goes. See Choosing the server. |
requestInterceptor |
Changes this request, after the constructor’s interceptor. |
signal |
An AbortSignal. |
The response has status, statusText, ok, url, headers, text, and body. Header values stay strings, as the server sent them. A repeated header, Set-Cookie included, is joined with ", ". response.request is the request as it was sent: final URL, method, headers, and body. That is what $url, $method, and $request.* read.
requestContentType is matched against the operation’s declared media types ignoring case and parameters, and a declared range covers a concrete type: application/json; charset=utf-8 matches application/json and */*. The form types, application/x-www-form-urlencoded and multipart/form-data, must be declared exactly, since they decide how the body is encoded. A media type that matches nothing throws a ClientError instead of sending a wrongly encoded body. Swagger 2.0, which has no request body object, takes the body as its body or formData parameter.
Choosing the server
By default a request goes to the first server the operation declares. For Swagger 2.0 that is schemes, host, and basePath. A relative server resolves against the URL the document was loaded from, so a document from https://petstore3.swagger.io/api/v3/openapi.json that declares /api/v3 needs no configuration.
server does two jobs:
// selection: the value matches a declared server, so its variable defaults
// come along and only the ones you name change
await executor.execute(locator, {
server: 'https://{region}.example.com/{basePath}', // the template, as declared
serverVariables: { region: 'us' }, // basePath keeps its default
});
// override: the value matches nothing declared, so it is the base URL
await executor.execute(locator, { server: 'http://localhost:8080/api/v3' });
There is no silent fallback. A server that matches nothing declared is never swapped for the first declared one, so a typo fails at the network instead of reaching the wrong host. An override replaces the base path too, so include it (/api/v3). Overrides work on Swagger 2.0; selection and serverVariables are OpenAPI 3.x only. For a document loaded from disk with relative servers, pass an absolute server.
In a workflow, pass server in executeOptions and it applies to every step.
The HTTP client
The HTTP client is a function from the built request to a WHATWG Response:
type HTTPClient = (request: OpenAPIOperationRequest) => Promise<Response>;
The default, httpClientFetch, is one line: (request) => fetch(request.url, request). Any HTTP stack drops in:
import { fetch, Agent } from 'undici';
import { OpenAPIOperationExecutor } from '@usearazzo/runner';
// undici with a dispatcher global fetch cannot take:
// connection pooling, a proxy, client certificates
const dispatcher = new Agent({ connections: 128, keepAliveTimeout: 60_000 });
const pooledExecutor = new OpenAPIOperationExecutor({
httpClient: (request) => fetch(request.url, { ...request, dispatcher }),
});
// canned responses in a test: no network
const offlineExecutor = new OpenAPIOperationExecutor({
httpClient: async () =>
new Response('[{"id":1,"status":"available"}]', {
status: 200,
headers: { 'content-type': 'application/json' },
}),
});
A client you write has three rules to follow:
- Resolve with a
Responsefor every HTTP status. A 404 or a 500 is a valid outcome for a step’ssuccessCriteriato judge, not an error. - Throw only when the request could not be made. The executor wraps the error in a
ClientError, with the original ascause. - Honor
request.signalwhen it is present, so a cancelled run cancels the request.
The response is read on the executor’s side of the client, so $response.body means the same thing whichever client you plug in. A client that only adds behaviour around the request can call httpClientFetch instead of reimplementing it.
Authentication
Arazzo says nothing about credentials, so the runner does not model them either. You add them with a requestInterceptor. It runs after the request is built and before it is sent. It may be async, and it may change the request or return a new one:
const executor = new OpenAPIOperationExecutor({
requestInterceptor: async (request) => {
request.headers.Authorization = `Bearer ${await acquireToken()}`;
},
});
A requestInterceptor in the execute options runs after this one. Since the interceptor sets headers directly, it works however the OpenAPI document declares its security schemes, or if it declares none. To apply credentials from those declarations instead, in the header, query, or cookie each scheme names, pass a securities object in the execute options. It reaches the request builder untouched; it is not part of the typed options.
Change requests here rather than in a custom HTTP client. The runner records response.request after the interceptors run and before the client is called, so only interceptor changes show up in $request.* and the trace.
Without a workflow
The operation executor works on its own, with an OpenAPI document and an operationId. The document’s operationIndex maps the operationId to the JSON Pointer the executor needs:
import { DocumentRegistry, OpenAPIOperationExecutor } from '@usearazzo/runner';
const registry = new DocumentRegistry();
const openapi = await registry.acquire('https://petstore3.swagger.io/api/v3/openapi.json');
const locator = {
document: openapi,
jsonPointer: openapi.operationIndex.get('getPetById'),
};
const executor = new OpenAPIOperationExecutor();
const response = await executor.execute(locator, { parameters: { petId: 1 } });
console.log(response.status, response.headers['content-type']);
console.log(response.request.method, response.request.url);
200 application/json
GET https://petstore3.swagger.io/api/v3/pet/1
A response with a non-2xx status comes back like any other: a missing pet gives 404 with ok: false. Whether that counts as success is a step’s successCriteria to judge. What throws is an operation that cannot be found, an unsupported OpenAPI version, a request that cannot be built (a required parameter is missing), and a transport failure. The last two are ClientErrors.
Errors
The runner keeps two things apart:
- A run that fails resolves normally, with
status: 'failed'. A step’s criteria went unmet, or adependsOnworkflow failed. That is an outcome about the API, not a mistake in your code. - A run that cannot go on throws: the document is wrong, a request could not be built or sent, or you cancelled it.
Every error the runner throws extends ArazzoRunnerError:
| Error | Thrown when | Extra fields |
|---|---|---|
ExecutionError |
The document cannot be run as written, a budget ran out, or the run was cancelled. | reason, workflowId, stepId, path |
ClientError |
A request could not be built (a required parameter is missing, a media type matches nothing) or sent. The underlying error is on cause. |
operationId, operationPath, uri |
ResolverError |
A parameter, body, or output could not be resolved. | reason, workflowId, stepId, target |
CriterionError |
A criterion could not be evaluated, for example a context that is not an expression. |
reason, condition, type |
RuntimeExpressionError |
An expression cannot be evaluated where it is used. | reason, expression |
InvalidEntryDocumentError |
The entry document is not Arazzo, or a registry passed to ArazzoRunner backs a different entry document. |
uri |
A document that cannot be read at all (a missing file, an HTTP error) throws the parser’s own error, not one of these.
import { ExecutionError, ClientError } from '@usearazzo/runner';
try {
const result = await executor.execute('authenticateAndOrderPet', { inputs });
if (result.settledStatus === 'failed') {
// the API said no: read result.steps to see where
}
} catch (error) {
if (error instanceof ExecutionError) {
error.reason; // 'workflow-not-found'
error.message; // 'workflow "nope" not found in Arazzo document at "https://usearazzo.com/assets/reference/runner/petstore-order.arazzo.yaml"'
}
if (error instanceof ClientError) {
error.cause; // what the HTTP client threw
}
throw error;
}
ExecutionError names its cause in reason:
reason |
Cause |
|---|---|
workflow-not-found |
No workflow has that id. The message names the document searched. |
goto-target-not-found |
A goto names a step that does not exist. |
goto-target-missing |
A goto names neither a stepId nor a workflowId. |
retry-target-not-found |
A retry references a step the workflow does not have. |
ambiguous-target |
A step, goto, or retry names more than one target. |
unknown-action-type |
An action’s type is not end, goto, or retry. |
malformed-steps, malformed-dependsOn |
A workflow’s steps or dependsOn is present but malformed. |
workflow-cycle, dependsOn-cycle |
A workflow is entered again while it is still running. |
workflow-depth |
Workflows nest deeper than maxWorkflowDepth. |
step-budget |
The run made more than maxSteps step attempts. |
aborted |
The run was cancelled. |
invalid-workflow-reference |
A $-prefixed workflow reference is not $sourceDescriptions.<name>.<workflowId>. |
source-description-not-found |
The named source description does not exist. |
source-description-not-arazzo |
A workflow reference points into a document that is not Arazzo. |
A workflow’s steps and dependsOn are checked before anything runs, so those mistakes never send a request. A mistake inside one step is found when the run reaches it, after earlier steps have run. That is deliberate: a broken step that a run never reaches should not fail the run.
Supported versions
Arazzo documents:
OpenAPI documents, as source descriptions:
Arazzo documents are also accepted as source descriptions, for workflows in other documents. OpenAPI 3.2 and AsyncAPI source descriptions are not supported yet.