Skip to content

Commit 3bb251f

Browse files
committed
Address issue with dropping params.
Reported on discord. params were being dropped on render because the skip flag was bugging things out.
1 parent 406f0ce commit 3bb251f

File tree

3 files changed

+338
-4
lines changed

3 files changed

+338
-4
lines changed

datamodel/high/node_builder.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,9 @@ func (n *NodeBuilder) AddYAMLNode(parent *yaml.Node, entry *nodes.NodeEntry) *ya
394394
sl := utils.CreateEmptySequenceNode()
395395
skip := false
396396
for i := 0; i < m.Len(); i++ {
397+
// Reset skip at the start of each iteration to handle items without low-level models
398+
// (e.g., newly created high-level objects appended to an existing slice)
399+
skip = false
397400
sqi := m.Index(i).Interface()
398401
// check if this is a reference.
399402
if glu, ok := sqi.(GoesLowUntyped); ok {
@@ -406,11 +409,7 @@ func (n *NodeBuilder) AddYAMLNode(parent *yaml.Node, entry *nodes.NodeEntry) *ya
406409
if !n.Resolve {
407410
sl.Content = append(sl.Content, n.renderReference(glu.GoLowUntyped().(low.IsReferenced)))
408411
skip = true
409-
} else {
410-
skip = false
411412
}
412-
} else {
413-
skip = false
414413
}
415414
}
416415
}

