Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for different bundling modes #350

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 153 additions & 35 deletions bundler/bundler.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,35 @@ package bundler

import (
"errors"
"fmt"
"strings"
"sync"

"github.com/davecgh/go-spew/spew"
"github.com/pb33f/libopenapi"
"github.com/pb33f/libopenapi/datamodel"
v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
highV3 "github.com/pb33f/libopenapi/datamodel/high/v3"
"github.com/pb33f/libopenapi/datamodel/low"
"github.com/pb33f/libopenapi/datamodel/low/base"
lowV3 "github.com/pb33f/libopenapi/datamodel/low/v3"
"github.com/pb33f/libopenapi/index"
"gopkg.in/yaml.v3"
)

// ErrInvalidModel is returned when the model is not usable.
var ErrInvalidModel = errors.New("invalid model")

type RefHandling string

const (
RefHandlingInline RefHandling = "inline"
RefHandlingCompose RefHandling = "compose"
)

type BundleOptions struct {
RelativeRefHandling RefHandling
}

// BundleBytes will take a byte slice of an OpenAPI specification and return a bundled version of it.
// This is useful for when you want to take a specification with external references, and you want to bundle it
// into a single document.
Expand All @@ -25,7 +43,7 @@ var ErrInvalidModel = errors.New("invalid model")
// document will be a valid OpenAPI specification, containing no references.
//
// Circular references will not be resolved and will be skipped.
func BundleBytes(bytes []byte, configuration *datamodel.DocumentConfiguration) ([]byte, error) {
func BundleBytes(bytes []byte, configuration *datamodel.DocumentConfiguration, opts BundleOptions) ([]byte, error) {
doc, err := libopenapi.NewDocumentWithConfiguration(bytes, configuration)
if err != nil {
return nil, err
Expand All @@ -37,7 +55,12 @@ func BundleBytes(bytes []byte, configuration *datamodel.DocumentConfiguration) (
return nil, errors.Join(ErrInvalidModel, err)
}

bundledBytes, e := bundle(&v3Doc.Model, configuration.BundleInlineRefs)
// Overwrite bundle options, if deprecated config field is used.
if configuration.BundleInlineRefs {
opts.RelativeRefHandling = RefHandlingInline
}

bundledBytes, e := bundle(&v3Doc.Model, opts)
return bundledBytes, errors.Join(err, e)
}

Expand All @@ -50,49 +73,144 @@ func BundleBytes(bytes []byte, configuration *datamodel.DocumentConfiguration) (
// document will be a valid OpenAPI specification, containing no references.
//
// Circular references will not be resolved and will be skipped.
func BundleDocument(model *v3.Document) ([]byte, error) {
return bundle(model, false)
func BundleDocument(model *highV3.Document) ([]byte, error) {
return bundle(model, BundleOptions{RelativeRefHandling: RefHandlingInline})
}

func bundle(model *v3.Document, inline bool) ([]byte, error) {
func bundle(model *highV3.Document, opts BundleOptions) (_ []byte, err error) {
rolodex := model.Rolodex

compact := func(idx *index.SpecIndex, root bool) {
mappedReferences := idx.GetMappedReferences()
sequencedReferences := idx.GetRawReferencesSequenced()
for _, sequenced := range sequencedReferences {
mappedReference := mappedReferences[sequenced.FullDefinition]

// if we're in the root document, don't bundle anything.
refExp := strings.Split(sequenced.FullDefinition, "#/")
if len(refExp) == 2 {
if refExp[0] == sequenced.Index.GetSpecAbsolutePath() || refExp[0] == "" {
if root && !inline {
idx.GetLogger().Debug("[bundler] skipping local root reference",
"ref", sequenced.Definition)
continue
}
}
idx := rolodex.GetRootIndex()
mappedReferences := idx.GetMappedReferences()
sequencedReferences := idx.GetRawReferencesSequenced()

for _, sequenced := range sequencedReferences {
mappedReference := mappedReferences[sequenced.FullDefinition]
if mappedReference == nil {
return nil, fmt.Errorf("no mapped reference found for: %s", sequenced.FullDefinition)
}

if mappedReference.DefinitionFile() == idx.GetSpecAbsolutePath() {
// Don't bundle anything that's in the main file.
continue
}

switch opts.RelativeRefHandling {
case RefHandlingInline:
// Just deal with simple inlining.
sequenced.Node.Content = mappedReference.Node.Content
case RefHandlingCompose:
// Recursively collect all reference targets to be bundled into the root
// file.
bundledComponents := make(map[string]*index.ReferenceNode)
if err := bundleRefTarget(sequenced, mappedReference, bundledComponents, opts); err != nil {
return nil, err
Comment on lines +105 to +107
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made a tiny bit of progress today. The idea is that bundledComponents now contains all the yaml.Nodes which are to be bundled into the root spec, using their Definition (i.e. #/components/schemas/MySchema) as keys.

What I'm stuck with now, is that I don't know how to actually use these nodes. I figured the "right" way, you mentioned in your previous comments, would be to manipulate the low model by adding to the components there. I just can't make sense of the types used there. See my next comment.

}

if mappedReference != nil && !mappedReference.Circular {
sequenced.Node.Content = mappedReference.Node.Content
continue
model, err = composeDocument(model, bundledComponents)
if err != nil {
return nil, err
}
}
}

return model.Render()
}

func composeDocument(model *highV3.Document, comps map[string]*index.ReferenceNode) (*highV3.Document, error) {
lowModel := model.GoLow()

components := lowModel.Components

for def, component := range comps {
defParts := strings.Split(def, "/")
// TODO: use constant from low model labels
if len(defParts) != 4 || defParts[1] != lowV3.ComponentsLabel {
return nil, fmt.Errorf("unsupported component section: %s", def)
}
spew.Dump(component)

if mappedReference != nil && mappedReference.Circular {
if idx.GetLogger() != nil {
idx.GetLogger().Warn("[bundler] skipping circular reference",
"ref", sequenced.FullDefinition)
}
switch defParts[2] {
case "schemas":
key := low.KeyReference[string]{
Value: defParts[3],
KeyNode: &yaml.Node{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you're creating a yaml.Node pointer, you will need to make sure the Line and Column` are set as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh no. Thanks. I thought I read somewhere that these would be set once you decide to render. This seems rather difficult.

Kind: yaml.ScalarNode,
Style: yaml.TaggedStyle,
Tag: "!!str",
Value: defParts[3],
},
}
value := low.ValueReference[*base.SchemaProxy]{
Reference: low.Reference{},
Value: &base.SchemaProxy{
Copy link
Member

@daveshanley daveshanley Jan 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of creating a SchemaProxy like this, use the technique here:

https://github.com/pb33f/libopenapi/blob/main/datamodel/low/base/schema_proxy_test.go#L29

For example:

var sch SchemaProxy
var myNode yaml.Node
myIndex := // some index some somewhere.

// .. do stuff to the node

ctx := context.WithValue(context.Background(), "key", "value")
err := sch.Build(ctx, &myNode, myNode.Content[0], myIndex)

Now you have a ready to go low level SchemaProxy

To do build a high level one from it?

https://github.com/pb33f/libopenapi/blob/main/datamodel/high/base/schema_proxy_test.go#L56C2-L56C31

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, I'll check that out.

Reference: low.Reference{},
NodeMap: &low.NodeMap{Nodes: &sync.Map{}},
},
ValueNode: &yaml.Node{},
}
components.Value.Schemas.Value.Set(key, value)
Comment on lines +135 to +152
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would appear, I need to put together the key and value so I can set them in the ordered map for Schemas. However, with all the Value, ValueNode, Reference etc. fields, I really don't understand what I'd be doing here. 😅 Can you once again point me in the right direction, please?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ValueNode is the yaml.Node that is a pointer to the actual node in memory, the Referenceis always there in a low model, but the properties are only filled in if the schema proxy is a reference or not.


default:
return nil, fmt.Errorf("unsupported component type: %s", defParts[2])
}
}

return nil, nil
}

func bundleRefTarget(ref, mappedRef *index.ReferenceNode, bundledComponents map[string]*index.ReferenceNode, opts BundleOptions) error {
idx := ref.Index
if mappedRef == nil {
if idx.GetLogger() != nil {
idx.GetLogger().Warn("[bundler] skipping unresolved reference",
"ref", ref.FullDefinition)
}
return nil
}

indexes := rolodex.GetIndexes()
for _, idx := range indexes {
compact(idx, false)
if mappedRef.Circular {
if idx.GetLogger() != nil {
idx.GetLogger().Warn("[bundler] skipping circular reference",
"ref", ref.FullDefinition)
}
return nil
}
compact(rolodex.GetRootIndex(), true)
return model.Render()

bundledRef, exists := bundledComponents[mappedRef.Definition]
if exists && bundledRef.FullDefinition != mappedRef.FullDefinition {
// TODO: we don't want to error here
return fmt.Errorf("duplicate component definition: %s", mappedRef.Definition)
} else {
bundledComponents[mappedRef.Definition] = mappedRef
ref.KeyNode.Value = mappedRef.Definition
}

// When composing, we need to update the ref values to point to a local reference. At the
// same time we need to track all components referenced by any children of the target, so
// that we can include them in the final document.
//
// One issue we might face is that the name of a target component in any given target
// document is the same as that of another component in a different target document or
// even the root document.

// Obtain the target's file's index because we should find child references using that.
// Otherwise ExtractRefs will use the ref's index and it's absolute spec path for
// the FullPath of any extracted ref targets.
targetIndex := idx
if targetFile := mappedRef.DefinitionFile(); targetFile != "" {
targetIndex = idx.GetRolodex().GetFileIndex(targetFile)
}

targetMappedReferences := targetIndex.GetMappedReferences()

childRefs := targetIndex.ExtractRefs(mappedRef.Node, mappedRef.ParentNode, make([]string, 0), 0, false, "")
for _, childRef := range childRefs {
childRefTarget := targetMappedReferences[childRef.FullDefinition]
if err := bundleRefTarget(childRef, childRefTarget, bundledComponents, opts); err != nil {
return err
}
}

return nil
}
43 changes: 33 additions & 10 deletions bundler/bundler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@ import (
"strings"
"testing"

"github.com/davecgh/go-spew/spew"
"github.com/pb33f/libopenapi"
"github.com/pb33f/libopenapi/datamodel"
"github.com/pb33f/libopenapi/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

var defaultBundleOpts = BundleOptions{RelativeRefHandling: RefHandlingInline}

func TestBundleDocument_DigitalOcean(t *testing.T) {

// test the mother of all exploded specs.
Expand Down Expand Up @@ -105,7 +108,27 @@ func TestBundleDocument_Circular(t *testing.T) {
assert.Len(t, logEntries, 0)
}

func TestBundleDocument_MinimalRemoteRefsBundledLocally(t *testing.T) {
func TestBundleDocument_MinimalRemoteRefsBundledLocallyComposed(t *testing.T) {
specBytes, err := os.ReadFile("../test_specs/minimal_remote_refs/openapi.yaml")
require.NoError(t, err)

require.NoError(t, err)

config := &datamodel.DocumentConfiguration{
AllowFileReferences: true,
AllowRemoteReferences: false,
BasePath: "../test_specs/minimal_remote_refs",
BaseURL: nil,
}
require.NoError(t, err)

bytes, e := BundleBytes(specBytes, config, BundleOptions{RelativeRefHandling: RefHandlingCompose})
spew.Dump(string(bytes))
assert.NoError(t, e)
assert.Contains(t, string(bytes), "Name of the account", "should contain all reference targets")
}

func TestBundleDocument_MinimalRemoteRefsBundledLocallyInline(t *testing.T) {
specBytes, err := os.ReadFile("../test_specs/minimal_remote_refs/openapi.yaml")
require.NoError(t, err)

Expand All @@ -114,13 +137,13 @@ func TestBundleDocument_MinimalRemoteRefsBundledLocally(t *testing.T) {
config := &datamodel.DocumentConfiguration{
AllowFileReferences: true,
AllowRemoteReferences: false,
BundleInlineRefs: false,
BasePath: "../test_specs/minimal_remote_refs",
BaseURL: nil,
}
require.NoError(t, err)

bytes, e := BundleBytes(specBytes, config)
bytes, e := BundleBytes(specBytes, config, BundleOptions{RelativeRefHandling: RefHandlingInline})
spew.Dump(string(bytes))
assert.NoError(t, e)
assert.Contains(t, string(bytes), "Name of the account", "should contain all reference targets")
}
Expand Down Expand Up @@ -166,7 +189,7 @@ func TestBundleDocument_MinimalRemoteRefsBundledRemotely(t *testing.T) {
}
require.NoError(t, err)

bytes, e := BundleBytes(specBytes, config)
bytes, e := BundleBytes(specBytes, config, defaultBundleOpts)
assert.NoError(t, e)
assert.Contains(t, string(bytes), "Name of the account", "should contain all reference targets")
}
Expand All @@ -185,7 +208,7 @@ func TestBundleBytes(t *testing.T) {
})),
}

bytes, e := BundleBytes(digi, config)
bytes, e := BundleBytes(digi, config, defaultBundleOpts)
assert.Error(t, e)
assert.Len(t, bytes, 2016)

Expand Down Expand Up @@ -214,7 +237,7 @@ components:
})),
}

_, e := BundleBytes(digi, config)
_, e := BundleBytes(digi, config, defaultBundleOpts)
require.Error(t, e)
unwrap := utils.UnwrapErrors(e)
require.Len(t, unwrap, 2)
Expand Down Expand Up @@ -270,7 +293,7 @@ components:
})),
}

bytes, e := BundleBytes(digi, config)
bytes, e := BundleBytes(digi, config, defaultBundleOpts)
assert.NoError(t, e)
assert.Len(t, bytes, 537)

Expand Down Expand Up @@ -312,7 +335,7 @@ components:
})),
}

