Skip to content

Commit 2abedeb

Browse files
committed
Address #597
added a `MarshalYAML` to the ordered map struct wrapper to prevent mutations.
1 parent d4c45ce commit 2abedeb

3 files changed

Lines changed: 311 additions & 0 deletions

File tree

issue597_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright 2026 Princess Beef Heavy Industries / Dave Shanley
2+
// https://pb33f.io
3+
// SPDX-License-Identifier: MIT
4+
package libopenapi
5+
6+
import (
7+
"testing"
8+
9+
"github.com/pb33f/libopenapi/datamodel"
10+
"github.com/pb33f/libopenapi/datamodel/low"
11+
"github.com/pb33f/libopenapi/orderedmap"
12+
"github.com/pb33f/testify/require"
13+
"go.yaml.in/yaml/v4"
14+
)
15+
16+
const issue597Spec = `openapi: 3.1.0
17+
info:
18+
title: Common schemas
19+
version: 0.0.1
20+
servers:
21+
- url: http://localhost:8080/
22+
components:
23+
schemas:
24+
foo:
25+
type: object
26+
properties:
27+
hello:
28+
type: string
29+
world:
30+
type: string
31+
x-custom: true
32+
`
33+
34+
func TestIssue597SchemaExtensionMarshalYAMLDoesNotChangeLowHash(t *testing.T) {
35+
tests := []struct {
36+
name string
37+
marshal func(*orderedmap.Map[string, *yaml.Node]) error
38+
}{
39+
{
40+
name: "direct MarshalYAML",
41+
marshal: func(extensions *orderedmap.Map[string, *yaml.Node]) error {
42+
_, err := extensions.MarshalYAML()
43+
return err
44+
},
45+
},
46+
{
47+
name: "yaml Marshal",
48+
marshal: func(extensions *orderedmap.Map[string, *yaml.Node]) error {
49+
_, err := yaml.Marshal(extensions)
50+
return err
51+
},
52+
},
53+
}
54+
55+
for _, tt := range tests {
56+
t.Run(tt.name, func(t *testing.T) {
57+
doc, err := NewDocumentWithConfiguration([]byte(issue597Spec), &datamodel.DocumentConfiguration{
58+
ExtractRefsSequentially: true,
59+
})
60+
require.NoError(t, err)
61+
62+
v3Model, err := doc.BuildV3Model()
63+
require.NoError(t, err)
64+
65+
schema := v3Model.Model.Components.Schemas.Value("foo").Schema()
66+
lowSchema := schema.GoLow()
67+
lowExtension := low.FindItemInOrderedMap[*yaml.Node]("x-custom", lowSchema.GetExtensions())
68+
require.NotNil(t, lowExtension)
69+
require.Equal(t, "!!bool", lowExtension.Value.Tag)
70+
71+
initialHash := lowSchema.Hash()
72+
low.ClearHashCache()
73+
74+
require.NoError(t, tt.marshal(schema.Extensions))
75+
require.Equal(t, "!!bool", lowExtension.Value.Tag)
76+
require.Equal(t, "true", lowExtension.Value.Value)
77+
78+
low.ClearHashCache()
79+
require.Equal(t, initialHash, lowSchema.Hash())
80+
})
81+
}
82+
}

orderedmap/builder.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package orderedmap
22

