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
28 changes: 23 additions & 5 deletions datamodel/high/base/dynamic_value.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@ import (
// - type: A = string (single type), B = []string (multiple types in 3.1)
// - exclusiveMinimum: A = bool (in 3.0), B = float64 (in 3.1)
//
// The N value indicates which value is set (0 = A, 1 = B), preventing the need to check both values.
// The N value indicates which value is set (0 = A, 1 == B), preventing the need to check both values.
type DynamicValue[A any, B any] struct {
N int // 0 == A, 1 == B
A A
B B
inline bool
N int // 0 == A, 1 == B
A A
B B
inline bool
renderCtx any // Context for inline rendering (typed as any to avoid import cycles)
}

// IsA will return true if the 'A' or left value is set.
Expand Down Expand Up @@ -65,6 +66,13 @@ func (d *DynamicValue[A, B]) MarshalYAML() (interface{}, error) {
switch to.Kind() {
case reflect.Ptr:
if d.inline {
// prefer context-aware method when context is available
if d.renderCtx != nil {
if r, ok := value.(high.RenderableInlineWithContext); ok {
return r.MarshalYAMLInlineWithContext(d.renderCtx)
}
}
// fall back to context-less method
if r, ok := value.(high.RenderableInline); ok {
return r.MarshalYAMLInline()
} else {
Expand Down Expand Up @@ -100,5 +108,15 @@ func (d *DynamicValue[A, B]) MarshalYAML() (interface{}, error) {
// references will be inlined instead of kept as references.
func (d *DynamicValue[A, B]) MarshalYAMLInline() (interface{}, error) {
d.inline = true
d.renderCtx = nil
return d.MarshalYAML()
}

// MarshalYAMLInlineWithContext will create a ready to render YAML representation of the DynamicValue object.
// The references will be inlined and the provided context will be passed through to nested schemas.
// The ctx parameter should be *InlineRenderContext but is typed as any to avoid import cycles.
func (d *DynamicValue[A, B]) MarshalYAMLInlineWithContext(ctx any) (interface{}, error) {
d.inline = true
d.renderCtx = ctx
return d.MarshalYAML()
}
148 changes: 148 additions & 0 deletions datamodel/high/base/dynamic_value_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,151 @@ func TestDynamicValue_MarshalYAMLInline_Error(t *testing.T) {
assert.Nil(t, rend)
assert.Error(t, er)
}

// Tests for MarshalYAMLInlineWithContext

func TestDynamicValue_MarshalYAMLInlineWithContext_PassesContextToSchemaProxy(t *testing.T) {
// Test that context is properly passed through DynamicValue to nested SchemaProxy
const ymlComponents = `components:
schemas:
rice:
type: string`

idx := func() *index.SpecIndex {
var idxNode yaml.Node
err := yaml.Unmarshal([]byte(ymlComponents), &idxNode)
assert.NoError(t, err)
return index.NewSpecIndexWithConfig(&idxNode, index.CreateOpenAPIIndexConfig())
}()

const ymlSchema = `type: string`
var node yaml.Node
_ = yaml.Unmarshal([]byte(ymlSchema), &node)

lowProxy := new(lowbase.SchemaProxy)
err := lowProxy.Build(context.Background(), nil, node.Content[0], idx)
assert.NoError(t, err)

lowRef := low.NodeReference[*lowbase.SchemaProxy]{
Value: lowProxy,
}

sp := NewSchemaProxy(&lowRef)

dv := &DynamicValue[*SchemaProxy, bool]{N: 0, A: sp}

// Test with validation context
ctx := NewInlineRenderContextForValidation()
result, err := dv.MarshalYAMLInlineWithContext(ctx)
assert.NoError(t, err)
assert.NotNil(t, result)
}

func TestDynamicValue_MarshalYAMLInlineWithContext_NilContextFallsBack(t *testing.T) {
// Test that nil context falls back to MarshalYAMLInline behavior
const ymlComponents = `components:
schemas:
rice:
type: string`

idx := func() *index.SpecIndex {
var idxNode yaml.Node
err := yaml.Unmarshal([]byte(ymlComponents), &idxNode)
assert.NoError(t, err)
return index.NewSpecIndexWithConfig(&idxNode, index.CreateOpenAPIIndexConfig())
}()

const ymlSchema = `type: string`
var node yaml.Node
_ = yaml.Unmarshal([]byte(ymlSchema), &node)

lowProxy := new(lowbase.SchemaProxy)
err := lowProxy.Build(context.Background(), nil, node.Content[0], idx)
assert.NoError(t, err)

lowRef := low.NodeReference[*lowbase.SchemaProxy]{
Value: lowProxy,
}

sp := NewSchemaProxy(&lowRef)

dv := &DynamicValue[*SchemaProxy, bool]{N: 0, A: sp}

// Test with nil context
result, err := dv.MarshalYAMLInlineWithContext(nil)
assert.NoError(t, err)
assert.NotNil(t, result)
}

func TestDynamicValue_MarshalYAMLInlineWithContext_BoolValue(t *testing.T) {
// Test that bool values work correctly with context
dv := &DynamicValue[*SchemaProxy, bool]{N: 1, B: true}

ctx := NewInlineRenderContextForValidation()
result, err := dv.MarshalYAMLInlineWithContext(ctx)
assert.NoError(t, err)
assert.NotNil(t, result)
}

func TestDynamicValue_MarshalYAMLInline_WithSchemaProxy(t *testing.T) {
// Test MarshalYAMLInline directly (covers lines 109-112)
// This tests the code path where renderCtx is explicitly set to nil
const ymlComponents = `components:
schemas:
rice:
type: string`

idx := func() *index.SpecIndex {
var idxNode yaml.Node
err := yaml.Unmarshal([]byte(ymlComponents), &idxNode)
assert.NoError(t, err)
return index.NewSpecIndexWithConfig(&idxNode, index.CreateOpenAPIIndexConfig())
}()

const ymlSchema = `type: string`
var node yaml.Node
_ = yaml.Unmarshal([]byte(ymlSchema), &node)

lowProxy := new(lowbase.SchemaProxy)
err := lowProxy.Build(context.Background(), nil, node.Content[0], idx)
assert.NoError(t, err)

lowRef := low.NodeReference[*lowbase.SchemaProxy]{
Value: lowProxy,
}

sp := NewSchemaProxy(&lowRef)

dv := &DynamicValue[*SchemaProxy, bool]{N: 0, A: sp}

// Call MarshalYAMLInline directly - this sets inline=true, renderCtx=nil
result, err := dv.MarshalYAMLInline()
assert.NoError(t, err)
assert.NotNil(t, result)

// Verify it rendered correctly
bits, _ := yaml.Marshal(result)
assert.Contains(t, string(bits), "type: string")
}

func TestDynamicValue_MarshalYAMLInline_PtrNotRenderableInline(t *testing.T) {
// Test the else branch at line 78 - pointer type that does NOT implement RenderableInline
// This covers the fallback path where we call n.Encode(value) directly
type simpleStruct struct {
Name string `yaml:"name"`
Value int `yaml:"value"`
}

dv := &DynamicValue[*simpleStruct, bool]{N: 0, A: &simpleStruct{Name: "test", Value: 42}}

// Call MarshalYAMLInline - simpleStruct doesn't implement RenderableInline
// so it should fall through to the else branch and use n.Encode()
result, err := dv.MarshalYAMLInline()
assert.NoError(t, err)
assert.NotNil(t, result)

// Verify it rendered correctly via the fallback path
bits, _ := yaml.Marshal(result)
assert.Contains(t, string(bits), "name: test")
assert.Contains(t, string(bits), "value: 42")
}
21 changes: 16 additions & 5 deletions datamodel/high/base/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -595,17 +595,28 @@ func (s *Schema) MarshalJSON() ([]byte, error) {
// The ctx parameter should be *InlineRenderContext but is typed as any to satisfy the
// high.RenderableInlineWithContext interface without import cycles.
func (s *Schema) MarshalYAMLInlineWithContext(ctx any) (interface{}, error) {
// If this schema has a discriminator, mark OneOf/AnyOf items to preserve their references.
// This ensures discriminator mapping refs are not inlined during bundling.
if s.Discriminator != nil {
// ensure we have a valid render context; create default bundle mode context if nil.
// this ensures backward compatibility where nil context = bundle mode behavior.
renderCtx, ok := ctx.(*InlineRenderContext)
if !ok || renderCtx == nil {
renderCtx = NewInlineRenderContext()
ctx = renderCtx
}

// determine if we should preserve discriminator refs based on rendering mode.
// in validation mode, we need to fully inline all refs for the JSON schema compiler.
// in bundle mode (default), we preserve discriminator refs for mapping compatibility.
if s.Discriminator != nil && renderCtx.Mode != RenderingModeValidation {
// mark oneOf/anyOf refs as preserved in the context (not on the SchemaProxy).
// this avoids mutating shared state and prevents race conditions.
for _, sp := range s.OneOf {
if sp != nil && sp.IsReference() {
sp.SetPreserveReference(true)
renderCtx.MarkRefAsPreserved(sp.GetReference())
}
}
for _, sp := range s.AnyOf {
if sp != nil && sp.IsReference() {
sp.SetPreserveReference(true)
renderCtx.MarkRefAsPreserved(sp.GetReference())
}
}
}
Expand Down
78 changes: 57 additions & 21 deletions datamodel/high/base/schema_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,39 @@ func IsBundlingMode() bool {
return bundlingModeCount.Load() > 0
}

// RenderingMode controls how inline rendering handles discriminator $refs.
type RenderingMode int

const (
// RenderingModeBundle is the default mode - preserves $refs in discriminator
// oneOf/anyOf for compatibility with discriminator mappings during bundling.
RenderingModeBundle RenderingMode = iota

// RenderingModeValidation forces full inlining of all $refs, ignoring
// discriminator preservation. Use this when rendering schemas for JSON
// Schema validation where the compiler needs a self-contained schema.
RenderingModeValidation
)

// InlineRenderContext provides isolated tracking for inline rendering operations.
// Each render call-chain should use its own context to prevent false positive
// cycle detection when multiple goroutines render the same schemas concurrently.
type InlineRenderContext struct {
tracker sync.Map
tracker sync.Map
Mode RenderingMode
preservedRefs sync.Map // tracks refs that should be preserved in this render
}

// NewInlineRenderContext creates a new isolated rendering context.
// NewInlineRenderContext creates a new isolated rendering context with default bundle mode.
func NewInlineRenderContext() *InlineRenderContext {
return &InlineRenderContext{}
return &InlineRenderContext{Mode: RenderingModeBundle}
}

// NewInlineRenderContextForValidation creates a context that fully inlines
// all refs, including discriminator oneOf/anyOf refs. Use this when rendering
// schemas for JSON Schema validation.
func NewInlineRenderContextForValidation() *InlineRenderContext {
return &InlineRenderContext{Mode: RenderingModeValidation}
}

// StartRendering marks a key as being rendered. Returns true if already rendering (cycle detected).
Expand All @@ -78,6 +101,23 @@ func (ctx *InlineRenderContext) StopRendering(key string) {
}
}

// MarkRefAsPreserved marks a reference as one that should be preserved (not inlined) in this render.
// used by discriminator handling to track which refs need preservation without mutating shared state.
func (ctx *InlineRenderContext) MarkRefAsPreserved(ref string) {
if ref != "" {
ctx.preservedRefs.Store(ref, true)
}
}

// ShouldPreserveRef returns true if the given reference was marked for preservation.
func (ctx *InlineRenderContext) ShouldPreserveRef(ref string) bool {
if ref == "" {
return false
}
_, ok := ctx.preservedRefs.Load(ref)
return ok
}

// SchemaProxy exists as a stub that will create a Schema once (and only once) the Schema() method is called. An
// underlying low-level SchemaProxy backs this high-level one.
//
Expand Down Expand Up @@ -112,12 +152,11 @@ func (ctx *InlineRenderContext) StopRendering(key string) {
// it's not actually JSONSchema until 3.1, so lots of times a bad schema will break parsing. Errors are only found
// when a schema is needed, so the rest of the document is parsed and ready to use.
type SchemaProxy struct {
schema *low.NodeReference[*base.SchemaProxy]
buildError error
rendered *Schema
refStr string
lock *sync.Mutex
preserveReference bool // When true, MarshalYAMLInline returns the ref node instead of inlining
schema *low.NodeReference[*base.SchemaProxy]
buildError error
rendered *Schema
refStr string
lock *sync.Mutex
}

// NewSchemaProxy creates a new high-level SchemaProxy from a low-level one.
Expand Down Expand Up @@ -226,7 +265,7 @@ func (sp *SchemaProxy) IsReference() bool {
if sp.refStr != "" {
return true
}
if sp.schema != nil {
if sp.schema != nil && sp.schema.Value != nil {
return sp.schema.Value.IsReference()
}
return false
Expand Down Expand Up @@ -324,13 +363,6 @@ func (sp *SchemaProxy) MarshalYAML() (interface{}, error) {
}
}

// SetPreserveReference sets whether this SchemaProxy should preserve its reference when rendering inline.
// When true, MarshalYAMLInline will return the reference node instead of inlining the schema.
// This is used for discriminator mapping scenarios where refs must be preserved.
func (sp *SchemaProxy) SetPreserveReference(preserve bool) {
sp.preserveReference = preserve
}

// getInlineRenderKey generates a unique key for tracking this schema during inline rendering.
// This prevents infinite recursion when schemas reference each other circularly.
func (sp *SchemaProxy) getInlineRenderKey() string {
Expand Down Expand Up @@ -388,10 +420,14 @@ func (sp *SchemaProxy) MarshalYAMLInline() (interface{}, error) {
}

func (sp *SchemaProxy) marshalYAMLInlineInternal(ctx *InlineRenderContext) (interface{}, error) {
// If preserveReference is set, return the reference node instead of inlining.
// This is used for discriminator mapping scenarios where refs must be preserved.
if sp.preserveReference && sp.IsReference() {
return sp.GetReferenceNode(), nil
// check if this reference should be preserved (set via context by discriminator handling).
// this avoids mutating shared SchemaProxy state and prevents race conditions.
// need to guard against nil schema.Value which can happen with bad/incomplete proxies.
if sp.IsReference() {
ref := sp.GetReference()
if ref != "" && ctx.ShouldPreserveRef(ref) {
return sp.GetReferenceNode(), nil
}
}

// In bundling mode, preserve local component refs that point to schemas in the SAME document.
Expand Down
Loading