@usearazzo/resolver handles the references inside Arazzo and OpenAPI documents. A reference can point at another part of the same document or at another document. The package does three things with them:

  • Dereference: replace every reference with its target.
  • Bundle: pull external documents into one compound document.
  • Resolve: return the set of documents the references reach.

All three work on SpecLynx ApiDOM trees. Documents are read with @usearazzo/parser.

Installation

npm install @usearazzo/resolver

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.

Exports

Export Kind What it is
dereferenceArazzo, dereferenceOpenAPI function Dereference a document from a path or URL.
dereferenceArazzoElement, dereferenceOpenAPIElement function Dereference a document you already parsed, or a single element inside it.
bundleArazzo, bundleOpenAPI function Bundle a document from a path or URL into one compound document.
resolveArazzo, resolveOpenAPI function Collect a document and every external document it references, from a path or URL.
resolveArazzoElement, resolveOpenAPIElement function The same for a document you already parsed, or a single element inside it.
defaultDereferenceArazzoOptions, defaultDereferenceOpenAPIOptions constant The options each dereference function starts from before yours are merged in.
defaultBundleArazzoOptions, defaultBundleOpenAPIOptions constant The same for the bundle functions.
defaultResolveArazzoOptions, defaultResolveOpenAPIOptions constant The same for the resolve functions.
DereferenceError, BundleError, ResolveError class Thrown when the matching operation fails.
DereferenceArazzoOptions, DereferenceOpenAPIOptions, BundleArazzoOptions, BundleOpenAPIOptions, ResolveArazzoOptions, ResolveOpenAPIOptions type PartialDeep<ApiDOMReferenceOptions>, all six.
ReferenceSet type What the resolve functions return.

There are no bundle functions for parsed elements: bundling always starts from a path or URL.

Three operations

Operation What happens to a reference Returns
Dereference Replaced by the content it points at (inlined). ParseResultElement with no references left
Bundle External targets are copied into the entry document’s components. ParseResultElement holding one compound document
Resolve Left alone. The documents it reaches are fetched and parsed. ReferenceSet of every document reached

Which one to use:

  • Dereference to read a document without chasing pointers, for example before executing a workflow.
  • Bundle to get one self-contained file that still reads like the original.
  • Resolve to work with the reference graph itself, for example to list every file a document depends on.

Inputs and options

These rules hold for all ten functions.

Inputs

The functions without Element in their name take a location, as a string:

  • A file system path. A relative path resolves against the current working directory.
  • A file:// URL.
  • An HTTP or HTTPS URL.

Inline content is not accepted here. Parse a string or an object with the parser first, then pass the result to an Element function. See Parsed documents and elements.

The absolute location is stored on the result as retrievalURI metadata. Relative references inside the document resolve against it.

Options

The second argument of every function is a deep-partial ApiDOM reference options object. It is merged over the function’s exported defaults.

The defaults reuse the parser’s resolvers and parsers. Documents are fetched and detected exactly as parseArazzo and parseOpenAPI would. Plain JSON, YAML, and binary parsers are added on top. They read referenced files that are not API descriptions, such as a bare JSON Schema.

The keys you are likely to set:

Option Default Effect
resolve.baseURI none Where to treat a parsed document as coming from. Needed only by the Element functions. See Base URI for inline content.
dereference.strategyOpts.sourceDescriptions false Arazzo only. Process the documents the sourceDescriptions array points at. Accepts true or an array of names. See Source descriptions.
dereference.strategyOpts.sourceDescriptionsMaxDepth +Infinity Arazzo only. How many levels of Arazzo source descriptions to follow.
dereference.continueOnError, bundle.continueOnError false Keep going when a reference cannot be resolved: true, or a callback that receives each error. See Unresolvable references.
dereference.circular 'ignore' What to do with a reference that closes a cycle: 'ignore', 'replace', or 'error'. See Cycles.
dereference.strategyOpts.parseResult none The parsed document a single element belongs to. See A single element.
import { defaultDereferenceArazzoOptions } from '@usearazzo/resolver';

