List Every Document an Arazzo Workflow Depends On

An Arazzo workflow names the APIs and other workflows it calls, and a run fetches all of them. Write a script that parses the entry document, follows every source description, and draws the graph of what each document is, which are shared, and which cannot be read.

Vladimír Gorej Updated 10 minute read

A rounded-square document node on the left with three lines branching to three nodes; the middle one continues to a fourth node that loops back to the first with a dotted line, and the bottom one is drawn as a dashed outline with a gap in its line
One entry document, everything it reaches, and the one it cannot.

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:

Dependency graph of onboarding.arazzo.yaml onboarding.arazzo.yaml, Arazzo 1.0.1, points at adopt-a-pet.arazzo.yaml (Arazzo 1.0.1) as adoption, at petstore.openapi.yaml (OpenAPI 3.1.0) as petstore, and with a dotted arrow at billing.openapi.yaml, drawn dashed and marked not parsed. adopt-a-pet.arazzo.yaml also points at the same petstore.openapi.yaml. adoption petstore billing petstore onboarding.arazzo.yaml Arazzo 1.0.1 adopt-a-pet.arazzo.yaml Arazzo 1.0.1 billing.openapi.yaml not parsed petstore.openapi.yaml OpenAPI 3.1.0

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, and sourceDescriptions also 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: true with strict: false puts 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.

Frequently Asked Questions

Parse the workflow document with @usearazzo/parser and the sourceDescriptions option turned on, then loop over the document’s sourceDescriptions. Each Source Description Object carries the parsed document it points at as a parseResult entry in its metadata:

import { parseArazzo } from '@usearazzo/parser';

const parseResult = await parseArazzo('onboarding.arazzo.yaml', {
  parse: { parserOpts: { sourceDescriptions: true } },
});

const sourceDescriptions = parseResult.api.sourceDescriptions;
for (let i = 0; i < sourceDescriptions.length; i += 1) {
  const nested = sourceDescriptions.get(i).meta.get('parseResult');
  nested.api; // the parsed document, or undefined if it could not be read
}

One call fetches and parses every document the workflow names, whatever its kind: the OpenAPI descriptions its steps call, other Arazzo documents whose workflows it calls, and their source descriptions in turn. AsyncAPI documents will appear the same way once the parser supports them. The walk step of this tutorial turns that loop into a graph.

Yes. A Source Description Object with type: arazzo names another workflow document, and a step calls one of its workflows with workflowId: $sourceDescriptions.<name>.<workflowId> instead of an operationId. That document has source descriptions of its own, so a workflow is really the root of a network of documents. @usearazzo/parser follows the whole network in one call when sourceDescriptions is on, nesting each Arazzo document’s results beneath the Source Description Object that named it.

Parse the workflow with sourceDescriptions: true, walk the source descriptions recursively, and print one Mermaid node per document and one arrow per source description. The seventy-line script in this tutorial does exactly that, and its output for a workflow that names two APIs and one other workflow is:

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

Paste that inside a fenced mermaid block in a GitHub comment, pull request, or README and it renders as a graph.

Nothing throws. The missing document’s parseResult has no api and carries an error annotation saying why, such as a file that could not be read or a document no parser recognised. The entry document and every other source description still parse, so your tool sees the whole network with one node marked as unreadable, rather than no network at all. The tutorial’s script draws such a document as a dashed node, reports the error on standard error, and exits with a nonzero status.

It is parsed once, whatever kind of document it is. When onboarding.arazzo.yaml and the adopt-a-pet.arazzo.yaml it names both point at petstore.openapi.yaml, the parser parses petstore for the first reference and points the second one’s parseResult at the same result, with an info annotation saying it was reused. A workflow document reached from two places is shared the same way. Keeping a Map from result to node id is enough to draw it as one node with two arrows into it. A true cycle, where a document names one of its own ancestors, is cut and reported as a warning annotation on the source description that closed the loop, so parsing never recurses forever.

No. Following them means reading files and making network requests, so it is opt-in per call with parse.parserOpts.sourceDescriptions. Pass true to follow all of them, or an array of names to follow only some, and cap how far nested Arazzo documents are followed with sourceDescriptionsMaxDepth. All three are in the source descriptions section of the API reference.

The parser detects what the document is from its content, not from the declared type, and parses it as what it is. The mismatch becomes a warning annotation on that source description’s result, such as a document declared as openapi that turns out to be Arazzo. The document is still parsed and still appears in the graph with its detected kind and version. Print nested.warnings to see these.

Arazzo 1.1.0 allows type: asyncapi, but @usearazzo/parser does not parse AsyncAPI documents yet. The source description gets an error annotation saying no parser could parse the file, the rest of the workflow document parses normally, and the tutorial’s script draws it as a dashed node like any other unreadable document. The compatibility table on the homepage tracks which document kinds and versions parse, validate, and run.

No. Following source descriptions is the parser’s own sourceDescriptions option. The resolver is for a different job: dereferencing $ref inside the documents and $components reusable references, which this tutorial never touches. The parser reads the network of documents and hands you a tree. What each document contains is left exactly as written.

The parser fetches it over HTTP or HTTPS, with a 15 second timeout and up to five redirects, and the script works unchanged. Relative URLs resolve against where the parent document was read from, so a parent fetched from https://example.com/flows/onboarding.arazzo.yaml with a source description of ./petstore.openapi.yaml fetches https://example.com/flows/petstore.openapi.yaml.