The rolodex of OpenAPI references

The rolodex holds all the references, and knows how to look them up.
This documentation only applies to v0.13+ of libopenapi. In all releases before then the index and resolver architecture was significantly different and the behavior and relationship of them was also different.

If you have ever used OpenAPI with golang, you’re probably aware of using $ref to reference other objects like Schemas or Parameters that we want to re-use across a specification, or across multiple specifications.

libopenapi needs a way to look up these references and resolve them.

What are references in OpenAPI?

A $ref is a JSON Pointer that points to a location in the specification. The location could be local to the current document, or it could be in another document on the local file system, or out there in cyberspace somewhere.

What is the rolodex?

A rolodex is a rotating file device used to store business contact information. Its name is a portmanteau of the words rolling and index.

The rolodex inside libopenapi is a data structure that holds all the references in an OpenAPI specification and dependent files, and knows how to look them up.


How does the rolodex work?

The rolodex is a directory system that holds every index created for every file or remote based reference.

The rolodex contains a local and remote virtual filesystem that are used to locate files and remote references, and then keep track of them.

Thr rolodex starts by indexing the root document. The indexing process will first locate all $ref nodes in the document and then try to map those references by first trying to open the referenced document (either via the local or remote file system), and then once opened, that referenced document will also in turn be indexed.

Once there is nothing left to lookup, the rolodex will have a complete index of the entire specification, and know the location of every referring document. The rolodex stores references as ‘absolute’ references, meaning that they are always recorded as a full URL, or a full file path, combined with the JSON Pointer to the object being referenced.

Rolodex architecture

model diagram of libopenapi

libopenapi starts with creating a new rolodex for a root OpenAPI document and then indexes it, recursively looking up each $ref as it comes across it.

Local references

The index will locate every $ref and determine what type of lookup to perform, if the reference is local, there is no prefix attached to the reference path, so it looks something like this:

  $ref: "#/components/schemas/Burger"

The # (hash) symbol indicates the root of the document.

The index looks up the component from within the same document and keeps a record of the found object. It does not search the rolodex for the object, because it’s already in the same document.

File references

If the reference is a file reference, it will look something like this:

  $ref: "some/path/to/openapi-schemas.yaml#/components/schemas/Burger"

The prefix (before the hash) is the path to the file that contains the lookup. libopenapi will attempt to locate the file on the local operating system and then load it into the index. If the path is relative, the rolodex will look from where it is being run as the working directory.

When a file reference is used, a whole new index is created for the entire file. The rolodex, maintains indexes for every file pulled in.

The rolodex contains a built-in local file system called LocalFS that works in two ways. By default it operates as a recursive lookup, which means it opens files as it scans through each OpenAPI file looking for references.

In another mode, it can be set to scan everything from the working directory down, regardless if the file is referenced or not, if it’s a YAML or JSON file, it will be indexed.

Remote references

If the reference is a remote reference, it will look something like this:

  $ref: "https://pb33f.io/openapi.yaml#/components/schemas/Burger"

Like file references, the prefix (before the hash) is the URI to the file that contains the lookup. libopenapi will pull down this remote document and then treat it as a local reference internally.

When a remote reference is used, a whole new index is created for the entire remote file (like file references).

The rolodex contains a built-in remote file system called RemoteFS that only works in recursive mode, which means it it can only lookup files that are referenced. It does not support scanning the entire remote file system.

Building a rolodex

If you’re building documents using the model, then you don’t need to worry about the one will be created for you based on the document configuration.

If a BaseURL or BasePath is provided or AllowFileReferences or AllowRemoteReferences for a document configuration are set, the a rolodex with local and/or remote file systems will be set up.

Adding remote and local file systems

The rolodex and the index(es) work symbiotically. You can create an index without a rolodex, but you cannot create a rolodex without an index, it has no value.

If your spec does NOT have any kind of remote/file based references, then you don’t need a rolodex, you can just create an index as everything will be looked up locally.

Below is a complete example of how to use the rolodex with both local and remote file systems.

import (
  "github.com/pb33f/libopenapi/index"
  "gopkg.in/yaml.v3"
  "os"
)

