Skip to content
This repository was archived by the owner on Dec 8, 2025. It is now read-only.
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: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/speakeasy-api/openapi-overlay

go 1.22
go 1.24
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Did we need to bump this (and in a patch version)?

Coming from oapi-codegen/oapi-codegen#2017 and we try and keep a go directive compatability where possible, so this was a surprise to see in a patch release

(it's a great patch to improve performance 👏🏽)


require (
github.com/speakeasy-api/jsonpath v0.6.0
Expand Down
40 changes: 18 additions & 22 deletions pkg/overlay/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,50 +148,43 @@ func (o *Overlay) applyUpdateAction(root *yaml.Node, action Action, warnings *[]
}

nodes := p.Query(root)
prior, err := yaml.Marshal(root)
if err != nil {
return err
}

didMakeChange := false
for _, node := range nodes {
if err := updateNode(node, &action.Update); err != nil {
return err
}
didMakeChange = updateNode(node, &action.Update) || didMakeChange
}
post, err := yaml.Marshal(root)
if err != nil {
return err
}
if warnings != nil && string(prior) == string(post) {
if !didMakeChange {
*warnings = append(*warnings, "does nothing")
}

return nil
}

func updateNode(node *yaml.Node, updateNode *yaml.Node) error {
mergeNode(node, updateNode)
return nil
func updateNode(node *yaml.Node, updateNode *yaml.Node) bool {
return mergeNode(node, updateNode)
}

func mergeNode(node *yaml.Node, merge *yaml.Node) {
func mergeNode(node *yaml.Node, merge *yaml.Node) bool {
if node.Kind != merge.Kind {
*node = *clone(merge)
return
return true
}
switch node.Kind {
default:
isChanged := node.Value != merge.Value
node.Value = merge.Value
return isChanged
case yaml.MappingNode:
mergeMappingNode(node, merge)
return mergeMappingNode(node, merge)
case yaml.SequenceNode:
mergeSequenceNode(node, merge)
return mergeSequenceNode(node, merge)
}
}

// mergeMappingNode will perform a shallow merge of the merge node into the main
// node.
func mergeMappingNode(node *yaml.Node, merge *yaml.Node) {
func mergeMappingNode(node *yaml.Node, merge *yaml.Node) bool {
anyChange := false
NextKey:
for i := 0; i < len(merge.Content); i += 2 {
mergeKey := merge.Content[i].Value
Expand All @@ -200,18 +193,21 @@ NextKey:
for j := 0; j < len(node.Content); j += 2 {
nodeKey := node.Content[j].Value
if nodeKey == mergeKey {
mergeNode(node.Content[j+1], mergeValue)
anyChange = mergeNode(node.Content[j+1], mergeValue) || anyChange
continue NextKey
}
}

node.Content = append(node.Content, merge.Content[i], clone(mergeValue))
anyChange = true
}
return anyChange
}

// mergeSequenceNode will append the merge node's content to the original node.
func mergeSequenceNode(node *yaml.Node, merge *yaml.Node) {
func mergeSequenceNode(node *yaml.Node, merge *yaml.Node) bool {
node.Content = append(node.Content, clone(merge).Content...)
return true
}

func clone(node *yaml.Node) *yaml.Node {
Expand Down
191 changes: 191 additions & 0 deletions pkg/overlay/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import (
"bytes"
"github.com/speakeasy-api/jsonpath/pkg/jsonpath"
"github.com/speakeasy-api/openapi-overlay/pkg/loader"
"github.com/speakeasy-api/openapi-overlay/pkg/overlay"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
"os"
"strconv"
"testing"
)

Expand Down Expand Up @@ -75,6 +77,195 @@ func TestApplyToStrict(t *testing.T) {
assert.Len(t, warnings, 1)
assert.Equal(t, "update action (2 / 2) target=$.info.title: does nothing", warnings[0])
NodeMatchesFile(t, node, "testdata/openapi-strict-onechange.yaml")

node, err = loader.LoadSpecification("testdata/openapi.yaml")
require.NoError(t, err)

o, err = loader.LoadOverlay("testdata/overlay.yaml")
require.NoError(t, err)

err = o.ApplyTo(node)
assert.NoError(t, err)

NodeMatchesFile(t, node, "testdata/openapi-overlayed.yaml")

}

func BenchmarkApplyToStrict(b *testing.B) {
openAPIBytes, err := os.ReadFile("testdata/openapi.yaml")
require.NoError(b, err)
overlayBytes, err := os.ReadFile("testdata/overlay-zero-change.yaml")
require.NoError(b, err)

var specNode yaml.Node
err = yaml.NewDecoder(bytes.NewReader(openAPIBytes)).Decode(&specNode)
require.NoError(b, err)

// Load overlay from bytes
var o overlay.Overlay
err = yaml.NewDecoder(bytes.NewReader(overlayBytes)).Decode(&o)
require.NoError(b, err)

// Apply overlay to spec
for b.Loop() {
_, _ = o.ApplyToStrict(&specNode)
}
}

func BenchmarkApplyToStrictBySize(b *testing.B) {
// Read the base OpenAPI spec
openAPIBytes, err := os.ReadFile("testdata/openapi.yaml")
require.NoError(b, err)

// Read the overlay spec
overlayBytes, err := os.ReadFile("testdata/overlay-zero-change.yaml")
require.NoError(b, err)

// Decode the base spec
var baseSpec yaml.Node
err = yaml.NewDecoder(bytes.NewReader(openAPIBytes)).Decode(&baseSpec)
require.NoError(b, err)

// Find the paths node and a path to duplicate
pathsNode := findPathsNode(&baseSpec)
require.NotNil(b, pathsNode)

// Get the first path item to use as template
var templatePath *yaml.Node
var templateKey string
for i := 0; i < len(pathsNode.Content); i += 2 {
if pathsNode.Content[i].Kind == yaml.ScalarNode && pathsNode.Content[i].Value[0] == '/' {
templateKey = pathsNode.Content[i].Value
templatePath = pathsNode.Content[i+1]
break
}
}
require.NotNil(b, templatePath)

// Target sizes: 2KB, 20KB, 200KB, 2MB, 20MB
targetSizes := []struct {
size int
name string
}{
{2 * 1024, "2KB"},
{20 * 1024, "20KB"},
{200 * 1024, "200KB"},
{2000 * 1024, "2M"},
}

// Calculate the base document size
var baseBuf bytes.Buffer
enc := yaml.NewEncoder(&baseBuf)
err = enc.Encode(&baseSpec)
require.NoError(b, err)
baseSize := baseBuf.Len()

// Calculate the size of a single path item by encoding it
var pathBuf bytes.Buffer
pathEnc := yaml.NewEncoder(&pathBuf)
tempNode := &yaml.Node{
Kind: yaml.MappingNode,
Content: []*yaml.Node{
{Kind: yaml.ScalarNode, Value: templateKey + "-test"},
cloneNode(templatePath),
},
}
err = pathEnc.Encode(tempNode)
require.NoError(b, err)
// Approximate size contribution of one path (accounting for YAML structure)
pathItemSize := pathBuf.Len() - 10 // Subtract some overhead

for _, target := range targetSizes {
b.Run(target.name, func(b *testing.B) {
// Create a copy of the base spec
specCopy := cloneNode(&baseSpec)
pathsNodeCopy := findPathsNode(specCopy)

// Calculate how many paths we need to add
bytesNeeded := target.size - baseSize
pathsToAdd := 0
if bytesNeeded > 0 {
pathsToAdd = bytesNeeded / pathItemSize
// Add a few extra to ensure we exceed the target
pathsToAdd += 5
}

// Add the calculated number of path duplicates
for i := 0; i < pathsToAdd; i++ {
newPathKey := yaml.Node{Kind: yaml.ScalarNode, Value: templateKey + "-duplicate-" + strconv.Itoa(i)}
newPathValue := cloneNode(templatePath)
pathsNodeCopy.Content = append(pathsNodeCopy.Content, &newPathKey, newPathValue)
}

// Verify final size
var finalBuf bytes.Buffer
finalEnc := yaml.NewEncoder(&finalBuf)
err = finalEnc.Encode(specCopy)
require.NoError(b, err)
actualSize := finalBuf.Len()
b.Logf("OpenAPI size: %d bytes (target: %d, paths added: %d)", actualSize, target.size, pathsToAdd)

// Load overlay
var o overlay.Overlay
err = yaml.NewDecoder(bytes.NewReader(overlayBytes)).Decode(&o)
require.NoError(b, err)

specForTest := cloneNode(specCopy)
// Run the benchmark
b.ResetTimer()
for b.Loop() {
_, _ = o.ApplyToStrict(specForTest)
}
})
}
}

// Helper function to find the paths node in the OpenAPI spec
func findPathsNode(node *yaml.Node) *yaml.Node {
if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
node = node.Content[0]
}

if node.Kind != yaml.MappingNode {
return nil
}

for i := 0; i < len(node.Content); i += 2 {
if node.Content[i].Value == "paths" {
return node.Content[i+1]
}
}
return nil
}

// Helper function to deep clone a YAML node
func cloneNode(node *yaml.Node) *yaml.Node {
if node == nil {
return nil
}

clone := &yaml.Node{
Kind: node.Kind,
Style: node.Style,
Tag: node.Tag,
Value: node.Value,
Anchor: node.Anchor,
Alias: node.Alias,
HeadComment: node.HeadComment,
LineComment: node.LineComment,
FootComment: node.FootComment,
Line: node.Line,
Column: node.Column,
}

if node.Content != nil {
clone.Content = make([]*yaml.Node, len(node.Content))
for i, child := range node.Content {
clone.Content[i] = cloneNode(child)
}
}

return clone
}

func TestApplyToOld(t *testing.T) {
Expand Down
10 changes: 10 additions & 0 deletions pkg/overlay/testdata/overlay-zero-change.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
overlay: 1.0.0
x-speakeasy-jsonpath: rfc9535
info:
title: Drinks Overlay
version: 0.0.0
actions:
- target: $.paths["/drink/{name}"].get.summary
update: "Read a drink."
- target: $.paths["/drink/{name}"].get.summary
update: "Get a drink."
Loading