An Arazzo document rarely stands alone. Its sourceDescriptions name the OpenAPI descriptions its steps call, and since a step can also call a workflow in another Arazzo document, the list can name other workflow documents too, each with source descriptions of its own. A run reads all of them. Before you commit a workflow to CI, review someone else’s, or hand one to an agent, it helps to know what that network is: which documents will be fetched, from where, what kind and version each one is, and which ones cannot be read.
A YAML loader shows you one file. This tutorial writes a script that draws the whole network, using @usearazzo/parser. The script is about seventy lines and you will have it running in a few minutes.
The end result
Here is where you will end up. Given a workflow document that names two APIs and one other workflow, the script prints a Mermaid graph. Paste it into a GitHub comment, a pull request description, or a README, and it renders like this:
The Mermaid source it printed:
graph LR
d1["onboarding.arazzo.yaml<br/>Arazzo 1.0.1"]
d2["petstore.openapi.yaml<br/>OpenAPI 3.1.0"]
d1 -->|petstore| d2
d3["adopt-a-pet.arazzo.yaml<br/>Arazzo 1.0.1"]
d1 -->|adoption| d3
d3 -->|petstore| d2
d4["billing.openapi.yaml<br/>not parsed"]:::missing
d1 -.->|billing| d4
classDef missing stroke-dasharray: 6 4
And on standard error, the one thing that went wrong, named after the source description that caused it:
billing: Error parsing source description "/home/you/inventory/billing.openapi.yaml": Error while reading file "/home/you/inventory/billing.openapi.yaml"
Every node comes from the documents themselves. The kind and version of each one is detected from its content, not from the type the parent declared. The petstore file is named by two workflow documents and appears once, with two arrows into it, because the parser parsed it once. The document that does not exist is a dashed node on a dotted arrow, and the script exits with a nonzero status so CI notices.
The three sample files, plus the finished script, are available to download from the site. Put them in a directory of their own. The entry document, onboarding.arazzo.yaml, looks like this:
arazzo: 1.0.1
info:
title: Onboarding
version: 1.0.0
sourceDescriptions:
- name: petstore
type: openapi
url: ./petstore.openapi.yaml
- name: adoption
type: arazzo
url: ./adopt-a-pet.arazzo.yaml
- name: billing
type: openapi
url: ./billing.openapi.yaml
workflows:
- workflowId: create-account
steps:
- stepId: adopt
workflowId: $sourceDescriptions.adoption.adopt-a-pet
adopt-a-pet.arazzo.yaml is a two-step workflow that names petstore.openapi.yaml as its only source description. There is deliberately no billing.openapi.yaml.
Install the parser
In that directory:
npm install @usearazzo/parser @speclynx/apidom-core
@usearazzo/parser does the parsing. @speclynx/apidom-core provides toValue, which turns a node of the parsed tree back into a plain JavaScript value so you can print it.
Parse the entry document
Start with the smallest script that proves the parser is working. Save this as inventory.mjs:
import path from 'node:path';
import { parseArazzo, ParseError } from '@usearazzo/parser';
import { toValue } from '@speclynx/apidom-core';
const entry = process.argv[2];
let parseResult;
try {
parseResult = await parseArazzo(entry);
} catch (error) {
if (error instanceof ParseError) {
console.error(error.message);
process.exit(2);
}
throw error;
}
const { api } = parseResult;
console.log(`${toValue(api.info.title)} Arazzo ${toValue(api.arazzo)}`);
Run it:
node inventory.mjs onboarding.arazzo.yaml
Onboarding Arazzo 1.0.1
Three things happened. parseArazzo read the file, checked that it is an Arazzo document, and returned a typed tree under api with a getter for every field the specification defines. A relative path resolves against the working directory, and the parser remembers where the document came from, which is what the relative URLs in its sourceDescriptions will resolve against in the next step. And a file that is not Arazzo at all, or cannot be read, throws a ParseError whose message says where the input came from. Try it on petstore.openapi.yaml to see that path.
Follow the source descriptions
By default the parser stops at the entry document. Turn on sourceDescriptions and it fetches and parses every document the sourceDescriptions array points at, in the same call:
parseResult = await parseArazzo(entry, {
parse: { parserOpts: { sourceDescriptions: true } },
});
Each parsed document is attached to the Source Description Object that named it, as a parseResult entry in that node’s metadata. So the natural way to walk them is to loop over the entry document’s sourceDescriptions, which also gives you the declared name and url for the label. Replace the last console.log with:
console.log(`${toValue(api.info.title)} Arazzo ${toValue(api.arazzo)}`);
const sourceDescriptions = api.sourceDescriptions;
for (let i = 0; i < sourceDescriptions.length; i += 1) {
const sourceDescription = sourceDescriptions.get(i);
const nested = sourceDescription.meta.get('parseResult');
const status = nested.api ? nested.api.element : 'not parsed';
console.log(` ${toValue(sourceDescription.name)} ${toValue(sourceDescription.url)} ${status}`);
}
Onboarding Arazzo 1.0.1
petstore ./petstore.openapi.yaml openApi3_1
adoption ./adopt-a-pet.arazzo.yaml arazzoSpecification1
billing ./billing.openapi.yaml not parsed
That is already the flat inventory. Each nested value is the same kind of result parseArazzo returned for the entry document: api is the parsed tree when there is one, and when there is not, the reason is waiting in nested.errors. Nothing threw. A source description that cannot be read never stops the parse of everything else, which is what you want from an inventory.
Say what each document is
openApi3_1 and arazzoSpecification1 are the element types of the parsed trees. They tell you what the parser found, whatever the parent’s type claimed, and each tree carries its own version field. A small helper turns that into a readable label. Add it above the loop:
function describe(api) {
if (api.element === 'arazzoSpecification1') return `Arazzo ${toValue(api.arazzo)}`;
if (api.element === 'swagger') return `OpenAPI ${toValue(api.swagger)}`;
return `OpenAPI ${toValue(api.openapi)}`;
}
and use it for the status:
const status = nested.api ? describe(nested.api) : 'not parsed';
OpenAPI 2.0 documents carry their version in a swagger field rather than openapi, hence the middle line. If a parent declares type: openapi for a file that turns out to be Arazzo, the document still parses as what it is, and a warning annotation lands in nested.warnings saying so. You will print those in a moment.
Walk into workflow documents
The adoption entry is an Arazzo document with source descriptions of its own, and the parser has already followed them: a workflow document reached through a source description is parsed with its own source descriptions followed in turn, so the whole network comes back from the one call. The script has to walk it, and since a network is a picture, it may as well draw one.
The output format is Mermaid, because it is the cheapest way to get a picture into the places reviewers look: GitHub renders a fenced mermaid block in comments, pull requests, and Markdown files. Each document becomes a node labelled with its file name and what it is, each source description becomes an arrow labelled with its name, and a document that could not be parsed becomes a dashed node on a dotted arrow.
Two details make this a walk over a network rather than an infinite loop. First, only Arazzo documents have sourceDescriptions, so an OpenAPI document yields an empty list and the walk stops there. Second, one document can be reached by more than one path: onboarding and adopt-a-pet both name petstore.openapi.yaml. The parser parses such a document once and points every later reference at the same result. Handing out a node id per result, on first sight, is enough: a result seen before gets a second arrow into its existing node and is not walked again.
Replace the title line and the loop from the previous step with:
function fileName(parseResult) {
return path.basename(toValue(parseResult.meta.get('retrievalURI')));
}
const ids = new Map();
const lines = ['graph LR'];
function idFor(parseResult) {
if (!ids.has(parseResult)) ids.set(parseResult, `d${ids.size + 1}`);
return ids.get(parseResult);
}
function walk(parseResult) {
const from = idFor(parseResult);
const sourceDescriptions = parseResult.api.sourceDescriptions ?? [];
for (let i = 0; i < sourceDescriptions.length; i += 1) {
const sourceDescription = sourceDescriptions.get(i);
const nested = sourceDescription.meta.get('parseResult');
const name = toValue(sourceDescription.name);
const seen = ids.has(nested);
const to = idFor(nested);
if (nested.api) {
if (!seen) lines.push(` ${to}["${fileName(nested)}<br/>${describe(nested.api)}"]`);
lines.push(` ${from} -->|${name}| ${to}`);
} else {
if (!seen) lines.push(` ${to}["${fileName(nested)}<br/>not parsed"]:::missing`);
lines.push(` ${from} -.->|${name}| ${to}`);
}
if (nested.api && !seen) walk(nested);
}
}
lines.push(` ${idFor(parseResult)}["${fileName(parseResult)}<br/>${describe(parseResult.api)}"]`);
walk(parseResult);
lines.push(' classDef missing stroke-dasharray: 6 4');
console.log(lines.join('\n'));
graph LR
d1["onboarding.arazzo.yaml<br/>Arazzo 1.0.1"]
d2["petstore.openapi.yaml<br/>OpenAPI 3.1.0"]
d1 -->|petstore| d2
d3["adopt-a-pet.arazzo.yaml<br/>Arazzo 1.0.1"]
d1 -->|adoption| d3
d3 -->|petstore| d2
d4["billing.openapi.yaml<br/>not parsed"]:::missing
d1 -.->|billing| d4
classDef missing stroke-dasharray: 6 4
That is the graph from the top of the page. Every document’s file name comes from the result’s retrievalURI metadata, which the parser sets for everything it read from a path or URL. A document that could not be read still has one, which is how the missing node gets its name.
What is left is saying why billing was not parsed, and where.
Report the problems
The reason billing was not parsed is in nested.errors, as annotation elements whose value is the message. Problems belong on standard error, so the graph on standard output stays clean enough to paste. Add a counter next to lines:
const lines = ['graph LR'];
let problems = 0;
Inside the loop in walk, after the two lines.push branches, print the annotations under the source description’s name and count the errors:
nested.errors.forEach((annotation) => console.error(`${name}: ${toValue(annotation)}`));
nested.warnings.forEach((annotation) => console.error(`${name}: warning: ${toValue(annotation)}`));
problems += nested.errors.length;
And exit with the count, after the console.log:
process.exit(problems > 0 ? 1 : 0);
Warnings are the parser saying “parsed, but you should know”. A parent that declares type: openapi for a file that is really Arazzo gets one, and so does a cycle, where a document names one of its own ancestors: the parser cuts the loop and reports it on the source description that closed it. Neither changes the exit code.
Run it
The complete script is inventory.mjs, seventy-one lines. Run it against the entry document:
node inventory.mjs onboarding.arazzo.yaml
graph LR
d1["onboarding.arazzo.yaml<br/>Arazzo 1.0.1"]
d2["petstore.openapi.yaml<br/>OpenAPI 3.1.0"]
d1 -->|petstore| d2
d3["adopt-a-pet.arazzo.yaml<br/>Arazzo 1.0.1"]
d1 -->|adoption| d3
d3 -->|petstore| d2
d4["billing.openapi.yaml<br/>not parsed"]:::missing
d1 -.->|billing| d4
classDef missing stroke-dasharray: 6 4
billing: Error parsing source description "/home/you/inventory/billing.openapi.yaml": Error while reading file "/home/you/inventory/billing.openapi.yaml"
echo $?
1
Wrap the first block in a fenced mermaid code block in a GitHub comment and you get the graph from the top of this page. To keep the graph and the problems apart, send them to different places:
node inventory.mjs onboarding.arazzo.yaml > dependencies.mmd 2> problems.txt
Run it against adopt-a-pet.arazzo.yaml instead and you get two nodes, one edge, and exit status 0. Create an empty billing.openapi.yaml and the message on standard error changes from a reading error to one saying no parser could parse the file. Point it at a workflow document of your own and the graph is whatever that document reaches.
What you have: one parseArazzo call that reads an entry document and everything it names, a walk over the result that gives every document a node with its kind, version, and file name, shared documents drawn once with every arrow into them, and unreadable ones drawn dashed, reported by name, and counted into the exit status. That is a dependency check for a CI job, a graph for a pull request, or the discovery step for a tool that needs to know what a workflow touches before doing anything else.
Next steps
- Large trees can be capped with
sourceDescriptionsMaxDepth, andsourceDescriptionsalso accepts an array of names to follow only some of them. Both are in the source descriptions section of the parser reference, along with the annotation classes and the shared-document rule this script relies on. - If your tool needs the line and column of a source description that failed, turn on source maps:
sourceMap: truewithstrict: falseputs a position on every node, including the Source Description Object in the parent. See Source maps. - The same result also holds every parsed document as a top-level member, which is the route to take when you want the documents without the declarations. See Result structure.
- Why a document is a network, and what else a parser has to get right, is the subject of the Parsing Arazzo Documents guide.
- The Runner is what consumes this tree for real: it executes a workflow against the APIs the tree describes.
- Something did not work as described? Say so in Discussions.