Let’s say you are building something that reads Arazzo documents. It might be an editor plugin, a linter, a generator, or an agent that turns workflows into tools. It might be just a script that lists every step in a repository’s workflows. Before any of that can start, the document has to be read. This guide is about that reading step. We’ll look at why it is more than a YAML loader, and at what a tool should expect from a parser. The problems and the results are the same whichever parser you use, or build.
If you only ever write Arazzo documents, you can skip this one. It is for the people building the tools that read them.
What a parsed document looks like
Here is the destination first, so the rest of the guide has something to aim at. Every sample below runs against this small workflow. It is saved as adopt-a-pet.arazzo.yaml, next to the OpenAPI description it names. Both files, plus the two variants we’ll use later, are available to download from the site.
arazzo: 1.0.1
info:
title: Pet adoption
version: 1.0.0
sourceDescriptions:
- name: petstore
type: openapi
url: ./petstore.openapi.yaml
workflows:
- workflowId: adopt-a-pet
inputs:
type: object
properties:
petId:
type: string
steps:
- stepId: find-pet
operationId: getPetById
parameters:
- name: petId
in: path
value: $inputs.petId
successCriteria:
- condition: $statusCode == 200
outputs:
name: $response.body#/name
- stepId: adopt
operationId: adoptPet
parameters:
- name: petId
in: path
value: $inputs.petId
A workflow document goes in and a tree comes out. The tree knows what every node is, where it came from in the source, and whether anything was wrong with it. For our sample, a good parse yields something like this, whichever parser produced it:
adopt-a-pet.arazzo.yaml
- documentarazzo 1.0.1lines 1 to 32
- infoPet adoptionlines 3 to 4
- source descriptionpetstoreopenapi, lines 6 to 8
- workflowadopt-a-petlines 10 to 32
- inputsJSON Schemalines 12 to 15
- stepfind-petlines 17 to 26
- operationIdgetPetByIdline 18
- parameter petId$inputs.petIdlines 20 to 22
- success criterion$statusCode == 200line 24
- output name$response.body#/nameline 26
- stepadoptlines 27 to 32
problemsnone
Every node carries the lines it came from, so you can match the figure against the numbered source above. To stay readable, the figure leaves out the list nodes sourceDescriptions, workflows, and steps.
Notice three things in that picture. First, the nodes are typed. steps is a list of step nodes, not a bag of maps, and the strings that are really expressions are known to be expressions. Second, every node carries its position in the original text. That is what a diagnostic, a hover, or a go-to-definition needs. And third, problems come back as data alongside the tree, not as a thrown exception. A half-broken document in someone’s editor still yields a tree you can work with.
Getting to that output is the hard part.
Why parsing Arazzo is hard
An Arazzo document looks like one file in one format. It is really several languages layered on top of each other, inside one node of a network of documents. A parser that stops after the first layer has not parsed the document. Let’s go through the layers one by one.
One document, two formats
Arazzo documents are written in YAML or JSON. JSON is a subset of YAML 1.2, so one YAML parser can read both. But the relation only runs one way. A YAML parser also accepts a .json file with comments, single-quoted strings, or a trailing comma, and none of that is JSON. So “it loaded” does not tell you which format you were given.
Why does it matter? A tool that hands the document on to a strict JSON consumer, or writes it back out, has to know. JSON also has a native parser that is faster and stricter, and its syntax errors and positions are worth reporting in JSON terms. So now we have two parsers, two ideas of what a “position” is, and two sets of syntax errors to report.
It also means the first job is detection. Given a string, is this Arazzo at all? And in which format, really? A YAML loader will happily load an OpenAPI description, a Kubernetes manifest, or a shopping list. Something has to check for the arazzo version field and refuse the rest, before any later stage runs.
The specification keeps moving
Arazzo has shipped three releases in two years. A document written last year says arazzo: 1.0.0. One written this month may say 1.1.0 and use $self, channelPath, action, or a Selector Object. None of these existed before.
A parser that hard-codes one version’s field list has two ways to fail. It can reject the newer document outright. Or it can load it and silently drop the fields it does not know. The second one is worse, because the tool downstream then reasons about a workflow that is missing parts.
So reading has to be tolerant of versions. Deciding what is valid for a version is a separate job, and one that changes with every release.
Documents that point at other documents
An Arazzo document describes calls against APIs it does not contain. Each Source Description names an external document by URL and declares its type: openapi, arazzo, and, since Arazzo 1.1.0, asyncapi. To resolve operationId: getPetById, or to know what $response.body#/name could contain, a tool has to fetch and parse those documents too. That opens several doors at once:
- More formats. An
openapisource can be any OpenAPI version, and 2.0, 3.0.x, 3.1.x, and 3.2 each have their own structure. Anasyncapisource is AsyncAPI v3. Each one is again YAML or JSON. - URI resolution. A source description URL may be absolute, relative to the workflow document, a local file path, or a remote HTTP(S) URL. Relative URLs need a base URI, and Arazzo 1.1.0 lets the document override that base with the
$selffield. - Partial failure. One unreachable source description should not make the workflow document unreadable. The tool needs the rest of the tree, plus an accurate note about what was missing.
The document is a network
The door that matters most is the arazzo source type. A source description can be another Arazzo document. That document has its own source descriptions, which can be Arazzo documents in turn. Steps reach across the links. workflowId: $sourceDescriptions.onboarding.create-account calls a workflow that lives in a different file, and dependsOn can name one too.
So the file you were handed is not the document. It is one node in a graph of documents, and the graph is what the workflow means. That graph has properties a single file does not have:
- Its size is unknown until you have walked it. Each Arazzo node can add any number of OpenAPI descriptions and further Arazzo nodes. A workflow library that composes shared sub-workflows across teams can pull in dozens of files, from a mix of local paths and remote URLs.
- It can loop. Document A lists B as a source. B lists C, and C lists A. Or B lists A directly. Nothing in the specification forbids it, and a walker that follows links without remembering where it has been never terminates.
- The same file can be reached by different names.
./shared/auth.arazzo.yamlfrom one document and../auth.arazzo.yamlfrom another are the same file. Cycle detection and caching both depend on resolving every link to one canonical URI first. - Depth is a policy decision. A linter in an editor should not fetch the whole company’s workflow graph on every keystroke. A runner probably should. The parser has to let the caller choose.
A parser that handles a single file well and leaves the graph to the caller has left the hardest part to the caller.
A grammar hidden in strings
Now let’s open the strings. Many values in the document are not really values. Take $inputs.petId, $steps.find-pet.outputs.name, $response.body#/pets/0/id, or $sourceDescriptions.petstore.getPetById. These are runtime expressions, a small language with its own ABNF grammar, embedded in ordinary strings.
To a YAML loader they are just strings. To a tool they are references. A reference can be well-formed or not, it can name a step that does not exist, and it can carry a JSON Pointer in its tail. Checking any of that means parsing the expression, not the document.
A programming language inside a string inside the document
The Criterion Object goes one step further. A step’s success criteria are conditions like $statusCode == 200 or $response.body#/status == 'available' && $response.header.X-Rate-Limit-Remaining > 0. The simple criterion type is a tiny expression language: comparisons, boolean operators, negation, literals, and runtime expressions as operands.
It also has its own way of reaching into a value. The specification’s example is $statusCode == 200 && $response.body.data != null. Notice how it navigates into the response body with a .data accessor. [0] style index accessors work the same way. Now compare $response.body#/pets/0/name with $response.body.pets[0].name. They name the same value through two different grammars, and the two do not mix. Once a # pointer starts, dots and brackets are pointer text, not navigation.
The other criterion types hand the condition to JSONPath, XPath, or a regular expression engine. So what do we have inside a single string? A condition grammar with its own accessors, which contains a runtime expression grammar, which contains a JSON Pointer. Three grammars deep, and we have not left one field yet.
A grammar the specification never wrote down
Runtime expressions have an ABNF grammar in the specification. simple conditions do not. The specification gives us a table of literals, a table of operators, and prose. Every tool has to answer the rest alone:
- Where does a runtime expression end and an operator begin? The two languages share characters:
.,=,<, and>. - Which binds tighter,
&&or||? - Is a chained comparison such as
$statusCode > 199 < 300allowed? - Do
.dataand[0]belong to the condition language or to the runtime expression? In$inputs.pet.name, is that an input namedpet.name, or thenamemember of an input namedpet?
This is an interoperability problem, not a style problem. Two tools that answer differently read the same condition differently. The same workflow then passes in one runner and fails in another.
The first question runs deeper than it looks. A name in a runtime expression may contain =, <, &, and even spaces. So may a JSON Pointer token, and a pointer has no terminator at all. Read as a whole, the ordinary $response.body#/status == 200 is also one valid runtime expression, with the pointer /status == 200. Nobody means that. But only a boundary rule can say so, and the specification has none.
A sensible rule, “an operand ends at whitespace or at an operator character”, settles the ordinary conditions. It also leaves a whole family with no right answer: any name or key that contains one of those characters.
- In
$request.query.a=b == 1, is the first=part of the parameter name, or the start of the operator? - In
$response.body#/a=b == 1, is it part of the pointer? The#/form is no escape, for the reason above.
Both readings are legal each time, and nothing in the specification picks one.
The full story is in The one Arazzo condition no tool can evaluate safely. Arazzo-Specification#518 asks the specification for a normative grammar, and proposes one in ABNF.
Schemas and other niceties
Workflow inputs are described with JSON Schema 2020-12, which is another language with its own reference and vocabulary semantics. Reusable Objects let a step reference a shared parameter, success action, or failure action by a runtime expression. Arazzo 1.1.0 adds Selector Objects to outputs and parameters.
None of this is exotic on its own. Together, it means that a “parsed” Arazzo document is a tree in which several nodes are the roots of further trees, in other languages.
Positions, for everything above
Every piece of tooling worth having reports where a problem is. For us that means three things:
- line and column for the document nodes;
- offsets inside an expression string, when the expression is what is wrong;
- the file in the graph that the problem came from.
There is one more catch. The Language Server Protocol counts positions in UTF-16 code units, and JavaScript strings index the same way. A parser aimed at editor tooling has to count the same way too.
So what is the real scope of “parsing an Arazzo document”? Let’s put it together:
- detect the format;
- parse it tolerantly, with positions;
- recognise the Arazzo structure in any 1.x version;
- resolve every referenced document to a canonical URI, and parse it in its own format;
- walk the graph of Arazzo sources without looping, to a depth the caller chose;
- parse the expression grammars inside the strings;
- report all of it as data.
Doing it by hand
How far does a plain loader get? It is worth seeing, because that is where most first attempts start.
import { readFile } from 'node:fs/promises';
import YAML from 'yaml';
const text = await readFile('./adopt-a-pet.arazzo.yaml', 'utf8');
const doc = YAML.parse(text);
for (const workflow of doc.workflows ?? []) {
for (const step of workflow.steps ?? []) {
console.log(workflow.workflowId, step.stepId);
}
}
For a script you run yourself, against documents you wrote, this is fine. You should not feel bad about it. The trouble starts as soon as the tool has to serve other people, or other documents:
- No detection.
YAML.parsereturns an object for any YAML. JSON is a subset of YAML 1.2, so it does the same for any JSON, including JSON with comments that no JSON parser would accept. Is what came back really Arazzo? Was the file really the format its extension claims? That is now your problem. - No positions. The object has no idea which line
step.stepIdcame from. Most YAML libraries can expose a concrete syntax tree with positions. But then you are walking that tree yourself, and mapping it back to the Arazzo structure. - Strings stay strings.
$steps.find-pet.outputs.nameis a string. To tell it apart from$steps.find-pet.outputs, which is not a valid expression, you need the runtime expression grammar. To check a success criterion, you need the condition grammar as well. Both grammars are published as standalone packages:@swaggerexpert/arazzo-runtime-expressionand@swaggerexpert/arazzo-criterion. You can wire them into your own loader, if that is all you need. - Source descriptions are URLs, nothing more. You write the fetch, and the relative URL resolution against the document’s directory. You write the format detection for each OpenAPI version. You write the error reporting for a source that is unreachable.
- The graph is yours to walk. You need recursion into Arazzo sources. You need canonical URIs, so the same file is not fetched twice under two names. You need an ancestor check, so real cycles terminate and a shared file is not mistaken for one. You need a depth limit, so an editor does not fetch the world. Get any of these wrong, and the failure mode is a hang, not an error.
- Damaged input throws. A document with one bad indent gives you an exception and no tree. An editor is in exactly that situation most of the time.
Each of these is a solved problem somewhere. The cost is in assembling the set and maintaining it, across two formats and five or six document types, for a specification that is still adding features.
If you would rather not, other parsers have done some of this assembly already. There is php-arazzo in PHP, roas in Rust, and the Arazzo support inside Redocly CLI. The Ecosystem page keeps the full list. Whichever you pick, or build, the next section is what to judge it by.
What a good parser gives you
Now let’s turn the problems around. This section takes them in the same order and gives the result to expect for each. It describes results, not an API. Use it as a checklist, for a parser you pick or one you build. One note about the figures: they are illustrations. Node names differ from parser to parser. The shape is what matters.
A format you can be sure of
- The parser checks that the input is Arazzo before anything else runs. It looks for the
arazzofield and its1.x.yversion. - It works out whether the text is really JSON or YAML from the content, not from the file extension.
- Anything else is refused, with an error that says why. An OpenAPI description passed by mistake is the common case.
- It reads text that has no file behind it. An editor buffer that was never saved is still a document.
Every 1.x version, nothing dropped
- A 1.0.0 document and a 1.1.0 document produce the same kind of tree.
- The fields that 1.1.0 added, such as
$self,channelPath,action, andcorrelationId, are typed like the rest. - Fields the parser does not know, from a future release or an
x-extension, are kept with their positions. They are never dropped.
And what about the things that 1.0.1 allows and 1.1.0 does not? That is a validation question. The parser does not answer it.
Source descriptions, read in their own format
- Each source description is parsed as what it is: an OpenAPI description in its own version, an AsyncAPI document, or another Arazzo document. Each one is again JSON or YAML.
- Relative URLs resolve against the location of the document that names them, honouring the
$selffield. Text with no location needs a base URI from the caller. - One bad source does not spoil the rest. An unreachable file, or a format the parser cannot read, becomes a problem on that entry. The main document still parses.
- Following sources means file system and network access, so the caller decides. A good parser lets the caller follow none, some by name, or all.
A word of caution here. Few tools read every legal source format. Check which OpenAPI and AsyncAPI versions a parser supports before you depend on it.
A graph walk that terminates
- The caller sets how deep the walk goes. An editor wants one level, a runner wants everything.
- Every link is made canonical before it is followed. Two spellings of one path count as one file.
- A document reached twice is fetched and parsed once.
- A cycle ends the walk on that branch, with a warning. It never ends in a hang.
The last two are easy to confuse, so here is an example. Take a second document, onboarding.arazzo.yaml. It lists adopt-a-pet.arazzo.yaml as an arazzo source and petstore.openapi.yaml as an openapi one. Now list onboarding back from adopt-a-pet. A parse of adopt-a-pet that follows sources should walk A, then B, and stop exactly where B points back at A:
Two things in that figure are deliberate:
- Only true cycles are cycles. A document is a cycle only when it is already on the chain of ancestors being parsed.
adopt-a-petis the root of this walk, so B’s reference back to it is one. That entry gets a warning and no tree. - Shared documents are not cycles. Both A and B name
petstore.openapi.yaml. Nothing in that file points back at either of them. Reaching it a second time is a shared dependency, a diamond, not a loop. A blunt “seen it before” check would wrongly skip it.
Runtime expressions as trees
A string that holds a runtime expression comes back as a syntax tree, not as a string. Nothing is evaluated, and no document is needed.
$steps.find-pet.outputs.name
- steps expression$steps
- step idfind-pet
- fieldoutputs
- output namename
$response.body#/pets/0/id
- response expression$response
- body referencebody
- JSON Pointer/pets/0/id
- tokenpets
- token0
- tokenid
- JSON Pointer/pets/0/id
- body referencebody
- The tree names the kind of expression and its parts. A tool can then ask whether step
find-petexists, without any string slicing. - The JSON Pointer in the tail is parsed too, down to its reference tokens.
- Invalid syntax is reported, not thrown.
$steps.find-pet.outputsis not a valid expression, because an outputs reference needs an output name. The result says that parsing failed, and gives the offset where the grammar gave up. That offset is where a diagnostic should point.
Criterion conditions as trees
A simple condition is a small expression language, so its result is an expression tree. In the figures, the colour of a node tells you which grammar it belongs to.
$response.body#/status == 'available' && $statusCode == 200
- logical and&&
- comparison==
- response expression$response.body
- JSON Pointer/status
- string'available'
- response expression$response.body
- comparison==
- status code expression$statusCode
- number200
- comparison==
As you can see, there are three colours in one tree. Here is what to expect from a parser that produces it:
- The grammar is published. The specification has no ABNF for conditions yet. So the parser says which grammar it follows, in ABNF, where anyone can read it. Two tools can only agree on a condition if they can compare grammars, and a grammar hidden in a hand-written tokenizer cannot be compared with anything. The grammar of
@swaggerexpert/arazzo-criterionis one published example. It is the one proposed in Arazzo-Specification#518. - Operators have a stated precedence. The usual choice is that
&&binds tighter than||. Parentheses and!negation are nodes of their own. A chained comparison such as$statusCode > 199 < 300does not parse. - The operand boundary is a stated rule. The usual rule is that an operand ends at whitespace or at an operator character. That rule is what makes
$response.body#/status == 200a comparison and not one long pointer. - The runtime expression grammar checks the operand. The condition parser does not copy the runtime expression rules. It hands the operand over, and the longest prefix that is a valid runtime expression wins. The rest is
.memberand[0]navigation. This way the two grammars cannot drift apart. It also settles$inputs.pet.name: a name may contain dots, so that is an input namedpet.name, with no navigation. - What the rule cannot express is refused, not guessed. Under that rule, a name or key that contains an operator character cannot be written.
$request.query.a=b == 1fails to parse, with an offset. So does$response.body#/a=b == 1. A silent wrong guess would be worse. The way out for such a key is ajsonpathcriterion, because JSONPath quotes member names:$.pets[?@['a=b'] == 1]. - Literals are typed. A string, a number, a boolean, and
nullare different nodes.200and'200'do not compare the same way, so a tool has to see the difference. - Each runtime expression operand is parsed. The condition tree holds the expression’s own tree, which holds its JSON Pointer. This is the three-grammars-deep field from earlier, unpacked.
- Accessors are navigation steps, not pointer text. The two ways of reaching into a value stay apart in the tree:
$response.body.pets[0].name == 'Rex'
- comparison==
- navigation.pets[0].name
- response expression$response.body
- memberpets
- index0
- membername
- string'Rex'
- navigation.pets[0].name
- Failure works as it does for expressions. A condition that does not parse gives a failed result and an offset, not an exception.
- Only
simpleconditions are parsed. The Criterion Object’stypesays which language the condition is in. Forjsonpath,xpath, andregex, the parser keeps the string as it is and records the type. The tool can then hand it to the right library.
Positions and problems, even for damaged input
- Every node carries its position in the original text. For editor tooling that means zero-based lines and characters, counted in UTF-16 code units.
- A problem inside an expression points inside the string, not at the whole field.
- A problem in a source description says which document it came from.
- Problems come back as data, next to the tree. A runner can still ask the parser to fail fast. An editor cannot work that way, because the document in an editor is broken most of the time.
To see it, damage the sample so that the second step’s operationId sits one space to the left of its siblings. That takes it out of the step’s mapping, and the lines after it no longer fit anywhere. The damaged file is available to download too.
- stepId: adopt
operationId: adoptPet # seven spaces instead of eight
parameters:
- name: petId
A plain loader throws here and returns nothing. A good parse gives you this:
adopt-a-pet.broken.arazzo.yaml
- documentarazzo 1.0.1
- workflowadopt-a-petfrom line 10
- stepfind-petlines 17 to 26
- stepadoptfrom line 27
- dropped fieldsoperationId, parameterslines 28 to 32
- workflowadopt-a-petfrom line 10
errorYAML syntax errorlines 27 to 32
The tree survives. The damage costs us the rest of that one step, and nothing else. The document, the workflow, the first step, and the second step’s stepId are all intact, with their positions. And the problem points at the damaged text, not at the whole document.
Where parsing should stop
That was a long checklist, so it is worth saying what is not on it. Knowing where a parser stops is as useful as knowing what it does, and the line is the same whichever parser you use. Four things look like parsing and are not:
- Dereferencing.
$refinside a source description’s JSON Schema, or a Reusable Object’sreference, point at content that may live in another file. Following them is resolution, a separate walk with its own caching and cycle rules. - Validation. A step that references a workflow that does not exist is a well-formed document. Whether it is a correct one depends on rules that change with every release. A parser that also validated would be wrong about validity each time the specification moved.
- Evaluation. An expression AST is structure. What
$steps.find-pet.outputs.nameis worth exists only during a run, so evaluation belongs to whatever runs the workflow. - JSONPath, XPath, and regular expressions. These are general-purpose languages with their own ecosystems. A parser that bundled them would be maintaining three more grammars.
So there are four jobs: a parser reads, a resolver dereferences, a validator judges, and a runner evaluates. A parser you build or pick should draw these lines somewhere. The one that does everything is the one nobody can keep up to date.
Next steps
- UseArazzo’s own answer to this checklist is
@usearazzo/parser, for JavaScript and TypeScript. Its API reference documents every function and option. - The tutorial List Every Document an Arazzo Workflow Depends On walks the network of source descriptions with it, end to end.
- Resolution is the next layer up: what it takes to follow the
$refs and$componentsreferences a parser leaves in place is the subject of Resolving Arazzo Documents, and@usearazzo/resolveris UseArazzo’s answer to it. - The Validator is the layer after that: what it means for a parsed document to be correct.