diff --git a/datamodel/high/base/dynamic_value.go b/datamodel/high/base/dynamic_value.go index f6ad94c1..9004d9d2 100644 --- a/datamodel/high/base/dynamic_value.go +++ b/datamodel/high/base/dynamic_value.go @@ -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. @@ -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 { @@ -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() } diff --git a/datamodel/high/base/dynamic_value_test.go b/datamodel/high/base/dynamic_value_test.go index bc9f3886..eab7e4ae 100644 --- a/datamodel/high/base/dynamic_value_test.go +++ b/datamodel/high/base/dynamic_value_test.go @@ -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") +} diff --git a/datamodel/high/base/schema.go b/datamodel/high/base/schema.go index 216d6c23..5d71e7f8 100644 --- a/datamodel/high/base/schema.go +++ b/datamodel/high/base/schema.go @@ -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()) } } } diff --git a/datamodel/high/base/schema_proxy.go b/datamodel/high/base/schema_proxy.go index 73ac6bb4..12487d81 100644 --- a/datamodel/high/base/schema_proxy.go +++ b/datamodel/high/base/schema_proxy.go @@ -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). @@ -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. // @@ -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. @@ -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 @@ -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 { @@ -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. diff --git a/datamodel/high/base/schema_proxy_test.go b/datamodel/high/base/schema_proxy_test.go index 92d083f3..7b7c8f6d 100644 --- a/datamodel/high/base/schema_proxy_test.go +++ b/datamodel/high/base/schema_proxy_test.go @@ -5,6 +5,7 @@ package base import ( "context" + "fmt" "os" "path/filepath" "strings" @@ -566,22 +567,22 @@ func TestSchemaProxy_ParentProxyPreservedForCachedSchemas(t *testing.T) { } func TestSetBundlingMode(t *testing.T) { - // First, reset to known state by decrementing until we hit 0 - // This handles any leftover state from parallel tests + // first, reset to known state by decrementing until we hit 0 + // this handles any leftover state from parallel tests for IsBundlingMode() { SetBundlingMode(false) } assert.False(t, IsBundlingMode(), "Bundling mode should be false initially") - // Toggle on + // toggle on SetBundlingMode(true) assert.True(t, IsBundlingMode(), "Bundling mode should be true after setting") - // Toggle off + // toggle off SetBundlingMode(false) assert.False(t, IsBundlingMode(), "Bundling mode should be false after unsetting") - // Test multiple increments (nested bundling) + // test multiple increments (nested bundling) SetBundlingMode(true) SetBundlingMode(true) assert.True(t, IsBundlingMode(), "Bundling mode should be true with count=2") @@ -593,78 +594,132 @@ func TestSetBundlingMode(t *testing.T) { assert.False(t, IsBundlingMode(), "Bundling mode should be false with count=0") } -func TestSetPreserveReference(t *testing.T) { +func TestInlineRenderContext_MarkRefAsPreserved(t *testing.T) { + ctx := NewInlineRenderContext() + + // initially ref should not be marked as preserved + assert.False(t, ctx.ShouldPreserveRef("#/components/schemas/Pet")) + + // mark the ref as preserved + ctx.MarkRefAsPreserved("#/components/schemas/Pet") + + // now it should be preserved + assert.True(t, ctx.ShouldPreserveRef("#/components/schemas/Pet")) + + // different ref should not be preserved + assert.False(t, ctx.ShouldPreserveRef("#/components/schemas/Other")) +} + +func TestInlineRenderContext_ShouldPreserveRef_EmptyString(t *testing.T) { + ctx := NewInlineRenderContext() + + // empty string should not be preserved + assert.False(t, ctx.ShouldPreserveRef("")) + + // marking empty string should be a no-op + ctx.MarkRefAsPreserved("") + assert.False(t, ctx.ShouldPreserveRef("")) +} + +func TestInlineRenderContext_PreservedRefs_Concurrent(t *testing.T) { + ctx := NewInlineRenderContext() + + // test concurrent access to preservedRefs + done := make(chan bool) + for i := 0; i < 10; i++ { + go func(n int) { + ref := fmt.Sprintf("#/components/schemas/Schema%d", n) + ctx.MarkRefAsPreserved(ref) + _ = ctx.ShouldPreserveRef(ref) + done <- true + }(i) + } + + // wait for all goroutines + for i := 0; i < 10; i++ { + <-done + } + + // verify all refs were preserved + for i := 0; i < 10; i++ { + ref := fmt.Sprintf("#/components/schemas/Schema%d", i) + assert.True(t, ctx.ShouldPreserveRef(ref)) + } +} + +func TestMarkRefAsPreserved(t *testing.T) { + ctx := NewInlineRenderContext() + ctx.MarkRefAsPreserved("#/components/schemas/Pet") + proxy := CreateSchemaProxyRef("#/components/schemas/Pet") - proxy.SetPreserveReference(true) - // Verify flag is set via marshaling behavior - result, err := proxy.MarshalYAML() + // MarshalYAMLInlineWithContext should return ref node when ref is marked as preserved + result, err := proxy.MarshalYAMLInlineWithContext(ctx) require.NoError(t, err) node, ok := result.(*yaml.Node) require.True(t, ok) - // Should render as $ref + // should render as $ref assert.Equal(t, yaml.MappingNode, node.Kind) assert.Equal(t, 2, len(node.Content)) assert.Equal(t, "$ref", node.Content[0].Value) assert.Equal(t, "#/components/schemas/Pet", node.Content[1].Value) } -func TestSetPreserveReference_WithBundlingMode(t *testing.T) { - // First, reset to known state +func TestMarkRefAsPreserved_WithBundlingMode(t *testing.T) { + // first, reset to known state for IsBundlingMode() { SetBundlingMode(false) } - // Test interaction with bundling mode + // test interaction with bundling mode SetBundlingMode(true) defer SetBundlingMode(false) proxy := CreateSchemaProxyRef("#/components/schemas/Test") - // Without SetPreserveReference, should still render as ref in bundling mode + // without marking ref as preserved, should still render as ref in bundling mode via MarshalYAML result, err := proxy.MarshalYAML() require.NoError(t, err) node := result.(*yaml.Node) assert.Equal(t, "$ref", node.Content[0].Value) } -func TestSetPreserveReference_MarshalYAMLInline(t *testing.T) { - // Test that SetPreserveReference affects MarshalYAMLInline behavior +func TestMarkRefAsPreserved_MarshalYAMLInlineWithContext(t *testing.T) { + // test that marking ref as preserved affects MarshalYAMLInlineWithContext behavior + ctx := NewInlineRenderContext() + ctx.MarkRefAsPreserved("#/components/schemas/Pet") + proxy := CreateSchemaProxyRef("#/components/schemas/Pet") - proxy.SetPreserveReference(true) - // MarshalYAMLInline should return ref node when preserveReference is true - result, err := proxy.MarshalYAMLInline() + // MarshalYAMLInlineWithContext should return ref node when ref is marked as preserved + result, err := proxy.MarshalYAMLInlineWithContext(ctx) require.NoError(t, err) node, ok := result.(*yaml.Node) require.True(t, ok) - // Should render as $ref + // should render as $ref assert.Equal(t, yaml.MappingNode, node.Kind) assert.Equal(t, 2, len(node.Content)) assert.Equal(t, "$ref", node.Content[0].Value) assert.Equal(t, "#/components/schemas/Pet", node.Content[1].Value) } -func TestSetPreserveReference_MarshalYAMLInline_NilRefNode(t *testing.T) { - // Test the fallback path when GetReferenceNode returns nil - // This happens when the proxy has refStr but no backing schema - proxy := &SchemaProxy{ - refStr: "#/components/schemas/Test", - preserveReference: true, - lock: &sync.Mutex{}, - } +func TestMarkRefAsPreserved_RefNotMarked(t *testing.T) { + // test that unmarked refs are NOT preserved and attempt to inline + ctx := NewInlineRenderContext() + // mark a DIFFERENT ref as preserved + ctx.MarkRefAsPreserved("#/components/schemas/Other") - result, err := proxy.MarshalYAMLInline() - require.NoError(t, err) + proxy := CreateSchemaProxyRef("#/components/schemas/Test") - node, ok := result.(*yaml.Node) - require.True(t, ok) - // Should create a ref node using utils.CreateRefNode fallback - assert.Equal(t, yaml.MappingNode, node.Kind) - assert.Equal(t, "$ref", node.Content[0].Value) - assert.Equal(t, "#/components/schemas/Test", node.Content[1].Value) + // MarshalYAMLInlineWithContext should attempt to inline when ref is not marked as preserved. + // since this proxy has no backing schema, it returns an error - this confirms the + // context-based preservation check is working (if it were preserved, we'd get ref node back) + result, err := proxy.MarshalYAMLInlineWithContext(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unable to render schema") + assert.Nil(t, result) } func TestMarshalYAMLInline_BundlingMode_PreservesLocalComponentRefs(t *testing.T) { @@ -889,8 +944,8 @@ func TestGetInlineRenderKey_NilSchemaValueReturnsRefStr(t *testing.T) { assert.Equal(t, "#/components/schemas/AnotherEarlyReturn", renderKey) } -func TestMarshalYAMLInline_PreserveReference_ViaLowLevel(t *testing.T) { - // Test preserveReference path when reference is set via low-level proxy +func TestMarshalYAMLInlineWithContext_PreserveReference_ViaLowLevel(t *testing.T) { + // test context-based ref preservation when reference is set via low-level proxy // (refStr is empty, so GetReferenceNode uses low-level path) lowProxy := &lowbase.SchemaProxy{} @@ -901,12 +956,15 @@ func TestMarshalYAMLInline_PreserveReference_ViaLowLevel(t *testing.T) { } proxy := &SchemaProxy{ - schema: &lowRef, - preserveReference: true, - lock: &sync.Mutex{}, + schema: &lowRef, + lock: &sync.Mutex{}, } - result, err := proxy.MarshalYAMLInline() + // create context and mark the ref as preserved + ctx := NewInlineRenderContext() + ctx.MarkRefAsPreserved("#/components/schemas/TestRef") + + result, err := proxy.MarshalYAMLInlineWithContext(ctx) require.NoError(t, err) node, ok := result.(*yaml.Node) @@ -1202,3 +1260,34 @@ func TestSchemaProxy_MarshalYAMLInlineWithContext_NilContext(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, result) } + +// RenderingMode tests + +func TestRenderingMode_Constants(t *testing.T) { + // Verify the constants have expected values (iota order) + assert.Equal(t, RenderingMode(0), RenderingModeBundle) + assert.Equal(t, RenderingMode(1), RenderingModeValidation) +} + +func TestNewInlineRenderContextForValidation(t *testing.T) { + ctx := NewInlineRenderContextForValidation() + assert.NotNil(t, ctx) + assert.Equal(t, RenderingModeValidation, ctx.Mode) +} + +func TestNewInlineRenderContext_DefaultMode(t *testing.T) { + ctx := NewInlineRenderContext() + assert.NotNil(t, ctx) + assert.Equal(t, RenderingModeBundle, ctx.Mode) +} + +func TestInlineRenderContext_ModePreservedDuringOperations(t *testing.T) { + // Verify mode is preserved when using start/stop rendering + ctx := NewInlineRenderContextForValidation() + + ctx.StartRendering("test-key") + assert.Equal(t, RenderingModeValidation, ctx.Mode) + + ctx.StopRendering("test-key") + assert.Equal(t, RenderingModeValidation, ctx.Mode) +} diff --git a/datamodel/high/base/schema_test.go b/datamodel/high/base/schema_test.go index da620993..13d55dfb 100644 --- a/datamodel/high/base/schema_test.go +++ b/datamodel/high/base/schema_test.go @@ -1871,6 +1871,291 @@ func TestSchema_RenderInlineWithContext_Error(t *testing.T) { assert.Contains(t, err.Error(), "circular reference") } +// Tests for RenderingModeValidation - discriminator refs should be inlined in validation mode + +func TestSchema_MarshalYAMLInlineWithContext_ValidationMode_InlinesDiscriminatorOneOfRefs(t *testing.T) { + // Test that in validation mode, discriminator oneOf refs are inlined (not preserved) + // This is the opposite of bundle mode behavior + + idxYaml := `openapi: 3.1.0 +components: + schemas: + Cat: + type: object + properties: + type: + type: string + meow: + type: boolean + Dog: + type: object + properties: + type: + type: string + bark: + type: boolean` + + testSpec := `discriminator: + propertyName: type + mapping: + cat: '#/components/schemas/Cat' + dog: '#/components/schemas/Dog' +oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog'` + + var compNode, idxNode yaml.Node + _ = yaml.Unmarshal([]byte(testSpec), &compNode) + _ = yaml.Unmarshal([]byte(idxYaml), &idxNode) + + idx := index.NewSpecIndexWithConfig(&idxNode, index.CreateOpenAPIIndexConfig()) + + sp := new(lowbase.SchemaProxy) + err := sp.Build(context.Background(), nil, compNode.Content[0], idx) + assert.NoError(t, err) + + lowproxy := low.NodeReference[*lowbase.SchemaProxy]{ + Value: sp, + ValueNode: compNode.Content[0], + } + + schemaProxy := NewSchemaProxy(&lowproxy) + compiled := schemaProxy.Schema() + + // Use validation mode context - refs should be inlined + ctx := NewInlineRenderContextForValidation() + result, err := compiled.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + + // Marshal to YAML to check output + yamlBytes, _ := yaml.Marshal(result) + output := string(yamlBytes) + + // In validation mode, the oneOf refs should be INLINED, not preserved as $ref + // Should contain the inlined properties + assert.Contains(t, output, "meow:") + assert.Contains(t, output, "bark:") +} + +func TestSchema_MarshalYAMLInlineWithContext_ValidationMode_InlinesDiscriminatorAnyOfRefs(t *testing.T) { + // Test that in validation mode, discriminator anyOf refs are inlined (not preserved) + + idxYaml := `openapi: 3.1.0 +components: + schemas: + Cat: + type: object + properties: + type: + type: string + meow: + type: boolean + Dog: + type: object + properties: + type: + type: string + bark: + type: boolean` + + testSpec := `discriminator: + propertyName: type +anyOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog'` + + var compNode, idxNode yaml.Node + _ = yaml.Unmarshal([]byte(testSpec), &compNode) + _ = yaml.Unmarshal([]byte(idxYaml), &idxNode) + + idx := index.NewSpecIndexWithConfig(&idxNode, index.CreateOpenAPIIndexConfig()) + + sp := new(lowbase.SchemaProxy) + err := sp.Build(context.Background(), nil, compNode.Content[0], idx) + assert.NoError(t, err) + + lowproxy := low.NodeReference[*lowbase.SchemaProxy]{ + Value: sp, + ValueNode: compNode.Content[0], + } + + schemaProxy := NewSchemaProxy(&lowproxy) + compiled := schemaProxy.Schema() + + // Use validation mode context - refs should be inlined + ctx := NewInlineRenderContextForValidation() + result, err := compiled.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + + // Marshal to YAML to check output + yamlBytes, _ := yaml.Marshal(result) + output := string(yamlBytes) + + // In validation mode, the anyOf refs should be INLINED, not preserved as $ref + // Should contain the inlined properties + assert.Contains(t, output, "meow:") + assert.Contains(t, output, "bark:") +} + +func TestSchema_MarshalYAMLInlineWithContext_BundleMode_PreservesDiscriminatorRefs(t *testing.T) { + // Test that in bundle mode (default), discriminator refs are preserved + + idxYaml := `openapi: 3.1.0 +components: + schemas: + Cat: + type: object + properties: + type: + type: string + meow: + type: boolean` + + testSpec := `discriminator: + propertyName: type +oneOf: + - $ref: '#/components/schemas/Cat'` + + var compNode, idxNode yaml.Node + _ = yaml.Unmarshal([]byte(testSpec), &compNode) + _ = yaml.Unmarshal([]byte(idxYaml), &idxNode) + + idx := index.NewSpecIndexWithConfig(&idxNode, index.CreateOpenAPIIndexConfig()) + + sp := new(lowbase.SchemaProxy) + err := sp.Build(context.Background(), nil, compNode.Content[0], idx) + assert.NoError(t, err) + + lowproxy := low.NodeReference[*lowbase.SchemaProxy]{ + Value: sp, + ValueNode: compNode.Content[0], + } + + schemaProxy := NewSchemaProxy(&lowproxy) + compiled := schemaProxy.Schema() + + // Use bundle mode context (default) - refs should be preserved + ctx := NewInlineRenderContext() + assert.Equal(t, RenderingModeBundle, ctx.Mode) + + result, err := compiled.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + + // Marshal to YAML to check output + yamlBytes, _ := yaml.Marshal(result) + output := string(yamlBytes) + + // In bundle mode, the oneOf refs should be PRESERVED as $ref + assert.Contains(t, output, "$ref:") + assert.Contains(t, output, "#/components/schemas/Cat") + // Should NOT contain the inlined properties + assert.NotContains(t, output, "meow:") +} + +func TestSchema_MarshalYAMLInlineWithContext_NilContext_PreservesDiscriminatorRefs(t *testing.T) { + // Test that with nil context (backward compatibility), discriminator refs are preserved + + idxYaml := `openapi: 3.1.0 +components: + schemas: + Cat: + type: object + properties: + meow: + type: boolean` + + testSpec := `discriminator: + propertyName: type +oneOf: + - $ref: '#/components/schemas/Cat'` + + var compNode, idxNode yaml.Node + _ = yaml.Unmarshal([]byte(testSpec), &compNode) + _ = yaml.Unmarshal([]byte(idxYaml), &idxNode) + + idx := index.NewSpecIndexWithConfig(&idxNode, index.CreateOpenAPIIndexConfig()) + + sp := new(lowbase.SchemaProxy) + err := sp.Build(context.Background(), nil, compNode.Content[0], idx) + assert.NoError(t, err) + + lowproxy := low.NodeReference[*lowbase.SchemaProxy]{ + Value: sp, + ValueNode: compNode.Content[0], + } + + schemaProxy := NewSchemaProxy(&lowproxy) + compiled := schemaProxy.Schema() + + // Pass nil context - should behave like bundle mode (backward compatible) + result, err := compiled.MarshalYAMLInlineWithContext(nil) + assert.NoError(t, err) + + // Marshal to YAML to check output + yamlBytes, _ := yaml.Marshal(result) + output := string(yamlBytes) + + // Nil context should preserve refs (like bundle mode) + assert.Contains(t, output, "$ref:") + assert.Contains(t, output, "#/components/schemas/Cat") + // Should NOT contain the inlined properties + assert.NotContains(t, output, "meow:") +} + +func TestSchema_MarshalYAMLInlineWithContext_NoDiscriminator_ModeDoesNotMatter(t *testing.T) { + // Test that without discriminator, both modes behave the same (refs inlined) + + idxYaml := `openapi: 3.1.0 +components: + schemas: + Cat: + type: object + properties: + meow: + type: boolean` + + testSpec := `oneOf: + - $ref: '#/components/schemas/Cat'` + + var compNode, idxNode yaml.Node + _ = yaml.Unmarshal([]byte(testSpec), &compNode) + _ = yaml.Unmarshal([]byte(idxYaml), &idxNode) + + idx := index.NewSpecIndexWithConfig(&idxNode, index.CreateOpenAPIIndexConfig()) + + sp := new(lowbase.SchemaProxy) + err := sp.Build(context.Background(), nil, compNode.Content[0], idx) + assert.NoError(t, err) + + lowproxy := low.NodeReference[*lowbase.SchemaProxy]{ + Value: sp, + ValueNode: compNode.Content[0], + } + + schemaProxy := NewSchemaProxy(&lowproxy) + compiled := schemaProxy.Schema() + + // Test with bundle mode + ctxBundle := NewInlineRenderContext() + resultBundle, err := compiled.MarshalYAMLInlineWithContext(ctxBundle) + assert.NoError(t, err) + yamlBundle, _ := yaml.Marshal(resultBundle) + + // Without discriminator, refs should be inlined in both modes + // Need to reset the proxy for second render + schemaProxy2 := NewSchemaProxy(&lowproxy) + compiled2 := schemaProxy2.Schema() + + ctxValidation := NewInlineRenderContextForValidation() + resultValidation, err := compiled2.MarshalYAMLInlineWithContext(ctxValidation) + assert.NoError(t, err) + yamlValidation, _ := yaml.Marshal(resultValidation) + + // Both should contain the inlined properties (refs not preserved without discriminator) + assert.Contains(t, string(yamlBundle), "meow:") + assert.Contains(t, string(yamlValidation), "meow:") +} + // TestNewSchema_Id tests that the $id field is correctly mapped from low to high level func TestNewSchema_Id(t *testing.T) { yml := `type: object