console.dir(defaultDereferenceArazzoOptions, { depth: null });

Errors

Each operation throws its own error class: DereferenceError, BundleError, or ResolveError. The message names the operation and the location you passed. The underlying error is on cause:

import { bundleArazzo, BundleError } from '@usearazzo/resolver';

try {
  await bundleArazzo('./missing.yaml');
} catch (error) {
  if (error instanceof BundleError) {
    error.message; // 'Failed to bundle Arazzo Document at "./missing.yaml"'
    error.cause.message; // 'Error while reading file "/home/you/project/missing.yaml"'
  }
  throw error;
}

A document of the wrong kind is an error too. dereferenceArazzo on an OpenAPI document throws a DereferenceError. Its cause is an UnmatchedDereferenceStrategyError. The bundle and resolve functions do the same with UnmatchedBundleStrategyError and UnmatchedResolveStrategyError.

One trap: the errors on cause come from ApiDOM, and ApiDOM has its own ResolveError class. It is not the class this package exports. error.cause instanceof ResolveError is false, even when the cause’s name reads ResolveError. Use instanceof on the outer error only, and treat cause as detail.

Unresolvable references

By default the first reference that cannot be resolved stops the whole operation. That is the right behaviour for a build step. An editor or a linter usually wants the opposite: process everything that works, and report what does not. Set continueOnError:

Value Effect
false (default) Throw on the first unresolvable reference.
true Skip unresolvable references silently.
a function Skip them, and call the function with each error.
import { dereferenceOpenAPI } from '@usearazzo/resolver';

const problems = [];
const parseResult = await dereferenceOpenAPI('/path/to/petstore.openapi.yaml', {
  dereference: { continueOnError: (error) => problems.push(error) },
});

problems.length; // 2
problems[0].message;
// 'Error while dereferencing Reference Object. Cannot resolve $ref "#/components/parameters/missing": ...'
problems[1].message;
// 'Error while dereferencing Schema Object. Cannot resolve $ref "./schemas/nope.yaml": ...'

Each error is an ApiDOM UnresolvableReferenceError. A skipped reference stays in the result exactly as written, for example { $ref: './schemas/nope.yaml' }. Every other reference is processed as usual. If the function itself throws, the operation stops, and that error becomes the cause.

The option lives under a different key for each operation:

Operation Key A skipped reference
Dereference dereference.continueOnError Stays in the result as written.
Bundle bundle.continueOnError Stays as written, so the bundle still points outside itself.
Resolve dereference.continueOnError Its document is missing from the ReferenceSet.

In Arazzo documents the option covers both kinds of reference. A Reusable Object reference that cannot be resolved, such as $components.parameters.missing, is skipped the same way as a $ref.

Dereference

Dereferencing replaces every reference with the content it points at. Other tools call this inlining references, or flattening a document.

The result is a graph, not a tree. Normally it is a directed acyclic graph (DAG). When the references form cycles, it is a directed cyclic graph (DCG).

The reason is sharing. A target referenced from several places is not copied in full each time. Each reference site gets its own top-level element. Everything beneath that element is shared, with the other sites and with the original under components:

const [first, second] = workflow.steps.map((step) => step.parameters.get(0));
const original = parseResult.api.components.parameters.get('limit');

first === second; // false: each site has its own element
first.get('name') === second.get('name'); // true: the content is shared
first.get('name') === original.get('name'); // true

A change made inside one target shows up everywhere it was referenced. Treat a dereferenced document as read-only. To change a part of it, clone that part first with cloneDeep from @speclynx/apidom-datamodel.

dereferenceArazzo

dereferenceArazzo(uri: string, options?: DereferenceArazzoOptions): Promise<ParseResultElement>

An Arazzo document has two kinds of reference. Both are replaced:

  • JSON Schema references (the $ref keyword).
  • Reusable Object references (the reference field, holding a $components... expression).