bytes, e := BundleBytes(digi, config)
bytes, e := BundleBytes(digi, config, defaultBundleOpts)
assert.Error(t, e)
assert.Len(t, bytes, 458)

Expand All @@ -321,7 +344,7 @@ components:
}

func TestBundleBytes_Bad(t *testing.T) {
bytes, e := BundleBytes(nil, nil)
bytes, e := BundleBytes(nil, nil, defaultBundleOpts)
assert.Error(t, e)
assert.Nil(t, bytes)
}
Expand All @@ -346,7 +369,7 @@ func TestBundleBytes_RootDocumentRefs(t *testing.T) {
ExtractRefsSequentially: true,
}

bundledSpec, err := BundleBytes(spec, config)
bundledSpec, err := BundleBytes(spec, config, defaultBundleOpts)
assert.NoError(t, err)

assert.Equal(t, string(spec), string(bundledSpec))
Expand Down
3 changes: 3 additions & 0 deletions datamodel/document_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ type DocumentConfiguration struct {

// BundleInlineRefs is used by the bundler module. If set to true, all references will be inlined, including
// local references (to the root document) as well as all external references. This is false by default.
//
// Deprecrated: BundleInlineRefs will be removed in the future in favour of
// [bundler.BundleOpts].
BundleInlineRefs bool
}

Expand Down
6 changes: 3 additions & 3 deletions datamodel/low/extraction_functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ func HashExtensions(ext *orderedmap.Map[KeyReference[string], ValueReference[*ya
}

// helper function to generate a list of all the things an index should be searched for.
func generateIndexCollection(idx *index.SpecIndex) []func() map[string]*index.Reference {
return []func() map[string]*index.Reference{
func generateIndexCollection(idx *index.SpecIndex) []func() map[string]*index.ReferenceNode {
return []func() map[string]*index.ReferenceNode{
idx.GetAllComponentSchemas,
idx.GetMappedReferences,
idx.GetAllExternalDocuments,
Expand All @@ -83,7 +83,7 @@ func LocateRefNodeWithContext(ctx context.Context, root *yaml.Node, idx *index.S
// run through everything and return as soon as we find a match.
// this operates as fast as possible as ever
collections := generateIndexCollection(idx)
var found map[string]*index.Reference
var found map[string]*index.ReferenceNode
for _, collection := range collections {
found = collection()
if found != nil && found[rv] != nil {
Expand Down
Loading
Loading