Skip to content

Commit c8a6018

Browse files
authored
feat: support copy action (#87)
Adds support for the copy action from Overlay Specification v1.1.0, enabling node duplication and move operations within OpenAPI documents. ### Changes - New functionality: copy field in Action schema allows copying nodes via JSONPath source selector - Precedence: actions processed as remove > update > copy (copy ignored when remove=true or update exists) - Move pattern: combine copy + remove actions to move nodes - Validation: strict mode enforces single-match source selector and existing source/target paths - Deep copy: cloned nodes prevent reference issues; merges into existing targets
1 parent cfefbfa commit c8a6018

15 files changed

+993
-14
lines changed

overlay/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,16 @@
2525
2626
## Features
2727

28-
- **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)
28+
- **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)
2929
- **JSONPath Target Selection**: Uses JSONPath expressions to select nodes for modification
30-
- **Remove and Update Actions**: Support for both remove actions (pruning nodes) and update actions (merging values)
30+
- **Remove, Update, and Copy Actions**: Support for remove actions (pruning nodes), update actions (merging values), and copy actions (duplicating or moving nodes)
3131
- **Flexible Input/Output**: Works with both YAML and JSON formats
3232
- **Batch Operations**: Apply multiple modifications to large numbers of nodes in a single operation
3333
- **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)
3434

3535
## About OpenAPI Overlays
3636

37-
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.
37+
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.
3838

3939
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.
4040

overlay/apply.go