Given this document:

workflows:
  - workflowId: adoptPet
    inputs:
      $ref: ./schemas/adoption.yaml
    steps:
      - stepId: findPet
        operationId: findPets
        parameters:
          - reference: $components.parameters.limit
components:
  parameters:
    limit:
      name: limit
      in: query
      value: 10

dereferencing fetches schemas/adoption.yaml, follows the references inside it, and inlines both targets:

import { dereferenceArazzo } from '@usearazzo/resolver';
import { toValue } from '@speclynx/apidom-core';

const parseResult = await dereferenceArazzo('/path/to/adopt-a-pet.arazzo.yaml');
const workflow = parseResult.api.workflows.get(0);

toValue(workflow.steps.get(0).parameters.get(0)); // { name: 'limit', in: 'query', value: 10 }
toValue(workflow.inputs).type; // 'object', the content of schemas/adoption.yaml
toValue(parseResult.meta.get('retrievalURI')); // '/path/to/adopt-a-pet.arazzo.yaml'

The result is the same ParseResultElement the parser returns, with api, errors, warnings, and annotations. The components section stays in place. Only the references to it are replaced.

To turn the result into a plain object, JSON, or YAML, see Plain JavaScript, JSON, and YAML.

Source descriptions

By default the documents listed under sourceDescriptions are left alone. With sourceDescriptions: true, each one is fetched, parsed, and dereferenced in the same call:

import { dereferenceArazzo } from '@usearazzo/resolver';
import { toValue } from '@speclynx/apidom-core';

const parseResult = await dereferenceArazzo('/path/to/adopt-a-pet.arazzo.yaml', {
  dereference: { strategyOpts: { sourceDescriptions: true } },
});

parseResult.length; // 2: the main document and petstore

const petstore = parseResult.get(1);
toValue(petstore.meta.get('name')); // 'petstore'
petstore.api.element; // 'openApi3_1', with its own $refs replaced
parseResult.api.sourceDescriptions.get(0).meta.get('parseResult') === petstore; // true

The result has the same shape as the parser’s parsed source descriptions:

  • Each direct source description is a further top-level member.
  • The member has the source-description class, and its name and type in metadata.
  • The same element is also on its SourceDescriptionElement, under the parseResult metadata key.

Pass an array of names to process only some source descriptions. An Arazzo source description can have source descriptions of its own. sourceDescriptionsMaxDepth caps how many levels are followed:

const parseResult = await dereferenceArazzo('/path/to/onboarding.arazzo.yaml', {
  dereference: {
    strategyOpts: { sourceDescriptions: ['petstore'], sourceDescriptionsMaxDepth: 2 },
  },
});

A source description that cannot be processed never throws. It comes back as a member holding an error annotation. The main document and the other source descriptions still dereference. At a depth of 0, for example, each member carries this annotation:

Maximum dereference depth of 0 has been exceeded by file "/path/to/adopt-a-pet.arazzo.yaml"

The options can also be scoped to the Arazzo strategy, as strategyOpts['arazzo-1'].sourceDescriptions. The OpenAPI functions have no such options, because an OpenAPI document has no source descriptions.

Cycles

References can form a cycle, most often a schema that refers to itself:

components:
  schemas:
    Node:
      type: object
      properties:
        next:
          $ref: '#/components/schemas/Node'

Dereferencing handles this without recursing forever. What it does with the reference that closes the loop is up to you. Set dereference.circular:

Value The closing reference becomes Use it when
'ignore' (default) The target element itself. The result loops. You walk the graph in memory.
'replace' A ref element holding the target’s absolute URI. The result has no loops. You serialize the result.
'error' Nothing. A DereferenceError is thrown. Cycles are not allowed in your documents.

With the default, two things need care:

  • A naive walk never ends. Track the elements you have visited.
  • toValue returns a plain object with the same loop. JSON.stringify rejects it with Converting circular structure to JSON.

