From fdbe98fb92398bb200fafc3efc228affcc547abf Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Mon, 7 Jul 2025 10:41:16 +0100 Subject: [PATCH 1/3] chore: add a benchmark --- pkg/overlay/apply_test.go | 191 ++++++++++++++++++ pkg/overlay/testdata/overlay-zero-change.yaml | 10 + 2 files changed, 201 insertions(+) create mode 100644 pkg/overlay/testdata/overlay-zero-change.yaml diff --git a/pkg/overlay/apply_test.go b/pkg/overlay/apply_test.go index 6c95c6e..607b3fd 100644 --- a/pkg/overlay/apply_test.go +++ b/pkg/overlay/apply_test.go @@ -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" ) @@ -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) { diff --git a/pkg/overlay/testdata/overlay-zero-change.yaml b/pkg/overlay/testdata/overlay-zero-change.yaml new file mode 100644 index 0000000..73b678d --- /dev/null +++ b/pkg/overlay/testdata/overlay-zero-change.yaml @@ -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." From 3c284e220b6cca8295e539fa0efdbe17195f961f Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Mon, 7 Jul 2025 11:27:34 +0100 Subject: [PATCH 2/3] chore: the speedup --- pkg/overlay/apply.go | 40 ++++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/pkg/overlay/apply.go b/pkg/overlay/apply.go index faaaf92..703abc9 100644 --- a/pkg/overlay/apply.go +++ b/pkg/overlay/apply.go @@ -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 @@ -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 { From 63ac80d44e25e5bf4fb0350e303abf78ff614f66 Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Mon, 7 Jul 2025 11:29:58 +0100 Subject: [PATCH 3/3] chore: bump go version --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 2950004..b4538fb 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/speakeasy-api/openapi-overlay -go 1.22 +go 1.24 require ( github.com/speakeasy-api/jsonpath v0.6.0