Lines changed: 112 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,17 @@ import (
1212
// ApplyTo will take an overlay and apply its changes to the given YAML
1313
// document.
1414
func (o *Overlay) ApplyTo(root *yaml.Node) error {
15+
// Priority is: remove > update > copy
16+
// Copy has no impact if remove is true or update contains a value
1517
for _, action := range o.Actions {
1618
var err error
17-
if action.Remove {
19+
switch {
20+
case action.Remove:
1821
err = o.applyRemoveAction(root, action, nil)
19-
} else {
22+
case !action.Update.IsZero():
2023
err = o.applyUpdateAction(root, action, &[]string{})
24+
case action.Copy != "":
25+
err = o.applyCopyAction(root, action, &[]string{})
2126
}
2227

2328
if err != nil {
@@ -32,6 +37,9 @@ func (o *Overlay) ApplyToStrict(root *yaml.Node) ([]string, error) {
3237
multiError := []string{}
3338
warnings := []string{}
3439
hasFilterExpression := false
40+
41+
// Priority is: remove > update > copy
42+
// Copy has no impact if remove is true or update contains a value
3543
for i, action := range o.Actions {
3644
tokens := token.NewTokenizer(action.Target, config.WithPropertyNameExtension()).Tokenize()
3745
for _, tok := range tokens {
@@ -45,19 +53,27 @@ func (o *Overlay) ApplyToStrict(root *yaml.Node) ([]string, error) {
4553
if err != nil {
4654
multiError = append(multiError, err.Error())
4755
}
48-
if action.Remove {
56+
57+
// Determine action type based on priority: remove > update > copy
58+
actionType := "unknown"
59+
switch {
60+
case action.Remove:
61+
actionType = "remove"
4962
err = o.applyRemoveAction(root, action, &actionWarnings)
50-
if err != nil {
51-
multiError = append(multiError, err.Error())
52-
}
53-
} else {
63+
case !action.Update.IsZero():
64+
actionType = "update"
5465
err = o.applyUpdateAction(root, action, &actionWarnings)
55-
if err != nil {
56-
multiError = append(multiError, err.Error())
57-
}
66+
case action.Copy != "":
67+
actionType = "copy"
68+
err = o.applyCopyAction(root, action, &actionWarnings)
69+
default:
70+
err = fmt.Errorf("unknown action type: %v", action)
71+
}
72+
if err != nil {
73+
return nil, err
5874
}
5975
for _, warning := range actionWarnings {
60-
warnings = append(warnings, fmt.Sprintf("update action (%v / %v) target=%s: %s", i+1, len(o.Actions), action.Target, warning))
76+
warnings = append(warnings, fmt.Sprintf("%s action (%v / %v) target=%s: %s", actionType, i+1, len(o.Actions), action.Target, warning))
6177
}
6278
}
6379

@@ -87,6 +103,24 @@ func (o *Overlay) validateSelectorHasAtLeastOneTarget(root *yaml.Node, action Ac
87103
return fmt.Errorf("selector %q did not match any targets", action.Target)
88104
}
89105

106+
// For copy actions, validate the source path (only if copy will actually be applied)
107+
// Copy has no impact if remove is true or update contains a value
108+
if action.Copy != "" && !action.Remove && action.Update.IsZero() {
109+
sourcePath, err := o.NewPath(action.Copy, nil)
110+
if err != nil {
111+
return err
112+
}
113+
114+
sourceNodes := sourcePath.Query(root)
115+
if len(sourceNodes) == 0 {
116+
return fmt.Errorf("copy source selector %q did not match any nodes", action.Copy)
117+
}
118+
119+
if len(sourceNodes) > 1 {
120+
return fmt.Errorf("copy source selector %q matched multiple nodes (%d), expected exactly one", action.Copy, len(sourceNodes))
121+
}
122+
}
123+
90124
return nil
91125
}
92126

@@ -189,6 +223,13 @@ func mergeNode(node *yaml.Node, merge *yaml.Node) bool {
189223
// node.
190224
func mergeMappingNode(node *yaml.Node, merge *yaml.Node) bool {
191225
anyChange := false
226+
227+
// If the target is an empty flow-style mapping and we're merging content,
228+
// convert to block style for better readability
229+
if len(node.Content) == 0 && node.Style == yaml.FlowStyle && len(merge.Content) > 0 {
230+
node.Style = 0 // Reset to default (block) style
231+
}
232+
192233
NextKey:
193234
for i := 0; i < len(merge.Content); i += 2 {
194235
mergeKey := merge.Content[i].Value
@@ -236,3 +277,63 @@ func clone(node *yaml.Node) *yaml.Node {
236277
}
237278
return newNode
238279
}
280+
281+
// applyCopyAction applies a copy action to the document
282+
// This is a stub implementation for the copy feature from Overlay Specification v1.1.0
283+
func (o *Overlay) applyCopyAction(root *yaml.Node, action Action, warnings *[]string) error {
284+
if action.Target == "" {
285+
return nil
286+
}
287+
288+
if action.Copy == "" {
289+
return nil
290+
}
291+
292+
// Parse the source path
293+
sourcePath, err := o.NewPath(action.Copy, warnings)
294+
if err != nil {
295+
return fmt.Errorf("invalid copy source path %q: %w", action.Copy, err)
296+
}
297+
298+
// Query the source nodes
299+
sourceNodes := sourcePath.Query(root)
300+
if len(sourceNodes) == 0 {
301+
// Source not found - in non-strict mode this is silently ignored
302+
// In strict mode, this will be caught by validateSelectorHasAtLeastOneTarget
303+
if warnings != nil {
304+
*warnings = append(*warnings, fmt.Sprintf("copy source %q not found", action.Copy))
305+
}
306+
return nil
307+
}
308+
309+
if len(sourceNodes) > 1 {
310+
return fmt.Errorf("copy source path %q matched multiple nodes (%d), expected exactly one", action.Copy, len(sourceNodes))
311+
}
312+
313+
sourceNode := sourceNodes[0]
314+
315+
// Parse the target path
316+
targetPath, err := o.NewPath(action.Target, warnings)
317+
if err != nil {
318+
return fmt.Errorf("invalid target path %q: %w", action.Target, err)
319+
}
320+
321+
// Query the target nodes
322+
targetNodes := targetPath.Query(root)
323+
324+
// Copy the source node to each target
325+
didMakeChange := false
326+
for _, targetNode := range targetNodes {
327+
// Clone the source node to avoid reference issues
328+
copiedNode := clone(sourceNode)
329+
330+
// Merge the copied node into the target
331+
didMakeChange = mergeNode(targetNode, copiedNode) || didMakeChange
332+
}
333+
334+
if !didMakeChange && warnings != nil {
335+
*warnings = append(*warnings, "does nothing")
336+
}
337+
338+
return nil
339+
}

overlay/apply_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,21 @@ func cloneNode(node *yaml.Node) *yaml.Node {
270270
return clone
271271
}
272272

273+
func TestApplyTo_CopyVersionToHeader(t *testing.T) {
274+
t.Parallel()
275+
276+
node, err := loader.LoadSpecification("testdata/openapi-version-header.yaml")
277+
require.NoError(t, err)
278+
279+
o, err := loader.LoadOverlay("testdata/overlay-version-header.yaml")
280+
require.NoError(t, err)
281+
282+
err = o.ApplyTo(node)
283+
require.NoError(t, err)
284+
285+
NodeMatchesFile(t, node, "testdata/openapi-version-header-expected.yaml")
286+
}
287+
273288
func TestApplyToOld(t *testing.T) {
274289
t.Parallel()
275290

0 commit comments

Comments
 (0)