func main() {

  // load in the spec
  actualYaml, _ := os.ReadFile("spec.yaml")

  // set our base directory
  basePath := "../some/basePath/where/files/live"

  // create an index config
  config := index.CreateOpenAPIIndexConfig()

  // the rolodex will automatically try and check for circular references, you don't want to do this
  // if you're resolving the spec, as the node tree is marked as 'seen' and you won't be able to resolve
  // correctly.
  config.AvoidCircularReferenceCheck = true

  // new in 0.13+ is the ability to add remote and local file systems to the index
  // requires a new part, the rolodex. It holds all the indexes and knows where to find
  // every reference across local and remote files.
  rolodex := index.NewRolodex(config)

  // create a local file system config, tell it where to look from and the index config to pay attention to.
  fsCfg := &index.LocalFSConfig{
      BaseDirectory: basePath,
      IndexConfig:   config,
  }

  // create a local file system using config.
  fileFS, err := index.NewLocalFSWithConfig(fsCfg)
  if err != nil {
      panic(err)
  }

  // unmarshal the spec into a yaml node
  var rootNode yaml.Node
  _ = yaml.Unmarshal([]byte(actualYaml), &rootNode)

  // set the root node of the rolodex (this is the root of the spec)
  rolodex.SetRootNode(&rootNode)

  // add local file system to rolodex
  rolodex.AddLocalFS(basePath, fileFS)

  // add a new remote file system.
  remoteFS, _ := index.NewRemoteFSWithConfig(config)

  // add the remote file system to the rolodex
  rolodex.AddRemoteFS("", remoteFS)

  // set the root node of the rolodex, this is your spec.
  rolodex.SetRootNode(&rootNode)

  // index the rolodex
  indexingError := rolodex.IndexTheRolodex()
  if indexingError != nil {
      panic(indexingError)
  }

  // resolve the rolodex (if you want to)
  rolodex.Resolve()

  // there should be no errors at this point
  resolvingErrors := rolodex.GetCaughtErrors()
  if resolvingErrors != nil {
      panic(resolvingErrors) 
  }
}

Index everything

When you’re ready to index everything, you can call IndexTheRolodex() on the rolodex. This will recursively index everything that can be found.

Any resolving or lookup errors will be returned as a single error, however the rolodex has joined multiple errors into a single error, so you can use utils.UnwrapError() to get the original errors returned as a slice.

Resolving

Resolving is a destructive operation, meaning that the node tree is permanently changed, references will be permanently shifted in that model.

Use the Resolve() method on the rolodex to resolve the entire specification.

Any errors that were found during resolution can be retrieved by calling GetCaughtErrors() on the rolodex. This will return a slice of errors.

Check for circular references

The rolodex will automatically check for circular references, but you can turn this off by setting the AvoidCircularReferenceCheck on the index config to true.

To manually check for circular references, you can call CheckForCircularReferences() on the rolodex. This will return a slice of errors, each error will be a circular reference error.

Any caught errors can be retrieved by calling GetCaughtErrors() on the rolodex. This will return a slice of errors.

Add a custom HTTP handler

If you need to customize how HTTP references are looked up by the rolodex, you can add a custom HTTP handler to the remote file system by setting SetRemoteHandlerFunc() and passing in any function that matches the signature func(url string) (*http.Response, error)

This is useful if your specifications are held in a private repository and require authentication to access.

import (
  "fmt"
  "github.com/pb33f/libopenapi/index"
  "net/http"
  "os"
  "time"
)

func customHttpHandler() {

  // create an index config
  config := index.CreateOpenAPIIndexConfig()

  // create a new rolodex
  rolodex := index.NewRolodex(config)

  // create a new remote fs and set the config for indexing.
  remoteFS, _ := index.NewRemoteFSWithConfig(config)

  // custom http client.  
  client := &http.Client{
      Timeout: time.Second * 120,
  }

  // custom handler func
  customHandler := func(url string) (*http.Response, error) {

    // create a new request
    request, _ := http.NewRequest(http.MethodGet, url, nil)
    request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", os.Getenv("GITHUB_TOKEN")))
    return client.Do(request)

  }

  // if a token is set, then use the custom handler.
  if os.Getenv("GITHUB_TOKEN") != "" {
    remoteFS.SetRemoteHandlerFunc(customHandler)
  }

  // add remote filesystem
  rolodex.AddRemoteFS("", remoteFS)
}

Add a custom file system

One of the design choices of libopenapi is to allow for custom file systems to be added to the rolodex. Some use-cases might include the need to look up references from databases, or other sources other than local filer or via http.

The rolodex uses fs.FS as the interface for file systems. This is a built-in interface in golang.

To add a custom file system, you need to implement the index.RolodexFS interface (which also defines the Open(name string) (fs.File, error) method)

// RolodexFS is an interface that represents a RolodexFS, is the same interface as `fs.FS`, except it
// also exposes a GetFiles() signature, to extract all files in the FS.
type RolodexFS interface {
  Open(name string) (fs.File, error)
  GetFiles() map[string]RolodexFile
}

The GetFiles() method returns a map with the key being the file path, and the value being a RolodexFile interface.

RolodexFile is an interface that exposes the fs.FileInfo and fs.File interface methods.

The rolodex understands two concepts, local files and remote files, so when deciding to add a custom file system, you should decide how you want rolodex to use it.

Anything reference that starts with http is considered remote by the rolodex, everything is else a file.

The rolodex comes with two built-in file systems, LocalFS and RemoteFS. To build custom file systems, you can use these as examples.