From ff11d6267f9ba5a278283a5e8d4c7c3be5148271 Mon Sep 17 00:00:00 2001 From: quobix Date: Tue, 6 Oct 2026 10:53:36 -0400 Subject: [PATCH 1/6] feat: add Overlay 1.2 reusable action models --- datamodel/high/overlay/action.go | 26 +++-- datamodel/high/overlay/components.go | 60 +++++++++++ datamodel/high/overlay/overlay.go | 16 ++- datamodel/high/overlay/overlay12_test.go | 106 +++++++++++++++++++ datamodel/high/overlay/reusable_action.go | 62 ++++++++++++ datamodel/high/overlay/uri.go | 50 +++++++++ datamodel/high/overlay/uri_test.go | 40 ++++++++ datamodel/low/overlay/action.go | 40 +++++++- datamodel/low/overlay/components.go | 118 ++++++++++++++++++++++ datamodel/low/overlay/constants.go | 6 +- datamodel/low/overlay/extract.go | 56 ++++++++++ datamodel/low/overlay/info.go | 21 +++- datamodel/low/overlay/overlay.go | 89 +++++++++------- datamodel/low/overlay/overlay12_test.go | 116 +++++++++++++++++++++ datamodel/low/overlay/overlay_test.go | 7 +- datamodel/low/overlay/reusable_action.go | 102 +++++++++++++++++++ overlay_test.go | 8 +- 17 files changed, 868 insertions(+), 55 deletions(-) create mode 100644 datamodel/high/overlay/components.go create mode 100644 datamodel/high/overlay/overlay12_test.go create mode 100644 datamodel/high/overlay/reusable_action.go create mode 100644 datamodel/high/overlay/uri.go create mode 100644 datamodel/high/overlay/uri_test.go create mode 100644 datamodel/low/overlay/components.go create mode 100644 datamodel/low/overlay/extract.go create mode 100644 datamodel/low/overlay/overlay12_test.go create mode 100644 datamodel/low/overlay/reusable_action.go diff --git a/datamodel/high/overlay/action.go b/datamodel/high/overlay/action.go index fc34e1a33..52c7a96c5 100644 --- a/datamodel/high/overlay/action.go +++ b/datamodel/high/overlay/action.go @@ -10,9 +10,11 @@ import ( "github.com/pb33f/libopenapi/orderedmap" ) -// Action represents a high-level Overlay Action Object. -// https://spec.openapis.org/overlay/v1.1.0#action-object +// Action represents an Overlay action or reusable action reference. +// Ref identifies a reusable action; the other fields provide local overrides. +// https://spec.openapis.org/overlay/v1.2.0#action-object type Action struct { + Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` Target string `json:"target,omitempty" yaml:"target,omitempty"` Description string `json:"description,omitempty" yaml:"description,omitempty"` Update *yaml.Node `json:"update,omitempty" yaml:"update,omitempty"` @@ -20,12 +22,15 @@ type Action struct { Copy string `json:"copy,omitempty" yaml:"copy,omitempty"` Extensions *orderedmap.Map[string, *yaml.Node] `json:"-" yaml:"-"` low *low.Action + removeSet bool } // NewAction creates a new high-level Action instance from a low-level one. func NewAction(action *low.Action) *Action { a := new(Action) a.low = action + a.Ref = action.Ref.Value + a.removeSet = action.Remove.KeyNode != nil if !action.Target.IsEmpty() { a.Target = action.Target.Value } @@ -63,19 +68,22 @@ func (a *Action) Render() ([]byte, error) { // MarshalYAML creates a ready to render YAML representation of the Action object. func (a *Action) MarshalYAML() (any, error) { m := orderedmap.New[string, any]() - if a.Target != "" { + if a.Ref != "" || (a.low != nil && a.low.Ref.KeyNode != nil) { + m.Set(low.RefLabel, a.Ref) + } + if a.Target != "" || (a.low != nil && a.low.Target.KeyNode != nil) { m.Set(low.TargetLabel, a.Target) } - if a.Description != "" { + if a.Description != "" || (a.low != nil && a.low.Description.KeyNode != nil) { m.Set(low.DescriptionLabel, a.Description) } - if a.Copy != "" { + if a.Copy != "" || (a.low != nil && a.low.Copy.KeyNode != nil) { m.Set(low.CopyLabel, a.Copy) } if a.Update != nil { m.Set(low.UpdateLabel, a.Update) } - if a.Remove { + if a.Remove || a.removeSet { m.Set(low.RemoveLabel, a.Remove) } for pair := a.Extensions.First(); pair != nil; pair = pair.Next() { @@ -83,3 +91,9 @@ func (a *Action) MarshalYAML() (any, error) { } return m, nil } + +// SetRemove sets remove and preserves an explicit false override when rendering or resolving a reusable action. +func (a *Action) SetRemove(remove bool) { a.Remove = remove; a.removeSet = true } + +// HasRemove reports whether remove is explicitly set, including a false override. +func (a *Action) HasRemove() bool { return a.Remove || a.removeSet } diff --git a/datamodel/high/overlay/components.go b/datamodel/high/overlay/components.go new file mode 100644 index 000000000..45b0254c0 --- /dev/null +++ b/datamodel/high/overlay/components.go @@ -0,0 +1,60 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "github.com/pb33f/go-yaml" + "github.com/pb33f/libopenapi/datamodel/high" + low "github.com/pb33f/libopenapi/datamodel/low/overlay" + "github.com/pb33f/libopenapi/orderedmap" +) + +// Components represents a high-level Overlay Components Object. +// https://spec.openapis.org/overlay/v1.2.0#components-object +type Components struct { + Actions *orderedmap.Map[string, *ReusableAction] `json:"actions,omitempty" yaml:"actions,omitempty"` + Extensions *orderedmap.Map[string, *yaml.Node] `json:"-" yaml:"-"` + low *low.Components +} + +// NewComponents creates a new high-level Components instance from a low-level one. +func NewComponents(info *low.Components) *Components { + i := new(Components) + i.low = info + if info.Actions.Value != nil { + i.Actions = orderedmap.New[string, *ReusableAction]() + for pair := info.Actions.Value.First(); pair != nil; pair = pair.Next() { + i.Actions.Set(pair.Key().Value, NewReusableAction(pair.Value().Value)) + } + } + i.Extensions = high.ExtractExtensions(info.Extensions) + return i +} + +// GoLow returns the low-level Components instance used to create the high-level one. +func (i *Components) GoLow() *low.Components { + return i.low +} + +// GoLowUntyped returns the low-level Components instance with no type. +func (i *Components) GoLowUntyped() any { + return i.low +} + +// Render returns a YAML representation of the Components object as a byte slice. +func (i *Components) Render() ([]byte, error) { + return yaml.Marshal(i) +} + +// MarshalYAML creates a ready to render YAML representation of the Components object. +func (i *Components) MarshalYAML() (any, error) { + m := orderedmap.New[string, any]() + if i.Actions != nil { + m.Set(low.ActionsLabel, i.Actions) + } + for pair := i.Extensions.First(); pair != nil; pair = pair.Next() { + m.Set(pair.Key(), pair.Value()) + } + return m, nil +} diff --git a/datamodel/high/overlay/overlay.go b/datamodel/high/overlay/overlay.go index 3e5cd1a04..4fb264ddd 100644 --- a/datamodel/high/overlay/overlay.go +++ b/datamodel/high/overlay/overlay.go @@ -11,8 +11,10 @@ import ( ) // Overlay represents a high-level OpenAPI Overlay document. -// https://spec.openapis.org/overlay/v1.0.0 +// https://spec.openapis.org/overlay/v1.2.0 type Overlay struct { + Self string `json:"$self,omitempty" yaml:"$self,omitempty"` + Components *Components `json:"components,omitempty" yaml:"components,omitempty"` Overlay string `json:"overlay,omitempty" yaml:"overlay,omitempty"` Info *Info `json:"info,omitempty" yaml:"info,omitempty"` Extends string `json:"extends,omitempty" yaml:"extends,omitempty"` @@ -25,6 +27,10 @@ type Overlay struct { func NewOverlay(overlay *low.Overlay) *Overlay { o := new(Overlay) o.low = overlay + o.Self = overlay.Self.Value + if overlay.Components.Value != nil { + o.Components = NewComponents(overlay.Components.Value) + } if !overlay.Overlay.IsEmpty() { o.Overlay = overlay.Overlay.Value } @@ -66,10 +72,16 @@ func (o *Overlay) MarshalYAML() (interface{}, error) { if o.Overlay != "" { m.Set("overlay", o.Overlay) } + if o.Self != "" || (o.low != nil && o.low.Self.KeyNode != nil) { + m.Set(low.SelfLabel, o.Self) + } + if o.Components != nil { + m.Set(low.ComponentsLabel, o.Components) + } if o.Info != nil { m.Set("info", o.Info) } - if o.Extends != "" { + if o.Extends != "" || (o.low != nil && o.low.Extends.KeyNode != nil) { m.Set("extends", o.Extends) } if len(o.Actions) > 0 { diff --git a/datamodel/high/overlay/overlay12_test.go b/datamodel/high/overlay/overlay12_test.go new file mode 100644 index 000000000..39bcbd050 --- /dev/null +++ b/datamodel/high/overlay/overlay12_test.go @@ -0,0 +1,106 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "context" + "github.com/pb33f/go-yaml" + "github.com/pb33f/libopenapi/datamodel/low" + lowoverlay "github.com/pb33f/libopenapi/datamodel/low/overlay" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" + "testing" +) + +func parseOverlay12(t *testing.T, text string) *Overlay { + t.Helper() + var node yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(text), &node)) + var model lowoverlay.Overlay + require.NoError(t, low.BuildModel(node.Content[0], &model)) + require.NoError(t, model.Build(context.Background(), nil, node.Content[0], nil)) + return NewOverlay(&model) +} + +func TestOverlay12RoundTrip(t *testing.T) { + text := `overlay: 1.2.0 +$self: https://example.com/overlay.yaml +extends: api.yaml +info: {title: Shared, version: '1'} +components: + x-source: docs + actions: + shared: + description: Reusable description + x-owner: docs + fields: + description: Field description + remove: true + copy: $.source + update: null + noop: {} +actions: + - $ref: '#/components/actions/shared' + target: $.info + description: '' + copy: '' + remove: false +` + ov := parseOverlay12(t, text) + c := ov.Components + assert.Equal(t, c.GoLow(), c.GoLowUntyped()) + reusable, ok := c.Actions.Get("shared") + require.True(t, ok) + assert.Equal(t, reusable.GoLow(), reusable.GoLowUntyped()) + componentBytes, err := c.Render() + require.NoError(t, err) + assert.Contains(t, string(componentBytes), "x-source: docs") + actionBytes, err := reusable.Render() + require.NoError(t, err) + assert.Contains(t, string(actionBytes), "x-owner: docs") + raw, err := ov.Render() + require.NoError(t, err) + assert.YAMLEq(t, text, string(raw)) + again := parseOverlay12(t, string(raw)) + assert.Equal(t, ov.GoLow().Hash(), again.GoLow().Hash()) + assert.True(t, again.Actions[0].HasRemove()) + assert.False(t, again.Actions[0].Remove) + ov.Actions[0].SetRemove(true) + assert.True(t, ov.Actions[0].Remove) + ov.Actions[0].SetRemove(false) + raw, err = ov.Actions[0].Render() + require.NoError(t, err) + assert.Contains(t, string(raw), "remove: false") + empty := parseOverlay12(t, "components: {}") + raw, err = empty.Components.Render() + require.NoError(t, err) + assert.YAMLEq(t, "{}", string(raw)) +} + +func TestOverlay12ExplicitEmptyURIs(t *testing.T) { + for _, text := range []string{ + "$self: https://example.com/overlay.yaml\nextends: ''", + "$self: ''\nextends: ''", + } { + ov := parseOverlay12(t, text) + got, err := ov.ResolveExtends("https://example.com/overlay.yaml") + require.NoError(t, err) + assert.Equal(t, "https://example.com/overlay.yaml", got) + raw, err := ov.Render() + require.NoError(t, err) + assert.YAMLEq(t, text, string(raw)) + } + absent := parseOverlay12(t, "$self: https://example.com/overlay.yaml") + got, err := absent.ResolveExtends("") + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestOverlay12EmptyReferenceAndTargetPreserved(t *testing.T) { + text := "actions: [{$ref: '', target: ''}]" + ov := parseOverlay12(t, text) + raw, err := ov.Render() + require.NoError(t, err) + assert.YAMLEq(t, text, string(raw)) +} diff --git a/datamodel/high/overlay/reusable_action.go b/datamodel/high/overlay/reusable_action.go new file mode 100644 index 000000000..e37b365e6 --- /dev/null +++ b/datamodel/high/overlay/reusable_action.go @@ -0,0 +1,62 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "github.com/pb33f/go-yaml" + "github.com/pb33f/libopenapi/datamodel/high" + low "github.com/pb33f/libopenapi/datamodel/low/overlay" + "github.com/pb33f/libopenapi/orderedmap" +) + +// ReusableAction represents a high-level Overlay ReusableAction Object. +// https://spec.openapis.org/overlay/v1.2.0#reusable-action-object +type ReusableAction struct { + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Fields *Action `json:"fields,omitempty" yaml:"fields,omitempty"` + Extensions *orderedmap.Map[string, *yaml.Node] `json:"-" yaml:"-"` + low *low.ReusableAction +} + +// NewReusableAction creates a new high-level ReusableAction instance from a low-level one. +func NewReusableAction(info *low.ReusableAction) *ReusableAction { + i := new(ReusableAction) + i.low = info + i.Description = info.Description.Value + if info.Fields.Value != nil { + i.Fields = NewAction(info.Fields.Value) + } + i.Extensions = high.ExtractExtensions(info.Extensions) + return i +} + +// GoLow returns the low-level ReusableAction instance used to create the high-level one. +func (i *ReusableAction) GoLow() *low.ReusableAction { + return i.low +} + +// GoLowUntyped returns the low-level ReusableAction instance with no type. +func (i *ReusableAction) GoLowUntyped() any { + return i.low +} + +// Render returns a YAML representation of the ReusableAction object as a byte slice. +func (i *ReusableAction) Render() ([]byte, error) { + return yaml.Marshal(i) +} + +// MarshalYAML creates a ready to render YAML representation of the ReusableAction object. +func (i *ReusableAction) MarshalYAML() (any, error) { + m := orderedmap.New[string, any]() + if i.Description != "" { + m.Set(low.DescriptionLabel, i.Description) + } + if i.Fields != nil { + m.Set(low.FieldsLabel, i.Fields) + } + for pair := i.Extensions.First(); pair != nil; pair = pair.Next() { + m.Set(pair.Key(), pair.Value()) + } + return m, nil +} diff --git a/datamodel/high/overlay/uri.go b/datamodel/high/overlay/uri.go new file mode 100644 index 000000000..9b2bdc40d --- /dev/null +++ b/datamodel/high/overlay/uri.go @@ -0,0 +1,50 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "fmt" + "net/url" + "strings" +) + +// ResolveExtends resolves extends against $self, then baseURI. baseURI is the +// retrieval URI, encapsulating entity URI, or application default supplied by +// the caller. With no absolute base, extends is returned unchanged. It performs no I/O. +func (o *Overlay) ResolveExtends(baseURI string) (string, error) { + base, err := documentURI(baseURI) + if err != nil { + return "", err + } + if o.Self != "" { + self, err := documentURI(o.Self) + if err != nil { + return "", err + } + if self.IsAbs() || !base.IsAbs() { + base = self + } else { + base = base.ResolveReference(self) + } + } + if o.Extends == "" && (o.low == nil || o.low.Extends.KeyNode == nil) { + return "", nil + } + extends, err := documentURI(o.Extends) + if err != nil { + return "", err + } + if extends.IsAbs() || !base.IsAbs() { + return extends.String(), nil + } + return base.ResolveReference(extends).String(), nil +} + +func documentURI(value string) (*url.URL, error) { + uri, err := url.Parse(value) + if err != nil || strings.Contains(value, "#") || strings.ContainsAny(value, " \t\r\n") { + return nil, fmt.Errorf("invalid overlay document URI %q: fragments and whitespace are not allowed", value) + } + return uri, nil +} diff --git a/datamodel/high/overlay/uri_test.go b/datamodel/high/overlay/uri_test.go new file mode 100644 index 000000000..30d66f496 --- /dev/null +++ b/datamodel/high/overlay/uri_test.go @@ -0,0 +1,40 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" + "testing" +) + +func TestOverlayResolveExtends(t *testing.T) { + for _, tc := range []struct{ name, self, base, extends, want string }{ + {"self wins", "https://example.com/overlays/a.yaml", "file:///local/a.yaml", "../api.yaml", "https://example.com/api.yaml"}, + {"retrieval", "", "file:///local/overlays/a.yaml", "../api.yaml", "file:///local/api.yaml"}, + {"relative self", "../shared/a.yaml", "https://example.com/overlays/a.yaml", "../api.yaml", "https://example.com/api.yaml"}, + {"absolute extends", "", "", "https://example.com/api.yaml", "https://example.com/api.yaml"}, + {"unresolved relative", "", "", "../api.yaml", "../api.yaml"}, + {"relative self without base", "overlays/a.yaml", "", "../api.yaml", "../api.yaml"}, + {"parent self without base", "../overlays/a.yaml", "", "api.yaml", "api.yaml"}, + {"relative retrieval", "", "overlays/a.yaml", "../api.yaml", "../api.yaml"}, + {"empty", "https://example.com/a.yaml", "", "", ""}, + {"query", "https://example.com/a.yaml", "", "?v=2", "https://example.com/a.yaml?v=2"}, + {"encoded fragment", "", "", "https://example.com/a%23b.yaml", "https://example.com/a%23b.yaml"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := (&Overlay{Self: tc.self, Extends: tc.extends}).ResolveExtends(tc.base) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } + for _, value := range []string{"a.yaml#", "a.yaml#part", "%zz", "a b.yaml", "https://[bad"} { + _, err := (&Overlay{Self: value}).ResolveExtends("") + require.Error(t, err) + _, err = (&Overlay{Extends: value}).ResolveExtends("") + require.Error(t, err) + _, err = (&Overlay{}).ResolveExtends(value) + require.Error(t, err) + } +} diff --git a/datamodel/low/overlay/action.go b/datamodel/low/overlay/action.go index 97a934cfd..234a8b7d7 100644 --- a/datamodel/low/overlay/action.go +++ b/datamodel/low/overlay/action.go @@ -5,6 +5,7 @@ package overlay import ( "context" + "fmt" "hash/maphash" "github.com/pb33f/go-yaml" @@ -15,8 +16,9 @@ import ( ) // Action represents a low-level Overlay Action Object. -// https://spec.openapis.org/overlay/v1.1.0#action-object +// https://spec.openapis.org/overlay/v1.2.0#action-object type Action struct { + Ref low.NodeReference[string] Target low.NodeReference[string] Description low.NodeReference[string] Update low.NodeReference[*yaml.Node] @@ -58,6 +60,9 @@ func (a *Action) GetKeyNode() *yaml.Node { // Build will extract extensions for the Action object. func (a *Action) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { + if err := requireMapping(root); err != nil { + return err + } a.KeyNode = keyNode root = utils.NodeAlias(root) a.RootNode = root @@ -69,6 +74,34 @@ func (a *Action) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index a.context = ctx low.ExtractExtensionNodes(ctx, a.Extensions, a.Nodes) + var err error + a.Ref, err = extractString(RefLabel, root) + if err != nil { + return err + } + a.Target, err = extractString(TargetLabel, root) + if err != nil { + return err + } + a.Description, err = extractString(DescriptionLabel, root) + if err != nil { + return err + } + a.Copy, err = extractString(CopyLabel, root) + if err != nil { + return err + } + a.Remove = low.NodeReference[bool]{} + if key, value := findField(RemoveLabel, root); value != nil { + if value.Kind != yaml.ScalarNode || value.Tag != "!!bool" { + return fmt.Errorf("remove must be a boolean") + } + var remove bool + if err := value.Decode(&remove); err != nil { + return err + } + a.Remove = low.NodeReference[bool]{Value: remove, KeyNode: key, ValueNode: value} + } // Extract the update node directly if present for i := 0; i < len(root.Content); i += 2 { if i+1 < len(root.Content) && root.Content[i].Value == UpdateLabel { @@ -91,6 +124,11 @@ func (a *Action) GetExtensions() *orderedmap.Map[low.KeyReference[string], low.V // Hash will return a consistent Hash of the Action object func (a *Action) Hash() uint64 { return low.WithHasher(func(h *maphash.Hash) uint64 { + if !a.Ref.IsEmpty() { + h.WriteString(RefLabel) + h.WriteString(a.Ref.Value) + h.WriteByte(low.HASH_PIPE) + } if !a.Target.IsEmpty() { h.WriteString(a.Target.Value) h.WriteByte(low.HASH_PIPE) diff --git a/datamodel/low/overlay/components.go b/datamodel/low/overlay/components.go new file mode 100644 index 000000000..b5a35dcb3 --- /dev/null +++ b/datamodel/low/overlay/components.go @@ -0,0 +1,118 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "context" + "fmt" + "hash/maphash" + + "github.com/pb33f/go-yaml" + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/index" + "github.com/pb33f/libopenapi/orderedmap" + "github.com/pb33f/libopenapi/utils" +) + +// Components represents a low-level Overlay Components Object. +// https://spec.openapis.org/overlay/v1.2.0#components-object +type Components struct { + Actions low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[*ReusableAction]]] + Extensions *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] + KeyNode *yaml.Node + RootNode *yaml.Node + index *index.SpecIndex + context context.Context + *low.Reference + low.NodeMap +} + +// GetIndex returns the index.SpecIndex instance attached to the Components object +func (i *Components) GetIndex() *index.SpecIndex { + return i.index +} + +// GetContext returns the context.Context instance used when building the Components object +func (i *Components) GetContext() context.Context { + return i.context +} + +// FindExtension returns a ValueReference containing the extension value, if found. +func (i *Components) FindExtension(ext string) *low.ValueReference[*yaml.Node] { + return low.FindItemInOrderedMap(ext, i.Extensions) +} + +// GetRootNode returns the root yaml node of the Components object +func (i *Components) GetRootNode() *yaml.Node { + return i.RootNode +} + +// GetKeyNode returns the key yaml node of the Components object +func (i *Components) GetKeyNode() *yaml.Node { + return i.KeyNode +} + +// Build extracts reusable actions and extensions. +func (i *Components) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { + if err := requireMapping(root); err != nil { + return err + } + i.KeyNode = keyNode + root = utils.NodeAlias(root) + i.RootNode = root + utils.CheckForMergeNodes(root) + i.Reference = new(low.Reference) + i.Nodes = low.ExtractNodes(ctx, root) + i.Extensions = low.ExtractExtensions(root) + i.index = idx + i.context = ctx + low.ExtractExtensionNodes(ctx, i.Extensions, i.Nodes) + key, value := findField(ActionsLabel, root) + if value != nil { + if err := requireMapping(value); err != nil { + return err + } + actions := orderedmap.New[low.KeyReference[string], low.ValueReference[*ReusableAction]]() + names := make(map[string]struct{}, len(value.Content)/2) + for n := 0; n < len(value.Content); n += 2 { + k, v := value.Content[n], value.Content[n+1] + if k.Kind != yaml.ScalarNode { + return fmt.Errorf("component action name must be a scalar") + } + if _, exists := names[k.Value]; exists { + return fmt.Errorf("duplicate component action %q", k.Value) + } + names[k.Value] = struct{}{} + action := new(ReusableAction) + if err := action.Build(ctx, k, v, idx); err != nil { + return err + } + actions.Set(low.KeyReference[string]{Value: k.Value, KeyNode: k}, low.ValueReference[*ReusableAction]{Value: action, ValueNode: v}) + } + i.Actions = low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[*ReusableAction]]]{Value: actions, KeyNode: key, ValueNode: value} + } + return nil +} + +// GetExtensions returns all Components extensions and satisfies the low.HasExtensions interface. +func (i *Components) GetExtensions() *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] { + return i.Extensions +} + +// Hash will return a consistent Hash of the Components object +func (inf *Components) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + for pair := orderedmap.SortAlpha(inf.Actions.Value).First(); pair != nil; pair = pair.Next() { + h.WriteString(pair.Key().Value) + h.WriteByte(low.HASH_PIPE) + h.WriteString(low.GenerateHashString(pair.Value().Value)) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(inf.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) +} diff --git a/datamodel/low/overlay/constants.go b/datamodel/low/overlay/constants.go index 0bbcaf019..55e4085b0 100644 --- a/datamodel/low/overlay/constants.go +++ b/datamodel/low/overlay/constants.go @@ -4,9 +4,13 @@ package overlay // Constants for labels used to look up values within OpenAPI Overlay specifications. -// https://spec.openapis.org/overlay/v1.1.0 +// https://spec.openapis.org/overlay/v1.2.0 const ( OverlayLabel = "overlay" + SelfLabel = "$self" + RefLabel = "$ref" + ComponentsLabel = "components" + FieldsLabel = "fields" InfoLabel = "info" ExtendsLabel = "extends" ActionsLabel = "actions" diff --git a/datamodel/low/overlay/extract.go b/datamodel/low/overlay/extract.go new file mode 100644 index 000000000..fb9439e16 --- /dev/null +++ b/datamodel/low/overlay/extract.go @@ -0,0 +1,56 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "context" + "fmt" + + "github.com/pb33f/go-yaml" + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/index" + "github.com/pb33f/libopenapi/utils" +) + +func requireMapping(node *yaml.Node) error { + node = utils.NodeAlias(node) + if node == nil || node.Kind != yaml.MappingNode || len(node.Content)%2 != 0 { + return fmt.Errorf("overlay object must be a mapping") + } + return nil +} + +func findField(label string, root *yaml.Node) (*yaml.Node, *yaml.Node) { + for i := 0; i+1 < len(root.Content); i += 2 { + if root.Content[i].Value == label { + return root.Content[i], utils.NodeAlias(root.Content[i+1]) + } + } + return nil, nil +} + +func extractString(label string, root *yaml.Node) (low.NodeReference[string], error) { + key, value := findField(label, root) + if value == nil { + return low.NodeReference[string]{}, nil + } + if value.Kind != yaml.ScalarNode || value.Tag != "!!str" { + return low.NodeReference[string]{}, fmt.Errorf("%s must be a string", label) + } + return low.NodeReference[string]{Value: value.Value, KeyNode: key, ValueNode: value}, nil +} + +// Overlay references are resolved by the overlay engine, never by the OpenAPI index. +func extractObject[T low.Buildable[N], N any](ctx context.Context, label string, root *yaml.Node, idx *index.SpecIndex) (low.NodeReference[T], error) { + var result low.NodeReference[T] + key, value := findField(label, root) + if value == nil { + return result, nil + } + var model T = new(N) + if err := model.Build(ctx, key, value, idx); err != nil { + return result, err + } + return low.NodeReference[T]{Value: model, KeyNode: key, ValueNode: value}, nil +} diff --git a/datamodel/low/overlay/info.go b/datamodel/low/overlay/info.go index 6569741b7..fe65f7bf4 100644 --- a/datamodel/low/overlay/info.go +++ b/datamodel/low/overlay/info.go @@ -5,6 +5,7 @@ package overlay import ( "context" + "fmt" "hash/maphash" "github.com/pb33f/go-yaml" @@ -15,7 +16,7 @@ import ( ) // Info represents a low-level Overlay Info Object. -// https://spec.openapis.org/overlay/v1.1.0#info-object +// https://spec.openapis.org/overlay/v1.2.0#info-object type Info struct { Title low.NodeReference[string] Version low.NodeReference[string] @@ -56,17 +57,33 @@ func (i *Info) GetKeyNode() *yaml.Node { // Build will extract extensions for the Info object. func (i *Info) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { + if err := requireMapping(root); err != nil { + return err + } i.KeyNode = keyNode root = utils.NodeAlias(root) i.RootNode = root utils.CheckForMergeNodes(root) + if _, ref := findField(RefLabel, root); ref != nil { + return fmt.Errorf("info does not support $ref") + } i.Reference = new(low.Reference) i.Nodes = low.ExtractNodes(ctx, root) i.Extensions = low.ExtractExtensions(root) i.index = idx i.context = ctx low.ExtractExtensionNodes(ctx, i.Extensions, i.Nodes) - return nil + var err error + i.Title, err = extractString(TitleLabel, root) + if err != nil { + return err + } + i.Version, err = extractString(VersionLabel, root) + if err != nil { + return err + } + i.Description, err = extractString(DescriptionLabel, root) + return err } // GetExtensions returns all Info extensions and satisfies the low.HasExtensions interface. diff --git a/datamodel/low/overlay/overlay.go b/datamodel/low/overlay/overlay.go index c10e2378e..01abdfdcd 100644 --- a/datamodel/low/overlay/overlay.go +++ b/datamodel/low/overlay/overlay.go @@ -5,6 +5,7 @@ package overlay import ( "context" + "fmt" "hash/maphash" "github.com/pb33f/go-yaml" @@ -15,9 +16,11 @@ import ( ) // Overlay represents a low-level OpenAPI Overlay document. -// https://spec.openapis.org/overlay/v1.0.0 +// https://spec.openapis.org/overlay/v1.2.0 type Overlay struct { Overlay low.NodeReference[string] + Self low.NodeReference[string] + Components low.NodeReference[*Components] Info low.NodeReference[*Info] Extends low.NodeReference[string] Actions low.NodeReference[[]low.ValueReference[*Action]] @@ -57,6 +60,9 @@ func (o *Overlay) GetKeyNode() *yaml.Node { // Build will extract all properties of the Overlay document. func (o *Overlay) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { + if err := requireMapping(root); err != nil { + return err + } o.KeyNode = keyNode root = utils.NodeAlias(root) o.RootNode = root @@ -68,52 +74,57 @@ func (o *Overlay) Build(ctx context.Context, keyNode, root *yaml.Node, idx *inde o.context = ctx low.ExtractExtensionNodes(ctx, o.Extensions, o.Nodes) + var err error + o.Overlay, err = extractString(OverlayLabel, root) + if err != nil { + return err + } + o.Extends, err = extractString(ExtendsLabel, root) + if err != nil { + return err + } + o.Self, err = extractString(SelfLabel, root) + if err != nil { + return err + } + o.Components, err = extractObject[*Components](ctx, ComponentsLabel, root, idx) + if err != nil { + return err + } + // Extract info object - info, err := low.ExtractObject[*Info](ctx, InfoLabel, root, idx) + info, err := extractObject[*Info](ctx, InfoLabel, root, idx) if err != nil { return err } o.Info = info // Extract actions array - o.Actions = o.extractActions(ctx, root, idx) - - return nil + o.Actions, err = o.extractActions(ctx, root, idx) + return err } -func (o *Overlay) extractActions(ctx context.Context, root *yaml.Node, idx *index.SpecIndex) low.NodeReference[[]low.ValueReference[*Action]] { +func (o *Overlay) extractActions(ctx context.Context, root *yaml.Node, idx *index.SpecIndex) (low.NodeReference[[]low.ValueReference[*Action]], error) { var result low.NodeReference[[]low.ValueReference[*Action]] - for i := 0; i < len(root.Content); i += 2 { - if i+1 >= len(root.Content) { - break - } - key := root.Content[i] - value := root.Content[i+1] - - if key.Value == ActionsLabel { - result.KeyNode = key - result.ValueNode = value - - if value.Kind != yaml.SequenceNode { - continue - } - - actions := make([]low.ValueReference[*Action], 0, len(value.Content)) - for _, actionNode := range value.Content { - action := &Action{} - _ = low.BuildModel(actionNode, action) - _ = action.Build(ctx, nil, actionNode, idx) - actions = append(actions, low.ValueReference[*Action]{ - Value: action, - ValueNode: actionNode, - }) - } - result.Value = actions - break + key, value := findField(ActionsLabel, root) + if value == nil { + return result, nil + } + result.KeyNode, result.ValueNode = key, value + if value.Kind != yaml.SequenceNode { + return result, fmt.Errorf("actions must be an array") + } + actions := make([]low.ValueReference[*Action], 0, len(value.Content)) + for _, actionNode := range value.Content { + action := new(Action) + if err := action.Build(ctx, nil, actionNode, idx); err != nil { + return result, err } + actions = append(actions, low.ValueReference[*Action]{Value: action, ValueNode: actionNode}) } - return result + result.Value = actions + return result, nil } // GetExtensions returns all Overlay extensions and satisfies the low.HasExtensions interface. @@ -128,6 +139,16 @@ func (o *Overlay) Hash() uint64 { h.WriteString(o.Overlay.Value) h.WriteByte(low.HASH_PIPE) } + if !o.Self.IsEmpty() { + h.WriteString(SelfLabel) + h.WriteString(o.Self.Value) + h.WriteByte(low.HASH_PIPE) + } + if !o.Components.IsEmpty() { + h.WriteString(ComponentsLabel) + h.WriteString(low.GenerateHashString(o.Components.Value)) + h.WriteByte(low.HASH_PIPE) + } if !o.Info.IsEmpty() { h.WriteString(low.GenerateHashString(o.Info.Value)) h.WriteByte(low.HASH_PIPE) diff --git a/datamodel/low/overlay/overlay12_test.go b/datamodel/low/overlay/overlay12_test.go new file mode 100644 index 000000000..9b9b124c0 --- /dev/null +++ b/datamodel/low/overlay/overlay12_test.go @@ -0,0 +1,116 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "context" + "github.com/pb33f/go-yaml" + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" + "strings" + "testing" +) + +const overlay12Model = `overlay: 1.2.0 +$self: https://example.com/overlay.yaml +info: {title: Reuse, version: '1'} +components: + x-source: docs + actions: + shared: + description: Shared metadata + x-owner: docs + fields: + description: Action description + update: {description: New} + remove: true + x-field: example +actions: + - $ref: '#/components/actions/shared' + target: $.info + remove: false +` + +func buildOverlay12(t *testing.T, text string) (*Overlay, error) { + t.Helper() + var node yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(text), &node)) + ov := new(Overlay) + if err := low.BuildModel(node.Content[0], ov); err != nil { + return nil, err + } + err := ov.Build(context.Background(), nil, node.Content[0], nil) + return ov, err +} + +func TestOverlay12ModelsAndHashes(t *testing.T) { + ov, err := buildOverlay12(t, overlay12Model) + require.NoError(t, err) + assert.Equal(t, "$self", ov.Self.KeyNode.Value) + c := ov.Components.Value + require.NotNil(t, c) + assert.Equal(t, "components", c.GetKeyNode().Value) + assert.Equal(t, ov.Components.ValueNode, c.GetRootNode()) + assert.Equal(t, context.Background(), c.GetContext()) + assert.Nil(t, c.GetIndex()) + assert.NotNil(t, c.FindExtension("x-source")) + assert.Equal(t, c.Extensions, c.GetExtensions()) + entry := low.FindItemInOrderedMap("shared", c.Actions.Value) + require.NotNil(t, entry) + reusable := entry.Value + assert.Equal(t, "shared", reusable.GetKeyNode().Value) + assert.Equal(t, entry.ValueNode, reusable.GetRootNode()) + assert.Equal(t, context.Background(), reusable.GetContext()) + assert.Nil(t, reusable.GetIndex()) + assert.NotNil(t, reusable.FindExtension("x-owner")) + assert.Equal(t, reusable.Extensions, reusable.GetExtensions()) + assert.True(t, reusable.Fields.Value.Remove.Value) + assert.Equal(t, "#/components/actions/shared", ov.Actions.Value[0].Value.Ref.Value) + require.NotNil(t, ov.Actions.Value[0].Value.Remove.KeyNode) + before := ov.Hash() + for _, change := range [][2]string{ + {"https://example.com/overlay.yaml", "https://example.com/other.yaml"}, + {"Shared metadata", "Other metadata"}, + {"Action description", "Different action"}, + {"description: New", "description: Changed"}, + {"remove: false", "remove: true"}, + {"#/components/actions/shared", "#/components/actions/other"}, + {"x-source: docs", "x-source: other"}, + {"x-owner: docs", "x-owner: other"}, + {"x-field: example", "x-field: other"}, + } { + modified, err := buildOverlay12(t, strings.Replace(overlay12Model, change[0], change[1], 1)) + require.NoError(t, err) + assert.NotEqual(t, before, modified.Hash(), change[0]) + } + again, err := buildOverlay12(t, overlay12Model) + require.NoError(t, err) + assert.Equal(t, before, again.Hash()) +} + +func TestOverlay12MalformedModels(t *testing.T) { + for _, text := range []string{ + "components: {actions: {a: {}, a: {}}}", "components: {actions: {? [a,b]: {}}}", + "overlay: []", "extends: {}", "info: {title: []}", "info: {version: []}", "info: {description: []}", + "actions: [{target: []}]", "actions: [{description: []}]", "actions: [{copy: []}]", "actions: [{remove: 'false'}]", + "components: {actions: {a: {description: []}}}", + "[]", "null", "$self: {}", "components: []", "components: {actions: []}", + "components: {actions: {a: false}}", "components: {actions: {a: {fields: []}}}", + "actions: [false]", "actions: [{$ref: {}}]", "actions: [{$ref: false}]", "info: []", + } { + t.Run(text, func(t *testing.T) { _, err := buildOverlay12(t, text); require.Error(t, err) }) + } +} + +func TestComponentsHashOrderIndependent(t *testing.T) { + a, err := buildOverlay12(t, "components: {actions: {a: {fields: {remove: true}}, b: {fields: {copy: '$.info'}}}}") + require.NoError(t, err) + b, err := buildOverlay12(t, "components: {actions: {b: {fields: {copy: '$.info'}}, a: {fields: {remove: true}}}}") + require.NoError(t, err) + assert.Equal(t, a.Hash(), b.Hash()) + empty, err := buildOverlay12(t, "components: {}") + require.NoError(t, err) + assert.NotZero(t, empty.Hash()) +} diff --git a/datamodel/low/overlay/overlay_test.go b/datamodel/low/overlay/overlay_test.go index 76b25f2c8..7109400b8 100644 --- a/datamodel/low/overlay/overlay_test.go +++ b/datamodel/low/overlay/overlay_test.go @@ -249,7 +249,7 @@ actions: not-a-sequence` require.NoError(t, err) err = overlay.Build(context.Background(), nil, node.Content[0], nil) - require.NoError(t, err) + require.Error(t, err) // Actions should be empty since it's not a sequence assert.True(t, overlay.Actions.IsEmpty() || len(overlay.Actions.Value) == 0) @@ -399,8 +399,7 @@ info: err = overlay.Build(context.Background(), nil, node.Content[0], nil) require.Error(t, err) - assert.Contains(t, err.Error(), "object extraction failed") - assert.Contains(t, err.Error(), "empty") + assert.Contains(t, err.Error(), "$ref") } // TestOverlay_Build_OddContentLengthExtractActions tests line 93 - break on odd content length @@ -433,5 +432,5 @@ info: // The loop will iterate: i=0 (overlay), i=2 (info), i=4 (orphan-key) // At i=4, i+1=5 >= len(Content)=5, so break is executed err = overlay.Build(context.Background(), nil, root, nil) - require.NoError(t, err) // Build should succeed, just skip the odd element + require.Error(t, err) // Build should succeed, just skip the odd element } diff --git a/datamodel/low/overlay/reusable_action.go b/datamodel/low/overlay/reusable_action.go new file mode 100644 index 000000000..2fc589c07 --- /dev/null +++ b/datamodel/low/overlay/reusable_action.go @@ -0,0 +1,102 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "context" + "hash/maphash" + + "github.com/pb33f/go-yaml" + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/index" + "github.com/pb33f/libopenapi/orderedmap" + "github.com/pb33f/libopenapi/utils" +) + +// ReusableAction represents a low-level Overlay ReusableAction Object. +// https://spec.openapis.org/overlay/v1.2.0#reusable-action-object +type ReusableAction struct { + Description low.NodeReference[string] + Fields low.NodeReference[*Action] + Extensions *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] + KeyNode *yaml.Node + RootNode *yaml.Node + index *index.SpecIndex + context context.Context + *low.Reference + low.NodeMap +} + +// GetIndex returns the index.SpecIndex instance attached to the ReusableAction object +func (i *ReusableAction) GetIndex() *index.SpecIndex { + return i.index +} + +// GetContext returns the context.Context instance used when building the ReusableAction object +func (i *ReusableAction) GetContext() context.Context { + return i.context +} + +// FindExtension returns a ValueReference containing the extension value, if found. +func (i *ReusableAction) FindExtension(ext string) *low.ValueReference[*yaml.Node] { + return low.FindItemInOrderedMap(ext, i.Extensions) +} + +// GetRootNode returns the root yaml node of the ReusableAction object +func (i *ReusableAction) GetRootNode() *yaml.Node { + return i.RootNode +} + +// GetKeyNode returns the key yaml node of the ReusableAction object +func (i *ReusableAction) GetKeyNode() *yaml.Node { + return i.KeyNode +} + +// Build extracts action fields and extensions. +func (i *ReusableAction) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { + if err := requireMapping(root); err != nil { + return err + } + i.KeyNode = keyNode + root = utils.NodeAlias(root) + i.RootNode = root + utils.CheckForMergeNodes(root) + i.Reference = new(low.Reference) + i.Nodes = low.ExtractNodes(ctx, root) + i.Extensions = low.ExtractExtensions(root) + i.index = idx + i.context = ctx + low.ExtractExtensionNodes(ctx, i.Extensions, i.Nodes) + var err error + i.Description, err = extractString(DescriptionLabel, root) + if err != nil { + return err + } + i.Fields, err = extractObject[*Action](ctx, FieldsLabel, root, idx) + return err +} + +// GetExtensions returns all ReusableAction extensions and satisfies the low.HasExtensions interface. +func (i *ReusableAction) GetExtensions() *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] { + return i.Extensions +} + +// Hash will return a consistent Hash of the ReusableAction object +func (inf *ReusableAction) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if !inf.Description.IsEmpty() { + h.WriteString(inf.Description.Value) + h.WriteByte(low.HASH_PIPE) + } + if !inf.Fields.IsEmpty() { + h.WriteString(low.GenerateHashString(inf.Fields.Value)) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(inf.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) +} diff --git a/overlay_test.go b/overlay_test.go index b7b54925e..be1833b72 100644 --- a/overlay_test.go +++ b/overlay_test.go @@ -92,15 +92,13 @@ actions: []` } func TestNewOverlayDocument_SequenceRoot(t *testing.T) { - // Sequence at root - BuildModel is lenient and returns empty overlay + // An overlay document must be an object. overlayYAML := `- item1 - item2` ov, err := NewOverlayDocument([]byte(overlayYAML)) - // BuildModel is lenient and doesn't fail, but the overlay will be empty - require.NoError(t, err) - assert.NotNil(t, ov) - assert.Empty(t, ov.Overlay) + require.Error(t, err) + assert.Nil(t, ov) } func TestNewOverlayDocument_WithExtensions(t *testing.T) { From a480042f9672cb1bd85de4160eb2eeca4157ecdf Mon Sep 17 00:00:00 2001 From: quobix Date: Tue, 6 Oct 2026 10:56:42 -0400 Subject: [PATCH 2/6] fix: apply Overlay 1.2 action and merge rules --- datamodel/low/overlay/overlay12_test.go | 2 +- docs/overlay-1.2.md | 49 ++++ overlay/conformance_test.go | 320 ++++++++++++++++++++++++ overlay/engine.go | 154 +++++++----- overlay/engine_test.go | 56 ++--- overlay/errors.go | 10 +- overlay/reusable.go | 96 +++++++ overlay/validation.go | 39 ++- overlay/validation_test.go | 12 +- overlay12_test.go | 102 ++++++++ 10 files changed, 725 insertions(+), 115 deletions(-) create mode 100644 docs/overlay-1.2.md create mode 100644 overlay/conformance_test.go create mode 100644 overlay/reusable.go create mode 100644 overlay12_test.go diff --git a/datamodel/low/overlay/overlay12_test.go b/datamodel/low/overlay/overlay12_test.go index 9b9b124c0..976b668fa 100644 --- a/datamodel/low/overlay/overlay12_test.go +++ b/datamodel/low/overlay/overlay12_test.go @@ -94,7 +94,7 @@ func TestOverlay12MalformedModels(t *testing.T) { for _, text := range []string{ "components: {actions: {a: {}, a: {}}}", "components: {actions: {? [a,b]: {}}}", "overlay: []", "extends: {}", "info: {title: []}", "info: {version: []}", "info: {description: []}", - "actions: [{target: []}]", "actions: [{description: []}]", "actions: [{copy: []}]", "actions: [{remove: 'false'}]", + "actions: [{remove: !!bool invalid}]", "actions: [{target: []}]", "actions: [{description: []}]", "actions: [{copy: []}]", "actions: [{remove: 'false'}]", "components: {actions: {a: {description: []}}}", "[]", "null", "$self: {}", "components: []", "components: {actions: []}", "components: {actions: {a: false}}", "components: {actions: {a: {fields: []}}}", diff --git a/docs/overlay-1.2.md b/docs/overlay-1.2.md new file mode 100644 index 000000000..819e99928 --- /dev/null +++ b/docs/overlay-1.2.md @@ -0,0 +1,49 @@ +# Overlay 1.2 + +The Overlay models and application engine implement the fields and action rules +in [Overlay 1.2.0](https://spec.openapis.org/overlay/v1.2.0.html). + +## Upgrade plan + +Extend the existing low/high models with `$self`, components, reusable actions, +and action references. Preserve `Actions []*Action`. Resolve each reference +locally before applying it. Reusable fields cannot contain `target` or `$ref`; +each reference supplies its own target. Preserve explicit overrides during +parse, render, and application. No network loader, OpenAPI index, or cache is needed. + +Use one merge engine for updates and copies. Validate homogeneous targets, +replace primitives with correct YAML types, append items to arrays, concatenate +arrays, and reject incompatible object property types. Snapshot copy sources +before changes, including when the source is a target. Removal takes precedence. + +Verify models, source metadata, hashes, rendering, public entry points, URI +resolution, sequential actions, failure cases, and representative performance. +Run independent correctness and de-slop reviews before publication. + +## Compatibility + +The existing public entry points and `Actions []*Action` remain available. +Supported version strings are 1.0.x, 1.1.x, and 1.2.x. The engine uses the current +merge rules for all supported versions. Previous behavior that disagreed with +the specification is corrected: + +- Primitive targets can be updated. The value and YAML type both change. +- An object or primitive appended to an array remains one item. +- Incompatible property types produce an error instead of replacing containers. +- `remove: true` suppresses copy and update. +- If both copy and update are present, neither changes the target, as stated by + the two field descriptions. Use separate actions to copy and then update. +- Every action needs a target. Malformed model structures produce errors. + +Reusable actions are in `Components.Actions`, with their action content in +`ReusableAction.Fields`. Set `Action.Ref` to `#/components/actions/name`. +JSON Pointer escapes and URI percent escapes are decoded. Same-document relative references are also supported when an absolute `$self` +identifies the overlay. References to other documents are rejected. Reference fields override the corresponding reusable fields; +the update payload is replaced, not merged with the reusable payload. +For a constructed reference that overrides removal with false, call +`action.SetRemove(false)`. Parsed explicit false values are preserved. + +`Overlay.ResolveExtends(baseURI)` resolves `extends` using `$self` and the supplied +retrieval, encapsulating, or default base URI. It performs no I/O. Apply functions +continue to use the target bytes supplied by the caller; they do not select or +fetch documents based on `extends`. With no absolute base, `ResolveExtends` returns the unresolved `extends` value. Document identifiers cannot contain fragments. diff --git a/overlay/conformance_test.go b/overlay/conformance_test.go new file mode 100644 index 000000000..e51b7df2b --- /dev/null +++ b/overlay/conformance_test.go @@ -0,0 +1,320 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "fmt" + "strings" + "testing" + + "github.com/pb33f/go-yaml" + highoverlay "github.com/pb33f/libopenapi/datamodel/high/overlay" + "github.com/pb33f/libopenapi/orderedmap" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" +) + +func TestApply_Overlay12MergeRules(t *testing.T) { + cases := []struct { + name, target, action, want string + err error + }{ + {"primitive type", "value: old", "target: $.value\nupdate: true", "value: true", nil}, + {"null primitive", "value: 42", "target: $.value\nupdate: null", "value: null", nil}, + {"string primitive", "value: false", "target: $.value\nupdate: 'false'", "value: 'false'", nil}, + {"array object append", "value: [old]", "target: $.value\nupdate: {name: new}", "value: [old, {name: new}]", nil}, + {"array primitive append", "value: [old]", "target: $.value\nupdate: 7", "value: [old, 7]", nil}, + {"array null append", "value: []", "target: $.value\nupdate: null", "value: [null]", nil}, + {"array concatenate", "value: [old]", "target: $.value\nupdate: [new, next]", "value: [old, new, next]", nil}, + {"object merge", "value: {keep: yes, nested: {tags: [old], flag: false}}", "target: $.value\nupdate: {nested: {tags: [new], flag: 7}, added: null}", "value: {keep: yes, nested: {tags: [old, new], flag: 7}, added: null}", nil}, + {"object rejects primitive", "value: {}", "target: $.value\nupdate: 7", "", ErrIncompatibleUpdate}, + {"primitive rejects object", "value: old", "target: $.value\nupdate: {}", "", ErrIncompatibleUpdate}, + {"nested array rejects primitive", "value: {tags: [old]}", "target: $.value\nupdate: {tags: new}", "", ErrIncompatibleUpdate}, + {"nested object rejects array", "value: {schema: {type: string}}", "target: $.value\nupdate: {schema: []}", "", ErrIncompatibleUpdate}, + {"mixed targets", "value: [old, {}]", "target: $.value[*]\nupdate: {}", "", ErrIncompatibleUpdate}, + {"mixed primitives", "value: [old, 7, null, false]", "target: $.value[*]\nupdate: true", "value: [true, true, true, true]", nil}, + {"missing target succeeds", "value: old", "target: $.missing\ncopy: $.alsoMissing", "value: old", nil}, + {"remove ignores invalid copy and update", "value: old\nkeep: true", "target: $.value\ncopy: invalid[\nupdate: {}\nremove: true", "keep: true", nil}, + {"copy primitive to array", "source: 7\nvalue: []", "target: $.value\ncopy: $.source", "source: 7\nvalue: [7]", nil}, + {"copy changes primitive type", "source: false\nvalue: old", "target: $.value\ncopy: $.source", "source: false\nvalue: false", nil}, + {"copy nested mismatch", "source: {tags: new}\nvalue: {tags: [old]}", "target: $.value\ncopy: $.source", "", ErrCopyTypeMismatch}, + {"copy self snapshot", "a: [one]\nb: []", "target: $.*\ncopy: $.a", "a: [one, one]\nb: [one]", nil}, + {"copy descendant snapshot", "value: {items: [one], child: {items: [two]}}", "target: $.value.child\ncopy: $.value", "value: {items: [one], child: {items: [two, one], child: {items: [two]}}}", nil}, + {"remove repeated selection", "value: [one, two]", "target: $.value[0,0]\nremove: true", "value: [two]", nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ov := parseOverlay(t, "overlay: 1.2.0\ninfo: {title: Rules, version: '1'}\nactions:\n - "+strings.ReplaceAll(tc.action, "\n", "\n ")) + result, err := Apply([]byte(tc.target), ov) + if tc.err != nil { + require.ErrorIs(t, err, tc.err) + require.Nil(t, result) + return + } + require.NoError(t, err) + assert.YAMLEq(t, tc.want, string(result.Bytes)) + }) + } +} + +func TestApply_ReusableActions(t *testing.T) { + ov := parseOverlay(t, `overlay: 1.2.7 +$self: https://example.com/overlays/errors.yaml +extends: ../openapi.yaml +info: {title: Shared errors, version: '1'} +components: + actions: + 'error/~default': + description: Shared error responses + fields: + description: Add error response + remove: true + update: + '404': {description: Not Found} +actions: + - $ref: '#/components/actions/error~1~0default' + target: $.paths.*.*.responses + description: '' + remove: false + - target: $.paths['/items'].get.responses['404'].description + update: Missing item +`) + target := `openapi: 3.2.0 +info: {title: Items, version: '1'} +paths: + /items: + get: + responses: + '200': {description: OK} + /other: + delete: + responses: + '204': {description: No Content} +` + before, err := ov.Render() + require.NoError(t, err) + for range 2 { + result, err := Apply([]byte(target), ov) + require.NoError(t, err) + assert.YAMLEq(t, `openapi: 3.2.0 +info: {title: Items, version: '1'} +paths: + /items: + get: + responses: + '200': {description: OK} + '404': {description: Missing item} + /other: + delete: + responses: + '204': {description: No Content} + '404': {description: Not Found} +`, string(result.Bytes)) + rendered, err := ov.Render() + require.NoError(t, err) + assert.Equal(t, before, rendered) + ov = parseOverlay(t, string(rendered)) + } + effective, err := resolveAction(ov, ov.Actions[0]) + require.NoError(t, err) + assert.Empty(t, effective.Description) + assert.False(t, effective.Remove) +} + +func TestApply_ReusableOverrides(t *testing.T) { + for _, action := range []string{ + "update: {title: Local}", + "copy: ''\nupdate: {title: Local}", + } { + ov := parseOverlay(t, "overlay: 1.2.0\ninfo: {title: Overrides, version: '1'}\ncomponents:\n actions:\n shared:\n fields:\n update: {title: Shared, description: Shared}\nactions:\n - $ref: '#/components/actions/shared'\n target: $.info\n "+strings.ReplaceAll(action, "\n", "\n ")) + result, err := Apply([]byte("info: {title: Original}"), ov) + require.NoError(t, err) + assert.YAMLEq(t, "info: {title: Local}", string(result.Bytes)) + } + // Constructed models can override a reusable removal without changing the bool API. + actions := orderedmap.New[string, *highoverlay.ReusableAction]() + actions.Set("shared", &highoverlay.ReusableAction{Fields: &highoverlay.Action{Remove: true}}) + action := &highoverlay.Action{Ref: "#/components/actions/shared", Target: "$.info"} + action.SetRemove(false) + ov := &highoverlay.Overlay{Overlay: "1.2.0", Info: &highoverlay.Info{Title: "Constructed", Version: "1"}, Components: &highoverlay.Components{Actions: actions}, Actions: []*highoverlay.Action{action}} + result, err := Apply([]byte("info: {title: Original}"), ov) + require.NoError(t, err) + assert.YAMLEq(t, "info: {title: Original}", string(result.Bytes)) +} + +func TestApply_InvalidReusableActions(t *testing.T) { + cases := []struct { + name, component, action string + err error + }{ + {"missing component", "{}", "{$ref: '#/components/actions/absent', target: '$'}", ErrInvalidActionReference}, + {"external ref", "{}", "{$ref: 'other.yaml#/components/actions/a', target: '$'}", ErrInvalidActionReference}, + {"wrong prefix", "{}", "{$ref: '#/actions/a', target: '$'}", ErrInvalidActionReference}, + {"bad escape", "{}", "{$ref: '#/components/actions/a~2', target: '$'}", ErrInvalidActionReference}, + {"bad percent", "{}", "{$ref: '#/components/actions/%zz', target: '$'}", ErrInvalidActionReference}, + {"nested pointer", "{}", "{$ref: '#/components/actions/a/fields', target: '$'}", ErrInvalidActionReference}, + {"empty ref", "{}", "{$ref: '', target: '$'}", ErrInvalidActionReference}, + {"missing target", "{a: {fields: {update: {}}}}", "{$ref: '#/components/actions/a'}", ErrMissingTarget}, + {"forbidden target", "{a: {fields: {target: '', update: {}}}}", "{target: '$'}", ErrInvalidReusableAction}, + {"nested reference", "{a: {fields: {$ref: '#/components/actions/a'}}}", "{target: '$'}", ErrInvalidReusableAction}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ov := parseOverlay(t, "overlay: 1.2.0\ninfo: {title: Invalid, version: '1'}\ncomponents:\n actions: "+tc.component+"\nactions: ["+tc.action+"]") + _, err := Apply([]byte("info: {}"), ov) + require.ErrorIs(t, err, tc.err) + }) + } +} + +func TestReusableKey(t *testing.T) { + for ref, want := range map[string]string{"#/components/actions/": "", "#/components/actions/a%20b": "a b", "#/components/actions/a~01": "a~1", "#/components/actions/a%7E1b": "a/b", "#/components/actions/a%23b": "a#b"} { + got, err := reusableKey(ref) + require.NoError(t, err) + assert.Equal(t, want, got) + } + for _, ref := range []string{"#/components/actions/a~", "#/components/actions/a b", "#/components/actions/a#b"} { + _, err := reusableKey(ref) + require.ErrorIs(t, err, ErrInvalidActionReference) + } +} + +func BenchmarkOverlayMergeWide(b *testing.B) { + for _, width := range []int{20, 200, 2000} { + b.Run(fmt.Sprint(width), func(b *testing.B) { + target := &yaml.Node{Kind: yaml.MappingNode} + update := &yaml.Node{Kind: yaml.MappingNode} + for i := range width { + target.Content = append(target.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: fmt.Sprint(i)}, &yaml.Node{Kind: yaml.ScalarNode, Value: "old"}) + update.Content = append(update.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: fmt.Sprint(i)}, &yaml.Node{Kind: yaml.ScalarNode, Value: "new"}) + } + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + if err := mergeNode(target, update); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func TestApply_SameDocumentRelativeReference(t *testing.T) { + for _, ref := range []string{"overlay.yaml#/components/actions/a", "./overlay.yaml#/components/actions/a", "../overlays/overlay.yaml#/components/actions/a"} { + ov := parseOverlay(t, "overlay: 1.2.0\n$self: https://example.com/overlays/overlay.yaml\ninfo: {title: Relative references, version: '1'}\ncomponents: {actions: {a: {fields: {update: {title: Changed}}}}}\nactions:\n - $ref: '"+ref+"'\n target: $.info") + result, err := Apply([]byte("info: {title: Original}"), ov) + require.NoError(t, err) + assert.YAMLEq(t, "info: {title: Changed}", string(result.Bytes)) + } + for _, ref := range []string{"other.yaml#/components/actions/a", "https://example.com/overlays/overlay.yaml#/components/actions/a", "overlay.yaml?other#/components/actions/a", "%zz#/components/actions/a"} { + _, err := actionReferenceKey(&highoverlay.Overlay{Self: "https://example.com/overlays/overlay.yaml"}, ref) + require.ErrorIs(t, err, ErrInvalidActionReference) + } +} + +func TestApply_ValidationBoundaries(t *testing.T) { + valid := func() *highoverlay.Overlay { + return &highoverlay.Overlay{Overlay: "1.2.0", Info: &highoverlay.Info{Title: "Valid", Version: "1"}, Actions: []*highoverlay.Action{{Target: "$"}}} + } + for _, version := range []string{"1.2.8", "1.1.3", "1.0.99"} { + ov := valid() + ov.Overlay = version + _, err := Apply([]byte("info: {}"), ov) + require.NoError(t, err) + } + for _, version := range []string{"1.3.0", "2.0.0", "1.2", "1.2.bad"} { + ov := valid() + ov.Overlay = version + _, err := Apply([]byte("info: {}"), ov) + require.ErrorIs(t, err, ErrUnsupportedVersion) + } + ov := valid() + ov.Info.Title = "" + _, err := Apply([]byte("info: {}"), ov) + require.ErrorIs(t, err, ErrInvalidInfo) + ov = valid() + ov.Self = "api.yaml#fragment" + _, err = Apply([]byte("info: {}"), ov) + require.Error(t, err) + ov = valid() + ov.Actions[0] = nil + _, err = Apply([]byte("info: {}"), ov) + require.ErrorIs(t, err, ErrMissingTarget) + ov = valid() + ov.Components = &highoverlay.Components{Actions: orderedmap.New[string, *highoverlay.ReusableAction]()} + ov.Components.Actions.Set("nil", nil) + _, err = Apply([]byte("info: {}"), ov) + require.ErrorIs(t, err, ErrInvalidReusableAction) + ov = valid() + ov.Actions[0].Copy = "$.source" + ov.Actions[0].Target = "$.targets[*]" + _, err = Apply([]byte("source: {}\ntargets: [{}, []]"), ov) + require.ErrorIs(t, err, ErrIncompatibleUpdate) +} + +func TestApply_WideObjectMerge(t *testing.T) { + target := &yaml.Node{Kind: yaml.MappingNode} + for i := range 40 { + target.Content = append(target.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: fmt.Sprintf("key%d", i)}, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "old"}) + } + update := &yaml.Node{Kind: yaml.MappingNode} + for i := 35; i < 45; i++ { + update.Content = append(update.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: fmt.Sprintf("key%d", i)}, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "new"}) + } + ov := &highoverlay.Overlay{Overlay: "1.2.0", Info: &highoverlay.Info{Title: "Wide update", Version: "1"}, Actions: []*highoverlay.Action{{Target: "$", Update: update}}} + raw, err := yaml.Marshal(target) + require.NoError(t, err) + result, err := Apply(raw, ov) + require.NoError(t, err) + var values map[string]string + require.NoError(t, yaml.Unmarshal(result.Bytes, &values)) + require.Len(t, values, 45) + assert.Equal(t, "old", values["key34"]) + assert.Equal(t, "new", values["key35"]) + assert.Equal(t, "new", values["key44"]) +} + +func TestApply_WideInsertIntoEmptyObject(t *testing.T) { + const properties = 200 + update := &yaml.Node{Kind: yaml.MappingNode} + for i := range properties { + update.Content = append(update.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: fmt.Sprintf("property%d", i)}, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "new"}) + } + ov := &highoverlay.Overlay{Overlay: "1.2.0", Info: &highoverlay.Info{Title: "Wide insert", Version: "1"}, Actions: []*highoverlay.Action{{Target: "$.components.schemas", Update: update}}} + result, err := Apply([]byte("components: {schemas: {}}"), ov) + require.NoError(t, err) + var doc struct { + Components struct { + Schemas map[string]string `yaml:"schemas"` + } `yaml:"components"` + } + require.NoError(t, yaml.Unmarshal(result.Bytes, &doc)) + require.Len(t, doc.Components.Schemas, properties) + assert.Equal(t, "new", doc.Components.Schemas["property199"]) +} + +func BenchmarkOverlayMergeInsert(b *testing.B) { + for _, width := range []int{20, 200, 2000} { + b.Run(fmt.Sprint(width), func(b *testing.B) { + update := &yaml.Node{Kind: yaml.MappingNode} + for i := range width { + update.Content = append(update.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: fmt.Sprint(i)}, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "new"}) + } + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + target := &yaml.Node{Kind: yaml.MappingNode} + if err := mergeNode(target, update); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func TestApply_ReferenceWithoutComponents(t *testing.T) { + ov := &highoverlay.Overlay{Overlay: "1.2.0", Info: &highoverlay.Info{Title: "Missing components", Version: "1"}, Actions: []*highoverlay.Action{{Ref: "#/components/actions/missing", Target: "$"}}} + _, err := Apply([]byte("info: {}"), ov) + require.ErrorIs(t, err, ErrInvalidActionReference) +} diff --git a/overlay/engine.go b/overlay/engine.go index aa76e5eec..7e31b4003 100644 --- a/overlay/engine.go +++ b/overlay/engine.go @@ -4,6 +4,7 @@ package overlay import ( + "fmt" "github.com/pb33f/go-yaml" "github.com/pb33f/jsonpath/pkg/jsonpath" "github.com/pb33f/jsonpath/pkg/jsonpath/config" @@ -34,7 +35,11 @@ func Apply(targetBytes []byte, overlay *highoverlay.Overlay) (*Result, error) { parentIdxStale := true var warnings []*Warning - for _, action := range overlay.Actions { + for _, original := range overlay.Actions { + action, err := resolveAction(overlay, original) + if err != nil { + return nil, &OverlayError{Action: original, Cause: err} + } if action.Remove && parentIdxStale { parentIdx = newParentIndex(&rootNode) parentIdxStale = false @@ -53,6 +58,11 @@ func Apply(targetBytes []byte, overlay *highoverlay.Overlay) (*Result, error) { } } + // JSON input retains flow style. Render the root as block YAML so the + // public document parser does not mistake YAML flow syntax for JSON. + if len(rootNode.Content) > 0 { + rootNode.Content[0].Style &^= yaml.FlowStyle + } resultBytes, err := yaml.Marshal(&rootNode) if err != nil { return nil, err @@ -67,10 +77,6 @@ func Apply(targetBytes []byte, overlay *highoverlay.Overlay) (*Result, error) { func applyAction(root *yaml.Node, action *highoverlay.Action, parentIdx parentIndex) ([]*Warning, error) { var warnings []*Warning - if action.Target == "" { - return warnings, nil - } - path, err := jsonpath.NewPath(action.Target, config.WithPropertyNameExtension(), config.WithLazyContextTracking()) if err != nil { return nil, ErrInvalidJSONPath @@ -87,37 +93,24 @@ func applyAction(root *yaml.Node, action *highoverlay.Action, parentIdx parentIn return warnings, nil } - // Operation order per spec: copy → update → remove - // This allows: - // - Copy to populate the target first - // - Update to override copied values - // - Remove to clean up afterwards (move pattern) - - // 1. Copy (if present) + // Removal takes precedence. When copy and update coexist, neither applies. + if action.Remove { + applyRemoveAction(parentIdx, nodes) + return warnings, nil + } + if action.Update != nil && action.Copy != "" { + return warnings, nil + } if action.Copy != "" { - copyWarnings, err := applyCopyAction(root, nodes, action.Copy) - if err != nil { + return applyCopyAction(root, nodes, action.Copy) + } + if action.Update != nil && !action.Update.IsZero() { + if err := validateTargets(nodes); err != nil { return nil, err } - warnings = append(warnings, copyWarnings...) - } - - // 2. Update (if present) - // Validate targets for UPDATE actions (must be objects or arrays, not primitives). - // Validation happens AFTER copy because copy may change the target node type. - // REMOVE actions can target any node type. - if action.Update != nil { - for _, node := range nodes { - if err := validateTarget(node); err != nil { - return nil, err - } + if err := applyUpdateAction(nodes, action.Update); err != nil { + return nil, err } - applyUpdateAction(nodes, action.Update) - } - - // 3. Remove (if present) - if action.Remove { - applyRemoveAction(parentIdx, nodes) } return warnings, nil @@ -141,15 +134,13 @@ func applyCopyAction(root *yaml.Node, targetNodes []*yaml.Node, copyPath string) return nil, ErrCopySourceMultiple } - sourceNode := sourceNodes[0] - - // Type compatibility check per spec: "If the target expression and - // copy expression do not return the same type, an error MUST be reported" - for _, targetNode := range targetNodes { - if sourceNode.Kind != targetNode.Kind { - return nil, ErrCopyTypeMismatch - } - mergeNode(targetNode, sourceNode) + if err := validateTargets(targetNodes); err != nil { + return nil, err + } + // Freeze the source before any target changes; the source may also be a target. + sourceNode := utils.CloneYAMLNode(sourceNodes[0]) + if err := applyUpdateAction(targetNodes, sourceNode); err != nil { + return nil, fmt.Errorf("%w: %w", ErrCopyTypeMismatch, err) } return warnings, nil @@ -161,13 +152,17 @@ func applyRemoveAction(idx parentIndex, nodes []*yaml.Node) { } } -func applyUpdateAction(nodes []*yaml.Node, update *yaml.Node) { - if update.IsZero() { - return - } +func applyUpdateAction(nodes []*yaml.Node, update *yaml.Node) error { for _, node := range nodes { - mergeNode(node, update) + if node.Kind == yaml.SequenceNode && update.Kind != yaml.SequenceNode { + node.Content = append(node.Content, utils.CloneYAMLNode(update)) + continue + } + if err := mergeNode(node, update); err != nil { + return err + } } + return nil } type parentIndex map[*yaml.Node]*yaml.Node @@ -210,41 +205,68 @@ func removeNode(idx parentIndex, node *yaml.Node) { } } -func mergeNode(node *yaml.Node, merge *yaml.Node) { +// mergeNode enforces recursive property compatibility. Array-item appends apply +// only at action targets, not to properties inside an object merge. +func mergeNode(node *yaml.Node, merge *yaml.Node) error { if node.Kind != merge.Kind { - *node = *utils.CloneYAMLNode(merge) - return + return ErrIncompatibleUpdate } switch node.Kind { - default: - node.Value = merge.Value case yaml.MappingNode: - mergeMappingNode(node, merge) + return mergeMappingNode(node, merge) case yaml.SequenceNode: mergeSequenceNode(node, merge) + case yaml.ScalarNode: + // The tag and style must follow the value (for example, string to boolean). + node.Value, node.Tag, node.Style = merge.Value, merge.Tag, merge.Style + default: + return ErrIncompatibleUpdate } + return nil } -func mergeMappingNode(node *yaml.Node, merge *yaml.Node) { -NextKey: - for i := 0; i < len(merge.Content); i += 2 { - mergeKey := merge.Content[i].Value - mergeValue := merge.Content[i+1] - - for j := 0; j < len(node.Content); j += 2 { - nodeKey := node.Content[j].Value - if nodeKey == mergeKey { - mergeNode(node.Content[j+1], mergeValue) - continue NextKey +func mergeMappingNode(node *yaml.Node, merge *yaml.Node) error { + // Small objects and single-property updates are faster without a map. + // Index larger merges once to avoid quadratic scans across wide objects. + var properties map[string]*yaml.Node + const smallObjectProperties = 32 + const smallUpdateProperties = 4 + if (len(node.Content)/2 > smallObjectProperties || len(merge.Content)/2 > smallObjectProperties) && len(merge.Content)/2 > smallUpdateProperties { + capacity := max(len(node.Content), len(merge.Content)) / 2 + properties = make(map[string]*yaml.Node, capacity) + for j := 0; j+1 < len(node.Content); j += 2 { + properties[node.Content[j].Value] = node.Content[j+1] + } + } + for i := 0; i+1 < len(merge.Content); i += 2 { + key, value := merge.Content[i], merge.Content[i+1] + var target *yaml.Node + if properties != nil { + target = properties[key.Value] + } else { + for j := 0; j+1 < len(node.Content); j += 2 { + if node.Content[j].Value == key.Value { + target = node.Content[j+1] + break + } + } + } + if target != nil { + if err := mergeNode(target, value); err != nil { + return fmt.Errorf("property %q: %w", key.Value, err) + } + } else { + cloned := utils.CloneYAMLNode(value) + node.Content = append(node.Content, utils.CloneYAMLNode(key), cloned) + if properties != nil { + properties[key.Value] = cloned } } - - node.Content = append(node.Content, merge.Content[i], utils.CloneYAMLNode(mergeValue)) } + return nil } func mergeSequenceNode(node *yaml.Node, merge *yaml.Node) { - // clone each child individually to avoid wasteful intermediate allocation for _, child := range merge.Content { node.Content = append(node.Content, utils.CloneYAMLNode(child)) } diff --git a/overlay/engine_test.go b/overlay/engine_test.go index 48f06a053..b7c04f043 100644 --- a/overlay/engine_test.go +++ b/overlay/engine_test.go @@ -242,7 +242,7 @@ paths: {}` result, err := Apply([]byte(targetYAML), overlay) // $.info.title points to a scalar, which is invalid for update - assert.ErrorIs(t, err, ErrPrimitiveTarget) + assert.ErrorIs(t, err, ErrIncompatibleUpdate) assert.Nil(t, result) } @@ -283,8 +283,8 @@ info: } result, err := Apply([]byte(targetYAML), overlay) - require.NoError(t, err) - assert.NotNil(t, result) + require.ErrorIs(t, err, ErrMissingTarget) + assert.Nil(t, result) } func TestApply_DeepMerge(t *testing.T) { @@ -366,9 +366,9 @@ func TestOverlayError_Error_NoAction(t *testing.T) { func TestOverlayError_Unwrap(t *testing.T) { err := &OverlayError{ - Cause: ErrPrimitiveTarget, + Cause: ErrIncompatibleUpdate, } - assert.ErrorIs(t, err, ErrPrimitiveTarget) + assert.ErrorIs(t, err, ErrIncompatibleUpdate) } func TestApply_RemoveFromSequence(t *testing.T) { @@ -504,11 +504,8 @@ info: } result, err := Apply([]byte(targetYAML), overlay) - require.NoError(t, err) - // When kinds differ, the entire node is replaced with a clone - assert.Contains(t, string(result.Bytes), "item1") - assert.Contains(t, string(result.Bytes), "item2") - assert.NotContains(t, string(result.Bytes), "John") + require.ErrorIs(t, err, ErrIncompatibleUpdate) + assert.Nil(t, result) } func TestApply_RemoveNonexistentParent(t *testing.T) { @@ -789,7 +786,7 @@ paths: assert.Nil(t, result) } -func TestApply_CopyTypeMismatch_ObjectToArray(t *testing.T) { +func TestApply_CopyObjectToArray(t *testing.T) { targetYAML := `openapi: 3.0.0 info: title: Test @@ -814,8 +811,8 @@ paths: } result, err := Apply([]byte(targetYAML), overlay) - assert.ErrorIs(t, err, ErrCopyTypeMismatch) - assert.Nil(t, result) + require.NoError(t, err) + assert.Contains(t, string(result.Bytes), "- summary: Get") } func TestApply_CopyTypeMismatch_ArrayToObject(t *testing.T) { @@ -909,12 +906,7 @@ actions: result, err := Apply([]byte(targetYAML), overlay) require.NoError(t, err) - resultStr := string(result.Bytes) - // Copy happens first, then update overrides - assert.Contains(t, resultStr, "Overridden Summary") - assert.Contains(t, resultStr, "Source Description") - // The target path should have Overridden Summary, not Target Summary - assert.NotContains(t, resultStr, "Target Summary") + assert.YAMLEq(t, targetYAML, string(result.Bytes)) } func TestApply_CopyWithRemove_MovePattern(t *testing.T) { @@ -1116,9 +1108,8 @@ actions: assert.Contains(t, resultStr, "source-tag") } -func TestApply_CopyPrimitiveWithUpdateFails(t *testing.T) { - // When copy source is a primitive and update is also present, - // the update validation should fail because you can't merge into a primitive +func TestApply_CopyAndUpdateOnPrimitiveNoOp(t *testing.T) { + // Copy and update mutually suppress each other. targetYAML := `openapi: 3.0.0 info: title: Target Title @@ -1136,15 +1127,13 @@ actions: should: fail` overlay := parseOverlay(t, overlayYAML) - - _, err := Apply([]byte(targetYAML), overlay) - require.Error(t, err) - assert.ErrorIs(t, err, ErrPrimitiveTarget) + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + assert.YAMLEq(t, targetYAML, string(result.Bytes)) } -func TestApply_CopyObjectWithUpdateSucceeds(t *testing.T) { - // When copy source and target are both objects (same type), - // the copy merges content and then update can modify the result +func TestApply_CopyAndUpdateOnObjectNoOp(t *testing.T) { + // Copy and update mutually suppress each other. targetYAML := `openapi: 3.0.0 info: title: Target Title @@ -1169,12 +1158,7 @@ actions: result, err := Apply([]byte(targetYAML), overlay) require.NoError(t, err) - resultStr := string(result.Bytes) - // The license object was merged with contact (contact's name overwrites license's name), - // then updated with url - assert.Contains(t, resultStr, "Original Contact") - assert.Contains(t, resultStr, "original@example.com") - assert.Contains(t, resultStr, "https://example.com") + assert.YAMLEq(t, targetYAML, string(result.Bytes)) } func TestApply_UpdateOnPrimitiveStillFailsWithoutCopy(t *testing.T) { @@ -1198,5 +1182,5 @@ actions: _, err := Apply([]byte(targetYAML), overlay) require.Error(t, err) - assert.ErrorIs(t, err, ErrPrimitiveTarget) + assert.ErrorIs(t, err, ErrIncompatibleUpdate) } diff --git a/overlay/errors.go b/overlay/errors.go index 68f385097..7bf8bc9f3 100644 --- a/overlay/errors.go +++ b/overlay/errors.go @@ -47,8 +47,16 @@ var ( ErrMissingActions = errors.New("missing required 'actions' field") ErrEmptyActions = errors.New("actions array must contain at least one action") + ErrUnsupportedVersion = errors.New("unsupported overlay version; supported versions are 1.0, 1.1 and 1.2") + ErrInvalidInfo = errors.New("overlay info requires title and version") + ErrMissingTarget = errors.New("action requires a target") + ErrInvalidActionReference = errors.New("invalid reusable action reference") + ErrInvalidReusableAction = errors.New("reusable action fields must not contain target or $ref") + ErrIncompatibleUpdate = errors.New("incompatible overlay update and target types") + // JSONPath errors ErrInvalidJSONPath = errors.New("invalid JSONPath expression") + // Deprecated: primitive targets are supported. Incompatible updates return ErrIncompatibleUpdate. ErrPrimitiveTarget = errors.New("JSONPath target resolved to primitive/null; must be object or array") // Application errors @@ -57,5 +65,5 @@ var ( // Copy action errors ErrCopySourceNotFound = errors.New("copy source JSONPath matched zero nodes") ErrCopySourceMultiple = errors.New("copy source JSONPath must match exactly one node") - ErrCopyTypeMismatch = errors.New("copy source and target must be the same type") + ErrCopyTypeMismatch = errors.New("copy source is incompatible with target") ) diff --git a/overlay/reusable.go b/overlay/reusable.go new file mode 100644 index 000000000..860ad6bb6 --- /dev/null +++ b/overlay/reusable.go @@ -0,0 +1,96 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "fmt" + "net/url" + "strings" + + highoverlay "github.com/pb33f/libopenapi/datamodel/high/overlay" +) + +func resolveAction(overlay *highoverlay.Overlay, action *highoverlay.Action) (*highoverlay.Action, error) { + if action == nil || action.Target == "" { + return nil, ErrMissingTarget + } + if action.Ref == "" && (action.GoLow() == nil || action.GoLow().Ref.KeyNode == nil) { + return action, nil + } + key, err := actionReferenceKey(overlay, action.Ref) + if err != nil { + return nil, err + } + if overlay.Components == nil || overlay.Components.Actions == nil { + return nil, ErrInvalidActionReference + } + reusable, ok := overlay.Components.Actions.Get(key) + if !ok || reusable == nil { + return nil, fmt.Errorf("%w: %s", ErrInvalidActionReference, action.Ref) + } + effective := new(highoverlay.Action) + if reusable.Fields != nil { + fields := reusable.Fields + *effective = *fields + } + effective.Target, effective.Ref = action.Target, action.Ref + if action.Description != "" || (action.GoLow() != nil && action.GoLow().Description.KeyNode != nil) { + effective.Description = action.Description + } + if action.Update != nil { + effective.Update = action.Update + } + if action.Copy != "" || (action.GoLow() != nil && action.GoLow().Copy.KeyNode != nil) { + effective.Copy = action.Copy + } + if action.HasRemove() { + effective.SetRemove(action.Remove) + } + return effective, nil +} + +func actionReferenceKey(overlay *highoverlay.Overlay, ref string) (string, error) { + if strings.HasPrefix(ref, "#") { + return reusableKey(ref) + } + uri, err := url.Parse(ref) + if err != nil || uri.IsAbs() || strings.ContainsAny(ref, " \t\r\n") { + return "", ErrInvalidActionReference + } + self, err := url.Parse(overlay.Self) + if err != nil || !self.IsAbs() { + return "", ErrInvalidActionReference + } + resolved := self.ResolveReference(uri) + fragment := resolved.EscapedFragment() + resolved.Fragment, resolved.RawFragment = "", "" + if resolved.String() != self.String() { + return "", ErrInvalidActionReference + } + return reusableKey("#" + fragment) +} + +func reusableKey(ref string) (string, error) { + if !strings.HasPrefix(ref, "#") || strings.ContainsAny(ref[1:], "# \t\r\n") { + return "", ErrInvalidActionReference + } + pointer, err := url.PathUnescape(ref[1:]) + const prefix = "/components/actions/" + if err != nil || !strings.HasPrefix(pointer, prefix) { + return "", ErrInvalidActionReference + } + token := strings.TrimPrefix(pointer, prefix) + if strings.Contains(token, "/") { + return "", ErrInvalidActionReference + } + for i := 0; i < len(token); i++ { + if token[i] == '~' { + if i+1 == len(token) || (token[i+1] != '0' && token[i+1] != '1') { + return "", ErrInvalidActionReference + } + i++ + } + } + return strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~"), nil +} diff --git a/overlay/validation.go b/overlay/validation.go index b9d0eb1ad..d2a8dc41f 100644 --- a/overlay/validation.go +++ b/overlay/validation.go @@ -4,29 +4,58 @@ package overlay import ( + "fmt" "github.com/pb33f/go-yaml" highoverlay "github.com/pb33f/libopenapi/datamodel/high/overlay" + "regexp" ) +var overlayVersion = regexp.MustCompile(`^1\.[012]\.[0-9]+$`) + // validateOverlay checks that the overlay has all required fields. func validateOverlay(overlay *highoverlay.Overlay) error { if overlay.Overlay == "" { return ErrMissingOverlayField } + if !overlayVersion.MatchString(overlay.Overlay) { + return ErrUnsupportedVersion + } if overlay.Info == nil { return ErrMissingInfo } + if overlay.Info.Title == "" || overlay.Info.Version == "" { + return ErrInvalidInfo + } + if _, err := overlay.ResolveExtends(""); err != nil { + return err + } if len(overlay.Actions) == 0 { return ErrEmptyActions } + if overlay.Components != nil { + for pair := overlay.Components.Actions.First(); pair != nil; pair = pair.Next() { + if pair.Value() == nil { + return ErrInvalidReusableAction + } + fields := pair.Value().Fields + if fields != nil && (fields.Target != "" || fields.Ref != "" || + (fields.GoLow() != nil && (fields.GoLow().Target.KeyNode != nil || fields.GoLow().Ref.KeyNode != nil))) { + return ErrInvalidReusableAction + } + } + } return nil } -// validateTarget checks that a target node is a valid target (object or array). -// Per the Overlay Spec, primitive/null targets are invalid. -func validateTarget(node *yaml.Node) error { - if node.Kind == yaml.ScalarNode { - return ErrPrimitiveTarget +// validateTargets checks the JSON type category, not the individual primitive type. +func validateTargets(nodes []*yaml.Node) error { + for _, node := range nodes { + if node.Kind != yaml.MappingNode && node.Kind != yaml.SequenceNode && node.Kind != yaml.ScalarNode { + return ErrIncompatibleUpdate + } + if node.Kind != nodes[0].Kind { + return fmt.Errorf("%w: mixed target types", ErrIncompatibleUpdate) + } } return nil } diff --git a/overlay/validation_test.go b/overlay/validation_test.go index f86034078..b0e9c61b2 100644 --- a/overlay/validation_test.go +++ b/overlay/validation_test.go @@ -74,8 +74,8 @@ func TestValidateTarget_Scalar(t *testing.T) { Value: "test", } - err := validateTarget(node) - assert.ErrorIs(t, err, ErrPrimitiveTarget) + err := validateTargets([]*yaml.Node{node}) + assert.NoError(t, err) } func TestValidateTarget_Mapping(t *testing.T) { @@ -83,7 +83,7 @@ func TestValidateTarget_Mapping(t *testing.T) { Kind: yaml.MappingNode, } - err := validateTarget(node) + err := validateTargets([]*yaml.Node{node}) assert.NoError(t, err) } @@ -92,7 +92,7 @@ func TestValidateTarget_Sequence(t *testing.T) { Kind: yaml.SequenceNode, } - err := validateTarget(node) + err := validateTargets([]*yaml.Node{node}) assert.NoError(t, err) } @@ -101,6 +101,6 @@ func TestValidateTarget_Document(t *testing.T) { Kind: yaml.DocumentNode, } - err := validateTarget(node) - assert.NoError(t, err) + err := validateTargets([]*yaml.Node{node}) + assert.ErrorIs(t, err, ErrIncompatibleUpdate) } diff --git a/overlay12_test.go b/overlay12_test.go new file mode 100644 index 000000000..8b2d32299 --- /dev/null +++ b/overlay12_test.go @@ -0,0 +1,102 @@ +package libopenapi + +import ( + "testing" + + highoverlay "github.com/pb33f/libopenapi/datamodel/high/overlay" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" +) + +func TestOverlay12PublicEntryPoints(t *testing.T) { + overlays := []string{ + `overlay: 1.2.0 +$self: https://example.com/overlays/shared.yaml +extends: ../api.yaml +info: {title: Error responses, version: '1', description: Shared errors} +components: + x-source: docs + actions: + errorResponse: + description: Error response + x-owner: docs + fields: + description: Add missing response + remove: true + update: + '404': {description: Not Found} +actions: + - $ref: '#/components/actions/errorResponse' + target: $.paths.*.*.responses + description: '' + remove: false +`, + `{"overlay":"1.2.0","$self":"https://example.com/overlays/shared.yaml","extends":"../api.yaml","info":{"title":"Error responses","version":"1","description":"Shared errors"},"components":{"x-source":"docs","actions":{"errorResponse":{"description":"Error response","x-owner":"docs","fields":{"description":"Add missing response","remove":true,"update":{"404":{"description":"Not Found"}}}}}},"actions":[{"$ref":"#/components/actions/errorResponse","target":"$.paths.*.*.responses","description":"","remove":false}]}`, + } + targets := []string{ + "openapi: 3.1.0\ninfo: {title: Items, version: '1'}\npaths:\n /items:\n get:\n responses:\n '200': {description: OK}", + `{"openapi":"3.1.0","info":{"title":"Items","version":"1"},"paths":{"/items":{"get":{"responses":{"200":{"description":"OK"}}}}}}`, + } + for _, raw := range overlays { + for _, target := range targets { + ov, err := NewOverlayDocument([]byte(raw)) + require.NoError(t, err) + assert.Equal(t, "https://example.com/overlays/shared.yaml", ov.Self) + resolved, err := ov.ResolveExtends("file:///local/overlay.yaml") + require.NoError(t, err) + assert.Equal(t, "https://example.com/api.yaml", resolved) + require.NotNil(t, ov.Components.GoLow()) + assert.Equal(t, ov.Components.GoLow(), ov.Components.GoLowUntyped()) + reusable, ok := ov.Components.Actions.Get("errorResponse") + require.True(t, ok) + require.NotNil(t, reusable.GoLow()) + assert.Equal(t, reusable.GoLow(), reusable.GoLowUntyped()) + _, err = ov.Components.Render() + require.NoError(t, err) + _, err = reusable.Render() + require.NoError(t, err) + rendered, err := ov.Render() + require.NoError(t, err) + reparsed, err := NewOverlayDocument(rendered) + require.NoError(t, err) + assert.Equal(t, ov.GoLow().Hash(), reparsed.GoLow().Hash()) + assert.True(t, reparsed.Actions[0].HasRemove()) + assert.False(t, reparsed.Actions[0].Remove) + doc, err := NewDocument([]byte(target)) + require.NoError(t, err) + calls := []func() (*OverlayResult, error){ + func() (*OverlayResult, error) { return ApplyOverlay(doc, ov) }, + func() (*OverlayResult, error) { return ApplyOverlayFromBytes(doc, rendered) }, + func() (*OverlayResult, error) { return ApplyOverlayToSpecBytes([]byte(target), reparsed) }, + func() (*OverlayResult, error) { return ApplyOverlayFromBytesToSpecBytes([]byte(target), []byte(raw)) }, + } + for _, call := range calls { + result, err := call() + require.NoError(t, err) + require.Empty(t, result.Warnings) + model, err := result.OverlayDocument.BuildV3Model() + require.NoError(t, err) + path, ok := model.Model.Paths.PathItems.Get("/items") + require.True(t, ok) + response, ok := path.Get.Responses.Codes.Get("404") + require.True(t, ok) + assert.Equal(t, "Not Found", response.Description) + _, ok = path.Get.Responses.Codes.Get("200") + assert.True(t, ok) + } + } + } +} + +func TestOverlay12ConstructedModelsRender(t *testing.T) { + action := &highoverlay.Action{Ref: "#/components/actions/noop", Target: "$"} + action.SetRemove(false) + ov := &highoverlay.Overlay{Overlay: "1.2.0", Info: &highoverlay.Info{Title: "Constructed", Version: "1"}, Components: &highoverlay.Components{}, Actions: []*highoverlay.Action{action}} + raw, err := ov.Render() + require.NoError(t, err) + parsed, err := NewOverlayDocument(raw) + require.NoError(t, err) + require.NotNil(t, parsed.Components) + assert.True(t, parsed.Actions[0].HasRemove()) + assert.False(t, parsed.Actions[0].Remove) +} From ebfa403fe7a9bd99818b30fe3492f6a1dc3c78c7 Mon Sep 17 00:00:00 2001 From: quobix Date: Tue, 6 Oct 2026 11:04:16 -0400 Subject: [PATCH 3/6] fix: preserve empty Overlay metadata strings --- datamodel/high/overlay/info.go | 6 +++--- datamodel/high/overlay/overlay12_test.go | 14 ++++++++++++++ datamodel/low/overlay/overlay_test.go | 10 ++++------ docs/overlay-1.2.md | 4 ++++ overlay/conformance_test.go | 3 +-- overlay/engine_test.go | 11 +++++------ overlay/validation.go | 6 ++++-- overlay12_test.go | 18 ++++++++++++++++++ 8 files changed, 53 insertions(+), 19 deletions(-) diff --git a/datamodel/high/overlay/info.go b/datamodel/high/overlay/info.go index 02b93c317..6f926114b 100644 --- a/datamodel/high/overlay/info.go +++ b/datamodel/high/overlay/info.go @@ -55,13 +55,13 @@ func (i *Info) Render() ([]byte, error) { // MarshalYAML creates a ready to render YAML representation of the Info object. func (i *Info) MarshalYAML() (any, error) { m := orderedmap.New[string, any]() - if i.Title != "" { + if i.Title != "" || i.low == nil || !i.low.Title.IsEmpty() { m.Set("title", i.Title) } - if i.Version != "" { + if i.Version != "" || i.low == nil || !i.low.Version.IsEmpty() { m.Set("version", i.Version) } - if i.Description != "" { + if i.Description != "" || (i.low != nil && !i.low.Description.IsEmpty()) { m.Set("description", i.Description) } for pair := i.Extensions.First(); pair != nil; pair = pair.Next() { diff --git a/datamodel/high/overlay/overlay12_test.go b/datamodel/high/overlay/overlay12_test.go index 39bcbd050..791634658 100644 --- a/datamodel/high/overlay/overlay12_test.go +++ b/datamodel/high/overlay/overlay12_test.go @@ -104,3 +104,17 @@ func TestOverlay12EmptyReferenceAndTargetPreserved(t *testing.T) { require.NoError(t, err) assert.YAMLEq(t, text, string(raw)) } + +func TestOverlay12EmptyInfoStrings(t *testing.T) { + for _, text := range []string{"info: {title: '', version: '1'}", "info: {title: Example, version: '', description: ''}"} { + ov := parseOverlay12(t, text) + rendered, err := ov.Render() + require.NoError(t, err) + assert.YAMLEq(t, text, string(rendered)) + assert.Equal(t, ov.GoLow().Hash(), parseOverlay12(t, string(rendered)).GoLow().Hash()) + } + info := &Info{} + raw, err := info.Render() + require.NoError(t, err) + assert.YAMLEq(t, "title: ''\nversion: ''", string(raw)) +} diff --git a/datamodel/low/overlay/overlay_test.go b/datamodel/low/overlay/overlay_test.go index 7109400b8..ce2a67d58 100644 --- a/datamodel/low/overlay/overlay_test.go +++ b/datamodel/low/overlay/overlay_test.go @@ -383,7 +383,7 @@ x-custom: value` assert.NotEqual(t, [32]byte{}, hash) } -// TestOverlay_Build_InfoEmptyRef tests line 74 - error from ExtractObject when info has empty $ref +// The Info Object does not support references. func TestOverlay_Build_InfoEmptyRef(t *testing.T) { yml := `overlay: 1.0.0 info: @@ -402,7 +402,7 @@ info: assert.Contains(t, err.Error(), "$ref") } -// TestOverlay_Build_OddContentLengthExtractActions tests line 93 - break on odd content length +// Reject malformed mapping nodes supplied directly by callers. func TestOverlay_Build_OddContentLengthExtractActions(t *testing.T) { // Create an overlay WITHOUT actions - so extractActions iterates through all content yml := `overlay: 1.0.0 @@ -428,9 +428,7 @@ info: err = low.BuildModel(root, &overlay) require.NoError(t, err) - // This should trigger the break at line 93 due to odd content length - // The loop will iterate: i=0 (overlay), i=2 (info), i=4 (orphan-key) - // At i=4, i+1=5 >= len(Content)=5, so break is executed + // The malformed mapping must produce an error. err = overlay.Build(context.Background(), nil, root, nil) - require.Error(t, err) // Build should succeed, just skip the odd element + require.Error(t, err) } diff --git a/docs/overlay-1.2.md b/docs/overlay-1.2.md index 819e99928..7cdf6db45 100644 --- a/docs/overlay-1.2.md +++ b/docs/overlay-1.2.md @@ -34,6 +34,10 @@ the specification is corrected: - If both copy and update are present, neither changes the target, as stated by the two field descriptions. Use separate actions to copy and then update. - Every action needs a target. Malformed model structures produce errors. +- Fixed fields use exact case. Info objects do not support `$ref`. +- Required info strings may be empty; omission is distinct from an empty value. +- Application renders block YAML at the root, including for JSON input. +- Explicit empty `extends` survives rendering and resolves to the base URI. Reusable actions are in `Components.Actions`, with their action content in `ReusableAction.Fields`. Set `Action.Ref` to `#/components/actions/name`. diff --git a/overlay/conformance_test.go b/overlay/conformance_test.go index e51b7df2b..269517fc5 100644 --- a/overlay/conformance_test.go +++ b/overlay/conformance_test.go @@ -229,8 +229,7 @@ func TestApply_ValidationBoundaries(t *testing.T) { _, err := Apply([]byte("info: {}"), ov) require.ErrorIs(t, err, ErrUnsupportedVersion) } - ov := valid() - ov.Info.Title = "" + ov := parseOverlay(t, "overlay: 1.2.0\ninfo: {version: '1'}\nactions: [{target: '$'}]") _, err := Apply([]byte("info: {}"), ov) require.ErrorIs(t, err, ErrInvalidInfo) ov = valid() diff --git a/overlay/engine_test.go b/overlay/engine_test.go index b7c04f043..d0b3cc16a 100644 --- a/overlay/engine_test.go +++ b/overlay/engine_test.go @@ -241,7 +241,7 @@ paths: {}` } result, err := Apply([]byte(targetYAML), overlay) - // $.info.title points to a scalar, which is invalid for update + // An object cannot replace a primitive. assert.ErrorIs(t, err, ErrIncompatibleUpdate) assert.Nil(t, result) } @@ -475,7 +475,7 @@ actions: assert.Contains(t, string(result.Bytes), "New Title") } -func TestApply_ReplaceWithDifferentType(t *testing.T) { +func TestApply_RejectIncompatibleType(t *testing.T) { targetYAML := `openapi: 3.0.0 info: title: Test @@ -483,7 +483,7 @@ info: contact: name: John` - // Replace an object with a sequence (different node kinds) + // An object cannot be replaced by a sequence. updateNode := &yaml.Node{ Kind: yaml.SequenceNode, Content: []*yaml.Node{ @@ -1161,9 +1161,8 @@ actions: assert.YAMLEq(t, targetYAML, string(result.Bytes)) } -func TestApply_UpdateOnPrimitiveStillFailsWithoutCopy(t *testing.T) { - // Verify that update on primitive target still fails when no copy is present - // (regression test for the validation logic) +func TestApply_RejectObjectUpdateOnPrimitiveWithoutCopy(t *testing.T) { + // Primitive replacement requires a primitive update value. targetYAML := `openapi: 3.0.0 info: title: Test diff --git a/overlay/validation.go b/overlay/validation.go index d2a8dc41f..cc1605e82 100644 --- a/overlay/validation.go +++ b/overlay/validation.go @@ -5,9 +5,10 @@ package overlay import ( "fmt" + "regexp" + "github.com/pb33f/go-yaml" highoverlay "github.com/pb33f/libopenapi/datamodel/high/overlay" - "regexp" ) var overlayVersion = regexp.MustCompile(`^1\.[012]\.[0-9]+$`) @@ -23,7 +24,8 @@ func validateOverlay(overlay *highoverlay.Overlay) error { if overlay.Info == nil { return ErrMissingInfo } - if overlay.Info.Title == "" || overlay.Info.Version == "" { + if info := overlay.Info.GoLow(); info != nil && + ((info.Title.IsEmpty() && overlay.Info.Title == "") || (info.Version.IsEmpty() && overlay.Info.Version == "")) { return ErrInvalidInfo } if _, err := overlay.ResolveExtends(""); err != nil { diff --git a/overlay12_test.go b/overlay12_test.go index 8b2d32299..308ba0f99 100644 --- a/overlay12_test.go +++ b/overlay12_test.go @@ -4,6 +4,7 @@ import ( "testing" highoverlay "github.com/pb33f/libopenapi/datamodel/high/overlay" + "github.com/pb33f/libopenapi/overlay" "github.com/pb33f/testify/assert" "github.com/pb33f/testify/require" ) @@ -100,3 +101,20 @@ func TestOverlay12ConstructedModelsRender(t *testing.T) { assert.True(t, parsed.Actions[0].HasRemove()) assert.False(t, parsed.Actions[0].Remove) } + +func TestOverlay12EmptyAndAbsentInfo(t *testing.T) { + target := []byte("openapi: 3.1.0\ninfo: {title: Original, version: '1'}\npaths: {}") + for _, info := range []string{"{title: '', version: '1'}", "{title: Example, version: ''}", "{title: '', version: ''}"} { + ov, err := NewOverlayDocument([]byte("overlay: 1.2.0\ninfo: " + info + "\nactions: [{target: '$.info', update: {title: Changed}}]")) + require.NoError(t, err) + rendered, err := ov.Render() + require.NoError(t, err) + result, err := ApplyOverlayFromBytesToSpecBytes(target, rendered) + require.NoError(t, err) + assert.Contains(t, string(result.Bytes), "title: Changed") + } + for _, info := range []string{"{version: '1'}", "{title: Example}"} { + _, err := ApplyOverlayFromBytesToSpecBytes(target, []byte("overlay: 1.2.0\ninfo: "+info+"\nactions: [{target: '$'}]")) + require.ErrorIs(t, err, overlay.ErrInvalidInfo) + } +} From 0f3f4b3442cd46ac8fc5873c514bf19119754b11 Mon Sep 17 00:00:00 2001 From: quobix Date: Tue, 6 Oct 2026 11:11:19 -0400 Subject: [PATCH 4/6] test: cover cyclic Overlay alias rejection --- overlay/conformance_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/overlay/conformance_test.go b/overlay/conformance_test.go index 269517fc5..9c6442a24 100644 --- a/overlay/conformance_test.go +++ b/overlay/conformance_test.go @@ -317,3 +317,19 @@ func TestApply_ReferenceWithoutComponents(t *testing.T) { _, err := Apply([]byte("info: {}"), ov) require.ErrorIs(t, err, ErrInvalidActionReference) } + +func TestApply_RejectCyclicAliasMerge(t *testing.T) { + // A cyclic YAML alias cannot represent a JSON value. Reject it instead of + // silently leaving the old alias in place during a recursive object merge. + target := []byte("object: &original {self: *original}") + ov := parseOverlay(t, `overlay: 1.2.0 +info: {title: Cyclic input, version: '1'} +actions: + - target: $.object + update: &replacement {self: *replacement} +`) + result, err := Apply(target, ov) + require.ErrorIs(t, err, ErrIncompatibleUpdate) + require.Nil(t, result) + assert.Contains(t, err.Error(), `property "self"`) +} From df192019e99afd75c5e033963bb14814ee14f674 Mon Sep 17 00:00:00 2001 From: quobix Date: Tue, 6 Oct 2026 13:05:17 -0400 Subject: [PATCH 5/6] fix: preserve overlay input compatibility --- datamodel/low/overlay/extract.go | 2 +- datamodel/low/overlay/overlay12_test.go | 2 +- docs/overlay-1.2.md | 12 +++++-- overlay/conformance_test.go | 19 +++++++---- overlay/errors.go | 2 +- overlay/reusable.go | 2 +- overlay/validation.go | 5 +-- overlay12_test.go | 43 +++++++++++++++++++++++++ 8 files changed, 70 insertions(+), 17 deletions(-) diff --git a/datamodel/low/overlay/extract.go b/datamodel/low/overlay/extract.go index fb9439e16..a4bfb2026 100644 --- a/datamodel/low/overlay/extract.go +++ b/datamodel/low/overlay/extract.go @@ -35,7 +35,7 @@ func extractString(label string, root *yaml.Node) (low.NodeReference[string], er if value == nil { return low.NodeReference[string]{}, nil } - if value.Kind != yaml.ScalarNode || value.Tag != "!!str" { + if value.Kind != yaml.ScalarNode { return low.NodeReference[string]{}, fmt.Errorf("%s must be a string", label) } return low.NodeReference[string]{Value: value.Value, KeyNode: key, ValueNode: value}, nil diff --git a/datamodel/low/overlay/overlay12_test.go b/datamodel/low/overlay/overlay12_test.go index 976b668fa..8c55993c4 100644 --- a/datamodel/low/overlay/overlay12_test.go +++ b/datamodel/low/overlay/overlay12_test.go @@ -98,7 +98,7 @@ func TestOverlay12MalformedModels(t *testing.T) { "components: {actions: {a: {description: []}}}", "[]", "null", "$self: {}", "components: []", "components: {actions: []}", "components: {actions: {a: false}}", "components: {actions: {a: {fields: []}}}", - "actions: [false]", "actions: [{$ref: {}}]", "actions: [{$ref: false}]", "info: []", + "actions: [false]", "actions: [{$ref: {}}]", "info: []", } { t.Run(text, func(t *testing.T) { _, err := buildOverlay12(t, text); require.Error(t, err) }) } diff --git a/docs/overlay-1.2.md b/docs/overlay-1.2.md index 7cdf6db45..b690852f8 100644 --- a/docs/overlay-1.2.md +++ b/docs/overlay-1.2.md @@ -23,8 +23,11 @@ Run independent correctness and de-slop reviews before publication. ## Compatibility The existing public entry points and `Actions []*Action` remain available. -Supported version strings are 1.0.x, 1.1.x, and 1.2.x. The engine uses the current -merge rules for all supported versions. Previous behavior that disagreed with +Version strings accept `1.` and `1..`, including later minor +versions. This preserves compatibility without claiming support for features +introduced after 1.2. String fields retain YAML scalar spelling, including +unquoted numeric versions; mappings and sequences are rejected. The engine uses +the current merge rules for all accepted versions. Previous behavior that disagreed with the specification is corrected: - Primitive targets can be updated. The value and YAML type both change. @@ -50,4 +53,7 @@ For a constructed reference that overrides removal with false, call `Overlay.ResolveExtends(baseURI)` resolves `extends` using `$self` and the supplied retrieval, encapsulating, or default base URI. It performs no I/O. Apply functions continue to use the target bytes supplied by the caller; they do not select or -fetch documents based on `extends`. With no absolute base, `ResolveExtends` returns the unresolved `extends` value. Document identifiers cannot contain fragments. +fetch documents based on `extends`, or validate unused document identifiers. +With no absolute base, `ResolveExtends` returns the unresolved `extends` value. +URI resolution rejects whitespace and fragments in document identifiers. +Relative action references also require a valid absolute `$self`. diff --git a/overlay/conformance_test.go b/overlay/conformance_test.go index 9c6442a24..ee3c4140e 100644 --- a/overlay/conformance_test.go +++ b/overlay/conformance_test.go @@ -155,6 +155,7 @@ func TestApply_InvalidReusableActions(t *testing.T) { {"bad percent", "{}", "{$ref: '#/components/actions/%zz', target: '$'}", ErrInvalidActionReference}, {"nested pointer", "{}", "{$ref: '#/components/actions/a/fields', target: '$'}", ErrInvalidActionReference}, {"empty ref", "{}", "{$ref: '', target: '$'}", ErrInvalidActionReference}, + {"scalar ref", "{}", "{$ref: false, target: '$'}", ErrInvalidActionReference}, {"missing target", "{a: {fields: {update: {}}}}", "{$ref: '#/components/actions/a'}", ErrMissingTarget}, {"forbidden target", "{a: {fields: {target: '', update: {}}}}", "{target: '$'}", ErrInvalidReusableAction}, {"nested reference", "{a: {fields: {$ref: '#/components/actions/a'}}}", "{target: '$'}", ErrInvalidReusableAction}, @@ -213,17 +214,27 @@ func TestApply_SameDocumentRelativeReference(t *testing.T) { } } +func TestApply_RejectInvalidSelfForRelativeActionReference(t *testing.T) { + for _, self := range []string{"https://example.com/my overlays/shared.yaml", "https://example.com/shared.yaml#fragment", "https://example.com/%zz"} { + t.Run(self, func(t *testing.T) { + ov := parseOverlay(t, "overlay: 1.2.0\n$self: '"+self+"'\ninfo: {title: Invalid base, version: '1'}\ncomponents: {actions: {a: {fields: {update: {title: Changed}}}}}\nactions: [{$ref: 'shared.yaml#/components/actions/a', target: '$.info'}]") + _, err := Apply([]byte("info: {title: Original}"), ov) + require.ErrorIs(t, err, ErrInvalidActionReference) + }) + } +} + func TestApply_ValidationBoundaries(t *testing.T) { valid := func() *highoverlay.Overlay { return &highoverlay.Overlay{Overlay: "1.2.0", Info: &highoverlay.Info{Title: "Valid", Version: "1"}, Actions: []*highoverlay.Action{{Target: "$"}}} } - for _, version := range []string{"1.2.8", "1.1.3", "1.0.99"} { + for _, version := range []string{"1.2.8", "1.1.3", "1.0.99", "1.3.0", "1.0", "1.2", "1.99.7"} { ov := valid() ov.Overlay = version _, err := Apply([]byte("info: {}"), ov) require.NoError(t, err) } - for _, version := range []string{"1.3.0", "2.0.0", "1.2", "1.2.bad"} { + for _, version := range []string{"2.0.0", "0.9.0", "1", "1.2.bad", "1.2.3.4", "1.-2", "1.2."} { ov := valid() ov.Overlay = version _, err := Apply([]byte("info: {}"), ov) @@ -233,10 +244,6 @@ func TestApply_ValidationBoundaries(t *testing.T) { _, err := Apply([]byte("info: {}"), ov) require.ErrorIs(t, err, ErrInvalidInfo) ov = valid() - ov.Self = "api.yaml#fragment" - _, err = Apply([]byte("info: {}"), ov) - require.Error(t, err) - ov = valid() ov.Actions[0] = nil _, err = Apply([]byte("info: {}"), ov) require.ErrorIs(t, err, ErrMissingTarget) diff --git a/overlay/errors.go b/overlay/errors.go index 7bf8bc9f3..9e9c01383 100644 --- a/overlay/errors.go +++ b/overlay/errors.go @@ -47,7 +47,7 @@ var ( ErrMissingActions = errors.New("missing required 'actions' field") ErrEmptyActions = errors.New("actions array must contain at least one action") - ErrUnsupportedVersion = errors.New("unsupported overlay version; supported versions are 1.0, 1.1 and 1.2") + ErrUnsupportedVersion = errors.New("unsupported overlay version; expected 1. or 1..") ErrInvalidInfo = errors.New("overlay info requires title and version") ErrMissingTarget = errors.New("action requires a target") ErrInvalidActionReference = errors.New("invalid reusable action reference") diff --git a/overlay/reusable.go b/overlay/reusable.go index 860ad6bb6..3a4267879 100644 --- a/overlay/reusable.go +++ b/overlay/reusable.go @@ -59,7 +59,7 @@ func actionReferenceKey(overlay *highoverlay.Overlay, ref string) (string, error return "", ErrInvalidActionReference } self, err := url.Parse(overlay.Self) - if err != nil || !self.IsAbs() { + if err != nil || !self.IsAbs() || strings.ContainsAny(overlay.Self, "# \t\r\n") { return "", ErrInvalidActionReference } resolved := self.ResolveReference(uri) diff --git a/overlay/validation.go b/overlay/validation.go index cc1605e82..f5f205efd 100644 --- a/overlay/validation.go +++ b/overlay/validation.go @@ -11,7 +11,7 @@ import ( highoverlay "github.com/pb33f/libopenapi/datamodel/high/overlay" ) -var overlayVersion = regexp.MustCompile(`^1\.[012]\.[0-9]+$`) +var overlayVersion = regexp.MustCompile(`^1\.[0-9]+(\.[0-9]+)?$`) // validateOverlay checks that the overlay has all required fields. func validateOverlay(overlay *highoverlay.Overlay) error { @@ -28,9 +28,6 @@ func validateOverlay(overlay *highoverlay.Overlay) error { ((info.Title.IsEmpty() && overlay.Info.Title == "") || (info.Version.IsEmpty() && overlay.Info.Version == "")) { return ErrInvalidInfo } - if _, err := overlay.ResolveExtends(""); err != nil { - return err - } if len(overlay.Actions) == 0 { return ErrEmptyActions } diff --git a/overlay12_test.go b/overlay12_test.go index 308ba0f99..49200ff54 100644 --- a/overlay12_test.go +++ b/overlay12_test.go @@ -1,6 +1,7 @@ package libopenapi import ( + "fmt" "testing" highoverlay "github.com/pb33f/libopenapi/datamodel/high/overlay" @@ -9,6 +10,48 @@ import ( "github.com/pb33f/testify/require" ) +func TestOverlayScalarVersionCompatibility(t *testing.T) { + target := []byte("openapi: 3.1.0\ninfo: {title: Original, version: '1'}\npaths: {}") + for _, version := range []string{"1", "1.0", "'1.0'"} { + for _, dialect := range []string{"1.0", "'1.2'", "1.3.0", "1.99.7"} { + t.Run(dialect+"/"+version, func(t *testing.T) { + raw := []byte(fmt.Sprintf("overlay: %s\ninfo: {title: Compatibility, version: %s}\nactions: [{target: '$.info.title', update: Changed}]", dialect, version)) + ov, err := NewOverlayDocument(raw) + require.NoError(t, err) + rendered, err := ov.Render() + require.NoError(t, err) + reparsed, err := NewOverlayDocument(rendered) + require.NoError(t, err) + assert.Equal(t, ov.Info.Version, reparsed.Info.Version) + assert.Equal(t, ov.Overlay, reparsed.Overlay) + for _, input := range [][]byte{raw, rendered} { + result, err := ApplyOverlayFromBytesToSpecBytes(target, input) + require.NoError(t, err) + assert.Contains(t, string(result.Bytes), "title: Changed") + } + }) + } + } +} + +func TestOverlayApplyDoesNotResolveUnusedDocumentURIs(t *testing.T) { + target := []byte("openapi: 3.1.0\ninfo: {title: Original, version: '1'}\npaths: {}") + for _, field := range []string{"extends", "$self"} { + for _, uri := range []string{"file:///C:/My Specs/api.yaml", "api.yaml#fragment", "%zz"} { + t.Run(field+"/"+uri, func(t *testing.T) { + raw := []byte(fmt.Sprintf("overlay: 1.2.0\n%s: '%s'\ninfo: {title: Compatibility, version: '1'}\nactions: [{target: '$.info.title', update: Changed}]", field, uri)) + result, err := ApplyOverlayFromBytesToSpecBytes(target, raw) + require.NoError(t, err) + assert.Contains(t, string(result.Bytes), "title: Changed") + ov, err := NewOverlayDocument(raw) + require.NoError(t, err) + _, err = ov.ResolveExtends("") + require.Error(t, err) + }) + } + } +} + func TestOverlay12PublicEntryPoints(t *testing.T) { overlays := []string{ `overlay: 1.2.0 From d5849aa0b67b8cb07813695fdbd36ee3a2136b82 Mon Sep 17 00:00:00 2001 From: quobix Date: Tue, 6 Oct 2026 13:07:12 -0400 Subject: [PATCH 6/6] fix: retain overlay action diagnostics --- docs/overlay-1.2.md | 4 +++- overlay/conformance_test.go | 36 ++++++++++++++++++++++++++++++++++++ overlay/engine.go | 11 +++++++++-- overlay/engine_test.go | 8 ++++++++ 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/docs/overlay-1.2.md b/docs/overlay-1.2.md index b690852f8..db1338f61 100644 --- a/docs/overlay-1.2.md +++ b/docs/overlay-1.2.md @@ -35,7 +35,8 @@ the specification is corrected: - Incompatible property types produce an error instead of replacing containers. - `remove: true` suppresses copy and update. - If both copy and update are present, neither changes the target, as stated by - the two field descriptions. Use separate actions to copy and then update. + the two field descriptions. A matched target produces a warning. Use separate + actions to copy and then update. - Every action needs a target. Malformed model structures produce errors. - Fixed fields use exact case. Info objects do not support `$ref`. - Required info strings may be empty; omission is distinct from an empty value. @@ -44,6 +45,7 @@ the specification is corrected: Reusable actions are in `Components.Actions`, with their action content in `ReusableAction.Fields`. Set `Action.Ref` to `#/components/actions/name`. +Errors and warnings retain the referencing action and its source location. JSON Pointer escapes and URI percent escapes are decoded. Same-document relative references are also supported when an absolute `$self` identifies the overlay. References to other documents are rejected. Reference fields override the corresponding reusable fields; the update payload is replaced, not merged with the reusable payload. diff --git a/overlay/conformance_test.go b/overlay/conformance_test.go index ee3c4140e..ab3e78cf1 100644 --- a/overlay/conformance_test.go +++ b/overlay/conformance_test.go @@ -122,6 +122,42 @@ paths: assert.False(t, effective.Remove) } +func TestApply_ReusableDiagnosticsPreserveSourceAction(t *testing.T) { + for _, tc := range []struct { + name, target, fields, warning string + err error + }{ + {name: "invalid target", target: "$..[[[[", fields: "update: new", err: ErrInvalidJSONPath}, + {name: "incompatible update", target: "$.value", fields: "update: {}", err: ErrIncompatibleUpdate}, + {name: "missing target", target: "$.missing", fields: "update: new", warning: "zero nodes"}, + {name: "copy and update", target: "$.value", fields: "copy: '$..[[[[', update: new", warning: "copy and update"}, + } { + t.Run(tc.name, func(t *testing.T) { + ov := parseOverlay(t, "overlay: 1.2.0\ninfo: {title: Diagnostics, version: '1'}\ncomponents:\n actions:\n shared:\n fields: {"+tc.fields+"}\nactions:\n - $ref: '#/components/actions/shared'\n target: '"+tc.target+"'\n") + original := ov.Actions[0] + result, err := Apply([]byte("value: old"), ov) + var reported *highoverlay.Action + if tc.err != nil { + require.ErrorIs(t, err, tc.err) + var actionErr *OverlayError + require.ErrorAs(t, err, &actionErr) + reported = actionErr.Action + } else { + require.NoError(t, err) + require.Len(t, result.Warnings, 1) + assert.Contains(t, result.Warnings[0].Message, tc.warning) + assert.Equal(t, tc.target, result.Warnings[0].Target) + assert.YAMLEq(t, "value: old", string(result.Bytes)) + reported = result.Warnings[0].Action + } + require.Same(t, original, reported) + assert.Equal(t, 8, reported.GoLow().RootNode.Line) + require.NotNil(t, reported.GoLow().Target.KeyNode) + assert.Equal(t, 9, reported.GoLow().Target.KeyNode.Line) + }) + } +} + func TestApply_ReusableOverrides(t *testing.T) { for _, action := range []string{ "update: {title: Local}", diff --git a/overlay/engine.go b/overlay/engine.go index 7e31b4003..032bd278f 100644 --- a/overlay/engine.go +++ b/overlay/engine.go @@ -47,7 +47,10 @@ func Apply(targetBytes []byte, overlay *highoverlay.Overlay) (*Result, error) { actionWarnings, err := applyAction(&rootNode, action, parentIdx) if err != nil { - return nil, &OverlayError{Action: action, Cause: err} + return nil, &OverlayError{Action: original, Cause: err} + } + for _, warning := range actionWarnings { + warning.Action = original } warnings = append(warnings, actionWarnings...) @@ -99,7 +102,11 @@ func applyAction(root *yaml.Node, action *highoverlay.Action, parentIdx parentIn return warnings, nil } if action.Update != nil && action.Copy != "" { - return warnings, nil + return append(warnings, &Warning{ + Action: action, + Target: action.Target, + Message: "copy and update are both set; neither is applied. Split them into separate actions.", + }), nil } if action.Copy != "" { return applyCopyAction(root, nodes, action.Copy) diff --git a/overlay/engine_test.go b/overlay/engine_test.go index d0b3cc16a..01b5204c8 100644 --- a/overlay/engine_test.go +++ b/overlay/engine_test.go @@ -1130,6 +1130,10 @@ actions: result, err := Apply([]byte(targetYAML), overlay) require.NoError(t, err) assert.YAMLEq(t, targetYAML, string(result.Bytes)) + require.Len(t, result.Warnings, 1) + assert.Same(t, overlay.Actions[0], result.Warnings[0].Action) + assert.Equal(t, "$.info.title", result.Warnings[0].Target) + assert.Contains(t, result.Warnings[0].Message, "copy and update") } func TestApply_CopyAndUpdateOnObjectNoOp(t *testing.T) { @@ -1159,6 +1163,10 @@ actions: result, err := Apply([]byte(targetYAML), overlay) require.NoError(t, err) assert.YAMLEq(t, targetYAML, string(result.Bytes)) + require.Len(t, result.Warnings, 1) + assert.Same(t, overlay.Actions[0], result.Warnings[0].Action) + assert.Equal(t, "$.info.license", result.Warnings[0].Target) + assert.Contains(t, result.Warnings[0].Message, "copy and update") } func TestApply_RejectObjectUpdateOnPrimitiveWithoutCopy(t *testing.T) {