Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions marshaller/coremodel.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ func (c *CoreModel) Marshal(ctx context.Context, w io.Writer) error {
resetNodeStylesForYAML(nodeToMarshal, cfg)
}

yml.StabilizeFoldedScalars(nodeToMarshal)

enc := yaml.NewEncoder(w)
enc.SetIndent(cfg.Indentation)
if err := enc.Encode(nodeToMarshal); err != nil {
Expand Down
52 changes: 52 additions & 0 deletions openapi/foldedscalar_marshalling_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package openapi_test

import (
"bytes"
"strings"
"testing"

"github.com/speakeasy-api/openapi/openapi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// The trailing table row is indented one space further than the rows above it, which
// makes yaml.v3 emit an extra line break before it on every encode. Marshalling has to
// stay a fixed point regardless of whether an overlay was involved.
const foldedScalarDocument = `openapi: 3.1.0
info:
title: Test
version: 1.0.0
description: >-
### Widgets

| Name | Kind |
| ---- | ---- |
| acme | ` + "`petstore`" + ` |
paths: {}
`

func TestMarshal_FoldedScalar_SurvivesRepeatedRoundTrips(t *testing.T) {
t.Parallel()

ctx := t.Context()

doc, validationErrs, err := openapi.Unmarshal(ctx, strings.NewReader(foldedScalarDocument))
require.NoError(t, err)
require.Empty(t, validationErrs)

want := doc.Info.GetDescription()
require.Contains(t, want, "| acme |")

current := foldedScalarDocument
for i := range 3 {
doc, _, err := openapi.Unmarshal(ctx, strings.NewReader(current))
require.NoError(t, err)

var buf bytes.Buffer
require.NoError(t, openapi.Marshal(ctx, doc, &buf))

current = buf.String()
assert.Equal(t, want, doc.Info.GetDescription(), "value changed after %d round trips", i+1)
}
}
3 changes: 3 additions & 0 deletions openapi/localize.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/speakeasy-api/openapi/references"
"github.com/speakeasy-api/openapi/sequencedmap"
"github.com/speakeasy-api/openapi/system"
"github.com/speakeasy-api/openapi/yml"
"gopkg.in/yaml.v3"
)

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

// Marshal back to YAML
yml.StabilizeFoldedScalars(&node)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be worth adding a test fixture with a more-indented string in https://github.com/speakeasy-api/openapi/tree/main/openapi/testdata/localize

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 77720c4. input/components.yaml now gives the User schema a folded description whose last table row is indented one space further, and both output_counter/components.yaml and output_pathbased/components.yaml pin the localized result as a literal block.

I checked it is a real guard and not just a snapshot: neutralizing the StabilizeFoldedScalars call in localize.go flips the goldens back to >- with the injected breaks, and both localize tests fail.


updatedContent, err := yaml.Marshal(&node)
if err != nil {
return nil, fmt.Errorf("failed to marshal updated YAML: %w", err)
Expand Down
8 changes: 8 additions & 0 deletions openapi/testdata/localize/input/components.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ components:
schemas:
User:
type: object
# The last row is deliberately indented further: that is what makes yaml.v3
# inject a line break into a folded scalar on encode.
description: >-
### User

| Field | Kind |
| ----- | ---- |
| id | string |
required:
- id
- name
Expand Down
6 changes: 6 additions & 0 deletions openapi/testdata/localize/output_counter/components.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ components:
schemas:
User:
type: object
# The last row is deliberately indented further: that is what makes yaml.v3
# inject a line break into a folded scalar on encode.
description: |-
### User
| Field | Kind | | ----- | ---- |
| id | string |
required:
- id
- name
Expand Down
6 changes: 6 additions & 0 deletions openapi/testdata/localize/output_pathbased/components.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ components:
schemas:
User:
type: object
# The last row is deliberately indented further: that is what makes yaml.v3
# inject a line break into a folded scalar on encode.
description: |-
### User
| Field | Kind | | ----- | ---- |
| id | string |
required:
- id
- name
Expand Down
114 changes: 114 additions & 0 deletions oq/foldedscalar_format_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package oq_test

import (
"strings"
"testing"

"github.com/speakeasy-api/openapi/graph"
"github.com/speakeasy-api/openapi/openapi"
"github.com/speakeasy-api/openapi/oq"
"github.com/speakeasy-api/openapi/references"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)

// The trailing table row is indented one space further than the rows above it, which
// makes yaml.v3 emit an extra line break before it on every encode. The break lands
// inside the scalar, so it changes the decoded value rather than just the layout.
const foldedScalarSpec = `openapi: 3.1.0
info:
title: Test
version: 1.0.0
paths: {}
components:
schemas:
Widget:
type: object
description: >-
### Widgets

| Name | Kind |
| ---- | ---- |
| acme | ` + "`petstore`" + ` |
`

func loadFoldedScalarGraph(t *testing.T) *graph.SchemaGraph {
t.Helper()

ctx := t.Context()

doc, _, err := openapi.Unmarshal(ctx, strings.NewReader(foldedScalarSpec), openapi.WithSkipValidation())
require.NoError(t, err)
require.NotNil(t, doc)

idx := openapi.BuildIndex(ctx, doc, references.ResolveOptions{
RootDocument: doc,
TargetDocument: doc,
TargetLocation: "spec.yaml",
})

return graph.Build(ctx, idx)
}

// formattedDescription pulls the description back out of a `key:\n <schema>` wrapper.
func formattedDescription(t *testing.T, formatted string) string {
t.Helper()

var decoded map[string]struct {
Description string `yaml:"description"`
}
require.NoError(t, yaml.Unmarshal([]byte(formatted), &decoded))
require.Len(t, decoded, 1)

for _, schema := range decoded {
return schema.Description
}

return ""
}

// `openapi spec query --format yaml` marshals graph nodes with its own encoder, so it
// needs the same stabilization as every other encode boundary.
func TestFormatYAML_FoldedScalarKeepsItsValue(t *testing.T) {
t.Parallel()

var want struct {
Components struct {
Schemas map[string]struct {
Description string `yaml:"description"`
} `yaml:"schemas"`
} `yaml:"components"`
}
require.NoError(t, yaml.Unmarshal([]byte(foldedScalarSpec), &want))
wantDescription := want.Components.Schemas["Widget"].Description
require.Contains(t, wantDescription, "| acme |")

g := loadFoldedScalarGraph(t)

result, err := oq.Execute(`schemas | where(name == "Widget")`, g)
require.NoError(t, err)
require.Len(t, result.Rows, 1)

formatted := oq.FormatYAML(result, g)
require.NotEmpty(t, formatted)

assert.Equal(t, wantDescription, formattedDescription(t, formatted))
assert.NotContains(t, formatted, ">-", "affected folded scalars should be emitted as literal blocks")
}

// Formatting the same result twice must not drift, since FormatYAML restyles graph
// nodes in place.
func TestFormatYAML_FoldedScalarIsAFixedPoint(t *testing.T) {
t.Parallel()

g := loadFoldedScalarGraph(t)

result, err := oq.Execute(`schemas | where(name == "Widget")`, g)
require.NoError(t, err)

first := oq.FormatYAML(result, g)
for i := range 3 {
assert.Equal(t, first, oq.FormatYAML(result, g), "output changed on format %d", i+2)
}
}
3 changes: 3 additions & 0 deletions oq/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
gcf "github.com/blackwell-systems/gcf-go"
"github.com/speakeasy-api/openapi/graph"
"github.com/speakeasy-api/openapi/oq/expr"
"github.com/speakeasy-api/openapi/yml"
"gopkg.in/yaml.v3"
)

Expand Down Expand Up @@ -310,6 +311,8 @@ func FormatYAML(result *Result, g *graph.SchemaGraph) string {
node,
},
}
yml.StabilizeFoldedScalars(wrapper)