datamodel/high/node_builder_test.go

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1406,3 +1406,195 @@ func TestNodeBuilder_RenderContext_FallbackToMarshalYAML_Slice(t *testing.T) {
14061406
data, _ := yaml.Marshal(node)
14071407
assert.Contains(t, string(data), "basic-item1")
14081408
}
1409+
1410+
// nilLowRenderable implements GoesLowUntyped but returns nil for GoLowUntyped()
1411+
// This simulates a newly created high-level object without a low-level model
1412+
type nilLowRenderable struct {
1413+
Value string
1414+
}
1415+
1416+
func (n *nilLowRenderable) MarshalYAML() (interface{}, error) {
1417+
return utils.CreateStringNode("nillow-" + n.Value), nil
1418+
}
1419+
1420+
func (n *nilLowRenderable) GoLowUntyped() any {
1421+
return nil // Simulates newly created high-level object without low-level model
1422+
}
1423+
1424+
type testWithMixedSlice struct {
1425+
Items []*valueReferenceStruct `yaml:"items,omitempty"`
1426+
}
1427+
1428+
type testWithNilLowSlice struct {
1429+
Items []*nilLowRenderable `yaml:"items,omitempty"`
1430+
}
1431+
1432+
// TestNodeBuilder_SliceRefFollowedByNilLow tests the bug fix for rendering slices
1433+
// where a $ref item is followed by a newly created item without a low-level model.
1434+
// Before the fix, the 'skip' variable was not reset between iterations, causing
1435+
// items following a $ref to be incorrectly skipped when they had no low-level model.
1436+
func TestNodeBuilder_SliceRefFollowedByNilLow(t *testing.T) {
1437+
// Create a slice with a $ref followed by a new item without low-level model
1438+
refItem := &valueReferenceStruct{
1439+
ref: true,
1440+
refStr: "#/components/parameters/RefId",
1441+
Value: "refValue",
1442+
}
1443+
newItem := &valueReferenceStruct{
1444+
ref: false, // Not a reference
1445+
Value: "newValue",
1446+
}
1447+
1448+
// The new item's GoLowUntyped returns a pointer to itself (not nil),
1449+
// but it's not a reference, so it should be rendered normally.
1450+
// However, to truly test the nil case, we need a different approach.
1451+
1452+
ty := []*valueReferenceStruct{refItem, newItem}
1453+
t1 := test1{
1454+
Throg: ty,
1455+
}
1456+
1457+
nb := NewNodeBuilder(&t1, &t1)
1458+
node := nb.Render()
1459+
1460+
data, _ := yaml.Marshal(node)
1461+
1462+
// Both items should be rendered - the ref as $ref and the new item normally
1463+
assert.Contains(t, string(data), "$ref: '#/components/parameters/RefId'")
1464+
assert.Contains(t, string(data), "pizza") // newItem renders as "pizza" via MarshalYAML
1465+
}
1466+
1467+
// testMixedItem can have either a reference low-level model or nil low-level model
1468+
type testMixedItem struct {
1469+
ref bool
1470+
refStr string
1471+
Value string
1472+
hasLowRef bool // if true, GoLowUntyped returns a reference; if false, returns nil
1473+
}
1474+
1475+
func (t *testMixedItem) MarshalYAML() (interface{}, error) {
1476+
return utils.CreateStringNode("mixed-" + t.Value), nil
1477+
}
1478+
1479+
func (t *testMixedItem) GoLowUntyped() any {
1480+
if t.hasLowRef {
1481+
return t
1482+
}
1483+
return nil
1484+
}
1485+
1486+
func (t *testMixedItem) IsReference() bool {
1487+
return t.ref
1488+
}
1489+
1490+
func (t *testMixedItem) GetReference() string {
1491+
return t.refStr
1492+
}
1493+
1494+
func (t *testMixedItem) SetReference(ref string, _ *yaml.Node) {
1495+
t.refStr = ref
1496+
}
1497+
1498+
func (t *testMixedItem) GetReferenceNode() *yaml.Node {
1499+
return nil
1500+
}
1501+
1502+
type testWithMixedItems struct {
1503+
Items []*testMixedItem `yaml:"items,omitempty"`
1504+
}
1505+
1506+
// TestNodeBuilder_SliceRefFollowedByNilLowItem tests the specific bug case:
1507+
// a $ref item followed by an item with nil GoLowUntyped() (newly created high-level object)
1508+
func TestNodeBuilder_SliceRefFollowedByNilLowItem(t *testing.T) {
1509+
// Create a slice where:
1510+
// 1. First item IS a reference with a low-level model
1511+
// 2. Second item has NO low-level model (GoLowUntyped returns nil)
1512+
refItem := &testMixedItem{
1513+
ref: true,
1514+
refStr: "#/components/parameters/RefId",
1515+
Value: "refValue",
1516+
hasLowRef: true, // Has a low-level reference
1517+
}
1518+
newItem := &testMixedItem{
1519+
ref: false,
1520+
Value: "newValue",
1521+
hasLowRef: false, // NO low-level model (simulates newly created item)
1522+
}
1523+
1524+
ty := []*testMixedItem{refItem, newItem}
1525+
t1 := testWithMixedItems{
1526+
Items: ty,
1527+
}
1528+
1529+
nb := NewNodeBuilder(&t1, nil)
1530+
node := nb.Render()
1531+
1532+
data, _ := yaml.Marshal(node)
1533+
result := strings.TrimSpace(string(data))
1534+
1535+
// The bug was that 'newItem' would be skipped because 'skip' wasn't reset
1536+
// after processing the $ref item, and newItem's GoLowUntyped() returns nil.
1537+
// After the fix, both items should be rendered.
1538+
assert.Contains(t, result, "$ref: '#/components/parameters/RefId'", "Reference item should be rendered")
1539+
assert.Contains(t, result, "mixed-newValue", "New item without low-level model should also be rendered")
1540+
}
1541+
1542+
// TestNodeBuilder_SliceNilLowFollowedByRef tests the reverse case:
1543+
// an item with nil GoLowUntyped() followed by a $ref (should work in both cases)
1544+
func TestNodeBuilder_SliceNilLowFollowedByRef(t *testing.T) {
1545+
// Create a slice where:
1546+
// 1. First item has NO low-level model
1547+
// 2. Second item IS a reference with a low-level model
1548+
newItem := &testMixedItem{
1549+
ref: false,
1550+
Value: "newValue",
1551+
hasLowRef: false, // NO low-level model
1552+
}
1553+
refItem := &testMixedItem{
1554+
ref: true,
1555+
refStr: "#/components/parameters/RefId",
1556+
Value: "refValue",
1557+
hasLowRef: true, // Has a low-level reference
1558+
}
1559+
1560+
ty := []*testMixedItem{newItem, refItem}
1561+
t1 := testWithMixedItems{
1562+
Items: ty,
1563+
}
1564+
1565+
nb := NewNodeBuilder(&t1, nil)
1566+
node := nb.Render()
1567+
1568+
data, _ := yaml.Marshal(node)
1569+
result := strings.TrimSpace(string(data))
1570+
1571+
// Both items should be rendered
1572+
assert.Contains(t, result, "mixed-newValue", "New item without low-level model should be rendered")
1573+
assert.Contains(t, result, "$ref: '#/components/parameters/RefId'", "Reference item should be rendered")
1574+
}
1575+
1576+
// TestNodeBuilder_SliceMultipleRefsAndNilLow tests multiple refs interspersed with nil-low items
1577+
func TestNodeBuilder_SliceMultipleRefsAndNilLow(t *testing.T) {
1578+
items := []*testMixedItem{
1579+
{ref: true, refStr: "#/ref1", Value: "ref1", hasLowRef: true},
1580+
{ref: false, Value: "new1", hasLowRef: false}, // nil low
1581+
{ref: true, refStr: "#/ref2", Value: "ref2", hasLowRef: true},
1582+
{ref: false, Value: "new2", hasLowRef: false}, // nil low
1583+
}
1584+
1585+
t1 := testWithMixedItems{
1586+
Items: items,
1587+
}
1588+
1589+
nb := NewNodeBuilder(&t1, nil)
1590+
node := nb.Render()
1591+
1592+
data, _ := yaml.Marshal(node)
1593+
result := strings.TrimSpace(string(data))
1594+
1595+
// All items should be rendered
1596+
assert.Contains(t, result, "$ref: '#/ref1'")
1597+
assert.Contains(t, result, "mixed-new1")
1598+
assert.Contains(t, result, "$ref: '#/ref2'")
1599+
assert.Contains(t, result, "mixed-new2")
1600+
}