With 'replace', both problems go away:

import { dereferenceOpenAPI } from '@usearazzo/resolver';
import { toValue } from '@speclynx/apidom-core';

const parseResult = await dereferenceOpenAPI('/path/to/cycle.openapi.yaml', {
  dereference: { circular: 'replace' },
});

const next = parseResult.api.components.schemas.get('Node').properties.get('next');
next.element; // 'ref'
toValue(next); // '/path/to/cycle.openapi.yaml#/components/schemas/Node'
JSON.stringify(toValue(parseResult.api)); // works

To choose the replacement yourself, add dereference.circularReplacer. It receives that ref element, and what it returns goes into the result:

const parseResult = await dereferenceOpenAPI('/path/to/cycle.openapi.yaml', {
  dereference: {
    circular: 'replace',
    circularReplacer: (ref) => {
      ref.content = '#/components/schemas/Node';
      return ref;
    },
  },
});
// Node serializes as { type: 'object', properties: { next: '#/components/schemas/Node' } }

With 'error', the cause names the reference: Cannot resolve $ref "#/components/schemas/Node": Circular reference detected.

The options work the same in dereferenceArazzo and the Element functions. When the goal is a file on disk, consider bundling instead. It keeps the cycle as a plain $ref.

dereferenceOpenAPI

dereferenceOpenAPI(uri: string, options?: DereferenceOpenAPIOptions): Promise<ParseResultElement>

Dereferences an OpenAPI document on its own: Reference Objects, Path Item references, and schema references, local and external. api is the element for the document’s version: openApi2, openApi3_0, or openApi3_1.

import { dereferenceOpenAPI } from '@usearazzo/resolver';

const parseResult = await dereferenceOpenAPI('/path/to/petstore.openapi.yaml');
await dereferenceOpenAPI('https://example.com/petstore.openapi.yaml');

Bundle

Bundling copies every externally referenced document into the entry document. One file then carries everything. Unlike dereferencing, bundling keeps the references. The result still reads like the original, reusable parts stay reusable, and cycles can still be written down.

bundleArazzo

bundleArazzo(uri: string, options?: BundleArazzoOptions): Promise<ParseResultElement>

The external references in an Arazzo document are JSON Schemas. They land in components.inputs. Each hoisted schema gets an $id that records where it came from. The $ref values stay as written. They now resolve against those $ids instead of the file system. This is standard JSON Schema bundling, explained in JSON Schema bundling finally formalised.

import { bundleArazzo } from '@usearazzo/resolver';
import { toYAML } from '@speclynx/apidom-core';

const parseResult = await bundleArazzo('/path/to/adopt-a-pet.arazzo.yaml');
console.log(toYAML(parseResult.api));

For the document from the dereference example, the output keeps the workflow untouched and gains two schemas:

workflows:
  - workflowId: adoptPet
    inputs:
      $ref: ./schemas/adoption.yaml
    # steps unchanged
components:
  parameters:
    # limit, unchanged
  inputs:
    adopter:
      type: object
      properties:
        name:
          type: string
      $id: schemas/adopter.yaml
    adoption:
      type: object
      properties:
        petId:
          type: integer
        adopter:
          $ref: ./adopter.yaml
      $id: schemas/adoption.yaml

Reusable Object references already point inside the document, so they are left alone. Source descriptions are not bundled. They are separate API descriptions, and the sourceDescriptions array keeps pointing at them.

bundleOpenAPI

bundleOpenAPI(uri: string, options?: BundleOpenAPIOptions): Promise<ParseResultElement>

The targets of external Reference Objects are hoisted into the matching section. The references are rewritten to point there:

Document An external target lands in The reference becomes
OpenAPI 3.1.x The matching components field. Path Items go to components.pathItems. #/components/parameters/limit
OpenAPI 3.0.x The matching components field. External Path Items are inlined, as 3.0 has no components.pathItems. #/components/parameters/limit
OpenAPI 2.0 The root parameters, responses, or definitions. #/parameters/limit

