diff --git a/datamodel/high/base/example.go b/datamodel/high/base/example.go index f58e9fa7..1dc1595c 100644 --- a/datamodel/high/base/example.go +++ b/datamodel/high/base/example.go @@ -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. diff --git a/datamodel/high/base/example_test.go b/datamodel/high/base/example_test.go index e5222f16..80e7635f 100644 --- a/datamodel/high/base/example_test.go +++ b/datamodel/high/base/example_test.go @@ -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) +} diff --git a/datamodel/high/base/schema.go b/datamodel/high/base/schema.go index bfe4ef8e..e5177bff 100644 --- a/datamodel/high/base/schema.go +++ b/datamodel/high/base/schema.go @@ -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. @@ -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 { @@ -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 { @@ -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) diff --git a/datamodel/high/base/schema_proxy.go b/datamodel/high/base/schema_proxy.go index 9ed5c375..73ac6bb4 100644 --- a/datamodel/high/base/schema_proxy.go +++ b/datamodel/high/base/schema_proxy.go @@ -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. // @@ -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() { @@ -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 @@ -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") } diff --git a/datamodel/high/base/schema_proxy_test.go b/datamodel/high/base/schema_proxy_test.go index 284e8426..92d083f3 100644 --- a/datamodel/high/base/schema_proxy_test.go +++ b/datamodel/high/base/schema_proxy_test.go @@ -742,8 +742,8 @@ func TestMarshalYAMLInline_BundlingMode_PreservesLocalComponentRefs(t *testing.T func TestMarshalYAMLInline_CircularReferenceDetection_WithReference(t *testing.T) { // Test that circular reference detection returns the ref node and error - // when a reference proxy is already being rendered. - // This covers lines 388-390 in schema_proxy.go + // when a reference proxy is already being rendered within the same context. + // This covers lines 421-425 in schema_proxy.go // Reset bundling mode state for IsBundlingMode() { @@ -761,12 +761,12 @@ func TestMarshalYAMLInline_CircularReferenceDetection_WithReference(t *testing.T renderKey := proxy.getInlineRenderKey() require.NotEmpty(t, renderKey, "render key should be generated from refStr") - // Store the key in the tracker to simulate a cycle - inlineRenderingTracker.Store(renderKey, true) - defer inlineRenderingTracker.Delete(renderKey) + // Create context and store the key to simulate a cycle + ctx := NewInlineRenderContext() + ctx.StartRendering(renderKey) - // MarshalYAMLInline should detect the cycle and return ref node + error - result, err := proxy.MarshalYAMLInline() + // MarshalYAMLInlineWithContext should detect the cycle and return ref node + error + result, err := proxy.MarshalYAMLInlineWithContext(ctx) // Should return an error about circular reference require.Error(t, err) @@ -782,8 +782,8 @@ func TestMarshalYAMLInline_CircularReferenceDetection_WithReference(t *testing.T func TestMarshalYAMLInline_CircularReferenceDetection_WithoutReference(t *testing.T) { // Test that circular reference detection returns an empty map node and error - // when an inline (non-reference) proxy is already being rendered. - // This covers lines 392-394 in schema_proxy.go + // when an inline (non-reference) proxy is already being rendered within the same context. + // This covers lines 427-429 in schema_proxy.go // Reset bundling mode state for IsBundlingMode() { @@ -810,12 +810,12 @@ func TestMarshalYAMLInline_CircularReferenceDetection_WithoutReference(t *testin renderKey := proxy.getInlineRenderKey() require.NotEmpty(t, renderKey, "render key should be generated from node position") - // Store the key in the tracker to simulate a cycle - inlineRenderingTracker.Store(renderKey, true) - defer inlineRenderingTracker.Delete(renderKey) + // Create context and store the key to simulate a cycle + ctx := NewInlineRenderContext() + ctx.StartRendering(renderKey) - // MarshalYAMLInline should detect the cycle and return empty map + error - result, err := proxy.MarshalYAMLInline() + // MarshalYAMLInlineWithContext should detect the cycle and return empty map + error + result, err := proxy.MarshalYAMLInlineWithContext(ctx) // Should return an error about circular reference require.Error(t, err) @@ -976,3 +976,229 @@ func TestMarshalYAMLInline_BundlingMode_ViaLowLevelRef(t *testing.T) { assert.Equal(t, "$ref", node.Content[0].Value) assert.Equal(t, "#/components/schemas/TestSchema", node.Content[1].Value) } + +// InlineRenderContext tests + +func TestInlineRenderContext_NewContext(t *testing.T) { + ctx := NewInlineRenderContext() + assert.NotNil(t, ctx) +} + +func TestInlineRenderContext_StartRendering_EmptyKey(t *testing.T) { + ctx := NewInlineRenderContext() + // Empty key should return false (no cycle) + assert.False(t, ctx.StartRendering("")) +} + +func TestInlineRenderContext_CycleDetection(t *testing.T) { + ctx := NewInlineRenderContext() + + // First call - no cycle + assert.False(t, ctx.StartRendering("file.yaml:#/Pet")) + + // Second call same key - cycle detected + assert.True(t, ctx.StartRendering("file.yaml:#/Pet")) + + // Stop rendering + ctx.StopRendering("file.yaml:#/Pet") + + // Can start again after stopping + assert.False(t, ctx.StartRendering("file.yaml:#/Pet")) +} + +func TestInlineRenderContext_Isolation(t *testing.T) { + ctx1 := NewInlineRenderContext() + ctx2 := NewInlineRenderContext() + + // Start in ctx1 + ctx1.StartRendering("key1") + + // ctx2 should NOT see ctx1's key + assert.False(t, ctx2.StartRendering("key1")) +} + +func TestInlineRenderContext_DifferentKeys(t *testing.T) { + ctx := NewInlineRenderContext() + + ctx.StartRendering("file1.yaml:#/Pet") + // Different key should not trigger cycle + assert.False(t, ctx.StartRendering("file2.yaml:#/Pet")) +} + +func TestInlineRenderContext_StopRendering_EmptyKey(t *testing.T) { + ctx := NewInlineRenderContext() + // Should not panic on empty key + ctx.StopRendering("") +} + +func TestSchemaProxy_MarshalYAMLInlineWithContext_Basic(t *testing.T) { + // Test basic rendering with context + 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: object +properties: + name: + 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) + require.NoError(t, err) + + highProxy := NewSchemaProxy(&low.NodeReference[*lowbase.SchemaProxy]{ + Value: lowProxy, + ValueNode: node.Content[0], + }) + + ctx := NewInlineRenderContext() + result, err := highProxy.MarshalYAMLInlineWithContext(ctx) + + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestSchemaProxy_MarshalYAMLInlineWithContext_Concurrent_NoFalsePositives(t *testing.T) { + // Test that concurrent rendering with separate contexts doesn't cause false positive cycles + const ymlComponents = `components: + schemas: + Pet: + type: object + properties: + name: + 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 = `$ref: '#/components/schemas/Pet'` + var node yaml.Node + _ = yaml.Unmarshal([]byte(ymlSchema), &node) + + lowProxy := new(lowbase.SchemaProxy) + err := lowProxy.Build(context.Background(), nil, node.Content[0], idx) + require.NoError(t, err) + + highProxy := NewSchemaProxy(&low.NodeReference[*lowbase.SchemaProxy]{ + Value: lowProxy, + ValueNode: node.Content[0], + }) + + // Run concurrent renders with separate contexts + var wg sync.WaitGroup + errorCount := 0 + var mu sync.Mutex + + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + ctx := NewInlineRenderContext() + _, err := highProxy.MarshalYAMLInlineWithContext(ctx) + if err != nil && strings.Contains(err.Error(), "circular reference") { + mu.Lock() + errorCount++ + mu.Unlock() + } + }() + } + + wg.Wait() + + // Should have NO false positive circular reference errors + assert.Equal(t, 0, errorCount, "Should not have false positive circular reference errors") +} + +func TestSchemaProxy_MarshalYAMLInlineWithContext_WrongContextType(t *testing.T) { + // Test the fallback branch when wrong context type is passed + // The function should fall back to creating a fresh InlineRenderContext + const ymlComponents = `components: + schemas: + Pet: + type: object + properties: + name: + 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: object +properties: + name: + 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) + require.NoError(t, err) + + highProxy := NewSchemaProxy(&low.NodeReference[*lowbase.SchemaProxy]{ + Value: lowProxy, + ValueNode: node.Content[0], + }) + + // Pass wrong context type (not *InlineRenderContext) + // Should fall back to creating fresh context + wrongCtx := struct{ foo string }{foo: "bar"} + result, err := highProxy.MarshalYAMLInlineWithContext(wrongCtx) + + assert.NoError(t, err) + assert.NotNil(t, result) +} + +func TestSchemaProxy_MarshalYAMLInlineWithContext_NilContext(t *testing.T) { + // Test when nil is passed as context + const ymlComponents = `components: + schemas: + Pet: + type: object` + + 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) + require.NoError(t, err) + + highProxy := NewSchemaProxy(&low.NodeReference[*lowbase.SchemaProxy]{ + Value: lowProxy, + ValueNode: node.Content[0], + }) + + // Pass nil - should fall back to creating fresh context + result, err := highProxy.MarshalYAMLInlineWithContext(nil) + + assert.NoError(t, err) + assert.NotNil(t, result) +} diff --git a/datamodel/high/base/schema_test.go b/datamodel/high/base/schema_test.go index d1c4dcb4..4e68a851 100644 --- a/datamodel/high/base/schema_test.go +++ b/datamodel/high/base/schema_test.go @@ -1815,3 +1815,58 @@ oneOf: assert.Contains(t, output, "meow:") assert.Contains(t, output, "type:") } + +func TestSchema_RenderInlineWithContext_Error(t *testing.T) { + // Test the error path in RenderInlineWithContext (line 506) + // Create a schema with a circular reference that will trigger an error + + idxYaml := `components: + schemas: + Circular: + type: object + properties: + self: + $ref: '#/components/schemas/Circular'` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(idxYaml), &idxNode) + + idx := index.NewSpecIndexWithConfig(&idxNode, index.CreateOpenAPIIndexConfig()) + + // Build the circular schema + schemas := idxNode.Content[0].Content[1].Content[1] // components -> schemas -> Circular + sp := new(lowbase.SchemaProxy) + err := sp.Build(context.Background(), nil, schemas.Content[1], idx) + assert.NoError(t, err) + + lowproxy := low.NodeReference[*lowbase.SchemaProxy]{ + Value: sp, + ValueNode: schemas.Content[1], + } + + schemaProxy := NewSchemaProxy(&lowproxy) + compiled := schemaProxy.Schema() + + // Create a context and pre-mark the schema's render key to simulate a cycle + ctx := NewInlineRenderContext() + + // Get the render key for the self-referencing property's schema proxy + if compiled.Properties != nil { + selfProp := compiled.Properties.GetOrZero("self") + if selfProp != nil { + // Pre-mark this key as rendering to force a cycle error + renderKey := selfProp.getInlineRenderKey() + if renderKey != "" { + ctx.StartRendering(renderKey) + } + } + } + + // RenderInlineWithContext should return an error due to the pre-marked cycle + result, err := compiled.RenderInlineWithContext(ctx) + + // The error path should be triggered + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "circular reference") +} diff --git a/datamodel/high/node_builder.go b/datamodel/high/node_builder.go index 1dbd4d9b..0e9aff5d 100644 --- a/datamodel/high/node_builder.go +++ b/datamodel/high/node_builder.go @@ -21,12 +21,20 @@ import ( // NodeBuilder is a structure used by libopenapi high-level objects, to render themselves back to YAML. // this allows high-level objects to be 'mutable' because all changes will be rendered out. type NodeBuilder struct { - Version float32 - Nodes []*nodes.NodeEntry - High any - Low any - Resolve bool // If set to true, all references will be rendered inline - Errors []error + Version float32 + Nodes []*nodes.NodeEntry + High any + Low any + Resolve bool // If set to true, all references will be rendered inline + RenderContext any // Context for inline rendering cycle detection (*base.InlineRenderContext) + Errors []error +} + +// RenderableInlineWithContext is an interface that can be implemented by types that support +// context-aware inline rendering for proper cycle detection in concurrent scenarios. +// The context parameter should be *base.InlineRenderContext but is typed as any to avoid import cycles. +type RenderableInlineWithContext interface { + MarshalYAMLInlineWithContext(ctx any) (interface{}, error) } const renderZero = "renderZero" @@ -415,8 +423,20 @@ func (n *NodeBuilder) AddYAMLNode(parent *yaml.Node, entry *nodes.NodeEntry) *ya nodeErrors = append(nodeErrors, ne) } else { // try and render inline, if we can, otherwise treat as normal. - if _, ko := er.(RenderableInline); ko { - rend, ne = er.(RenderableInline).MarshalYAMLInline() + // Prefer a context-aware method when RenderContext is available + if n.RenderContext != nil { + if ctxRenderer, ko := er.(RenderableInlineWithContext); ko { + rend, ne = ctxRenderer.MarshalYAMLInlineWithContext(n.RenderContext) + nodeErrors = append(nodeErrors, ne) + } else if inliner, ko := er.(RenderableInline); ko { + rend, ne = inliner.MarshalYAMLInline() + nodeErrors = append(nodeErrors, ne) + } else { + rend, ne = er.MarshalYAML() + nodeErrors = append(nodeErrors, ne) + } + } else if inliner, ko := er.(RenderableInline); ko { + rend, ne = inliner.MarshalYAMLInline() nodeErrors = append(nodeErrors, ne) } else { rend, ne = er.MarshalYAML() @@ -514,10 +534,20 @@ func (n *NodeBuilder) AddYAMLNode(parent *yaml.Node, entry *nodes.NodeEntry) *ya nodeErrors = append(nodeErrors, ne) } else { // try an inline render if we can, otherwise there is no option but to default to the - // full render. - - if _, ko := r.(RenderableInline); ko { - rawRender, ne = r.(RenderableInline).MarshalYAMLInline() + // full render. Prefer a context-aware method when RenderContext is available + if n.RenderContext != nil { + if ctxRenderer, ko := r.(RenderableInlineWithContext); ko { + rawRender, ne = ctxRenderer.MarshalYAMLInlineWithContext(n.RenderContext) + nodeErrors = append(nodeErrors, ne) + } else if inliner, ko := r.(RenderableInline); ko { + rawRender, ne = inliner.MarshalYAMLInline() + nodeErrors = append(nodeErrors, ne) + } else { + rawRender, ne = r.MarshalYAML() + nodeErrors = append(nodeErrors, ne) + } + } else if inliner, ko := r.(RenderableInline); ko { + rawRender, ne = inliner.MarshalYAMLInline() nodeErrors = append(nodeErrors, ne) } else { rawRender, ne = r.MarshalYAML() diff --git a/datamodel/high/node_builder_test.go b/datamodel/high/node_builder_test.go index f8912762..dfdbb1e8 100644 --- a/datamodel/high/node_builder_test.go +++ b/datamodel/high/node_builder_test.go @@ -1223,3 +1223,186 @@ func TestNodeBuilder_LowPointerIsNotEmpty(t *testing.T) { output := strings.TrimSpace(string(data)) assert.Equal(t, "{}", output) } + +// contextAwareRenderable implements RenderableInlineWithContext for testing +type contextAwareRenderable struct { + Value string +} + +func (c *contextAwareRenderable) MarshalYAML() (interface{}, error) { + return utils.CreateStringNode("normal-" + c.Value), nil +} + +func (c *contextAwareRenderable) MarshalYAMLInline() (interface{}, error) { + return utils.CreateStringNode("inline-" + c.Value), nil +} + +func (c *contextAwareRenderable) MarshalYAMLInlineWithContext(ctx any) (interface{}, error) { + return utils.CreateStringNode("context-" + c.Value), nil +} + +// Test struct with context-aware renderable pointer field +type testWithContextAwarePtr struct { + Item *contextAwareRenderable `yaml:"item,omitempty"` +} + +// Test struct with context-aware renderable slice field +type testWithContextAwareSlice struct { + Items []*contextAwareRenderable `yaml:"items,omitempty"` +} + +func TestNodeBuilder_RenderContext_PointerField(t *testing.T) { + high := &testWithContextAwarePtr{ + Item: &contextAwareRenderable{Value: "test"}, + } + + // Without context - should use MarshalYAMLInline + nb := NewNodeBuilder(high, nil) + nb.Resolve = true + node := nb.Render() + data, _ := yaml.Marshal(node) + assert.Contains(t, string(data), "inline-test") + + // With context - should use MarshalYAMLInlineWithContext + nb2 := NewNodeBuilder(high, nil) + nb2.Resolve = true + nb2.RenderContext = struct{}{} // any non-nil context + node2 := nb2.Render() + data2, _ := yaml.Marshal(node2) + assert.Contains(t, string(data2), "context-test") +} + +func TestNodeBuilder_RenderContext_SliceField(t *testing.T) { + high := &testWithContextAwareSlice{ + Items: []*contextAwareRenderable{ + {Value: "item1"}, + {Value: "item2"}, + }, + } + + // Without context - should use MarshalYAMLInline + nb := NewNodeBuilder(high, nil) + nb.Resolve = true + node := nb.Render() + data, _ := yaml.Marshal(node) + assert.Contains(t, string(data), "inline-item1") + assert.Contains(t, string(data), "inline-item2") + + // With context - should use MarshalYAMLInlineWithContext + nb2 := NewNodeBuilder(high, nil) + nb2.Resolve = true + nb2.RenderContext = struct{}{} // any non-nil context + node2 := nb2.Render() + data2, _ := yaml.Marshal(node2) + assert.Contains(t, string(data2), "context-item1") + assert.Contains(t, string(data2), "context-item2") +} + +// noContextRenderable only implements RenderableInline, not RenderableInlineWithContext +type noContextRenderable struct { + Value string +} + +func (n *noContextRenderable) MarshalYAML() (interface{}, error) { + return utils.CreateStringNode("normal-" + n.Value), nil +} + +func (n *noContextRenderable) MarshalYAMLInline() (interface{}, error) { + return utils.CreateStringNode("inline-" + n.Value), nil +} + +type testWithNoContextPtr struct { + Item *noContextRenderable `yaml:"item,omitempty"` +} + +type testWithNoContextSlice struct { + Items []*noContextRenderable `yaml:"items,omitempty"` +} + +func TestNodeBuilder_RenderContext_FallbackToInline_Pointer(t *testing.T) { + // Test fallback when RenderContext is set but object doesn't implement RenderableInlineWithContext + high := &testWithNoContextPtr{ + Item: &noContextRenderable{Value: "test"}, + } + + // With context but object doesn't implement RenderableInlineWithContext + // Should fall back to MarshalYAMLInline + nb := NewNodeBuilder(high, nil) + nb.Resolve = true + nb.RenderContext = struct{}{} + node := nb.Render() + data, _ := yaml.Marshal(node) + assert.Contains(t, string(data), "inline-test") +} + +func TestNodeBuilder_RenderContext_FallbackToInline_Slice(t *testing.T) { + // Test fallback when RenderContext is set but object doesn't implement RenderableInlineWithContext + high := &testWithNoContextSlice{ + Items: []*noContextRenderable{ + {Value: "item1"}, + }, + } + + // With context but object doesn't implement RenderableInlineWithContext + // Should fall back to MarshalYAMLInline + nb := NewNodeBuilder(high, nil) + nb.Resolve = true + nb.RenderContext = struct{}{} + node := nb.Render() + data, _ := yaml.Marshal(node) + assert.Contains(t, string(data), "inline-item1") +} + +// basicRenderable only implements Renderable (MarshalYAML), not RenderableInline or RenderableInlineWithContext +// This tests the final else fallback branch in node_builder.go lines 434-436 and 545-547 +type basicRenderable struct { + Value string +} + +func (b *basicRenderable) MarshalYAML() (interface{}, error) { + return utils.CreateStringNode("basic-" + b.Value), nil +} + +type testWithBasicPtr struct { + Item *basicRenderable `yaml:"item,omitempty"` +} + +type testWithBasicSlice struct { + Items []*basicRenderable `yaml:"items,omitempty"` +} + +func TestNodeBuilder_RenderContext_FallbackToMarshalYAML_Pointer(t *testing.T) { + // Test the final else branch when RenderContext is set but object only implements Renderable + // (not RenderableInlineWithContext or RenderableInline) + high := &testWithBasicPtr{ + Item: &basicRenderable{Value: "test"}, + } + + // With context but object only implements MarshalYAML + // Should fall back to MarshalYAML + nb := NewNodeBuilder(high, nil) + nb.Resolve = true + nb.RenderContext = struct{}{} + node := nb.Render() + data, _ := yaml.Marshal(node) + assert.Contains(t, string(data), "basic-test") +} + +func TestNodeBuilder_RenderContext_FallbackToMarshalYAML_Slice(t *testing.T) { + // Test the final else branch when RenderContext is set but object only implements Renderable + // (not RenderableInlineWithContext or RenderableInline) + high := &testWithBasicSlice{ + Items: []*basicRenderable{ + {Value: "item1"}, + }, + } + + // With context but object only implements MarshalYAML + // Should fall back to MarshalYAML + nb := NewNodeBuilder(high, nil) + nb.Resolve = true + nb.RenderContext = struct{}{} + node := nb.Render() + data, _ := yaml.Marshal(node) + assert.Contains(t, string(data), "basic-item1") +} diff --git a/datamodel/high/shared.go b/datamodel/high/shared.go index 90db4091..1f8d0808 100644 --- a/datamodel/high/shared.go +++ b/datamodel/high/shared.go @@ -49,6 +49,16 @@ func RenderInline(high, low any) (interface{}, error) { return nb.Render(), nil } +// RenderInlineWithContext creates an inline YAML representation of a high-level object with all references resolved. +// Uses the provided context for cycle detection during inline rendering. +// The ctx parameter should be *base.InlineRenderContext but is typed as any to avoid import cycles. +func RenderInlineWithContext(high, low, ctx any) (interface{}, error) { + nb := NewNodeBuilder(high, low) + nb.Resolve = true + nb.RenderContext = ctx + return nb.Render(), nil +} + // UnpackExtensions is a convenience function that makes it easy and simple to unpack an objects extensions // into a complex type, provided as a generic. This function is for high-level models that implement `GoesLow()` // and for low-level models that support extensions via `HasExtensions`. diff --git a/datamodel/high/shared_test.go b/datamodel/high/shared_test.go index 383dbf00..bc491422 100644 --- a/datamodel/high/shared_test.go +++ b/datamodel/high/shared_test.go @@ -169,3 +169,40 @@ func TestRenderInline_WithLow(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, result) } + +func TestRenderInlineWithContext(t *testing.T) { + // Create a simple struct to test rendering with context + type testStruct struct { + Name string `yaml:"name,omitempty"` + Version string `yaml:"version,omitempty"` + } + + high := &testStruct{Name: "test", Version: "1.0.0"} + // Pass a mock context (any type is accepted) + ctx := struct{}{} + result, err := RenderInlineWithContext(high, nil, ctx) + + assert.NoError(t, err) + assert.NotNil(t, result) + + // Verify the result is a yaml.Node + node, ok := result.(*yaml.Node) + require.True(t, ok) + assert.Equal(t, yaml.MappingNode, node.Kind) +} + +func TestRenderInlineWithContext_WithLow(t *testing.T) { + // Test with both high and low models and context + type testStruct struct { + Name string `yaml:"name,omitempty"` + } + + high := &testStruct{Name: "test"} + low := &testStruct{Name: "low-test"} + ctx := struct{}{} + + result, err := RenderInlineWithContext(high, low, ctx) + + assert.NoError(t, err) + assert.NotNil(t, result) +} diff --git a/datamodel/high/v3/callback.go b/datamodel/high/v3/callback.go index 677c1ab7..39ee6b38 100644 --- a/datamodel/high/v3/callback.go +++ b/datamodel/high/v3/callback.go @@ -145,6 +145,18 @@ func (c *Callback) MarshalYAML() (interface{}, error) { // MarshalYAMLInline will create a ready to render YAML representation of the Callback object, // with all references resolved inline. func (c *Callback) MarshalYAMLInline() (interface{}, error) { + return c.marshalYAMLInlineInternal(nil) +} + +// MarshalYAMLInlineWithContext will create a ready to render YAML representation of the Callback object, +// resolving any references inline where possible. Uses the provided context for cycle detection. +// The ctx parameter should be *base.InlineRenderContext but is typed as any to satisfy the +// high.RenderableInlineWithContext interface without import cycles. +func (c *Callback) MarshalYAMLInlineWithContext(ctx any) (interface{}, error) { + return c.marshalYAMLInlineInternal(ctx) +} + +func (c *Callback) marshalYAMLInlineInternal(ctx any) (interface{}, error) { // reference-only objects render as $ref nodes if c.Reference != "" { return utils.CreateRefNode(c.Reference), nil @@ -181,6 +193,7 @@ func (c *Callback) MarshalYAMLInline() (interface{}, error) { nb := high.NewNodeBuilder(c, c.low) nb.Resolve = true + nb.RenderContext = ctx extNode := nb.Render() if extNode != nil && extNode.Content != nil { var label string @@ -201,7 +214,12 @@ func (c *Callback) MarshalYAMLInline() (interface{}, error) { }) for _, mp := range mapped { if mp.pi != nil { - rendered, _ := mp.pi.MarshalYAMLInline() + var rendered interface{} + if ctx != nil { + rendered, _ = mp.pi.MarshalYAMLInlineWithContext(ctx) + } else { + rendered, _ = mp.pi.MarshalYAMLInline() + } kn := utils.CreateStringNode(mp.path) kn.Style = mp.style diff --git a/datamodel/high/v3/callback_test.go b/datamodel/high/v3/callback_test.go index 0b0c9550..95465671 100644 --- a/datamodel/high/v3/callback_test.go +++ b/datamodel/high/v3/callback_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/pb33f/libopenapi/datamodel/high/base" "github.com/pb33f/libopenapi/datamodel/low" v3 "github.com/pb33f/libopenapi/datamodel/low/v3" "github.com/pb33f/libopenapi/index" @@ -186,3 +187,44 @@ func TestCallback_IsReference_False(t *testing.T) { assert.False(t, cb.IsReference()) assert.Equal(t, "", cb.GetReference()) } + +func TestCallback_MarshalYAMLInlineWithContext(t *testing.T) { + ext := orderedmap.New[string, *yaml.Node]() + ext.Set("x-burgers", utils.CreateStringNode("why not?")) + + cb := &Callback{ + Expression: orderedmap.ToOrderedMap(map[string]*PathItem{ + "https://pb33f.io": { + Get: &Operation{ + OperationId: "oneTwoThree", + }, + }, + "https://pb33f.io/libopenapi": { + Get: &Operation{ + OperationId: "openaypeeeye", + }, + }, + }), + Extensions: ext, + } + + ctx := base.NewInlineRenderContext() + node, err := cb.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + assert.NotNil(t, node) + + rend, _ := yaml.Marshal(node) + assert.Equal(t, "x-burgers: why not?\nhttps://pb33f.io:\n get:\n operationId: oneTwoThree\nhttps://pb33f.io/libopenapi:\n get:\n operationId: openaypeeeye\n", string(rend)) +} + +func TestCallback_MarshalYAMLInlineWithContext_Reference(t *testing.T) { + cb := CreateCallbackRef("#/components/callbacks/WebhookCallback") + + ctx := base.NewInlineRenderContext() + node, err := cb.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + + yamlNode, ok := node.(*yaml.Node) + assert.True(t, ok) + assert.Equal(t, "$ref", yamlNode.Content[0].Value) +} diff --git a/datamodel/high/v3/encoding.go b/datamodel/high/v3/encoding.go index 1ce587c4..afe7d79f 100644 --- a/datamodel/high/v3/encoding.go +++ b/datamodel/high/v3/encoding.go @@ -64,6 +64,14 @@ func (e *Encoding) MarshalYAMLInline() (interface{}, error) { return high.RenderInline(e, e.low) } +// MarshalYAMLInlineWithContext will create a ready to render YAML representation of the Encoding object, +// resolving any references inline where possible. Uses the provided context for cycle detection. +// The ctx parameter should be *base.InlineRenderContext but is typed as any to satisfy the +// high.RenderableInlineWithContext interface without import cycles. +func (e *Encoding) MarshalYAMLInlineWithContext(ctx any) (interface{}, error) { + return high.RenderInlineWithContext(e, e.low, ctx) +} + // ExtractEncoding converts hard to navigate low-level plumbing Encoding definitions, into a high-level simple map func ExtractEncoding(elements *orderedmap.Map[lowmodel.KeyReference[string], lowmodel.ValueReference[*lowv3.Encoding]]) *orderedmap.Map[string, *Encoding] { return low.FromReferenceMapWithFunc(elements, NewEncoding) diff --git a/datamodel/high/v3/encoding_test.go b/datamodel/high/v3/encoding_test.go index 36192400..8d6c1998 100644 --- a/datamodel/high/v3/encoding_test.go +++ b/datamodel/high/v3/encoding_test.go @@ -7,8 +7,10 @@ import ( "strings" "testing" + "github.com/pb33f/libopenapi/datamodel/high/base" "github.com/pb33f/libopenapi/orderedmap" "github.com/stretchr/testify/assert" + "go.yaml.in/yaml/v4" ) func TestEncoding_MarshalYAML(t *testing.T) { @@ -67,3 +69,31 @@ style: simple` assert.Equal(t, desired, strings.TrimSpace(string(rend))) } + +func TestEncoding_MarshalYAMLInlineWithContext(t *testing.T) { + explode := true + encoding := &Encoding{ + ContentType: "application/json", + Headers: orderedmap.ToOrderedMap(map[string]*Header{ + "x-pizza-time": {Description: "oh yes please"}, + }), + Style: "simple", + Explode: &explode, + } + + ctx := base.NewInlineRenderContext() + node, err := encoding.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + assert.NotNil(t, node) + + rend, _ := yaml.Marshal(node) + + desired := `contentType: application/json +headers: + x-pizza-time: + description: oh yes please +style: simple +explode: true` + + assert.Equal(t, desired, strings.TrimSpace(string(rend))) +} diff --git a/datamodel/high/v3/header.go b/datamodel/high/v3/header.go index 8625a8e0..afbb3cc1 100644 --- a/datamodel/high/v3/header.go +++ b/datamodel/high/v3/header.go @@ -15,7 +15,7 @@ import ( "go.yaml.in/yaml/v4" ) -// Header represents a high-level OpenAPI 3+ Header object that is backed by a low-level one. +// Header represents a high-level OpenAPI 3+ Header object backed by a low-level one. // - https://spec.openapis.org/oas/v3.1.0#header-object type Header struct { Reference string `json:"$ref,omitempty" yaml:"$ref,omitempty"` @@ -116,8 +116,22 @@ func (h *Header) MarshalYAMLInline() (interface{}, error) { return nb.Render(), nil } +// MarshalYAMLInlineWithContext will create a ready to render YAML representation of the Header object, +// resolving any references inline where possible. Uses the provided context for cycle detection. +// The ctx parameter should be *base.InlineRenderContext but is typed as any to satisfy the +// high.RenderableInlineWithContext interface without import cycles. +func (h *Header) MarshalYAMLInlineWithContext(ctx any) (interface{}, error) { + if h.Reference != "" { + return utils.CreateRefNode(h.Reference), nil + } + nb := high.NewNodeBuilder(h, h.low) + nb.Resolve = true + nb.RenderContext = ctx + return nb.Render(), nil +} + // CreateHeaderRef creates a Header that renders as a $ref to another header definition. -// This is useful when building OpenAPI specs programmatically and you want to reference +// This is useful when building OpenAPI specs programmatically, and you want to reference // a header defined in components/headers rather than inlining the full definition. // // Example: diff --git a/datamodel/high/v3/header_test.go b/datamodel/high/v3/header_test.go index 95c029d5..08337e7c 100644 --- a/datamodel/high/v3/header_test.go +++ b/datamodel/high/v3/header_test.go @@ -144,3 +144,57 @@ func TestHeader_RenderInline_NonReference(t *testing.T) { assert.Contains(t, string(rendered), "A rate limit header") assert.Contains(t, string(rendered), "required:") } + +func TestHeader_MarshalYAMLInlineWithContext(t *testing.T) { + ext := orderedmap.New[string, *yaml.Node]() + ext.Set("x-burgers", utils.CreateStringNode("why not?")) + + header := &Header{ + Description: "A header", + Required: true, + Deprecated: true, + AllowEmptyValue: true, + Style: "simple", + Explode: true, + AllowReserved: true, + Example: utils.CreateStringNode("example"), + Examples: orderedmap.ToOrderedMap(map[string]*base.Example{ + "example": {Value: utils.CreateStringNode("example")}, + }), + Extensions: ext, + } + + ctx := base.NewInlineRenderContext() + node, err := header.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + assert.NotNil(t, node) + + rend, _ := yaml.Marshal(node) + + desired := `description: A header +required: true +deprecated: true +allowEmptyValue: true +style: simple +explode: true +allowReserved: true +example: example +examples: + example: + value: example +x-burgers: why not?` + + assert.Equal(t, desired, strings.TrimSpace(string(rend))) +} + +func TestHeader_MarshalYAMLInlineWithContext_Reference(t *testing.T) { + h := CreateHeaderRef("#/components/headers/X-Rate-Limit") + + ctx := base.NewInlineRenderContext() + node, err := h.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + + yamlNode, ok := node.(*yaml.Node) + assert.True(t, ok) + assert.Equal(t, "$ref", yamlNode.Content[0].Value) +} diff --git a/datamodel/high/v3/link.go b/datamodel/high/v3/link.go index 33f4baa8..da1370db 100644 --- a/datamodel/high/v3/link.go +++ b/datamodel/high/v3/link.go @@ -97,8 +97,19 @@ func (l *Link) MarshalYAMLInline() (interface{}, error) { return high.RenderInline(l, l.low) } +// MarshalYAMLInlineWithContext will create a ready to render YAML representation of the Link object, +// resolving any references inline where possible. Uses the provided context for cycle detection. +// The ctx parameter should be *base.InlineRenderContext but is typed as any to satisfy the +// high.RenderableInlineWithContext interface without import cycles. +func (l *Link) MarshalYAMLInlineWithContext(ctx any) (interface{}, error) { + if l.Reference != "" { + return utils.CreateRefNode(l.Reference), nil + } + return high.RenderInlineWithContext(l, l.low, ctx) +} + // CreateLinkRef creates a Link that renders as a $ref to another link definition. -// This is useful when building OpenAPI specs programmatically and you want to reference +// This is useful when building OpenAPI specs programmatically, and you want to reference // a link defined in components/links rather than inlining the full definition. // // Example: diff --git a/datamodel/high/v3/link_test.go b/datamodel/high/v3/link_test.go index 7c7fdcca..847929da 100644 --- a/datamodel/high/v3/link_test.go +++ b/datamodel/high/v3/link_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/pb33f/libopenapi/datamodel/high/base" "github.com/pb33f/libopenapi/orderedmap" "github.com/stretchr/testify/assert" "go.yaml.in/yaml/v4" @@ -108,3 +109,47 @@ func TestLink_IsReference_False(t *testing.T) { assert.False(t, l.IsReference()) assert.Equal(t, "", l.GetReference()) } + +func TestLink_MarshalYAMLInlineWithContext(t *testing.T) { + link := Link{ + OperationRef: "somewhere", + OperationId: "somewhereOutThere", + Parameters: orderedmap.ToOrderedMap(map[string]string{ + "over": "theRainbow", + }), + RequestBody: "hello?", + Description: "are you there?", + Server: &Server{ + URL: "https://pb33f.io", + }, + } + + ctx := base.NewInlineRenderContext() + node, err := link.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + assert.NotNil(t, node) + + dat, _ := yaml.Marshal(node) + desired := `operationRef: somewhere +operationId: somewhereOutThere +parameters: + over: theRainbow +requestBody: hello? +description: are you there? +server: + url: https://pb33f.io` + + assert.Equal(t, desired, strings.TrimSpace(string(dat))) +} + +func TestLink_MarshalYAMLInlineWithContext_Reference(t *testing.T) { + l := CreateLinkRef("#/components/links/GetUserByUserId") + + ctx := base.NewInlineRenderContext() + node, err := l.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + + yamlNode, ok := node.(*yaml.Node) + assert.True(t, ok) + assert.Equal(t, "$ref", yamlNode.Content[0].Value) +} diff --git a/datamodel/high/v3/media_type.go b/datamodel/high/v3/media_type.go index febbc200..1d90efa1 100644 --- a/datamodel/high/v3/media_type.go +++ b/datamodel/high/v3/media_type.go @@ -80,6 +80,17 @@ func (m *MediaType) MarshalYAMLInline() (interface{}, error) { return nb.Render(), nil } +// MarshalYAMLInlineWithContext will create a ready to render YAML representation of the MediaType object, +// resolving any references inline where possible. Uses the provided context for cycle detection. +// The ctx parameter should be *base.InlineRenderContext but is typed as any to satisfy the +// high.RenderableInlineWithContext interface without import cycles. +func (m *MediaType) MarshalYAMLInlineWithContext(ctx any) (interface{}, error) { + nb := high.NewNodeBuilder(m, m.low) + nb.Resolve = true + nb.RenderContext = ctx + return nb.Render(), nil +} + // ExtractContent takes in a complex and hard to navigate low-level content map, and converts it in to a much simpler // and easier to navigate high-level one. func ExtractContent(elements *orderedmap.Map[lowmodel.KeyReference[string], lowmodel.ValueReference[*low.MediaType]]) *orderedmap.Map[string, *MediaType] { diff --git a/datamodel/high/v3/media_type_test.go b/datamodel/high/v3/media_type_test.go index 8b4ed54c..7ae981f7 100644 --- a/datamodel/high/v3/media_type_test.go +++ b/datamodel/high/v3/media_type_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/pb33f/libopenapi/datamodel" + "github.com/pb33f/libopenapi/datamodel/high/base" "github.com/pb33f/libopenapi/datamodel/low" v3 "github.com/pb33f/libopenapi/datamodel/low/v3" "github.com/pb33f/libopenapi/index" @@ -189,3 +190,35 @@ func TestMediaType_Examples_NotFromSchema(t *testing.T) { assert.Equal(t, 0, orderedmap.Len(r.Examples)) } + +func TestMediaType_MarshalYAMLInlineWithContext(t *testing.T) { + yml := `examples: + pbjBurger: + summary: A horrible, nutty, sticky mess. + value: + name: Peanut And Jelly + numPatties: 3 + cakeBurger: + summary: A sickly, sweet, atrocity + value: + name: Chocolate Cake Burger + numPatties: 5` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + idx := index.NewSpecIndexWithConfig(&idxNode, index.CreateOpenAPIIndexConfig()) + + var n v3.MediaType + _ = low.BuildModel(idxNode.Content[0], &n) + _ = n.Build(context.Background(), nil, idxNode.Content[0], idx) + + r := NewMediaType(&n) + + ctx := base.NewInlineRenderContext() + node, err := r.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + assert.NotNil(t, node) + + rend, _ := yaml.Marshal(node) + assert.Len(t, rend, 290) +} diff --git a/datamodel/high/v3/parameter.go b/datamodel/high/v3/parameter.go index 5e2c3bb3..25916d54 100644 --- a/datamodel/high/v3/parameter.go +++ b/datamodel/high/v3/parameter.go @@ -129,6 +129,20 @@ func (p *Parameter) MarshalYAMLInline() (interface{}, error) { return nb.Render(), nil } +// MarshalYAMLInlineWithContext will create a ready to render YAML representation of the Parameter object, +// resolving any references inline where possible. Uses the provided context for cycle detection. +// The ctx parameter should be *base.InlineRenderContext but is typed as any to satisfy the +// high.RenderableInlineWithContext interface without import cycles. +func (p *Parameter) MarshalYAMLInlineWithContext(ctx any) (interface{}, error) { + if p.Reference != "" { + return utils.CreateRefNode(p.Reference), nil + } + nb := high.NewNodeBuilder(p, p.low) + nb.Resolve = true + nb.RenderContext = ctx + return nb.Render(), nil +} + // IsExploded will return true if the parameter is exploded, false otherwise. func (p *Parameter) IsExploded() bool { if p.Explode == nil { diff --git a/datamodel/high/v3/parameter_test.go b/datamodel/high/v3/parameter_test.go index fb727e6a..177864f3 100644 --- a/datamodel/high/v3/parameter_test.go +++ b/datamodel/high/v3/parameter_test.go @@ -315,3 +315,58 @@ func TestParameter_Integration_MixedRefAndInline(t *testing.T) { assert.Contains(t, output, "name: status") assert.Contains(t, output, "in: query") } + +func TestParameter_MarshalYAMLInlineWithContext(t *testing.T) { + ext := orderedmap.New[string, *yaml.Node]() + ext.Set("x-burgers", utils.CreateStringNode("why not?")) + + explode := true + param := Parameter{ + Name: "chicken", + In: "nuggets", + Description: "beefy", + Deprecated: true, + Style: "simple", + Explode: &explode, + AllowReserved: true, + Example: utils.CreateStringNode("example"), + Examples: orderedmap.ToOrderedMap(map[string]*base.Example{ + "example": {Value: utils.CreateStringNode("example")}, + }), + Extensions: ext, + } + + ctx := base.NewInlineRenderContext() + node, err := param.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + assert.NotNil(t, node) + + rend, _ := yaml.Marshal(node) + + desired := `name: chicken +in: nuggets +description: beefy +deprecated: true +style: simple +explode: true +allowReserved: true +example: example +examples: + example: + value: example +x-burgers: why not?` + + assert.Equal(t, desired, strings.TrimSpace(string(rend))) +} + +func TestParameter_MarshalYAMLInlineWithContext_Reference(t *testing.T) { + p := CreateParameterRef("#/components/parameters/limitParam") + + ctx := base.NewInlineRenderContext() + node, err := p.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + + yamlNode, ok := node.(*yaml.Node) + assert.True(t, ok) + assert.Equal(t, "$ref", yamlNode.Content[0].Value) +} diff --git a/datamodel/high/v3/path_item.go b/datamodel/high/v3/path_item.go index 648a0a6b..d39109cd 100644 --- a/datamodel/high/v3/path_item.go +++ b/datamodel/high/v3/path_item.go @@ -272,6 +272,20 @@ func (p *PathItem) MarshalYAMLInline() (interface{}, error) { return nb.Render(), nil } +// MarshalYAMLInlineWithContext will create a ready to render YAML representation of the PathItem object, +// resolving any references inline where possible. Uses the provided context for cycle detection. +// The ctx parameter should be *base.InlineRenderContext but is typed as any to satisfy the +// high.RenderableInlineWithContext interface without import cycles. +func (p *PathItem) MarshalYAMLInlineWithContext(ctx any) (interface{}, error) { + if p.Reference != "" { + return utils.CreateRefNode(p.Reference), nil + } + nb := high.NewNodeBuilder(p, p.low) + nb.Resolve = true + nb.RenderContext = ctx + return nb.Render(), nil +} + // CreatePathItemRef creates a PathItem that renders as a $ref to another path item definition. // This is useful when building OpenAPI specs programmatically and you want to reference // a path item defined in components/pathItems rather than inlining the full definition. diff --git a/datamodel/high/v3/path_item_test.go b/datamodel/high/v3/path_item_test.go index e4026a25..5cd90a98 100644 --- a/datamodel/high/v3/path_item_test.go +++ b/datamodel/high/v3/path_item_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/pb33f/libopenapi/datamodel/high/base" "github.com/pb33f/libopenapi/datamodel/low" lowV3 "github.com/pb33f/libopenapi/datamodel/low/v3" "github.com/pb33f/libopenapi/index" @@ -464,3 +465,60 @@ func TestPathItem_IsReference_False(t *testing.T) { assert.Equal(t, "", pi.GetReference()) } +func TestPathItem_MarshalYAMLInlineWithContext(t *testing.T) { + pi := &PathItem{ + Description: "a path item", + Summary: "It's a test, don't worry about it, Jim", + Servers: []*Server{ + { + Description: "a server", + }, + }, + Parameters: []*Parameter{ + { + Name: "I am a query parameter", + In: "query", + }, + }, + Get: &Operation{ + Description: "a get operation", + }, + Post: &Operation{ + Description: "a post operation", + }, + } + + ctx := base.NewInlineRenderContext() + node, err := pi.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + assert.NotNil(t, node) + + rend, _ := yaml.Marshal(node) + + desired := `description: a path item +summary: It's a test, don't worry about it, Jim +get: + description: a get operation +post: + description: a post operation +servers: + - description: a server +parameters: + - name: I am a query parameter + in: query` + + assert.Equal(t, desired, strings.TrimSpace(string(rend))) +} + +func TestPathItem_MarshalYAMLInlineWithContext_Reference(t *testing.T) { + pi := CreatePathItemRef("#/components/pathItems/CommonPathItem") + + ctx := base.NewInlineRenderContext() + node, err := pi.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + + yamlNode, ok := node.(*yaml.Node) + assert.True(t, ok) + assert.Equal(t, "$ref", yamlNode.Content[0].Value) +} + diff --git a/datamodel/high/v3/request_body.go b/datamodel/high/v3/request_body.go index c99c2208..535f3f04 100644 --- a/datamodel/high/v3/request_body.go +++ b/datamodel/high/v3/request_body.go @@ -87,6 +87,20 @@ func (r *RequestBody) MarshalYAMLInline() (interface{}, error) { return nb.Render(), nil } +// MarshalYAMLInlineWithContext will create a ready to render YAML representation of the RequestBody object, +// resolving any references inline where possible. Uses the provided context for cycle detection. +// The ctx parameter should be *base.InlineRenderContext but is typed as any to satisfy the +// high.RenderableInlineWithContext interface without import cycles. +func (r *RequestBody) MarshalYAMLInlineWithContext(ctx any) (interface{}, error) { + if r.Reference != "" { + return utils.CreateRefNode(r.Reference), nil + } + nb := high.NewNodeBuilder(r, r.low) + nb.Resolve = true + nb.RenderContext = ctx + return nb.Render(), nil +} + // CreateRequestBodyRef creates a RequestBody that renders as a $ref to another request body definition. // This is useful when building OpenAPI specs programmatically and you want to reference // a request body defined in components/requestBodies rather than inlining the full definition. diff --git a/datamodel/high/v3/request_body_test.go b/datamodel/high/v3/request_body_test.go index 277537ce..4d11a624 100644 --- a/datamodel/high/v3/request_body_test.go +++ b/datamodel/high/v3/request_body_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/pb33f/libopenapi/datamodel/high/base" "github.com/pb33f/libopenapi/orderedmap" "github.com/pb33f/libopenapi/utils" "github.com/stretchr/testify/assert" @@ -159,3 +160,40 @@ func TestRequestBody_IsReference_False(t *testing.T) { assert.False(t, rb.IsReference()) assert.Equal(t, "", rb.GetReference()) } + +func TestRequestBody_MarshalYAMLInlineWithContext(t *testing.T) { + ext := orderedmap.New[string, *yaml.Node]() + ext.Set("x-high-gravity", utils.CreateStringNode("why not?")) + + rb := true + req := &RequestBody{ + Description: "beer", + Required: &rb, + Extensions: ext, + } + + ctx := base.NewInlineRenderContext() + node, err := req.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + assert.NotNil(t, node) + + rend, _ := yaml.Marshal(node) + + desired := `description: beer +required: true +x-high-gravity: why not?` + + assert.Equal(t, desired, strings.TrimSpace(string(rend))) +} + +func TestRequestBody_MarshalYAMLInlineWithContext_Reference(t *testing.T) { + rb := CreateRequestBodyRef("#/components/requestBodies/UserInput") + + ctx := base.NewInlineRenderContext() + node, err := rb.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + + yamlNode, ok := node.(*yaml.Node) + assert.True(t, ok) + assert.Equal(t, "$ref", yamlNode.Content[0].Value) +} diff --git a/datamodel/high/v3/response.go b/datamodel/high/v3/response.go index 5f6d18a9..f63002ab 100644 --- a/datamodel/high/v3/response.go +++ b/datamodel/high/v3/response.go @@ -99,6 +99,20 @@ func (r *Response) MarshalYAMLInline() (interface{}, error) { return nb.Render(), nil } +// MarshalYAMLInlineWithContext will create a ready to render YAML representation of the Response object, +// resolving any references inline where possible. Uses the provided context for cycle detection. +// The ctx parameter should be *base.InlineRenderContext but is typed as any to satisfy the +// high.RenderableInlineWithContext interface without import cycles. +func (r *Response) MarshalYAMLInlineWithContext(ctx any) (interface{}, error) { + if r.Reference != "" { + return utils.CreateRefNode(r.Reference), nil + } + nb := high.NewNodeBuilder(r, r.low) + nb.Resolve = true + nb.RenderContext = ctx + return nb.Render(), nil +} + // CreateResponseRef creates a Response that renders as a $ref to another response definition. // This is useful when building OpenAPI specs programmatically and you want to reference // a response defined in components/responses rather than inlining the full definition. diff --git a/datamodel/high/v3/response_test.go b/datamodel/high/v3/response_test.go index 967d5c81..a222986e 100644 --- a/datamodel/high/v3/response_test.go +++ b/datamodel/high/v3/response_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/pb33f/libopenapi/datamodel/high/base" "github.com/pb33f/libopenapi/datamodel/low" v3 "github.com/pb33f/libopenapi/datamodel/low/v3" "github.com/pb33f/libopenapi/index" @@ -178,3 +179,46 @@ func TestResponse_IsReference_False(t *testing.T) { assert.False(t, r.IsReference()) assert.Equal(t, "", r.GetReference()) } + +func TestResponse_MarshalYAMLInlineWithContext(t *testing.T) { + yml := `description: this is a response +headers: + someHeader: + description: a header +content: + something/thing: + example: cake +links: + someLink: + description: a link!` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + idx := index.NewSpecIndexWithConfig(&idxNode, index.CreateOpenAPIIndexConfig()) + + var n v3.Response + _ = low.BuildModel(idxNode.Content[0], &n) + _ = n.Build(context.Background(), nil, idxNode.Content[0], idx) + + r := NewResponse(&n) + + ctx := base.NewInlineRenderContext() + node, err := r.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + assert.NotNil(t, node) + + rendered, _ := yaml.Marshal(node) + assert.Equal(t, yml, strings.TrimSpace(string(rendered))) +} + +func TestResponse_MarshalYAMLInlineWithContext_Reference(t *testing.T) { + r := CreateResponseRef("#/components/responses/NotFound") + + ctx := base.NewInlineRenderContext() + node, err := r.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + + yamlNode, ok := node.(*yaml.Node) + assert.True(t, ok) + assert.Equal(t, "$ref", yamlNode.Content[0].Value) +} diff --git a/datamodel/high/v3/security_scheme.go b/datamodel/high/v3/security_scheme.go index 6a672c14..3573a6d9 100644 --- a/datamodel/high/v3/security_scheme.go +++ b/datamodel/high/v3/security_scheme.go @@ -102,6 +102,17 @@ func (s *SecurityScheme) MarshalYAMLInline() (interface{}, error) { return high.RenderInline(s, s.low) } +// MarshalYAMLInlineWithContext will create a ready to render YAML representation of the SecurityScheme object, +// resolving any references inline where possible. Uses the provided context for cycle detection. +// The ctx parameter should be *base.InlineRenderContext but is typed as any to satisfy the +// high.RenderableInlineWithContext interface without import cycles. +func (s *SecurityScheme) MarshalYAMLInlineWithContext(ctx any) (interface{}, error) { + if s.Reference != "" { + return utils.CreateRefNode(s.Reference), nil + } + return high.RenderInlineWithContext(s, s.low, ctx) +} + // CreateSecuritySchemeRef creates a SecurityScheme that renders as a $ref to another security scheme definition. // This is useful when building OpenAPI specs programmatically and you want to reference // a security scheme defined in components/securitySchemes rather than inlining the full definition. diff --git a/datamodel/high/v3/security_scheme_test.go b/datamodel/high/v3/security_scheme_test.go index 255d291b..c2d37c15 100644 --- a/datamodel/high/v3/security_scheme_test.go +++ b/datamodel/high/v3/security_scheme_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/pb33f/libopenapi/datamodel/high/base" "github.com/pb33f/libopenapi/datamodel/low" v3 "github.com/pb33f/libopenapi/datamodel/low/v3" "github.com/pb33f/libopenapi/index" @@ -119,3 +120,40 @@ func TestSecurityScheme_IsReference_False(t *testing.T) { assert.Equal(t, "", ss.GetReference()) } +func TestSecurityScheme_MarshalYAMLInlineWithContext(t *testing.T) { + ss := &SecurityScheme{ + Type: "apiKey", + Description: "this is a description", + Name: "superSecret", + In: "header", + Scheme: "https", + } + + ctx := base.NewInlineRenderContext() + node, err := ss.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + assert.NotNil(t, node) + + dat, _ := yaml.Marshal(node) + + desired := `type: apiKey +description: this is a description +name: superSecret +in: header +scheme: https` + + assert.Equal(t, desired, strings.TrimSpace(string(dat))) +} + +func TestSecurityScheme_MarshalYAMLInlineWithContext_Reference(t *testing.T) { + ss := CreateSecuritySchemeRef("#/components/securitySchemes/BearerAuth") + + ctx := base.NewInlineRenderContext() + node, err := ss.MarshalYAMLInlineWithContext(ctx) + assert.NoError(t, err) + + yamlNode, ok := node.(*yaml.Node) + assert.True(t, ok) + assert.Equal(t, "$ref", yamlNode.Content[0].Value) +} +