data, err := yaml.Marshal(wrapper)
if err != nil {
sb.WriteString("# error marshalling: " + err.Error() + "\n")
Expand Down
6 changes: 6 additions & 0 deletions overlay/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/speakeasy-api/jsonpath/pkg/jsonpath/config"
"github.com/speakeasy-api/jsonpath/pkg/jsonpath/token"
"github.com/speakeasy-api/openapi/yml"
"gopkg.in/yaml.v3"
)

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

yml.StabilizeFoldedScalars(root)

return nil
}

Expand Down Expand Up @@ -84,6 +87,9 @@ func (o *Overlay) ApplyToStrict(root *yaml.Node) ([]string, error) {
if len(multiError) > 0 {
return warnings, fmt.Errorf("error applying overlay (strict): %v", strings.Join(multiError, ","))
}

yml.StabilizeFoldedScalars(root)

return warnings, nil
}

Expand Down
17 changes: 17 additions & 0 deletions overlay/foldedscalar.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package overlay

import (
"github.com/speakeasy-api/openapi/yml"
)

// stabilizeFoldedScalars restyles folded block scalars in the overlay's own update
// payloads so that serializing the overlay round trips unchanged.
func (o *Overlay) stabilizeFoldedScalars() {
if o == nil {
return
}

for i := range o.Actions {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
yml.StabilizeFoldedScalars(&o.Actions[i].Update)
}
}
Loading