External schemas depend on the version:

  • OpenAPI 3.1.x. Schemas are full JSON Schema. A hoisted schema gains an $id and its $ref stays as written, as in Arazzo.
  • OpenAPI 3.0.x and 2.0. A schema reference is an ordinary Reference Object. It is rewritten to #/components/schemas/<name> or #/definitions/<name>.
import { bundleOpenAPI } from '@usearazzo/resolver';

const parseResult = await bundleOpenAPI('/path/to/petstore.openapi.yaml');

Resolve

Resolving fetches and parses every document the references reach. It changes nothing. You get back the list of documents.

resolveArazzo

resolveArazzo(uri: string, options?: ResolveArazzoOptions): Promise<ReferenceSet>
import { resolveArazzo } from '@usearazzo/resolver';

const refSet = await resolveArazzo('/path/to/adopt-a-pet.arazzo.yaml');

refSet.size; // 3
refSet.refs.map((ref) => ref.uri);
// [
//   '/path/to/adopt-a-pet.arazzo.yaml',
//   '/path/to/schemas/adoption.yaml',
//   '/path/to/schemas/adopter.yaml',
// ]

refSet.rootRef.uri; // '/path/to/adopt-a-pet.arazzo.yaml'
refSet.rootRef.value.api.element; // 'arazzoSpecification1'

A ReferenceSet holds one Reference per document. Each Reference has a uri (absolute) and a value (the parsed ParseResultElement). rootRef is the entry document, with its references still in place. Documents reached only through other documents are included too. schemas/adopter.yaml above is one.

With dereference.strategyOpts.sourceDescriptions set, source descriptions are followed too. The set then also holds each source description document, and every document its references reach.

resolveOpenAPI

resolveOpenAPI(uri: string, options?: ResolveOpenAPIOptions): Promise<ReferenceSet>

The same for an OpenAPI document of any supported version.

import { resolveOpenAPI } from '@usearazzo/resolver';

const refSet = await resolveOpenAPI('/path/to/petstore.openapi.yaml');
refSet.refs.map((ref) => ref.uri);
// ['/path/to/petstore.openapi.yaml', '/path/to/schemas/pet.yaml']

Parsed documents and elements

dereferenceArazzoElement<T extends Element>(element: T, options?: DereferenceArazzoOptions): Promise<T>
dereferenceOpenAPIElement<T extends Element>(element: T, options?: DereferenceOpenAPIOptions): Promise<T>
resolveArazzoElement<T extends Element>(element: T, options?: ResolveArazzoOptions): Promise<ReferenceSet>
resolveOpenAPIElement<T extends Element>(element: T, options?: ResolveOpenAPIOptions): Promise<ReferenceSet>

The Element functions start from something you already parsed, not from a location. They take the same options and throw the same errors. Their messages name no location: Failed to dereference Arazzo Document.

A parsed document

Pass the ParseResultElement the parser gave you. If it was parsed from a path or URL, it carries its own retrievalURI. Nothing else is needed:

import { parseArazzo } from '@usearazzo/parser';
import { dereferenceArazzoElement, resolveArazzoElement } from '@usearazzo/resolver';

const parseResult = await parseArazzo('/path/to/adopt-a-pet.arazzo.yaml');

const dereferenced = await dereferenceArazzoElement(parseResult);
const refSet = await resolveArazzoElement(parseResult);

Dereferencing returns a new element. The one you passed in keeps its references, so you can hold both. Source descriptions work as they do for dereferenceArazzo.

Base URI for inline content

A document parsed from a string or an object has no location. Relative references inside it have nothing to resolve against. Pass resolve.baseURI:

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

const parseResult = await parseArazzo(yamlText);
const dereferenced = await dereferenceArazzoElement(parseResult, {
  resolve: { baseURI: 'https://example.com/adopt-a-pet.arazzo.yaml' },
});

Without it, the call fails before anything is fetched:

baseURI option is required when dereferencing a ParseResultElement without retrievalURI metadata

The resolve functions say when resolving instead.

A retrievalURI on the element wins over the option. The parser’s own resolve.baseURI sets that retrievalURI. If you parsed with it, you do not need the option here.

A single element

You can dereference or resolve one element, such as a workflow or a path item. The rest of the document is not processed. An element does not know which document it belongs to, so pass that document as dereference.strategyOpts.parseResult. References to components resolve against it:

import { parseArazzo } from '@usearazzo/parser';
import { dereferenceArazzoElement } from '@usearazzo/resolver';
import { toValue } from '@speclynx/apidom-core';

const parseResult = await parseArazzo('/path/to/adopt-a-pet.arazzo.yaml');
const workflow = parseResult.api.workflows.get(0);

const dereferenced = await dereferenceArazzoElement(workflow, {
  dereference: { strategyOpts: { parseResult } },
});

dereferenced.element; // 'workflow'
toValue(dereferenced.steps.get(0).parameters.get(0)); // { name: 'limit', in: 'query', value: 10 }

The return value has the type you passed in. A WorkflowElement goes in, a dereferenced WorkflowElement comes out. If the parent document has no retrievalURI, add resolve.baseURI as above.

The resolve functions accept a single element the same way, with two differences:

  • The ReferenceSet covers what is reachable from that element, not from the whole document. Its rootRef has the document’s URI, but holds a wrapper around a copy of the element.
  • parseResult is optional. Without it, pass both resolve.baseURI and parse.mediaType. The element alone cannot say what kind of document it came from.

Plain JavaScript, JSON, and YAML

Every function on this page returns ApiDOM elements, not plain objects. To leave ApiDOM, use the serializers in @speclynx/apidom-core:

npm install @speclynx/apidom-core
Function Returns Use it to
toValue(element) A plain JavaScript value Hand the document to code that expects objects and arrays.
toJSON(element, replacer?, space?) A JSON string Write a .json file or send the document over the wire.
toYAML(element) A YAML string Write a .yaml file.

All three accept any element: the whole document (parseResult.api), a workflow, a single schema.

import { dereferenceArazzo } from '@usearazzo/resolver';
import { toValue, toJSON, toYAML } from '@speclynx/apidom-core';

const parseResult = await dereferenceArazzo('/path/to/adopt-a-pet.arazzo.yaml');

const document = toValue(parseResult.api);
document.workflows[0].steps[0].parameters[0]; // { name: 'limit', in: 'query', value: 10 }
document.workflows[0].inputs.type; // 'object'

toJSON(parseResult.api); // '{"arazzo":"1.0.1","info":{"title":"Pet adoption", ...'
toJSON(parseResult.api, undefined, 2); // the same, indented by two spaces
toYAML(parseResult.api); // 'arazzo: 1.0.1\ninfo:\n  title: Pet adoption\n ...'

Pass parseResult.api, not parseResult. The parse result is a list of the document and its annotations, so toValue(parseResult) returns an array.

Two properties of a dereferenced result carry over to the plain value:

  • Sharing. toValue keeps shared content shared. A target referenced from two places becomes two objects whose nested objects are the same. A change inside one shows up in the other. structuredClone keeps that sharing too. For a copy with nothing shared, go through JSON: JSON.parse(toJSON(element)).
  • Cycles. With the default circular: 'ignore', toValue returns an object that loops, and JSON.stringify rejects it. toJSON and toYAML do not throw. They write null where the loop closes, so the output is silently incomplete. Dereference with circular: 'replace' before you serialize, or bundle instead.

A bundled result needs no such care. Its references stay $refs, cycles included, so nothing loops and all three functions work on it as they are.

For cloning and walking the tree, see Working with the tree in the parser reference.

Supported versions

Arazzo documents:

OpenAPI documents, as source descriptions or on their own:

Both JSON and YAML are accepted for every version. The format is detected from the content, not the file extension.