Circular References in OpenAPI (v0.12)

How libopenapi detects and warns about them (v0.12).

[Version 0.12 and below only]

Since v0.13 the index and resolver architecture has changed significantly, these documents are for v0.12 and below.

View the latest documentation for circular references

Circular references are loops in a data structure or Schema that causes loops.

components:
  schemas:
    One:
      properties:
        thing:
          "$ref": "#/components/schemas/Two"
      required:
        - thing
    Two:
      description: "test two"
      properties:
        testThing:
          "$ref": "#/components/schemas/One"

This creates a circular reference. One depends Two which depends on One One –> Two –> One –>… and so on.

The effect of ‘required’

A circular reference isn’t considered something that’s broken, unless required is set to true. When a schema is required AND it’s part of a loop - an unrecoverable infinite loop.

Detecting circular references

Internally, the resolver looks at every single reference found in the document, by using the index to look them all up, and then it follows the dependency graph for each and every other reference found.

There are opportunities to use references everywhere, and in Schemas, it can get quite messy. Polymorphic dependencies can create all kinds of chaos at scale.

libopenapi will find them all, and be able to detect if they are infinte loops.

There are two ways to detect circular references programmatically:

Automatically with a model

When building a data model, A resolver instance is created during the build, and runs the CheckForCircularReferences().

CheckForCircularReferences() is a non-destructive operation on the resolver that follows the same code-path as the resolver (so it can detect all the circles), however it doesn’t actually resolve anything or change the tree in anyway.

If any infinite loops were detected, they are collected up and returned as a slice of resolver.ResolvingError and returned by the second argument of the BuildVXModel().

It’s easy to extract details about the circular reference from the ResolvingError because, there will be a pointer to a index.CircularReferenceResult as a part of the error, available via the CircularReference property of the ResolvingError struct.

For example:

import (
    "fmt"
    "github.com/pb33f/libopenapi"
    "github.com/pb33f/libopenapi/resolver"
    "io/ioutil"
)

