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
11 changes: 11 additions & 0 deletions datamodel/high/base/example.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,17 @@ func (e *Example) MarshalYAMLInline() (interface{}, error) {
return high.RenderInline(e, e.low)
}

// MarshalYAMLInlineWithContext will create a ready to render YAML representation of the Example object,
// resolving any references inline where possible. Uses the provided context for cycle detection.
// The ctx parameter should be *InlineRenderContext but is typed as any to satisfy the
// high.RenderableInlineWithContext interface without import cycles.
func (e *Example) MarshalYAMLInlineWithContext(ctx any) (interface{}, error) {
if e.Reference != "" {
return utils.CreateRefNode(e.Reference), nil
}
return high.RenderInlineWithContext(e, e.low, ctx)
}

// CreateExampleRef creates an Example that renders as a $ref to another example definition.
// This is useful when building OpenAPI specs programmatically and you want to reference
// an example defined in components/examples rather than inlining the full definition.
Expand Down
41 changes: 41 additions & 0 deletions datamodel/high/base/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,44 @@ func TestExample_IsReference_False(t *testing.T) {
assert.False(t, e.IsReference())
assert.Equal(t, "", e.GetReference())
}

func TestExample_MarshalYAMLInlineWithContext(t *testing.T) {
var cNode yaml.Node

yml := `summary: an example
description: something more
value: a thing
externalValue: https://pb33f.io`

_ = yaml.Unmarshal([]byte(yml), &cNode)

// build low
var lowExample lowbase.Example
_ = lowmodel.BuildModel(cNode.Content[0], &lowExample)
_ = lowExample.Build(context.Background(), &cNode, cNode.Content[0], nil)

// build high
highExample := NewExample(&lowExample)

ctx := NewInlineRenderContext()
result, err := highExample.MarshalYAMLInlineWithContext(ctx)
assert.NoError(t, err)
assert.NotNil(t, result)

// verify the result is a yaml.Node
node, ok := result.(*yaml.Node)
assert.True(t, ok)
assert.Equal(t, yaml.MappingNode, node.Kind)
}

func TestExample_MarshalYAMLInlineWithContext_Reference(t *testing.T) {
e := CreateExampleRef("#/components/examples/UserExample")

ctx := NewInlineRenderContext()
node, err := e.MarshalYAMLInlineWithContext(ctx)
assert.NoError(t, err)

yamlNode, ok := node.(*yaml.Node)
assert.True(t, ok)
assert.Equal(t, "$ref", yamlNode.Content[0].Value)
}
36 changes: 29 additions & 7 deletions datamodel/high/base/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -496,16 +496,26 @@ func (s *Schema) Render() ([]byte, error) {
return yaml.Marshal(s)
}

// RenderInlineWithContext will return a YAML representation of the Schema object as a byte slice
// using the provided InlineRenderContext for cycle detection.
// Use this when multiple goroutines may render the same schemas concurrently.
// The ctx parameter should be *InlineRenderContext but is typed as any to avoid import cycles.
func (s *Schema) RenderInlineWithContext(ctx any) ([]byte, error) {
d, err := s.MarshalYAMLInlineWithContext(ctx)
if err != nil {
return nil, err
}
return yaml.Marshal(d)
}

// RenderInline will return a YAML representation of the Schema object as a byte slice.
// All the $ref values will be inlined, as in resolved in place.
// This method creates a fresh InlineRenderContext internally.
//
// Make sure you don't have any circular references!
func (s *Schema) RenderInline() ([]byte, error) {
d, err := s.MarshalYAMLInline()
if err != nil {
return nil, err
}
return yaml.Marshal(d)
ctx := NewInlineRenderContext()
return s.RenderInlineWithContext(ctx)
}

// MarshalYAML will create a ready to render YAML representation of the Schema object.
Expand Down Expand Up @@ -544,8 +554,12 @@ func (s *Schema) MarshalJSON() ([]byte, error) {
return json.Marshal(renderedJSON)
}

