Bundle OpenAPI references into a single file

Compact multiple OpenAPI references into a single file

Lots of OpenAPI specifications are exploded out across lots of different files. This is great for maintainability, but not so great for portability.

Lots of OpenAPI tools do not support exploded / multi-file OpenAPI specifications. Loading and locating references can be tricky, particularly when they are spread across local or remote file systems, so lots of tools just give up on trying to locate references and just ask for a single monolithic file.

libopenapi fully supports exploded / multi-file OpenAPI specifications.

Bundling modes

There are two ways to bundle specs in libopenapi.

  • Composed
  • Inlined

Composed bundling

Composed bundling will re-structure and re-build the exploded / distributed spec into a new single spec. This new spec will maintain all the references that exist today, except they will all be made local to the single document.

All the references will be detected (as best possible) and then recomposed into the components section of the bundled specification.

TLDR; Everything will be plucked from where it lives and recomposed into a single doc.

Composed bundling was added in v0.22.0

Inlined bundling

Inlined bundling will perform the role of a ‘resolver’ and will extract every reference and replace every $ref with the concrete data referenced. This means every reference will have the same copy of data inlined where the reference once stood.

Inlining means tools that do not support OpenAPI specs with references or multiple files will be able to work.

Inline bundling will create large file sizes.


Composed bundling of raw bytes

// BundleBytesComposed will take a byte slice of an OpenAPI specification and return a composed bundled version of it.
// this is the same as BundleBytes, but it will compose the bundling instead of inline it.
func BundleBytesComposed(bytes []byte, configuration *datamodel.DocumentConfiguration,
	compositionConfig *BundleCompositionConfig) ([]byte, error) {

The bytes are the OpenAPI specification bytes, the a DocumentConfiguration pointer sets up any optional configuration options, and the BundleCompositionConfig looks like this:

// BundleCompositionConfig is used to configure the composition of OpenAPI documents when using BundleDocumentComposed.
type BundleCompositionConfig struct {
	Delimiter string // Delimiter is used to separate clashing names. Defaults to `__`.
}

The Demimiter is used for handling name collisions.

When composing a document, libopenapi will attempt to bring in objects from every remote file. A lot of the time, names will collide as common naming patterns are re-used across multiple files.

To counter collisions, libopenapi will compose a unique name, and will use the Delimiter as a word separator.

for example: AddUser__corporate__data

The default delimiter is __

Composed bundling of a document

// BundleDocumentComposed will take a v3.Document and return a composed bundled version of it. Composed means
// that every external file will have references lifted out and added to the `components` section of the document.
// Names will be preserved where possible, conflicts will be appended with a number. If the type of the reference cannot
// be determined, it will be added to the `components` section as a `Schema` type, a warning will be logged.
// The document model will be mutated permanently.
//
// Circular references will not be resolved and will be skipped.
func BundleDocumentComposed(model *v3.Document, compositionConfig *BundleCompositionConfig) ([]byte, error) {
	return compose(model, compositionConfig)
}

Inline bundling raw bytes

func BundleBytes(bytes []byte, configuration *datamodel.DocumentConfiguration) ([]byte, error)

This method takes a slice of bytes, and a DocumentConfiguration pointer and returns a slice of bytes and an error if something went wrong.

The returned bytes is a single, fully bundled OpenAPI specification, all external references are resolved and inlined.

Local (references inside the root document) references are not bundled, They stay in place.

Inline bundling a document

The BundleDocument function from the bundler package is a convenience function that takes a v3.Document pointer and returns a slice of bytes and an error if something went wrong:

func BundleDocument(model *v3.Document) ([]byte, error)

Here is an example of checking out the mother of all exploded specifications the Digital Ocean OpenAPI specification and bundling it into a single file:

import (
  "bytes"
  "github.com/pb33f/libopenapi"
  "github.com/pb33f/libopenapi/bundler"
  "github.com/pb33f/libopenapi/datamodel"
  "github.com/stretchr/testify/assert"
  "log"
  "log/slog"
  "os"
  "os/exec"
  "path/filepath"
  "runtime"
  "strings"
)

func main() {

  // check out the mother of all exploded specifications
  tmp, _ := os.MkdirTemp("", "openapi")
  cmd := exec.Command("git", "clone", "https://github.com/digitalocean/openapi", tmp)
  defer os.RemoveAll(filepath.Join(tmp, "openapi"))

  err := cmd.Run()
  if err != nil {
      log.Fatalf("cmd.Run() failed with %s\n", err)
  }

  spec, _ := filepath.Abs(filepath.Join(tmp+"/specification", "DigitalOcean-public.v2.yaml"))
  specBytes, _ := os.ReadFile(spec)

  doc, err := libopenapi.NewDocumentWithConfiguration([]byte(specBytes), &datamodel.DocumentConfiguration{
      Logger: slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
          Level: slog.LevelWarn,
      })),
  }) 
  if err != nil {
      panic(err) 
  }

  v3Doc, errs := doc.BuildV3Model()
  if len(errs) > 0 {
      panic(errs)
  }

  bytes, e := bundler.BundleDocument(&v3Doc.Model)

 //... do something with the bytes
}

