Skip to content

Commit a1a2ace

Browse files
AshGodfreyclaude
andauthored
fix(yml): keep folded block scalars stable across encode round trips (#244)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent a9b7ba1 commit a1a2ace

15 files changed

Lines changed: 697 additions & 0 deletions

File tree

marshaller/coremodel.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ func (c *CoreModel) Marshal(ctx context.Context, w io.Writer) error {
177177
resetNodeStylesForYAML(nodeToMarshal, cfg)
178178
}
179179

180+
yml.StabilizeFoldedScalars(nodeToMarshal)
181+
180182
enc := yaml.NewEncoder(w)
181183
enc.SetIndent(cfg.Indentation)
182184
if err := enc.Encode(nodeToMarshal); err != nil {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package openapi_test
2+
3+
import (
4+
"bytes"
5+
"strings"
6+
"testing"
7+
8+
"github.com/speakeasy-api/openapi/openapi"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// The trailing table row is indented one space further than the rows above it, which
14+
// makes yaml.v3 emit an extra line break before it on every encode. Marshalling has to
15+
// stay a fixed point regardless of whether an overlay was involved.
16+
const foldedScalarDocument = `openapi: 3.1.0
17+
info:
18+
title: Test
19+
version: 1.0.0
20+
description: >-
21+
### Widgets
22+
23+
| Name | Kind |
24+
| ---- | ---- |
25+
| acme | ` + "`petstore`" + ` |
26+
paths: {}
27+
`
28+
29+
func TestMarshal_FoldedScalar_SurvivesRepeatedRoundTrips(t *testing.T) {
30+
t.Parallel()
31+
32+
ctx := t.Context()
33+
34+
doc, validationErrs, err := openapi.Unmarshal(ctx, strings.NewReader(foldedScalarDocument))
35+
require.NoError(t, err)
36+
require.Empty(t, validationErrs)
37+
38+
want := doc.Info.GetDescription()
39+
require.Contains(t, want, "| acme |")
40+
41+
current := foldedScalarDocument
42+
for i := range 3 {
43+
doc, _, err := openapi.Unmarshal(ctx, strings.NewReader(current))
44+
require.NoError(t, err)
45+
46+
var buf bytes.Buffer
47+
require.NoError(t, openapi.Marshal(ctx, doc, &buf))
48+
49+
current = buf.String()
50+
assert.Equal(t, want, doc.Info.GetDescription(), "value changed after %d round trips", i+1)
51+
}
52+
}

openapi/localize.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/speakeasy-api/openapi/references"
1515
"github.com/speakeasy-api/openapi/sequencedmap"
1616
"github.com/speakeasy-api/openapi/system"
17+
"github.com/speakeasy-api/openapi/yml"
1718
"gopkg.in/yaml.v3"
1819
)
1920

@@ -581,6 +582,8 @@ func rewriteInternalReferences(content []byte, originalRef string, storage *loca
581582
}
582583

583584
// Marshal back to YAML
585+
yml.StabilizeFoldedScalars(&node)
586+
584587
updatedContent, err := yaml.Marshal(&node)
585588
if err != nil {
586589
return nil, fmt.Errorf("failed to marshal updated YAML: %w", err)

openapi/testdata/localize/input/components.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@ components:
22
schemas:
33
User:
44
type: object
5+
# The last row is deliberately indented further: that is what makes yaml.v3
6+
# inject a line break into a folded scalar on encode.
7+
description: >-
8+
### User
9+
10+
| Field | Kind |
11+
| ----- | ---- |
12+
| id | string |
513
required:
614
- id
715
- name

openapi/testdata/localize/output_counter/components.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ components:
22
schemas:
33
User:
44
type: object
5+
# The last row is deliberately indented further: that is what makes yaml.v3
6+
# inject a line break into a folded scalar on encode.
7+
description: |-
8+
### User
9+
| Field | Kind | | ----- | ---- |
10+
| id | string |
511
required:
612
- id
713
- name

openapi/testdata/localize/output_pathbased/components.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ components:
22
schemas:
33
User:
44
type: object
5+
# The last row is deliberately indented further: that is what makes yaml.v3
6+
# inject a line break into a folded scalar on encode.
7+
description: |-
8+
### User
9+
| Field | Kind | | ----- | ---- |
10+
| id | string |
511
required:
612
- id
713
- name

oq/foldedscalar_format_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package oq_test
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/speakeasy-api/openapi/graph"
8+
"github.com/speakeasy-api/openapi/openapi"
9+
"github.com/speakeasy-api/openapi/oq"
10+
"github.com/speakeasy-api/openapi/references"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
"gopkg.in/yaml.v3"
14+
)
15+
16+
// The trailing table row is indented one space further than the rows above it, which
17+
// makes yaml.v3 emit an extra line break before it on every encode. The break lands
18+
// inside the scalar, so it changes the decoded value rather than just the layout.
19+
const foldedScalarSpec = `openapi: 3.1.0
20+
info:
21+
title: Test
22+
version: 1.0.0
23+
paths: {}
24+
components:
25+
schemas:
26+
Widget:
27+
type: object
28+
description: >-
29+
### Widgets
30+
31+
| Name | Kind |
32+
| ---- | ---- |
33+
| acme | ` + "`petstore`" + ` |
34+
`
35+
36+
func loadFoldedScalarGraph(t *testing.T) *graph.SchemaGraph {
37+
t.Helper()
38+
39+
ctx := t.Context()
40+
41+
doc, _, err := openapi.Unmarshal(ctx, strings.NewReader(foldedScalarSpec), openapi.WithSkipValidation())
42+
require.NoError(t, err)
43+
require.NotNil(t, doc)
44+
45+
idx := openapi.BuildIndex(ctx, doc, references.ResolveOptions{
46+
RootDocument: doc,
47+
TargetDocument: doc,
48+
TargetLocation: "spec.yaml",
49+
})
50+
51+
return graph.Build(ctx, idx)
52+
}
53+
54+
// formattedDescription pulls the description back out of a `key:\n <schema>` wrapper.
55+
func formattedDescription(t *testing.T, formatted string) string {
56+
t.Helper()
57+
58+
var decoded map[string]struct {
59+
Description string `yaml:"description"`
60+
}
61+
require.NoError(t, yaml.Unmarshal([]byte(formatted), &decoded))
62+
require.Len(t, decoded, 1)
63+
64+
for _, schema := range decoded {
65+
return schema.Description
66+
}
67+
68+
return ""
69+
}
70+
71+
// `openapi spec query --format yaml` marshals graph nodes with its own encoder, so it
72+
// needs the same stabilization as every other encode boundary.
73+
func TestFormatYAML_FoldedScalarKeepsItsValue(t *testing.T) {
74+
t.Parallel()
75+
76+
var want struct {
77+
Components struct {
78+
Schemas map[string]struct {
79+
Description string `yaml:"description"`
80+
} `yaml:"schemas"`
81+
} `yaml:"components"`
82+
}
83+
require.NoError(t, yaml.Unmarshal([]byte(foldedScalarSpec), &want))
84+
wantDescription := want.Components.Schemas["Widget"].Description
85+
require.Contains(t, wantDescription, "| acme |")
86+
87+
g := loadFoldedScalarGraph(t)
88+
89+
result, err := oq.Execute(`schemas | where(name == "Widget")`, g)
90+
require.NoError(t, err)
91+
require.Len(t, result.Rows, 1)
92+
93+
formatted := oq.FormatYAML(result, g)
94+
require.NotEmpty(t, formatted)
95+
96+
assert.Equal(t, wantDescription, formattedDescription(t, formatted))
97+
assert.NotContains(t, formatted, ">-", "affected folded scalars should be emitted as literal blocks")
98+
}
99+
100+
// Formatting the same result twice must not drift, since FormatYAML restyles graph
101+
// nodes in place.
102+
func TestFormatYAML_FoldedScalarIsAFixedPoint(t *testing.T) {
103+
t.Parallel()
104+
105+
g := loadFoldedScalarGraph(t)
106+
107+
result, err := oq.Execute(`schemas | where(name == "Widget")`, g)
108+
require.NoError(t, err)
109+
110+
first := oq.FormatYAML(result, g)
111+
for i := range 3 {
112+
assert.Equal(t, first, oq.FormatYAML(result, g), "output changed on format %d", i+2)
113+
}
114+
}

oq/format.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
gcf "github.com/blackwell-systems/gcf-go"
99
"github.com/speakeasy-api/openapi/graph"
1010
"github.com/speakeasy-api/openapi/oq/expr"
11+
"github.com/speakeasy-api/openapi/yml"
1112
"gopkg.in/yaml.v3"
1213
)
1314

@@ -310,6 +311,8 @@ func FormatYAML(result *Result, g *graph.SchemaGraph) string {
310311
node,
311312
},
312313
}
314+
yml.StabilizeFoldedScalars(wrapper)
315+
313316
data, err := yaml.Marshal(wrapper)
314317
if err != nil {
315318
sb.WriteString("# error marshalling: " + err.Error() + "\n")

overlay/apply.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"github.com/speakeasy-api/jsonpath/pkg/jsonpath/config"
88
"github.com/speakeasy-api/jsonpath/pkg/jsonpath/token"
9+
"github.com/speakeasy-api/openapi/yml"
910
"gopkg.in/yaml.v3"
1011
)
1112

@@ -30,6 +31,8 @@ func (o *Overlay) ApplyTo(root *yaml.Node) error {
3031
}
3132
}
3233

34+
yml.StabilizeFoldedScalars(root)
35+
3336
return nil
3437
}
3538

@@ -84,6 +87,9 @@ func (o *Overlay) ApplyToStrict(root *yaml.Node) ([]string, error) {
8487
if len(multiError) > 0 {
8588
return warnings, fmt.Errorf("error applying overlay (strict): %v", strings.Join(multiError, ","))
8689
}
90+
91+
yml.StabilizeFoldedScalars(root)
92+
8793
return warnings, nil
8894
}
8995

overlay/foldedscalar.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package overlay
2+
3+
import (
4+
"github.com/speakeasy-api/openapi/yml"
5+
)
6+
7+
// stabilizeFoldedScalars restyles folded block scalars in the overlay's own update
8+
// payloads so that serializing the overlay round trips unchanged.
9+
func (o *Overlay) stabilizeFoldedScalars() {
10+
if o == nil {
11+
return
12+
}
13+
14+
for i := range o.Actions {
15+
yml.StabilizeFoldedScalars(&o.Actions[i].Update)
16+
}
17+
}

0 commit comments

Comments
 (0)