// MarshalYAMLInline will render out the Schema pointer as YAML, and all refs will be inlined fully
func (s *Schema) MarshalYAMLInline() (interface{}, error) {
// MarshalYAMLInlineWithContext will render out the Schema pointer as YAML using the provided
// InlineRenderContext for cycle detection. All refs will be inlined fully.
// Use this when multiple goroutines may render the same schemas concurrently.
// 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 {
Expand All @@ -563,6 +577,7 @@ func (s *Schema) MarshalYAMLInline() (interface{}, error) {

nb := high.NewNodeBuilder(s, s.low)
nb.Resolve = true
nb.RenderContext = ctx
// determine index version
idx := s.GoLow().Index
if idx != nil {
Expand All @@ -573,6 +588,13 @@ func (s *Schema) MarshalYAMLInline() (interface{}, error) {
return nb.Render(), errors.Join(nb.Errors...)
}

// MarshalYAMLInline will render out the Schema pointer as YAML, and all refs will be inlined fully.
// This method creates a fresh InlineRenderContext internally.
func (s *Schema) MarshalYAMLInline() (interface{}, error) {
ctx := NewInlineRenderContext()
return s.MarshalYAMLInlineWithContext(ctx)
}

// MarshalJSONInline will render out the Schema pointer as JSON, and all refs will be inlined fully
func (s *Schema) MarshalJSONInline() ([]byte, error) {
nb := high.NewNodeBuilder(s, s.low)
Expand Down
85 changes: 65 additions & 20 deletions datamodel/high/base/schema_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,35 @@ func IsBundlingMode() bool {
return bundlingModeCount.Load() > 0
}

// 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
}

// NewInlineRenderContext creates a new isolated rendering context.
func NewInlineRenderContext() *InlineRenderContext {
return &InlineRenderContext{}
}

// StartRendering marks a key as being rendered. Returns true if already rendering (cycle detected).
// The key should be stable and unique per schema instance (e.g., filePath:$ref).
func (ctx *InlineRenderContext) StartRendering(key string) bool {
if key == "" {
return false
}
_, loaded := ctx.tracker.LoadOrStore(key, true)
return loaded
}

// StopRendering marks a key as done rendering.
func (ctx *InlineRenderContext) StopRendering(key string) {
if key != "" {
ctx.tracker.Delete(key)
}
}

// 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 @@ -335,10 +364,30 @@ func (sp *SchemaProxy) getInlineRenderKey() string {
return ""
}

// MarshalYAMLInlineWithContext will create a ready to render YAML representation of the SchemaProxy object
// using the provided InlineRenderContext for cycle detection. Use this when multiple goroutines may render
// the same schemas concurrently to avoid false positive cycle detection.
// The ctx parameter should be *InlineRenderContext but is typed as any to satisfy the
// high.RenderableInlineWithContext interface without import cycles.
func (sp *SchemaProxy) MarshalYAMLInlineWithContext(ctx any) (interface{}, error) {
if renderCtx, ok := ctx.(*InlineRenderContext); ok {
return sp.marshalYAMLInlineInternal(renderCtx)
}
// Fallback to fresh context if wrong type passed
return sp.marshalYAMLInlineInternal(NewInlineRenderContext())
}

// MarshalYAMLInline will create a ready to render YAML representation of the SchemaProxy object. The
// $ref values will be inlined instead of kept as is. All circular references will be ignored, regardless
// of the type of circular reference, they are all bad when rendering.
// This method creates a fresh InlineRenderContext internally. For concurrent scenarios, use
// MarshalYAMLInlineWithContext instead.
func (sp *SchemaProxy) MarshalYAMLInline() (interface{}, error) {
ctx := NewInlineRenderContext()
return sp.marshalYAMLInlineInternal(ctx)
}

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() {
Expand Down Expand Up @@ -370,27 +419,22 @@ func (sp *SchemaProxy) MarshalYAMLInline() (interface{}, error) {
}
}

// Check for recursive rendering using the tracking map.
// Check for recursive rendering using the context's tracker.
// This prevents infinite recursion when circular references aren't properly detected.
// Using a scoped context instead of a global tracker prevents false positive cycle detection
// when multiple goroutines render the same schemas concurrently.
renderKey := sp.getInlineRenderKey()
if renderKey != "" {
// LoadOrStore atomically checks and sets - if already present, we have a cycle
if _, loaded := inlineRenderingTracker.LoadOrStore(renderKey, true); loaded {
// We're already rendering this schema - return ref to break the cycle
if sp.IsReference() {
return sp.GetReferenceNode(),
fmt.Errorf("schema render failure, circular reference: `%s`", sp.GetReference())
}
// For inline schemas, return an empty map to avoid infinite recursion
return &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"},
fmt.Errorf("schema render failure, circular reference detected during inline rendering")
if ctx.StartRendering(renderKey) {
// We're already rendering this schema in THIS call chain - return ref to break the cycle
if sp.IsReference() {
return sp.GetReferenceNode(),
fmt.Errorf("schema render failure, circular reference: `%s`", sp.GetReference())
}

// Ensure we clean up when done
defer func() {
inlineRenderingTracker.Delete(renderKey)
}()
// For inline schemas, return an empty map to avoid infinite recursion
return &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"},
fmt.Errorf("schema render failure, circular reference detected during inline rendering")
}
defer ctx.StopRendering(renderKey)

var s *Schema
var err error
Expand Down Expand Up @@ -455,10 +499,11 @@ func (sp *SchemaProxy) MarshalYAMLInline() (interface{}, error) {
return nil, err
}
if s != nil {
// Delegate to Schema.MarshalYAMLInline to ensure discriminator handling is applied.
// Schema.MarshalYAMLInline sets preserveReference on OneOf/AnyOf items when
// Delegate to Schema.MarshalYAMLInlineWithContext to ensure discriminator handling is applied
// and cycle detection context is propagated.
// Schema.MarshalYAMLInlineWithContext sets preserveReference on OneOf/AnyOf items when
// a discriminator is present, which is required for proper bundling.
return s.MarshalYAMLInline()
return s.MarshalYAMLInlineWithContext(ctx)
}
return nil, errors.New("unable to render schema")
}
Loading