Skip to content
Merged
43 changes: 42 additions & 1 deletion datamodel/document_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ import (
"github.com/pb33f/libopenapi/utils"
)

// PropertyMergeStrategy defines how conflicting properties are handled during reference resolution
type PropertyMergeStrategy int

const (
// PreserveLocal means local properties take precedence over referenced properties
PreserveLocal PropertyMergeStrategy = iota
// OverwriteWithRemote means referenced properties overwrite local properties
OverwriteWithRemote
// RejectConflicts means throw error when properties conflict
RejectConflicts
)

// DocumentConfiguration is used to configure the document creation process. It was added in v0.6.0 to allow
// for more fine-grained control over controls and new features.
//
Expand Down Expand Up @@ -145,19 +157,48 @@ type DocumentConfiguration struct {
// used to determine changes.
UseSchemaQuickHash bool

// AllowUnknownExtensionContentDetection will enable content detection for remote URLs that don't have
// AllowUnknownExtensionContentDetection will enable content detection for remote URLs that don't have
// a known file extension. When enabled, libopenapi will fetch the first 1-2KB of unknown URLs to determine
// if they contain valid JSON or YAML content. This is disabled by default for security and performance.
//
// If disabled, URLs without recognized extensions (.yaml, .yml, .json) will be rejected.
// If enabled, unknown URLs will be fetched and analyzed for JSON/YAML content with retry logic.
AllowUnknownExtensionContentDetection bool

// TransformSiblingRefs enables OpenAPI 3.1/JSON Schema Draft 2020-12 compliance for sibling refs.
// When enabled, schemas with $ref and additional properties like:
// title: MySchema
// $ref: '#/components/schemas/Base'
// Will be transformed to:
// allOf:
// - title: MySchema
// - $ref: '#/components/schemas/Base'
// This is enabled by default to ensure OpenAPI 3.1+ compliance.
TransformSiblingRefs bool

// MergeReferencedProperties enables enhanced reference resolution that preserves local properties
// when resolving references. For example:
// $ref: '#/components/schemas/Address'
// example:
// street: '123 Main St'
// city: 'Somewhere'
// The example will be preserved during reference resolution instead of being overwritten.
MergeReferencedProperties bool

// PropertyMergeStrategy determines how conflicting properties are handled during reference resolution.
// - PreserveLocal: Local properties take precedence over referenced properties
// - OverwriteWithRemote: Referenced properties overwrite local properties
// - RejectConflicts: Throw error when properties conflict
PropertyMergeStrategy PropertyMergeStrategy
}

func NewDocumentConfiguration() *DocumentConfiguration {
return &DocumentConfiguration{
Logger: slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelError,
})),
TransformSiblingRefs: true, // enable openapi 3.1 compliance by default
MergeReferencedProperties: true, // enable enhanced resolution by default
PropertyMergeStrategy: PreserveLocal, // local properties take precedence
}
}
53 changes: 53 additions & 0 deletions datamodel/document_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,56 @@ func TestNewClosedDocumentConfiguration(t *testing.T) {
cfg := NewDocumentConfiguration()
assert.NotNil(t, cfg)
}

func TestNewDocumentConfiguration_DefaultSiblingRefTransformation(t *testing.T) {
cfg := NewDocumentConfiguration()
assert.True(t, cfg.TransformSiblingRefs, "TransformSiblingRefs should be enabled by default for OpenAPI 3.1 compliance")
}

func TestDocumentConfiguration_SiblingRefTransformationDisabled(t *testing.T) {
cfg := NewDocumentConfiguration()
cfg.TransformSiblingRefs = false
assert.False(t, cfg.TransformSiblingRefs, "TransformSiblingRefs should be configurable")
}

func TestNewDocumentConfiguration_DefaultPropertyMerging(t *testing.T) {
cfg := NewDocumentConfiguration()
assert.True(t, cfg.MergeReferencedProperties, "MergeReferencedProperties should be enabled by default")
assert.Equal(t, PreserveLocal, cfg.PropertyMergeStrategy, "PropertyMergeStrategy should default to PreserveLocal")
}

func TestDocumentConfiguration_PropertyMergeStrategies(t *testing.T) {
cfg := NewDocumentConfiguration()

t.Run("preserve local strategy", func(t *testing.T) {
cfg.PropertyMergeStrategy = PreserveLocal
assert.Equal(t, PreserveLocal, cfg.PropertyMergeStrategy)
})

t.Run("overwrite with remote strategy", func(t *testing.T) {
cfg.PropertyMergeStrategy = OverwriteWithRemote
assert.Equal(t, OverwriteWithRemote, cfg.PropertyMergeStrategy)
})

t.Run("reject conflicts strategy", func(t *testing.T) {
cfg.PropertyMergeStrategy = RejectConflicts
assert.Equal(t, RejectConflicts, cfg.PropertyMergeStrategy)
})
}

func TestDocumentConfiguration_PropertyMergingDisabled(t *testing.T) {
cfg := NewDocumentConfiguration()
cfg.MergeReferencedProperties = false
assert.False(t, cfg.MergeReferencedProperties, "MergeReferencedProperties should be configurable")
}

