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
6 changes: 3 additions & 3 deletions overlay/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,16 @@

## Features

- **OpenAPI Overlay Specification Compliance**: Full implementation of the [OpenAPI Overlay Specification](https://github.com/OAI/Overlay-Specification/blob/3f398c6/versions/1.0.0.md) (2023-10-12)
- **OpenAPI Overlay Specification Compliance**: Full implementation of the [OpenAPI Overlay Specification](https://github.com/OAI/Overlay-Specification/blob/3f398c6/versions/1.0.0.md) (2023-10-12) and [version 1.1.0](https://github.com/OAI/Overlay-Specification/blob/e2c3cec/versions/1.1.0-dev.md)
- **JSONPath Target Selection**: Uses JSONPath expressions to select nodes for modification
- **Remove and Update Actions**: Support for both remove actions (pruning nodes) and update actions (merging values)
- **Remove, Update, and Copy Actions**: Support for remove actions (pruning nodes), update actions (merging values), and copy actions (duplicating or moving nodes)
- **Flexible Input/Output**: Works with both YAML and JSON formats
- **Batch Operations**: Apply multiple modifications to large numbers of nodes in a single operation
- **YAML v1.2 Support**: Uses [gopkg.in/yaml.v3](https://pkg.go.dev/gopkg.in/yaml.v3) for YAML v1.2 parsing (superset of JSON)

## About OpenAPI Overlays

This specification defines a means of editing an 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.
This specification defines a means of editing an 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. 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 OpenAPI Specification, but that is not required.

Expand Down
123 changes: 112 additions & 11 deletions overlay/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@ import (
// 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 @@ -32,6 +37,9 @@ 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 @@ -45,19 +53,27 @@ func (o *Overlay) ApplyToStrict(root *yaml.Node) ([]string, error) {
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)
if err != nil {
multiError = append(multiError, err.Error())
}
} else {
case !action.Update.IsZero():
actionType = "update"
err = o.applyUpdateAction(root, action, &actionWarnings)
if err != nil {
multiError = append(multiError, err.Error())
}
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 Down Expand Up @@ -87,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 Down Expand Up @@ -189,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 @@ -236,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
}
15 changes: 15 additions & 0 deletions overlay/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,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)
require.NoError(t, err)

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

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

Expand Down
Loading