33
import (
44
"fmt"
5+
"reflect"
56
"strings"
67

78
"github.com/pb33f/libopenapi/datamodel/high/nodes"
@@ -33,6 +34,65 @@ type findValueUntyped interface {
3334
FindValueUntyped(k string) any
3435
}
3536

37+
// MarshalYAML implements yaml.Marshaler for libopenapi's ordered map wrapper.
38+
func (o *Map[K, V]) MarshalYAML() (interface{}, error) {
39+
if o == nil {
40+
return nil, nil
41+
}
42+
43+
node := yaml.Node{Kind: yaml.MappingNode}
44+
for pair := First(o); pair != nil; pair = pair.Next() {
45+
keyNode := &yaml.Node{}
46+
keyValue, err := encodeMarshalYAMLValue(pair.Key())
47+
if err != nil {
48+
return nil, err
49+
}
50+
if err = keyNode.Encode(keyValue); err != nil {
51+
return nil, err
52+
}
53+
54+
valueNode := &yaml.Node{}
55+
value, err := encodeMarshalYAMLValue(pair.Value())
56+
if err != nil {
57+
return nil, err
58+
}
59+
if err = valueNode.Encode(value); err != nil {
60+
return nil, err
61+
}
62+
63+
node.Content = append(node.Content, keyNode, valueNode)
64+
}
65+
66+
return &node, nil
67+
}
68+
69+
func encodeMarshalYAMLValue(value any) (any, error) {
70+
for {
71+
if value == nil {
72+
return nil, nil
73+
}
74+
if node, ok := value.(*yaml.Node); ok {
75+
return utils.CloneYAMLNode(node), nil
76+
}
77+
78+
rv := reflect.ValueOf(value)
79+
if rv.Kind() == reflect.Ptr && rv.IsNil() {
80+
return value, nil
81+
}
82+
83+
m, ok := value.(marshaler)
84+
if !ok {
85+
return value, nil
86+
}
87+
88+
marshaled, err := m.MarshalYAML()
89+
if err != nil {
90+
return nil, err
91+
}
92+
value = marshaled
93+
}
94+
}
95+
3696
// ToYamlNode converts the ordered map to a yaml node ready for marshalling.
3797
func (o *Map[K, V]) ToYamlNode(n NodeBuilder, l any) *yaml.Node {
3898
p := utils.CreateEmptyMapNode()

orderedmap/orderedmap_test.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,27 @@ import (
1010
"time"
1111

1212
"github.com/pb33f/libopenapi/datamodel"
13+
"github.com/pb33f/libopenapi/datamodel/low"
1314
"github.com/pb33f/libopenapi/orderedmap"
1415
"github.com/pb33f/testify/assert"
1516
"github.com/pb33f/testify/require"
17+
"go.yaml.in/yaml/v4"
1618
)
1719

20+
type yamlErrorMarshaler struct{}
21+
22+
func (yamlErrorMarshaler) MarshalYAML() (interface{}, error) {
23+
return nil, errors.New("yaml marshal failed")
24+
}
25+
26+
type yamlNodeMarshaler struct {
27+
node *yaml.Node
28+
}
29+
30+
func (y yamlNodeMarshaler) MarshalYAML() (interface{}, error) {
31+
return y.node, nil
32+
}
33+
1834
func TestOrderedMap(t *testing.T) {
1935
t.Run("Empty", func(t *testing.T) {
2036
m := orderedmap.New[string, int]()
@@ -265,6 +281,159 @@ func TestMap(t *testing.T) {
265281
})
266282
}
267283

284+
func TestMapMarshalYAMLDoesNotMutateYAMLNodeValues(t *testing.T) {
285+
t.Run("nil map marshals as null", func(t *testing.T) {
286+
var m *orderedmap.Map[string, *yaml.Node]
287+
288+
node, err := m.MarshalYAML()
289+
require.NoError(t, err)
290+
require.Nil(t, node)
291+
292+
rendered, err := yaml.Marshal(m)
293+
require.NoError(t, err)
294+
require.Equal(t, "null\n", string(rendered))
295+
})
296+
297+
t.Run("nil value marshals as null", func(t *testing.T) {
298+
m := orderedmap.New[string, any]()
299+
m.Set("x-null", nil)
300+
301+
rendered, err := yaml.Marshal(m)
302+
require.NoError(t, err)
303+
require.Equal(t, "x-null: null\n", string(rendered))
304+
})
305+
306+
t.Run("nil pointer value marshals as null", func(t *testing.T) {
307+
var value *yamlErrorMarshaler
308+
m := orderedmap.New[string, *yamlErrorMarshaler]()
309+
m.Set("x-null", value)
310+
311+
rendered, err := yaml.Marshal(m)
312+
require.NoError(t, err)
313+
require.Equal(t, "x-null: null\n", string(rendered))
314+
})
315+
316+
t.Run("key marshaler error", func(t *testing.T) {
317+
m := orderedmap.New[yamlErrorMarshaler, string]()
318+
m.Set(yamlErrorMarshaler{}, "value")
319+
320+
_, err := m.MarshalYAML()
321+
require.ErrorContains(t, err, "yaml marshal failed")
322+
})
323+
324+
t.Run("value marshaler error", func(t *testing.T) {
325+
m := orderedmap.New[string, yamlErrorMarshaler]()
326+
m.Set("x-error", yamlErrorMarshaler{})
327+
328+
_, err := m.MarshalYAML()
329+
require.ErrorContains(t, err, "yaml marshal failed")
330+
})
331+
332+
t.Run("key encode error", func(t *testing.T) {
333+
m := orderedmap.New[chan int, string]()
334+
m.Set(make(chan int), "value")
335+
336+
_, err := m.MarshalYAML()
337+
require.Error(t, err)
338+
})
339+
340+
t.Run("value encode error", func(t *testing.T) {
341+
m := orderedmap.New[string, chan int]()
342+
m.Set("x-error", make(chan int))
343+
344+
_, err := m.MarshalYAML()
345+
require.Error(t, err)
346+
})
347+
348+
t.Run("direct scalar node", func(t *testing.T) {
349+
valueNode := &yaml.Node{
350+
Kind: yaml.ScalarNode,
351+
Tag: "!!bool",
352+
Value: "true",
353+
}
354+
355+
m := orderedmap.New[string, *yaml.Node]()
356+
m.Set("x-custom", valueNode)
357+
358+
_, err := m.MarshalYAML()
359+
require.NoError(t, err)
360+
361+
require.Equal(t, yaml.ScalarNode, valueNode.Kind)
362+
require.Equal(t, "!!bool", valueNode.Tag)
363+
require.Equal(t, "true", valueNode.Value)
364+
require.Equal(t, yaml.Style(0), valueNode.Style)
365+
})
366+
367+
t.Run("nested node tree", func(t *testing.T) {
368+
valueNode := &yaml.Node{
369+
Kind: yaml.MappingNode,
370+
Tag: "!!map",
371+
Content: []*yaml.Node{
372+
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "enabled"},
373+
{Kind: yaml.ScalarNode, Tag: "!!bool", Value: "true"},
374+
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "levels"},
375+
{
376+
Kind: yaml.SequenceNode,
377+
Tag: "!!seq",
378+
Content: []*yaml.Node{
379+
{Kind: yaml.ScalarNode, Tag: "!!int", Value: "1"},
380+
{Kind: yaml.ScalarNode, Tag: "!!int", Value: "2"},
381+
},
382+
},
383+
},
384+
}
385+
386+
m := orderedmap.New[string, *yaml.Node]()
387+
m.Set("x-nested", valueNode)
388+
389+
_, err := yaml.Marshal(m)
390+
require.NoError(t, err)
391+
392+
require.Equal(t, "!!map", valueNode.Tag)
393+
require.Equal(t, "!!str", valueNode.Content[0].Tag)
394+
require.Equal(t, "!!bool", valueNode.Content[1].Tag)
395+
require.Equal(t, "!!str", valueNode.Content[2].Tag)
396+
require.Equal(t, "!!seq", valueNode.Content[3].Tag)
397+
require.Equal(t, "!!int", valueNode.Content[3].Content[0].Tag)
398+
require.Equal(t, "!!int", valueNode.Content[3].Content[1].Tag)
399+
})
400+
401+
t.Run("marshaler key returning node", func(t *testing.T) {
402+
keyNode := &yaml.Node{
403+
Kind: yaml.ScalarNode,
404+
Tag: "!!str",
405+
Value: "x-custom",
406+
}
407+
m := orderedmap.New[low.KeyReference[string], string]()
408+
m.Set(low.KeyReference[string]{
409+
Value: "x-custom",
410+
KeyNode: keyNode,
411+
}, "true")
412+
413+
_, err := yaml.Marshal(m)
414+
require.NoError(t, err)
415+
416+
require.Equal(t, "!!str", keyNode.Tag)
417+
require.Equal(t, "x-custom", keyNode.Value)
418+
})
419+
420+
t.Run("recursive marshaler returning node", func(t *testing.T) {
421+
valueNode := &yaml.Node{
422+
Kind: yaml.ScalarNode,
423+
Tag: "!!bool",
424+
Value: "true",
425+
}
426+
m := orderedmap.New[string, yamlNodeMarshaler]()
427+
m.Set("x-custom", yamlNodeMarshaler{node: valueNode})
428+
429+
_, err := yaml.Marshal(m)
430+
require.NoError(t, err)
431+
432+
require.Equal(t, "!!bool", valueNode.Tag)
433+
require.Equal(t, "true", valueNode.Value)
434+
})
435+
}
436+
268437
func TestFirst(t *testing.T) {
269438
t.Run("Nil", func(t *testing.T) {
270439
pair := orderedmap.First[string, int](nil)

0 commit comments

Comments
 (0)