document_test.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,6 +887,149 @@ func TestDocument_InputAsJSON_LargeIndent(t *testing.T) {
887887
assert.Equal(t, d, strings.TrimSpace(string(rend)))
888888
}
889889

890+
// TestDocument_AppendParameterAfterRef tests the bug fix for appending new parameters
891+
// to an operation that has $ref parameters. Before the fix, if a $ref was the last
892+
// parameter in the original spec, any appended parameters would be silently dropped
893+
// during rendering because the 'skip' flag wasn't reset between slice iterations.
894+
func TestDocument_AppendParameterAfterRef(t *testing.T) {
895+
spec := `openapi: "3.1.0"
896+
info:
897+
title: Test
898+
version: 1.0.0
899+
paths:
900+
/test/{id}:
901+
get:
902+
parameters:
903+
- $ref: '#/components/parameters/IdParam'
904+
responses:
905+
"200":
906+
description: ok
907+
components:
908+
parameters:
909+
IdParam:
910+
name: id
911+
in: path
912+
required: true
913+
schema:
914+
type: string`
915+
916+
doc, err := NewDocument([]byte(spec))
917+
if err != nil {
918+
t.Fatalf("failed to create document: %v", err)
919+
}
920+
921+
result, errs := doc.BuildV3Model()
922+
assert.NoError(t, errs)
923+
924+
// Get the operation and append a new parameter
925+
pathItem := result.Model.Paths.PathItems.GetOrZero("/test/{id}")
926+
assert.NotNil(t, pathItem)
927+
assert.NotNil(t, pathItem.Get)
928+
929+
// Verify we have the original $ref parameter
930+
assert.Len(t, pathItem.Get.Parameters, 1)
931+
932+
// Append a new parameter (simulating what the user does)
933+
newParam := &v3high.Parameter{
934+
Name: "Host",
935+
In: "header",
936+
}
937+
pathItem.Get.Parameters = append(pathItem.Get.Parameters, newParam)
938+
939+
// Render and reload
940+
_, newDoc, _, err := doc.RenderAndReload()
941+
assert.NoError(t, err)
942+
943+
// Build the new model
944+
newResult, errs := newDoc.BuildV3Model()
945+
assert.NoError(t, errs)
946+
947+
// Check that the new parameter is present
948+
newPathItem := newResult.Model.Paths.PathItems.GetOrZero("/test/{id}")
949+
assert.NotNil(t, newPathItem)
950+
assert.NotNil(t, newPathItem.Get)
951+
952+
// This was the bug: the new "Host" parameter was being dropped
953+
assert.Len(t, newPathItem.Get.Parameters, 2, "Expected 2 parameters (original $ref + appended Host)")
954+
955+
// Verify the Host parameter is present
956+
foundHost := false
957+
for _, p := range newPathItem.Get.Parameters {
958+
if p.Name == "Host" && p.In == "header" {
959+
foundHost = true
960+
break
961+
}
962+
}
963+
assert.True(t, foundHost, "Expected to find the appended 'Host' header parameter")
964+
}
965+
966+
// TestDocument_AppendParameterBeforeRef tests that appending works when $ref is not last
967+
func TestDocument_AppendParameterBeforeRef(t *testing.T) {
968+
spec := `openapi: "3.1.0"
969+
info:
970+
title: Test
971+
version: 1.0.0
972+
paths:
973+
/test/{id}:
974+
get:
975+
parameters:
976+
- name: existing
977+
in: query
978+
schema:
979+
type: string
980+
- $ref: '#/components/parameters/IdParam'
981+
responses:
982+
"200":
983+
description: ok
984+
components:
985+
parameters:
986+
IdParam:
987+
name: id
988+
in: path
989+
required: true
990+
schema:
991+
type: string`
992+
993+
doc, err := NewDocument([]byte(spec))
994+
if err != nil {
995+
t.Fatalf("failed to create document: %v", err)
996+
}
997+
998+
result, errs := doc.BuildV3Model()
999+
assert.NoError(t, errs)
1000+
1001+
// Get the operation and append a new parameter
1002+
pathItem := result.Model.Paths.PathItems.GetOrZero("/test/{id}")
1003+
1004+
// Append a new parameter
1005+
newParam := &v3high.Parameter{
1006+
Name: "Host",
1007+
In: "header",
1008+
}
1009+
pathItem.Get.Parameters = append(pathItem.Get.Parameters, newParam)
1010+
1011+
// Render and reload
1012+
_, newDoc, _, err := doc.RenderAndReload()
1013+
assert.NoError(t, err)
1014+
1015+
newResult, errs := newDoc.BuildV3Model()
1016+
assert.NoError(t, errs)
1017+
1018+
newPathItem := newResult.Model.Paths.PathItems.GetOrZero("/test/{id}")
1019+
1020+
// Should have 3 parameters: existing + $ref + Host
1021+
assert.Len(t, newPathItem.Get.Parameters, 3, "Expected 3 parameters")
1022+
1023+
foundHost := false
1024+
for _, p := range newPathItem.Get.Parameters {
1025+
if p.Name == "Host" && p.In == "header" {
1026+
foundHost = true
1027+
break
1028+
}
1029+
}
1030+
assert.True(t, foundHost, "Expected to find the appended 'Host' header parameter")
1031+
}
1032+
8901033
func TestDocument_RenderWithIndention(t *testing.T) {
8911034
spec := `openapi: "3.1.0"
8921035
info:

0 commit comments

Comments
 (0)