func main() {
    
    // load an OpenAPI 3 specification with circular refs from bytes
    circularBytes, _ := ioutil.ReadFile("test_specs/circular_tests.yaml")

    // create a new document from specification bytes
    document, _ := libopenapi.NewDocument(circularBytes)

    // build a v3 model from the document.
    v3Model, errors := document.BuildV3Model()

    // print out resolving errors: 
    // a slice of errors will be returned
    for i := range errors {
      
      // check if this is a resolving error.
      if err, ok := errors[i].(resolver.ResolvingError); ok {
        
        // check if there is a circular reference attached.
        if err.CircularReference != nil {
          
          fmt.Printf("Circular reference found: %s\n", 
             err.CircularReference.GeneerateJourneyPath())
        } 
    }
}

Using the resolver

If your application’s use case doesn’t require a model, you can perform the check independently using the resolver (this is what the model builder does under the hood)

import (
    "fmt"
    "io/ioutil" 
    "gopkg.in/yaml.v3"   
    "github.com/pb33f/libopenapi/index"
    "github.com/pb33f/libopenapi/resolver"
)

func main {
    
    // load an OpenAPI 3 specification with circular refs from bytes
    circularBytes, _ := ioutil.ReadFile("test_specs/circular_tests.yaml")
    
    // create a new root node *yaml.Node reference
    var rootNode yaml.Node
    
    // unmarshal the specification bytes into the root node
    _ = yaml.Unmarshal(circularBytes, &rootNode)
    
    // create and index from the specification root node
    idx := index.NewSpecIndex(&rootNode)
    
    // create a new resolver from the index
    resolverRef := resolver.NewResolver(idx)
    
    // get circular reference errors (without resolving)
    circularErrors := resolverRef.CheckForCircularReferences()
    
    // iterate through circular errors extacted from resolver.
    for _, err := range circularErrors {
        fmt.Printf("Error: %s\n", err.Error())
    }
}

Circular reference results

When a circular reference is detected, a index.CircularReferenceResult is created that contains all the information about the circular reference, including the path taken to get there.

// CircularReferenceResult contains a circular reference found when traversing the graph.
type CircularReferenceResult struct {
  Journey             []*Reference
  Start               *Reference
  LoopIndex           int
  LoopPoint           *Reference
  IsArrayResult       bool   // if this result comes from an array loop.
  PolymorphicType     string // which type of polymorphic loop is this? (oneOf, anyOf, allOf)
  IsPolymorphicResult bool   // if this result comes from a polymorphic loop.
  IsInfiniteLoop      bool   // if all the definitions in the reference loop are marked as required
}

When a polymorphic or array based circular reference is detected, the IsPolymorphicResult or IsArrayResult properties are set. This is useful for ignoring these types of circular references, as they may be valid use cases for a particular specification design.

The PolymorphicType property is set when the IsPolymorphicResult is set to true, and will be set to either oneOf, anyOf or allOf depending on the type of polymorphic loop detected.

CheckForCircularReferences() is non-destructive and won’t change the node tree in any way.

Polymorphic circular references

Using oneOf, anyOf or allOf can create circular references, and they are detected in the same way as any other loop, however these may actually be valid use cases for a particular specification design. In some cases, it may be desirable to loop in common properties.

Here is an example of a polymorphic circular reference:

components:
  schemas:
    One:
      properties:
        thing:
          oneOf:
            - "$ref": "#/components/schemas/Two"
            - "$ref": "#/components/schemas/Three"
      required:
        - thing
    Two:
      description: "test two"
      properties:
        testThing:
          "$ref": "#/components/schemas/One"
    Three:
      description: "test three"
      properties:
        testThing:
          "$ref": "#/components/schemas/One"

This creates a circular reference. One depends Two which depends on One –> Two –> One –>… and so on.

Ignore polymorphic references

To stop libopenapi from complaining about circles found through polymorphic references, you can tell the resolver to simply ignore them by using the IgnorePolymorphicCircularReferences() method available on the resolver.

newResolver := resolver.NewResolver(idx)
newResolver.IgnoreArrayCircularReferences()

If you’re building an OpenAPI document, there is a configuration property named IgnorePolymorphicCircularReferences on the datamodel.DocucmentConfiguration struct to ignore polymorphic circular references when building a model at a high level.

var spec = `openapi: 3.1.0
...`

docConfig := datamodel.NewClosedDocumentConfiguration()

// ignore polymorphic circular references
docConfig.IgnorePolymorphicCircularReferences = true

document, err := NewDocumentWithConfiguration([]byte(spec), config)
...

Array based circular references

It’s common to see circular references in arrays, and they are detected in the same way as any other loop, however these may actually be valid use cases for a particular specification design. In some cases, it may be desirable to loop in common properties.

Here is an example of an array based circular reference

components:
  schemas:
    ProductCategory:
      type: "object"
      properties:
        name:
          type: "string"
        children:
          type: "array"
          items:
            $ref: "#/components/schemas/ProductCategory"
          description: "Array of sub-categories in the same format."
      required:
        - "name"
        - "children"

Ignore array references

To stop libopenapi from complaining about circles found through polymorphic references, you can tell the resolver to simply ignore them by using the IgnorePolymorphicCircularReferences() method available on the resolver.

newResolver := resolver.NewResolver(idx)
newResolver.IgnoreArrayCircularReferences()

If you’re building an OpenAPI document, there is a configuration property named IgnoreArrayCircularReferences on the datamodel.DocucmentConfiguration struct to ignore array based circular references when building a model at a high level.

var spec = `openapi: 3.1.0
...`

docConfig := datamodel.NewClosedDocumentConfiguration()

// ignore array circular references
docConfig.IgnoreArrayCircularReferences = true

document, err := NewDocumentWithConfiguration([]byte(spec), config)
...