And now we have a single, bundled OpenAPI specification that we can use with any tool that doesn’t support exploded specs.


Advanced inline bundling

For more advanced inline bundling scenarios, libopenapi provides configuration options to handle special cases. These use the BundleInlineConfig struct and enhanced bundling functions.

Advanced inline bundling configuration was added in v0.30

BundleInlineConfig

The BundleInlineConfig struct provides fine-grained control over the inline bundling process:

// BundleInlineConfig provides configuration options for inline bundling.
type BundleInlineConfig struct {
    // ResolveDiscriminatorExternalRefs when true, copies external schemas referenced
    // by discriminator mappings to the root document's components section.
    // This ensures the bundled output is valid and self-contained.
    ResolveDiscriminatorExternalRefs bool
}

Configurable inline bundling

Two new functions accept the BundleInlineConfig parameter:

// Bundle raw bytes with configuration
func BundleBytesWithConfig(bytes []byte, configuration *datamodel.DocumentConfiguration,
    bundleConfig *BundleInlineConfig) ([]byte, error)

// Bundle a document with configuration
func BundleDocumentWithConfig(model *v3.Document, bundleConfig *BundleInlineConfig) ([]byte, error)

Discriminator external references

When an OpenAPI specification uses discriminators with external schema references, the default inline bundling behavior may leave us with invalid references in the bundled output. The ResolveDiscriminatorExternalRefs option ensures all discriminator-mapped schemas are copied into the components section.

To use, set:

ResolveDiscriminatorExternalRefs: true

In the following use-cases:

  • The OpenAPI spec uses discriminators (oneOf/anyOf with discriminator mappings)
  • The discriminator and its referenced schemas are defined in external files
  • We need a fully self-contained bundled output with all schemas present in the components section
  • We’re seeing errors about missing schema references like #/components/schemas/TypeA after bundling

Example

Here’s an example with discriminator resolution enabled:

import (
  "github.com/pb33f/libopenapi"
  "github.com/pb33f/libopenapi/bundler"
  "github.com/pb33f/libopenapi/datamodel"
  "log/slog"
  "os"
)

func main() {
  // Read the OpenAPI specification
  specBytes, _ := os.ReadFile("path/to/spec.yaml")

  // Configure document loading
  docConfig := &datamodel.DocumentConfiguration{
      AllowFileReferences:     true,
      Logger: slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
          Level: slog.LevelWarn,
      })),
  }

  // Create the document
  doc, err := libopenapi.NewDocumentWithConfiguration(specBytes, docConfig)
  if err != nil {
      panic(err)
  }

  v3Doc, errs := doc.BuildV3Model()
  if len(errs) > 0 {
      panic(errs)
  }

  // Configure inline bundling with discriminator resolution
  bundleConfig := &bundler.BundleInlineConfig{
      ResolveDiscriminatorExternalRefs: true,
  }

  // Bundle with configuration
  bundledBytes, err := bundler.BundleDocumentWithConfig(&v3Doc.Model, bundleConfig)
  if err != nil {
      panic(err)
  }

  // Write the bundled specification
  os.WriteFile("bundled-spec.yaml", bundledBytes, 0644)
}

We can also bundle directly from bytes:

// Bundle bytes with discriminator resolution
bundleConfig := &bundler.BundleInlineConfig{
    ResolveDiscriminatorExternalRefs: true,
}

bundledBytes, err := bundler.BundleBytesWithConfig(specBytes, docConfig, bundleConfig)

When ResolveDiscriminatorExternalRefs is enabled:

  • The bundler identifies all discriminator mappings in the specification
  • For each mapping that references an external schema (e.g., external-schemas.yaml#/TypeA)
  • The referenced schema is extracted from the external file
  • The schema is copied to the root document’s components/schemas section
  • The discriminator mapping reference is updated to point to the local component (e.g., #/components/schemas/TypeA)

This ensures the bundled output is completely self-contained with no dangling external references.

If ResolveDiscriminatorExternalRefs is set to false (the default), external discriminator references will be preserved as-is. This maintains backward compatibility but may result in invalid bundled output if the external files are not available.