Skip to content
Merged
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
68 changes: 68 additions & 0 deletions bundler/bundler_composer.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"sync"

v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
lowbase "github.com/pb33f/libopenapi/datamodel/low/base"
v3low "github.com/pb33f/libopenapi/datamodel/low/v3"
"github.com/pb33f/libopenapi/index"
"github.com/pb33f/libopenapi/orderedmap"
Expand Down Expand Up @@ -217,6 +218,23 @@ func processReference(model *v3.Document, pr *processRef, cf *handleIndexConfig)
}

if len(location) > 0 {
if isJSONSchemaDefsLocation(location) {
defName := decodeJSONSchemaDefsSegment(location[len(location)-1])
componentName := jsonSchemaDefsComponentName(pr, defName, compositionDelimiter(cf))
pr.originalName = defName
pr.name = componentName
pr.location = []string{v3low.ComponentsLabel, v3low.SchemasLabel, componentName}
handled, err := composeReferenceAs(v3low.SchemasLabel, componentName, components, pr, idx, cf)
if err != nil {
return err
}
if handled {
return nil
}
unknown(pr, cf)
return nil
}

pr.location = location
if location[0] == v3low.ComponentsLabel {
if len(location) > 2 {
Expand Down Expand Up @@ -269,6 +287,56 @@ func processReference(model *v3.Document, pr *processRef, cf *handleIndexConfig)
return nil
}

func isJSONSchemaDefsLocation(location []string) bool {
return len(location) >= 2 && location[0] == lowbase.DefsLabel
}

func jsonSchemaDefsComponentName(pr *processRef, defName, delimiter string) string {
sourceName := ""
if pr != nil && pr.ref != nil {
sourceName = deriveNameFromFullDefinition(pr.ref.FullDefinition)
}
if sourceName == "." || sourceName == "" || sourceName == defName {
return sanitizeComponentName(defName)
}
return sanitizeComponentName(sourceName + delimiter + defName)
}

func decodeJSONSchemaDefsSegment(segment string) string {
if decoded, err := url.PathUnescape(segment); err == nil {
segment = decoded
}
return decodeSingleSegmentPointer(segment)
}

func sanitizeComponentName(name string) string {
if name == "" {
return fallbackSchemaComponentName
}
var b strings.Builder
b.Grow(len(name))
lastReplacement := false
for _, r := range name {
if isOpenAPIComponentNameChar(r) {
b.WriteRune(r)
lastReplacement = false
continue
}
if !lastReplacement {
b.WriteByte('_')
lastReplacement = true
}
}
return b.String()
}

func isOpenAPIComponentNameChar(r rune) bool {
return r >= 'a' && r <= 'z' ||
r >= 'A' && r <= 'Z' ||
r >= '0' && r <= '9' ||
r == '.' || r == '_' || r == '-'
}

// enqueueDiscriminatorMappingTargets ensures mapping targets are composed into components.
// This handles cases where a schema is ONLY referenced via discriminator mapping.
func enqueueDiscriminatorMappingTargets(
Expand Down
52 changes: 52 additions & 0 deletions bundler/bundler_composer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (

"github.com/pb33f/libopenapi"
"github.com/pb33f/libopenapi/datamodel"
highbase "github.com/pb33f/libopenapi/datamodel/high/base"
v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
"github.com/pb33f/libopenapi/index"
"github.com/pb33f/libopenapi/orderedmap"
Expand Down Expand Up @@ -87,6 +88,57 @@ func TestProcessReference_ContextualSingleSegmentRejectsUnsafeNode(t *testing.T)
assert.Same(t, pr, cf.inlineRequired[0])
}

func TestProcessReference_JSONSchemaDefsReturnsComposeError(t *testing.T) {
model := &v3.Document{
Components: &v3.Components{
Schemas: orderedmap.New[string, *highbase.SchemaProxy](),
},
}
idx := newVersionedIndex(3.2)
cf := &handleIndexConfig{
idx: idx,
rootIdx: idx,
compositionConfig: &BundleCompositionConfig{Delimiter: "__"},
}
pr := &processRef{
idx: idx,
ref: &index.Reference{
FullDefinition: "/tmp/widget.json#/$defs/company",
},
seqRef: &index.Reference{},
}

err := processReference(model, pr, cf)
require.Error(t, err)
assert.Equal(t, "node is nil", err.Error())
}

func TestProcessReference_JSONSchemaDefsFallsBackWhenSchemasMissing(t *testing.T) {
model := &v3.Document{
Components: &v3.Components{},
}
idx := newVersionedIndex(3.2)
cf := &handleIndexConfig{
idx: idx,
rootIdx: idx,
compositionConfig: &BundleCompositionConfig{Delimiter: "__"},
}
pr := &processRef{
idx: idx,
ref: &index.Reference{
FullDefinition: "/tmp/widget.json#/$defs/company",
},
seqRef: &index.Reference{},
}

require.NoError(t, processReference(model, pr, cf))
require.Len(t, cf.inlineRequired, 1)
assert.Same(t, pr, cf.inlineRequired[0])
assert.Equal(t, "company", pr.originalName)
assert.Equal(t, "widget__company", pr.name)
assert.Equal(t, []string{"components", "schemas", "widget__company"}, pr.location)
}

func TestCheckFileIteration(t *testing.T) {
name := calculateCollisionName("bundled", "/test/specs/bundled.yaml", "__", 1)
assert.Equal(t, "bundled__specs", name)
Expand Down
78 changes: 54 additions & 24 deletions bundler/composer_functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,16 @@ import (
"go.yaml.in/yaml/v4"
)

const contextualRefKeySeparator = "\x00"
const (
contextualRefKeySeparator = "\x00"
defaultCompositionDelimiter = "__"
fallbackSchemaComponentName = "schema"
)

type rewriteVisitKey struct {
node *yaml.Node
inExtension bool
}

// extractFragment returns the JSON pointer fragment from a full definition.
// e.g., "file.yaml#/components/schemas/Pet" -> "#/components/schemas/Pet"
Expand Down Expand Up @@ -221,7 +230,7 @@ func composeReferenceAs(
idx *index.SpecIndex,
cf *handleIndexConfig,
) (bool, error) {
delimiter := cf.compositionConfig.Delimiter
delimiter := compositionDelimiter(cf)

switch componentType {
case v3low.SchemasLabel:
Expand Down Expand Up @@ -294,7 +303,7 @@ func fileImportLocationForType(
pr *processRef,
cf *handleIndexConfig,
) (bool, []string) {
delimiter := cf.compositionConfig.Delimiter
delimiter := compositionDelimiter(cf)

switch componentType {
case v3low.SchemasLabel:
Expand Down Expand Up @@ -492,6 +501,10 @@ func renameRefWithSource(
processedNodes *orderedmap.Map[string, *processRef],
) string {
if strings.Contains(def, "#/") {
if pr := processedRefFor(processedNodes, def, source); pr != nil && len(pr.location) > 0 && pr.location[0] == v3low.ComponentsLabel {
return "#/" + joinLocationAsJSONPointer(pr.location)
}

defSplit := strings.Split(def, "#/")
if len(defSplit) != 2 {
return def
Expand Down Expand Up @@ -699,7 +712,7 @@ func rewriteAllRefs(
processedNodes *orderedmap.Map[string, *processRef],
rolodex *index.Rolodex,
) {
walkAndRewriteRefs(idx.GetRootNode(), idx, processedNodes, rolodex, false)
walkAndRewriteRefs(idx.GetRootNode(), idx, processedNodes, rolodex, false, make(map[rewriteVisitKey]struct{}))
}

func walkAndRewriteRefs(
Expand All @@ -708,46 +721,63 @@ func walkAndRewriteRefs(
processedNodes *orderedmap.Map[string, *processRef],
rolodex *index.Rolodex,
inExtension bool, // Tracks if we're under an x-* key
visited map[rewriteVisitKey]struct{},
) {
if node == nil {
return
}
// Filter leaves before touching the active-path map; scalar and alias nodes
// cannot recurse, and they dominate large documents.
switch node.Kind {
case yaml.DocumentNode, yaml.SequenceNode, yaml.MappingNode:
default:
return
}
if visited == nil {
visited = make(map[rewriteVisitKey]struct{})
}
key := rewriteVisitKey{node: node, inExtension: inExtension}
if _, ok := visited[key]; ok {
return
}
visited[key] = struct{}{}

switch node.Kind {
case yaml.DocumentNode:
if len(node.Content) > 0 {
walkAndRewriteRefs(node.Content[0], sourceIdx, processedNodes, rolodex, inExtension)
walkAndRewriteRefs(node.Content[0], sourceIdx, processedNodes, rolodex, inExtension, visited)
}
return
case yaml.SequenceNode:
for _, child := range node.Content {
walkAndRewriteRefs(child, sourceIdx, processedNodes, rolodex, inExtension)
walkAndRewriteRefs(child, sourceIdx, processedNodes, rolodex, inExtension, visited)
}
return
case yaml.MappingNode:
// Continue below
default:
return
}

for i := 0; i < len(node.Content); i += 2 {
keyNode := node.Content[i]
valueNode := node.Content[i+1]
for i := 0; i < len(node.Content); i += 2 {
keyNode := node.Content[i]
valueNode := node.Content[i+1]

// Track extension scope
childInExtension := inExtension || strings.HasPrefix(keyNode.Value, "x-")
// Track extension scope
childInExtension := inExtension || strings.HasPrefix(keyNode.Value, "x-")

if keyNode.Value == "$ref" && valueNode.Kind == yaml.ScalarNode && !inExtension {
newRef := resolveRefToComposed(valueNode.Value, sourceIdx, processedNodes, rolodex)
if newRef != valueNode.Value {
valueNode.Value = newRef
if keyNode.Value == "$ref" && valueNode.Kind == yaml.ScalarNode && !inExtension {
newRef := resolveRefToComposed(valueNode.Value, sourceIdx, processedNodes, rolodex)
if newRef != valueNode.Value {
valueNode.Value = newRef
}
} else {
walkAndRewriteRefs(valueNode, sourceIdx, processedNodes, rolodex, childInExtension, visited)
}
} else {
walkAndRewriteRefs(valueNode, sourceIdx, processedNodes, rolodex, childInExtension)
}
}
}

func compositionDelimiter(cf *handleIndexConfig) string {
if cf == nil || cf.compositionConfig == nil || cf.compositionConfig.Delimiter == "" {
return defaultCompositionDelimiter
}
return cf.compositionConfig.Delimiter
}

func resolveRefToComposed(
refValue string,
sourceIdx *index.SpecIndex,
Expand Down
Loading
Loading