func TestDocumentConfiguration_BackwardsCompatibility(t *testing.T) {
// verify that all new features can be disabled to preserve existing behavior
cfg := NewDocumentConfiguration()
cfg.TransformSiblingRefs = false
cfg.MergeReferencedProperties = false

assert.False(t, cfg.TransformSiblingRefs)
assert.False(t, cfg.MergeReferencedProperties)
// when disabled, should behave exactly like pre-enhancement versions
}
2 changes: 1 addition & 1 deletion datamodel/high/base/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ func (s *Schema) RenderInline() ([]byte, error) {
return yaml.Marshal(d)
}

// MarshalYAML will create a ready to render YAML representation of the ExternalDoc object.
// MarshalYAML will create a ready to render YAML representation of the Schema object.
func (s *Schema) MarshalYAML() (interface{}, error) {
nb := high.NewNodeBuilder(s, s.low)

Expand Down
151 changes: 151 additions & 0 deletions datamodel/low/base/property_merger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Copyright 2025 Princess B33f Heavy Industries / Dave Shanley
// SPDX-License-Identifier: MIT

package base

import (
"fmt"

"github.com/pb33f/libopenapi/datamodel"
"github.com/pb33f/libopenapi/utils"
"go.yaml.in/yaml/v4"
)

// PropertyMerger handles merging of local properties with referenced schema properties
type PropertyMerger struct {
strategy datamodel.PropertyMergeStrategy
}

// NewPropertyMerger creates a new property merger with the specified strategy
func NewPropertyMerger(strategy datamodel.PropertyMergeStrategy) *PropertyMerger {
return &PropertyMerger{
strategy: strategy,
}
}

// MergeProperties merges local properties with referenced schema properties based on strategy
// localNode contains properties that should be preserved (e.g., examples, descriptions)
// referencedNode contains the resolved reference content
func (pm *PropertyMerger) MergeProperties(localNode, referencedNode *yaml.Node) (*yaml.Node, error) {
if localNode == nil && referencedNode == nil {
return nil, nil
}
if localNode == nil {
return pm.copyNode(referencedNode), nil
}
if referencedNode == nil {
return pm.copyNode(localNode), nil
}

// extract properties from both nodes
localProps := pm.extractProperties(localNode)
referencedProps := pm.extractProperties(referencedNode)

// create merged node starting with referenced content
merged := pm.copyNode(referencedNode)
mergedProps := pm.extractProperties(merged)

// apply merge strategy for each local property
for key, localValue := range localProps {
if _, exists := referencedProps[key]; exists {
// property exists in both - apply strategy
switch pm.strategy {
case datamodel.PreserveLocal:
mergedProps[key] = localValue
case datamodel.OverwriteWithRemote:
// keep referenced value (already in merged)
continue
case datamodel.RejectConflicts:
return nil, fmt.Errorf("property conflict: '%s' exists in both local and referenced schema", key)
}
} else {
// property only exists locally - always preserve
mergedProps[key] = localValue
}
}

// rebuild the merged node content
return pm.rebuildNodeFromProperties(merged, mergedProps), nil
}

// extractProperties extracts key-value pairs from a yaml mapping node
func (pm *PropertyMerger) extractProperties(node *yaml.Node) map[string]*yaml.Node {
props := make(map[string]*yaml.Node)
if !utils.IsNodeMap(node) {
return props
}

for i := 0; i < len(node.Content); i += 2 {
if i+1 < len(node.Content) {
key := node.Content[i].Value
value := node.Content[i+1]
props[key] = value
}
}
return props
}

// rebuildNodeFromProperties reconstructs a yaml mapping node from property map
func (pm *PropertyMerger) rebuildNodeFromProperties(baseNode *yaml.Node, props map[string]*yaml.Node) *yaml.Node {
result := &yaml.Node{
Kind: yaml.MappingNode,
Style: baseNode.Style,
Tag: baseNode.Tag,
Line: baseNode.Line,
Column: baseNode.Column,
HeadComment: baseNode.HeadComment,
LineComment: baseNode.LineComment,
FootComment: baseNode.FootComment,
}

// rebuild content from properties
for key, value := range props {
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key}
result.Content = append(result.Content, keyNode, pm.copyNode(value))
}

return result
}

// copyNode creates a deep copy of a yaml node
func (pm *PropertyMerger) copyNode(node *yaml.Node) *yaml.Node {
if node == nil {
return nil
}

copied := &yaml.Node{
Kind: node.Kind,
Style: node.Style,
Tag: node.Tag,
Value: node.Value,
Anchor: node.Anchor,
Alias: node.Alias,
Line: node.Line,
Column: node.Column,
HeadComment: node.HeadComment,
LineComment: node.LineComment,
FootComment: node.FootComment,
}

if node.Content != nil {
copied.Content = make([]*yaml.Node, len(node.Content))
for i, child := range node.Content {
copied.Content[i] = pm.copyNode(child)
}
}

return copied
}

// ShouldMergeProperties determines if property merging should be applied based on configuration
func (pm *PropertyMerger) ShouldMergeProperties(localNode, referencedNode *yaml.Node, config *datamodel.DocumentConfiguration) bool {
if config == nil || !config.MergeReferencedProperties {
return false
}

// only merge if both nodes have properties to merge
localProps := pm.extractProperties(localNode)
referencedProps := pm.extractProperties(referencedNode)

return len(localProps) > 0 && len(referencedProps) > 0
}
Loading
Loading