Skip to content
This repository was archived by the owner on Dec 8, 2025. It is now read-only.
Open
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
[![Go Report Card](https://goreportcard.com/badge/github.com/speakeasy-api/openapi-overlay)](https://goreportcard.com/report/github.com/speakeasy-api/openapi-overlay)
[![GoDoc](https://godoc.org/github.com/speakeasy-api/openapi-overlay?status.svg)](https://godoc.org/github.com/speakeasy-api/openapi-overlay)


# OpenAPI Overlay

<a href="https://speakeasy.com/openapi/overlays"><img src="https://custom-icon-badges.demolab.com/badge/-Overlay%20Reference-212015?style=for-the-badge&logoColor=FBE331&logo=speakeasy&labelColor=545454" /></a>
Expand All @@ -15,7 +14,9 @@ Specification](https://github.com/OAI/Overlay-Specification/blob/3f398c6/version
(2023-10-12). This specification defines a means of editing a OpenAPI
Specification file by applying a list of actions. Each action is either a remove
action that prunes nodes or an update that merges a value into nodes. The nodes
impacted are selected by a target expression which uses JSONPath.
impacted are selected by a target expression which uses JSONPath. This
implementation also supports [version 1.1.0](https://github.com/OAI/Overlay-Specification/blob/e2c3cec/versions/1.1.0-dev.md)
which adds a `copy` action for duplicating or moving nodes within the document.

The specification itself says very little about the input file to be modified or
the output file. The presumed intention is that the input and output be an
Expand Down
129 changes: 117 additions & 12 deletions pkg/overlay/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,27 @@ package overlay

import (
"fmt"
"strings"

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

// ApplyTo will take an overlay and apply its changes to the given YAML
// document.
func (o *Overlay) ApplyTo(root *yaml.Node) error {
// Priority is: remove > update > copy
// Copy has no impact if remove is true or update contains a value
for _, action := range o.Actions {
var err error
if action.Remove {
switch {
case action.Remove:
err = o.applyRemoveAction(root, action, nil)
} else {
case !action.Update.IsZero():
err = o.applyUpdateAction(root, action, &[]string{})
case action.Copy != "":
err = o.applyCopyAction(root, action, &[]string{})
}

if err != nil {
Expand All @@ -27,10 +33,13 @@ func (o *Overlay) ApplyTo(root *yaml.Node) error {
return nil
}

func (o *Overlay) ApplyToStrict(root *yaml.Node) (error, []string) {
func (o *Overlay) ApplyToStrict(root *yaml.Node) ([]string, error) {
multiError := []string{}
warnings := []string{}
hasFilterExpression := false

// Priority is: remove > update > copy
// Copy has no impact if remove is true or update contains a value
for i, action := range o.Actions {
tokens := token.NewTokenizer(action.Target, config.WithPropertyNameExtension()).Tokenize()
for _, tok := range tokens {
Expand All @@ -44,13 +53,27 @@ func (o *Overlay) ApplyToStrict(root *yaml.Node) (error, []string) {
if err != nil {
multiError = append(multiError, err.Error())
}
if action.Remove {

// Determine action type based on priority: remove > update > copy
actionType := "unknown"
switch {
case action.Remove:
actionType = "remove"
err = o.applyRemoveAction(root, action, &actionWarnings)
} else {
case !action.Update.IsZero():
actionType = "update"
err = o.applyUpdateAction(root, action, &actionWarnings)
case action.Copy != "":
actionType = "copy"
err = o.applyCopyAction(root, action, &actionWarnings)
default:
err = fmt.Errorf("unknown action type: %v", action)
}
if err != nil {
return nil, err
}
for _, warning := range actionWarnings {
warnings = append(warnings, fmt.Sprintf("update action (%v / %v) target=%s: %s", i+1, len(o.Actions), action.Target, warning))
warnings = append(warnings, fmt.Sprintf("%s action (%v / %v) target=%s: %s", actionType, i+1, len(o.Actions), action.Target, warning))
}
}

Expand All @@ -59,9 +82,9 @@ func (o *Overlay) ApplyToStrict(root *yaml.Node) (error, []string) {
}

if len(multiError) > 0 {
return fmt.Errorf("error applying overlay (strict): %v", strings.Join(multiError, ",")), warnings
return warnings, fmt.Errorf("error applying overlay (strict): %v", strings.Join(multiError, ","))
}
return nil, warnings
return warnings, nil
}

func (o *Overlay) validateSelectorHasAtLeastOneTarget(root *yaml.Node, action Action) error {
Expand All @@ -80,6 +103,24 @@ func (o *Overlay) validateSelectorHasAtLeastOneTarget(root *yaml.Node, action Ac
return fmt.Errorf("selector %q did not match any targets", action.Target)
}

// For copy actions, validate the source path (only if copy will actually be applied)
// Copy has no impact if remove is true or update contains a value
if action.Copy != "" && !action.Remove && action.Update.IsZero() {
sourcePath, err := o.NewPath(action.Copy, nil)
if err != nil {
return err
}

sourceNodes := sourcePath.Query(root)
if len(sourceNodes) == 0 {
return fmt.Errorf("copy source selector %q did not match any nodes", action.Copy)
}

if len(sourceNodes) > 1 {
return fmt.Errorf("copy source selector %q matched multiple nodes (%d), expected exactly one", action.Copy, len(sourceNodes))
}
}

return nil
}

Expand All @@ -96,9 +137,6 @@ func (o *Overlay) applyRemoveAction(root *yaml.Node, action Action, warnings *[]
}

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

for _, node := range nodes {
removeNode(idx, node)
Expand Down Expand Up @@ -185,6 +223,13 @@ func mergeNode(node *yaml.Node, merge *yaml.Node) bool {
// node.
func mergeMappingNode(node *yaml.Node, merge *yaml.Node) bool {
anyChange := false

// If the target is an empty flow-style mapping and we're merging content,
// convert to block style for better readability
if len(node.Content) == 0 && node.Style == yaml.FlowStyle && len(merge.Content) > 0 {
node.Style = 0 // Reset to default (block) style
}

NextKey:
for i := 0; i < len(merge.Content); i += 2 {
mergeKey := merge.Content[i].Value
Expand Down Expand Up @@ -232,3 +277,63 @@ func clone(node *yaml.Node) *yaml.Node {
}
return newNode
}

// applyCopyAction applies a copy action to the document
// This is a stub implementation for the copy feature from Overlay Specification v1.1.0
func (o *Overlay) applyCopyAction(root *yaml.Node, action Action, warnings *[]string) error {
if action.Target == "" {
return nil
}

if action.Copy == "" {
return nil
}

// Parse the source path
sourcePath, err := o.NewPath(action.Copy, warnings)
if err != nil {
return fmt.Errorf("invalid copy source path %q: %w", action.Copy, err)
}

// Query the source nodes
sourceNodes := sourcePath.Query(root)
if len(sourceNodes) == 0 {
// Source not found - in non-strict mode this is silently ignored
// In strict mode, this will be caught by validateSelectorHasAtLeastOneTarget
if warnings != nil {
*warnings = append(*warnings, fmt.Sprintf("copy source %q not found", action.Copy))
}
return nil
}

if len(sourceNodes) > 1 {
return fmt.Errorf("copy source path %q matched multiple nodes (%d), expected exactly one", action.Copy, len(sourceNodes))
}

sourceNode := sourceNodes[0]

// Parse the target path
targetPath, err := o.NewPath(action.Target, warnings)
if err != nil {
return fmt.Errorf("invalid target path %q: %w", action.Target, err)
}

// Query the target nodes
targetNodes := targetPath.Query(root)

// Copy the source node to each target
didMakeChange := false
for _, targetNode := range targetNodes {
// Clone the source node to avoid reference issues
copiedNode := clone(sourceNode)

// Merge the copied node into the target
didMakeChange = mergeNode(targetNode, copiedNode) || didMakeChange
}

if !didMakeChange && warnings != nil {
*warnings = append(*warnings, "does nothing")
}

return nil
}
34 changes: 25 additions & 9 deletions pkg/overlay/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ package overlay_test

import (
"bytes"
"os"
"strconv"
"testing"

"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"
)

// NodeMatchesFile is a test that marshals the YAML file from the given node,
Expand Down Expand Up @@ -65,14 +66,14 @@ func TestApplyToStrict(t *testing.T) {
o, err := loader.LoadOverlay("testdata/overlay-mismatched.yaml")
require.NoError(t, err)

err, warnings := o.ApplyToStrict(node)
warnings, err := o.ApplyToStrict(node)
assert.Error(t, err, "error applying overlay (strict): selector \"$.unknown-attribute\" did not match any targets")
assert.Len(t, warnings, 2)
o.Actions = o.Actions[1:]
node, err = loader.LoadSpecification("testdata/openapi.yaml")
require.NoError(t, err)

err, warnings = o.ApplyToStrict(node)
warnings, err = o.ApplyToStrict(node)
assert.NoError(t, err)
assert.Len(t, warnings, 1)
assert.Equal(t, "update action (2 / 2) target=$.info.title: does nothing", warnings[0])
Expand Down Expand Up @@ -268,6 +269,21 @@ func cloneNode(node *yaml.Node) *yaml.Node {
return clone
}

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

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

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

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

NodeMatchesFile(t, node, "testdata/openapi-version-header-expected.yaml")
}

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

Expand All @@ -280,7 +296,7 @@ func TestApplyToOld(t *testing.T) {
o, err := loader.LoadOverlay("testdata/overlay-old.yaml")
require.NoError(t, err)

err, warnings := o.ApplyToStrict(nodeOld)
warnings, err := o.ApplyToStrict(nodeOld)
require.NoError(t, err)
require.Len(t, warnings, 2)
require.Contains(t, warnings[0], "invalid rfc9535 jsonpath")
Expand All @@ -292,15 +308,15 @@ func TestApplyToOld(t *testing.T) {
require.NoError(t, err)
require.Equal(t, 0, len(result))
o.JSONPathVersion = "rfc9535"
err, warnings = o.ApplyToStrict(nodeNew)
_, err = o.ApplyToStrict(nodeNew)
require.ErrorContains(t, err, "unexpected token") // should error out: invalid nodepath
// now lets fix it.
o.Actions[0].Target = "$.paths.*[?(@[\"x-my-ignore\"])]"
err, warnings = o.ApplyToStrict(nodeNew)
_, err = o.ApplyToStrict(nodeNew)
require.ErrorContains(t, err, "did not match any targets")
// Now lets fix it.
o.Actions[0].Target = "$.paths[?(@[\"x-my-ignore\"])]" // @ should always refer to the child node in RFC 9535..
err, warnings = o.ApplyToStrict(nodeNew)
_, err = o.ApplyToStrict(nodeNew)
require.NoError(t, err)
result = path.Query(nodeNew)
require.NoError(t, err)
Expand Down
Loading