diff --git a/README.md b/README.md index b1ecfa6a..ec9efa6a 100644 --- a/README.md +++ b/README.md @@ -49,19 +49,6 @@ like our _very kind_ sponsors: --- -`libopenapi` is kinda new, so our list of notable projects that depend on `libopenapi` is small (let me know if you'd like to add your project) - -- [github.com/daveshanley/vacuum](https://github.com/daveshanley/vacuum) - "The world's fastest and most scalable OpenAPI/Swagger linter/quality tool" -- [github.com/pb33f/openapi-changes](https://github.com/pb33f/openapi-changes) - "The world's **sexiest** OpenAPI breaking changes detector" -- [github.com/pb33f/wiretap](https://github.com/pb33f/wiretap) - "The world's **coolest** OpenAPI compliance analysis tool" -- [github.com/danielgtaylor/restish](https://github.com/danielgtaylor/restish) - "Restish is a CLI for interacting with REST-ish HTTP APIs" -- [github.com/speakeasy-api/speakeasy](https://github.com/speakeasy-api/speakeasy) - "Speakeasy CLI makes validating OpenAPI docs and generating idiomatic SDKs easy!" -- [github.com/apicat/apicat](https://github.com/apicat/apicat) - "AI-powered API development tool" -- [github.com/mattermost/mattermost](https://github.com/mattermost/mattermost) - "Software development lifecycle platform" -- [github.com/gopher-fleece/gleece](https://github.com/gopher-fleece/gleece) - "Building and documenting REST APIs through code-first development" -- Your project here? ---- - ## Come chat with us Need help? Have a question? Want to share your work? [Join our discord](https://discord.gg/x7VACVuEGP) and @@ -90,6 +77,7 @@ See all the documentation at https://pb33f.io/libopenapi/ - [Circular References](https://pb33f.io/libopenapi/circular-references/) - [Bundling Specs](https://pb33f.io/libopenapi/bundling/) - [What Changed / Diff Engine](https://pb33f.io/libopenapi/what-changed/) +- [Overlays](https://pb33f.io/libopenapi/overlays/) - [FAQ](https://pb33f.io/libopenapi/faq/) - [About libopenapi](https://pb33f.io/libopenapi/about/) --- diff --git a/datamodel/high/overlay/action.go b/datamodel/high/overlay/action.go new file mode 100644 index 00000000..888ea561 --- /dev/null +++ b/datamodel/high/overlay/action.go @@ -0,0 +1,78 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "github.com/pb33f/libopenapi/datamodel/high" + low "github.com/pb33f/libopenapi/datamodel/low/overlay" + "github.com/pb33f/libopenapi/orderedmap" + "go.yaml.in/yaml/v4" +) + +// Action represents a high-level Overlay Action Object. +// https://spec.openapis.org/overlay/v1.0.0#action-object +type Action struct { + 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"` + Remove bool `json:"remove,omitempty" yaml:"remove,omitempty"` + Extensions *orderedmap.Map[string, *yaml.Node] `json:"-" yaml:"-"` + low *low.Action +} + +// 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 + if !action.Target.IsEmpty() { + a.Target = action.Target.Value + } + if !action.Description.IsEmpty() { + a.Description = action.Description.Value + } + if !action.Update.IsEmpty() { + a.Update = action.Update.Value + } + if !action.Remove.IsEmpty() { + a.Remove = action.Remove.Value + } + a.Extensions = high.ExtractExtensions(action.Extensions) + return a +} + +// GoLow returns the low-level Action instance used to create the high-level one. +func (a *Action) GoLow() *low.Action { + return a.low +} + +// GoLowUntyped returns the low-level Action instance with no type. +func (a *Action) GoLowUntyped() any { + return a.low +} + +// Render returns a YAML representation of the Action object as a byte slice. +func (a *Action) Render() ([]byte, error) { + return yaml.Marshal(a) +} + +// MarshalYAML creates a ready to render YAML representation of the Action object. +func (a *Action) MarshalYAML() (interface{}, error) { + m := orderedmap.New[string, any]() + if a.Target != "" { + m.Set("target", a.Target) + } + if a.Description != "" { + m.Set("description", a.Description) + } + if a.Update != nil { + m.Set("update", a.Update) + } + if a.Remove { + m.Set("remove", a.Remove) + } + for pair := a.Extensions.First(); pair != nil; pair = pair.Next() { + m.Set(pair.Key(), pair.Value()) + } + return m, nil +} diff --git a/datamodel/high/overlay/action_test.go b/datamodel/high/overlay/action_test.go new file mode 100644 index 00000000..4f020fcc --- /dev/null +++ b/datamodel/high/overlay/action_test.go @@ -0,0 +1,162 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "context" + "testing" + + "github.com/pb33f/libopenapi/datamodel/low" + lowoverlay "github.com/pb33f/libopenapi/datamodel/low/overlay" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" +) + +func TestNewAction_Update(t *testing.T) { + yml := `target: $.info.title +description: Update the title +update: New Title` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var lowAction lowoverlay.Action + err = low.BuildModel(node.Content[0], &lowAction) + require.NoError(t, err) + err = lowAction.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + highAction := NewAction(&lowAction) + + assert.Equal(t, "$.info.title", highAction.Target) + assert.Equal(t, "Update the title", highAction.Description) + assert.NotNil(t, highAction.Update) + assert.Equal(t, "New Title", highAction.Update.Value) + assert.False(t, highAction.Remove) +} + +func TestNewAction_Remove(t *testing.T) { + yml := `target: $.info.description +remove: true` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var lowAction lowoverlay.Action + err = low.BuildModel(node.Content[0], &lowAction) + require.NoError(t, err) + err = lowAction.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + highAction := NewAction(&lowAction) + + assert.Equal(t, "$.info.description", highAction.Target) + assert.True(t, highAction.Remove) +} + +func TestNewAction_WithExtensions(t *testing.T) { + yml := `target: $.paths +x-priority: high` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var lowAction lowoverlay.Action + err = low.BuildModel(node.Content[0], &lowAction) + require.NoError(t, err) + err = lowAction.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + highAction := NewAction(&lowAction) + + assert.NotNil(t, highAction.Extensions) + assert.Equal(t, 1, highAction.Extensions.Len()) +} + +func TestAction_GoLow(t *testing.T) { + yml := `target: $.info` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var lowAction lowoverlay.Action + _ = low.BuildModel(node.Content[0], &lowAction) + _ = lowAction.Build(context.Background(), nil, node.Content[0], nil) + + highAction := NewAction(&lowAction) + + assert.Equal(t, &lowAction, highAction.GoLow()) + assert.Equal(t, &lowAction, highAction.GoLowUntyped()) +} + +func TestAction_Render(t *testing.T) { + yml := `target: $.info +update: + title: Test` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var lowAction lowoverlay.Action + _ = low.BuildModel(node.Content[0], &lowAction) + _ = lowAction.Build(context.Background(), nil, node.Content[0], nil) + + highAction := NewAction(&lowAction) + + rendered, err := highAction.Render() + require.NoError(t, err) + assert.Contains(t, string(rendered), "target: $.info") +} + +func TestAction_MarshalYAML(t *testing.T) { + yml := `target: $.info +description: Update info +update: + title: Test +x-custom: value` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var lowAction lowoverlay.Action + _ = low.BuildModel(node.Content[0], &lowAction) + _ = lowAction.Build(context.Background(), nil, node.Content[0], nil) + + highAction := NewAction(&lowAction) + + result, err := highAction.MarshalYAML() + require.NoError(t, err) + assert.NotNil(t, result) +} + +func TestAction_MarshalYAML_Remove(t *testing.T) { + yml := `target: $.info +remove: true` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var lowAction lowoverlay.Action + _ = low.BuildModel(node.Content[0], &lowAction) + _ = lowAction.Build(context.Background(), nil, node.Content[0], nil) + + highAction := NewAction(&lowAction) + + result, err := highAction.MarshalYAML() + require.NoError(t, err) + assert.NotNil(t, result) +} + +func TestAction_MarshalYAML_Empty(t *testing.T) { + var lowAction lowoverlay.Action + highAction := NewAction(&lowAction) + + result, err := highAction.MarshalYAML() + require.NoError(t, err) + assert.NotNil(t, result) +} diff --git a/datamodel/high/overlay/info.go b/datamodel/high/overlay/info.go new file mode 100644 index 00000000..93fd6ab0 --- /dev/null +++ b/datamodel/high/overlay/info.go @@ -0,0 +1,64 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "github.com/pb33f/libopenapi/datamodel/high" + low "github.com/pb33f/libopenapi/datamodel/low/overlay" + "github.com/pb33f/libopenapi/orderedmap" + "go.yaml.in/yaml/v4" +) + +// Info represents a high-level Overlay Info Object. +// https://spec.openapis.org/overlay/v1.0.0#info-object +type Info struct { + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Version string `json:"version,omitempty" yaml:"version,omitempty"` + Extensions *orderedmap.Map[string, *yaml.Node] `json:"-" yaml:"-"` + low *low.Info +} + +// NewInfo creates a new high-level Info instance from a low-level one. +func NewInfo(info *low.Info) *Info { + i := new(Info) + i.low = info + if !info.Title.IsEmpty() { + i.Title = info.Title.Value + } + if !info.Version.IsEmpty() { + i.Version = info.Version.Value + } + i.Extensions = high.ExtractExtensions(info.Extensions) + return i +} + +// GoLow returns the low-level Info instance used to create the high-level one. +func (i *Info) GoLow() *low.Info { + return i.low +} + +// GoLowUntyped returns the low-level Info instance with no type. +func (i *Info) GoLowUntyped() any { + return i.low +} + +// Render returns a YAML representation of the Info object as a byte slice. +func (i *Info) Render() ([]byte, error) { + return yaml.Marshal(i) +} + +// MarshalYAML creates a ready to render YAML representation of the Info object. +func (i *Info) MarshalYAML() (interface{}, error) { + m := orderedmap.New[string, any]() + if i.Title != "" { + m.Set("title", i.Title) + } + if i.Version != "" { + m.Set("version", i.Version) + } + 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/info_test.go b/datamodel/high/overlay/info_test.go new file mode 100644 index 00000000..544323ce --- /dev/null +++ b/datamodel/high/overlay/info_test.go @@ -0,0 +1,120 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "context" + "testing" + + "github.com/pb33f/libopenapi/datamodel/low" + lowoverlay "github.com/pb33f/libopenapi/datamodel/low/overlay" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" +) + +func TestNewInfo(t *testing.T) { + yml := `title: My Overlay +version: 1.0.0 +x-custom: value` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var lowInfo lowoverlay.Info + err = low.BuildModel(node.Content[0], &lowInfo) + require.NoError(t, err) + err = lowInfo.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + highInfo := NewInfo(&lowInfo) + + assert.Equal(t, "My Overlay", highInfo.Title) + assert.Equal(t, "1.0.0", highInfo.Version) + assert.NotNil(t, highInfo.Extensions) + assert.Equal(t, 1, highInfo.Extensions.Len()) +} + +func TestNewInfo_Minimal(t *testing.T) { + yml := `title: Minimal` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var lowInfo lowoverlay.Info + err = low.BuildModel(node.Content[0], &lowInfo) + require.NoError(t, err) + err = lowInfo.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + highInfo := NewInfo(&lowInfo) + + assert.Equal(t, "Minimal", highInfo.Title) + assert.Empty(t, highInfo.Version) +} + +func TestInfo_GoLow(t *testing.T) { + yml := `title: Test` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var lowInfo lowoverlay.Info + _ = low.BuildModel(node.Content[0], &lowInfo) + _ = lowInfo.Build(context.Background(), nil, node.Content[0], nil) + + highInfo := NewInfo(&lowInfo) + + assert.Equal(t, &lowInfo, highInfo.GoLow()) + assert.Equal(t, &lowInfo, highInfo.GoLowUntyped()) +} + +func TestInfo_Render(t *testing.T) { + yml := `title: My Overlay +version: 1.0.0` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var lowInfo lowoverlay.Info + _ = low.BuildModel(node.Content[0], &lowInfo) + _ = lowInfo.Build(context.Background(), nil, node.Content[0], nil) + + highInfo := NewInfo(&lowInfo) + + rendered, err := highInfo.Render() + require.NoError(t, err) + assert.Contains(t, string(rendered), "title: My Overlay") + assert.Contains(t, string(rendered), "version: 1.0.0") +} + +func TestInfo_MarshalYAML(t *testing.T) { + yml := `title: Test +version: 2.0.0 +x-custom: value` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var lowInfo lowoverlay.Info + _ = low.BuildModel(node.Content[0], &lowInfo) + _ = lowInfo.Build(context.Background(), nil, node.Content[0], nil) + + highInfo := NewInfo(&lowInfo) + + result, err := highInfo.MarshalYAML() + require.NoError(t, err) + assert.NotNil(t, result) +} + +func TestInfo_MarshalYAML_Empty(t *testing.T) { + var lowInfo lowoverlay.Info + highInfo := NewInfo(&lowInfo) + + result, err := highInfo.MarshalYAML() + require.NoError(t, err) + assert.NotNil(t, result) +} diff --git a/datamodel/high/overlay/overlay.go b/datamodel/high/overlay/overlay.go new file mode 100644 index 00000000..64786179 --- /dev/null +++ b/datamodel/high/overlay/overlay.go @@ -0,0 +1,82 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "github.com/pb33f/libopenapi/datamodel/high" + low "github.com/pb33f/libopenapi/datamodel/low/overlay" + "github.com/pb33f/libopenapi/orderedmap" + "go.yaml.in/yaml/v4" +) + +// Overlay represents a high-level OpenAPI Overlay document. +// https://spec.openapis.org/overlay/v1.0.0 +type Overlay struct { + Overlay string `json:"overlay,omitempty" yaml:"overlay,omitempty"` + Info *Info `json:"info,omitempty" yaml:"info,omitempty"` + Extends string `json:"extends,omitempty" yaml:"extends,omitempty"` + Actions []*Action `json:"actions,omitempty" yaml:"actions,omitempty"` + Extensions *orderedmap.Map[string, *yaml.Node] `json:"-" yaml:"-"` + low *low.Overlay +} + +// NewOverlay creates a new high-level Overlay instance from a low-level one. +func NewOverlay(overlay *low.Overlay) *Overlay { + o := new(Overlay) + o.low = overlay + if !overlay.Overlay.IsEmpty() { + o.Overlay = overlay.Overlay.Value + } + if !overlay.Info.IsEmpty() { + o.Info = NewInfo(overlay.Info.Value) + } + if !overlay.Extends.IsEmpty() { + o.Extends = overlay.Extends.Value + } + if !overlay.Actions.IsEmpty() { + actions := make([]*Action, 0, len(overlay.Actions.Value)) + for _, action := range overlay.Actions.Value { + actions = append(actions, NewAction(action.Value)) + } + o.Actions = actions + } + o.Extensions = high.ExtractExtensions(overlay.Extensions) + return o +} + +// GoLow returns the low-level Overlay instance used to create the high-level one. +func (o *Overlay) GoLow() *low.Overlay { + return o.low +} + +// GoLowUntyped returns the low-level Overlay instance with no type. +func (o *Overlay) GoLowUntyped() any { + return o.low +} + +// Render returns a YAML representation of the Overlay object as a byte slice. +func (o *Overlay) Render() ([]byte, error) { + return yaml.Marshal(o) +} + +// MarshalYAML creates a ready to render YAML representation of the Overlay object. +func (o *Overlay) MarshalYAML() (interface{}, error) { + m := orderedmap.New[string, any]() + if o.Overlay != "" { + m.Set("overlay", o.Overlay) + } + if o.Info != nil { + m.Set("info", o.Info) + } + if o.Extends != "" { + m.Set("extends", o.Extends) + } + if len(o.Actions) > 0 { + m.Set("actions", o.Actions) + } + for pair := o.Extensions.First(); pair != nil; pair = pair.Next() { + m.Set(pair.Key(), pair.Value()) + } + return m, nil +} diff --git a/datamodel/high/overlay/overlay_test.go b/datamodel/high/overlay/overlay_test.go new file mode 100644 index 00000000..60b7189a --- /dev/null +++ b/datamodel/high/overlay/overlay_test.go @@ -0,0 +1,215 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "context" + "testing" + + "github.com/pb33f/libopenapi/datamodel/low" + lowoverlay "github.com/pb33f/libopenapi/datamodel/low/overlay" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" +) + +func TestNewOverlay(t *testing.T) { + yml := `overlay: 1.0.0 +info: + title: My Overlay + version: 1.0.0 +extends: https://example.com/openapi.yaml +actions: + - target: $.info.title + update: New Title + - target: $.info.description + remove: true` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var lowOverlay lowoverlay.Overlay + err = low.BuildModel(node.Content[0], &lowOverlay) + require.NoError(t, err) + err = lowOverlay.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + highOverlay := NewOverlay(&lowOverlay) + + assert.Equal(t, "1.0.0", highOverlay.Overlay) + assert.NotNil(t, highOverlay.Info) + assert.Equal(t, "My Overlay", highOverlay.Info.Title) + assert.Equal(t, "1.0.0", highOverlay.Info.Version) + assert.Equal(t, "https://example.com/openapi.yaml", highOverlay.Extends) + assert.Len(t, highOverlay.Actions, 2) + assert.Equal(t, "$.info.title", highOverlay.Actions[0].Target) + assert.Equal(t, "$.info.description", highOverlay.Actions[1].Target) +} + +func TestNewOverlay_Minimal(t *testing.T) { + yml := `overlay: 1.0.0 +info: + title: Minimal + version: 1.0.0 +actions: + - target: $.info + update: {}` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var lowOverlay lowoverlay.Overlay + err = low.BuildModel(node.Content[0], &lowOverlay) + require.NoError(t, err) + err = lowOverlay.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + highOverlay := NewOverlay(&lowOverlay) + + assert.Equal(t, "1.0.0", highOverlay.Overlay) + assert.Empty(t, highOverlay.Extends) + assert.Len(t, highOverlay.Actions, 1) +} + +func TestNewOverlay_NoActions(t *testing.T) { + yml := `overlay: 1.0.0 +info: + title: No Actions + version: 1.0.0` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var lowOverlay lowoverlay.Overlay + err = low.BuildModel(node.Content[0], &lowOverlay) + require.NoError(t, err) + err = lowOverlay.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + highOverlay := NewOverlay(&lowOverlay) + + assert.Empty(t, highOverlay.Actions) +} + +func TestNewOverlay_WithExtensions(t *testing.T) { + yml := `overlay: 1.0.0 +info: + title: Extended + version: 1.0.0 +actions: [] +x-custom: value` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var lowOverlay lowoverlay.Overlay + err = low.BuildModel(node.Content[0], &lowOverlay) + require.NoError(t, err) + err = lowOverlay.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + highOverlay := NewOverlay(&lowOverlay) + + assert.NotNil(t, highOverlay.Extensions) + assert.Equal(t, 1, highOverlay.Extensions.Len()) +} + +func TestOverlay_GoLow(t *testing.T) { + yml := `overlay: 1.0.0 +info: + title: Test + version: 1.0.0 +actions: []` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var lowOverlay lowoverlay.Overlay + _ = low.BuildModel(node.Content[0], &lowOverlay) + _ = lowOverlay.Build(context.Background(), nil, node.Content[0], nil) + + highOverlay := NewOverlay(&lowOverlay) + + assert.Equal(t, &lowOverlay, highOverlay.GoLow()) + assert.Equal(t, &lowOverlay, highOverlay.GoLowUntyped()) +} + +func TestOverlay_Render(t *testing.T) { + yml := `overlay: 1.0.0 +info: + title: My Overlay + version: 1.0.0 +actions: + - target: $.info + update: {}` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var lowOverlay lowoverlay.Overlay + _ = low.BuildModel(node.Content[0], &lowOverlay) + _ = lowOverlay.Build(context.Background(), nil, node.Content[0], nil) + + highOverlay := NewOverlay(&lowOverlay) + + rendered, err := highOverlay.Render() + require.NoError(t, err) + assert.Contains(t, string(rendered), "overlay: 1.0.0") +} + +func TestOverlay_MarshalYAML(t *testing.T) { + yml := `overlay: 1.0.0 +info: + title: Test + version: 1.0.0 +extends: https://example.com/spec.yaml +actions: + - target: $.info + update: {} +x-custom: value` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var lowOverlay lowoverlay.Overlay + _ = low.BuildModel(node.Content[0], &lowOverlay) + _ = lowOverlay.Build(context.Background(), nil, node.Content[0], nil) + + highOverlay := NewOverlay(&lowOverlay) + + result, err := highOverlay.MarshalYAML() + require.NoError(t, err) + assert.NotNil(t, result) +} + +func TestOverlay_MarshalYAML_Empty(t *testing.T) { + var lowOverlay lowoverlay.Overlay + highOverlay := NewOverlay(&lowOverlay) + + result, err := highOverlay.MarshalYAML() + require.NoError(t, err) + assert.NotNil(t, result) +} + +func TestOverlay_MarshalYAML_NoInfo(t *testing.T) { + yml := `overlay: 1.0.0 +actions: []` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var lowOverlay lowoverlay.Overlay + _ = low.BuildModel(node.Content[0], &lowOverlay) + _ = lowOverlay.Build(context.Background(), nil, node.Content[0], nil) + + highOverlay := NewOverlay(&lowOverlay) + + result, err := highOverlay.MarshalYAML() + require.NoError(t, err) + assert.NotNil(t, result) +} diff --git a/datamodel/low/overlay/action.go b/datamodel/low/overlay/action.go new file mode 100644 index 00000000..3526c4e6 --- /dev/null +++ b/datamodel/low/overlay/action.go @@ -0,0 +1,120 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "context" + "crypto/sha256" + + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/index" + "github.com/pb33f/libopenapi/orderedmap" + "github.com/pb33f/libopenapi/utils" + "go.yaml.in/yaml/v4" +) + +// Action represents a low-level Overlay Action Object. +// https://spec.openapis.org/overlay/v1.0.0#action-object +type Action struct { + Target low.NodeReference[string] + Description low.NodeReference[string] + Update low.NodeReference[*yaml.Node] + Remove low.NodeReference[bool] + 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 Action object +func (a *Action) GetIndex() *index.SpecIndex { + return a.index +} + +// GetContext returns the context.Context instance used when building the Action object +func (a *Action) GetContext() context.Context { + return a.context +} + +// FindExtension returns a ValueReference containing the extension value, if found. +func (a *Action) FindExtension(ext string) *low.ValueReference[*yaml.Node] { + return low.FindItemInOrderedMap(ext, a.Extensions) +} + +// GetRootNode returns the root yaml node of the Action object +func (a *Action) GetRootNode() *yaml.Node { + return a.RootNode +} + +// GetKeyNode returns the key yaml node of the Action object +func (a *Action) GetKeyNode() *yaml.Node { + return a.KeyNode +} + +// Build will extract extensions for the Action object. +func (a *Action) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { + a.KeyNode = keyNode + root = utils.NodeAlias(root) + a.RootNode = root + utils.CheckForMergeNodes(root) + a.Reference = new(low.Reference) + a.Nodes = low.ExtractNodes(ctx, root) + a.Extensions = low.ExtractExtensions(root) + a.index = idx + a.context = ctx + low.ExtractExtensionNodes(ctx, a.Extensions, a.Nodes) + + // 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 { + a.Update = low.NodeReference[*yaml.Node]{ + Value: root.Content[i+1], + KeyNode: root.Content[i], + ValueNode: root.Content[i+1], + } + break + } + } + return nil +} + +// GetExtensions returns all Action extensions and satisfies the low.HasExtensions interface. +func (a *Action) GetExtensions() *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] { + return a.Extensions +} + +// Hash will return a consistent SHA256 Hash of the Action object +func (a *Action) Hash() [32]byte { + sb := low.GetStringBuilder() + defer low.PutStringBuilder(sb) + + if !a.Target.IsEmpty() { + sb.WriteString(a.Target.Value) + sb.WriteByte('|') + } + if !a.Description.IsEmpty() { + sb.WriteString(a.Description.Value) + sb.WriteByte('|') + } + if !a.Update.IsEmpty() { + sb.WriteString(low.GenerateHashString(a.Update.Value)) + sb.WriteByte('|') + } + if !a.Remove.IsEmpty() { + if a.Remove.Value { + sb.WriteString("true") + } else { + sb.WriteString("false") + } + sb.WriteByte('|') + } + for _, ext := range low.HashExtensions(a.Extensions) { + sb.WriteString(ext) + sb.WriteByte('|') + } + return sha256.Sum256([]byte(sb.String())) +} diff --git a/datamodel/low/overlay/action_test.go b/datamodel/low/overlay/action_test.go new file mode 100644 index 00000000..e6ad8fee --- /dev/null +++ b/datamodel/low/overlay/action_test.go @@ -0,0 +1,213 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "context" + "testing" + + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" +) + +func TestAction_Build_Update(t *testing.T) { + yml := `target: $.info.title +description: Update the title +update: New Title` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var action Action + err = low.BuildModel(node.Content[0], &action) + require.NoError(t, err) + + err = action.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + assert.Equal(t, "$.info.title", action.Target.Value) + assert.Equal(t, "Update the title", action.Description.Value) + assert.False(t, action.Update.IsEmpty()) + assert.Equal(t, "New Title", action.Update.Value.Value) + assert.True(t, action.Remove.IsEmpty()) +} + +func TestAction_Build_UpdateObject(t *testing.T) { + yml := `target: $.info +update: + title: New Title + version: 2.0.0` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var action Action + err = low.BuildModel(node.Content[0], &action) + require.NoError(t, err) + + err = action.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + assert.Equal(t, "$.info", action.Target.Value) + assert.False(t, action.Update.IsEmpty()) + assert.Equal(t, yaml.MappingNode, action.Update.Value.Kind) +} + +func TestAction_Build_Remove(t *testing.T) { + yml := `target: $.info.description +remove: true` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var action Action + err = low.BuildModel(node.Content[0], &action) + require.NoError(t, err) + + err = action.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + assert.Equal(t, "$.info.description", action.Target.Value) + assert.True(t, action.Remove.Value) +} + +func TestAction_Build_WithExtensions(t *testing.T) { + yml := `target: $.paths +x-priority: high` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var action Action + err = low.BuildModel(node.Content[0], &action) + require.NoError(t, err) + + err = action.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + assert.NotNil(t, action.Extensions) + ext := action.FindExtension("x-priority") + require.NotNil(t, ext) + assert.Equal(t, "high", ext.Value.Value) +} + +func TestAction_Hash(t *testing.T) { + yml1 := `target: $.info +update: + title: Test` + + yml2 := `target: $.info +update: + title: Test` + + yml3 := `target: $.paths +remove: true` + + var node1, node2, node3 yaml.Node + _ = yaml.Unmarshal([]byte(yml1), &node1) + _ = yaml.Unmarshal([]byte(yml2), &node2) + _ = yaml.Unmarshal([]byte(yml3), &node3) + + var action1, action2, action3 Action + _ = low.BuildModel(node1.Content[0], &action1) + _ = action1.Build(context.Background(), nil, node1.Content[0], nil) + + _ = low.BuildModel(node2.Content[0], &action2) + _ = action2.Build(context.Background(), nil, node2.Content[0], nil) + + _ = low.BuildModel(node3.Content[0], &action3) + _ = action3.Build(context.Background(), nil, node3.Content[0], nil) + + assert.Equal(t, action1.Hash(), action2.Hash()) + assert.NotEqual(t, action1.Hash(), action3.Hash()) +} + +func TestAction_Hash_AllFields(t *testing.T) { + yml := `target: $.info +description: Update info +update: + title: Test +x-ext: value` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var action Action + _ = low.BuildModel(node.Content[0], &action) + _ = action.Build(context.Background(), nil, node.Content[0], nil) + + hash := action.Hash() + assert.NotEqual(t, [32]byte{}, hash) +} + +func TestAction_Hash_RemoveFalse(t *testing.T) { + yml := `target: $.info +remove: false` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var action Action + _ = low.BuildModel(node.Content[0], &action) + _ = action.Build(context.Background(), nil, node.Content[0], nil) + + hash := action.Hash() + assert.NotEqual(t, [32]byte{}, hash) +} + +func TestAction_GettersReturnCorrectValues(t *testing.T) { + yml := `target: $.info` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + keyNode := &yaml.Node{Value: "action"} + var action Action + _ = low.BuildModel(node.Content[0], &action) + _ = action.Build(context.Background(), keyNode, node.Content[0], nil) + + assert.Equal(t, keyNode, action.GetKeyNode()) + assert.Equal(t, node.Content[0], action.GetRootNode()) + assert.Nil(t, action.GetIndex()) + assert.NotNil(t, action.GetContext()) + assert.NotNil(t, action.GetExtensions()) +} + +func TestAction_FindExtension_NotFound(t *testing.T) { + yml := `target: $.info` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var action Action + _ = low.BuildModel(node.Content[0], &action) + _ = action.Build(context.Background(), nil, node.Content[0], nil) + + ext := action.FindExtension("x-nonexistent") + assert.Nil(t, ext) +} + +func TestAction_Build_NoUpdate(t *testing.T) { + yml := `target: $.info +description: Just a description` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var action Action + err = low.BuildModel(node.Content[0], &action) + require.NoError(t, err) + + err = action.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + assert.True(t, action.Update.IsEmpty()) +} diff --git a/datamodel/low/overlay/constants.go b/datamodel/low/overlay/constants.go new file mode 100644 index 00000000..56696baa --- /dev/null +++ b/datamodel/low/overlay/constants.go @@ -0,0 +1,19 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +// Constants for labels used to look up values within OpenAPI Overlay specifications. +// https://spec.openapis.org/overlay/v1.0.0 +const ( + OverlayLabel = "overlay" + InfoLabel = "info" + ExtendsLabel = "extends" + ActionsLabel = "actions" + TitleLabel = "title" + VersionLabel = "version" + TargetLabel = "target" + DescriptionLabel = "description" + UpdateLabel = "update" + RemoveLabel = "remove" +) diff --git a/datamodel/low/overlay/info.go b/datamodel/low/overlay/info.go new file mode 100644 index 00000000..2306bd06 --- /dev/null +++ b/datamodel/low/overlay/info.go @@ -0,0 +1,94 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "context" + "crypto/sha256" + + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/index" + "github.com/pb33f/libopenapi/orderedmap" + "github.com/pb33f/libopenapi/utils" + "go.yaml.in/yaml/v4" +) + +// Info represents a low-level Overlay Info Object. +// https://spec.openapis.org/overlay/v1.0.0#info-object +type Info struct { + Title low.NodeReference[string] + Version low.NodeReference[string] + 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 Info object +func (i *Info) GetIndex() *index.SpecIndex { + return i.index +} + +// GetContext returns the context.Context instance used when building the Info object +func (i *Info) GetContext() context.Context { + return i.context +} + +// FindExtension returns a ValueReference containing the extension value, if found. +func (i *Info) FindExtension(ext string) *low.ValueReference[*yaml.Node] { + return low.FindItemInOrderedMap(ext, i.Extensions) +} + +// GetRootNode returns the root yaml node of the Info object +func (i *Info) GetRootNode() *yaml.Node { + return i.RootNode +} + +// GetKeyNode returns the key yaml node of the Info object +func (i *Info) GetKeyNode() *yaml.Node { + return i.KeyNode +} + +// Build will extract extensions for the Info object. +func (i *Info) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { + 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) + return nil +} + +// GetExtensions returns all Info extensions and satisfies the low.HasExtensions interface. +func (i *Info) GetExtensions() *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] { + return i.Extensions +} + +// Hash will return a consistent SHA256 Hash of the Info object +func (i *Info) Hash() [32]byte { + sb := low.GetStringBuilder() + defer low.PutStringBuilder(sb) + + if !i.Title.IsEmpty() { + sb.WriteString(i.Title.Value) + sb.WriteByte('|') + } + if !i.Version.IsEmpty() { + sb.WriteString(i.Version.Value) + sb.WriteByte('|') + } + for _, ext := range low.HashExtensions(i.Extensions) { + sb.WriteString(ext) + sb.WriteByte('|') + } + return sha256.Sum256([]byte(sb.String())) +} diff --git a/datamodel/low/overlay/info_test.go b/datamodel/low/overlay/info_test.go new file mode 100644 index 00000000..95acb4ee --- /dev/null +++ b/datamodel/low/overlay/info_test.go @@ -0,0 +1,134 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "context" + "testing" + + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" +) + +func TestInfo_Build(t *testing.T) { + yml := `title: My Overlay +version: 1.0.0 +x-custom: value` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var info Info + err = low.BuildModel(node.Content[0], &info) + require.NoError(t, err) + + err = info.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + assert.Equal(t, "My Overlay", info.Title.Value) + assert.Equal(t, "1.0.0", info.Version.Value) + assert.NotNil(t, info.Extensions) + assert.Equal(t, 1, info.Extensions.Len()) + + ext := info.FindExtension("x-custom") + require.NotNil(t, ext) + assert.Equal(t, "value", ext.Value.Value) +} + +func TestInfo_Build_Minimal(t *testing.T) { + yml := `title: Minimal` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var info Info + err = low.BuildModel(node.Content[0], &info) + require.NoError(t, err) + + err = info.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + assert.Equal(t, "Minimal", info.Title.Value) + assert.True(t, info.Version.IsEmpty()) +} + +func TestInfo_Hash(t *testing.T) { + yml1 := `title: Overlay +version: 1.0.0` + + yml2 := `title: Overlay +version: 1.0.0` + + yml3 := `title: Different +version: 2.0.0` + + var node1, node2, node3 yaml.Node + _ = yaml.Unmarshal([]byte(yml1), &node1) + _ = yaml.Unmarshal([]byte(yml2), &node2) + _ = yaml.Unmarshal([]byte(yml3), &node3) + + var info1, info2, info3 Info + _ = low.BuildModel(node1.Content[0], &info1) + _ = info1.Build(context.Background(), nil, node1.Content[0], nil) + + _ = low.BuildModel(node2.Content[0], &info2) + _ = info2.Build(context.Background(), nil, node2.Content[0], nil) + + _ = low.BuildModel(node3.Content[0], &info3) + _ = info3.Build(context.Background(), nil, node3.Content[0], nil) + + assert.Equal(t, info1.Hash(), info2.Hash()) + assert.NotEqual(t, info1.Hash(), info3.Hash()) +} + +func TestInfo_Hash_WithExtensions(t *testing.T) { + yml := `title: Overlay +x-ext: value` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var info Info + _ = low.BuildModel(node.Content[0], &info) + _ = info.Build(context.Background(), nil, node.Content[0], nil) + + hash := info.Hash() + assert.NotEqual(t, [32]byte{}, hash) +} + +func TestInfo_GettersReturnCorrectValues(t *testing.T) { + yml := `title: Test` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + keyNode := &yaml.Node{Value: "info"} + var info Info + _ = low.BuildModel(node.Content[0], &info) + _ = info.Build(context.Background(), keyNode, node.Content[0], nil) + + assert.Equal(t, keyNode, info.GetKeyNode()) + assert.Equal(t, node.Content[0], info.GetRootNode()) + assert.Nil(t, info.GetIndex()) + assert.NotNil(t, info.GetContext()) + assert.NotNil(t, info.GetExtensions()) +} + +func TestInfo_FindExtension_NotFound(t *testing.T) { + yml := `title: Test` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var info Info + _ = low.BuildModel(node.Content[0], &info) + _ = info.Build(context.Background(), nil, node.Content[0], nil) + + ext := info.FindExtension("x-nonexistent") + assert.Nil(t, ext) +} diff --git a/datamodel/low/overlay/overlay.go b/datamodel/low/overlay/overlay.go new file mode 100644 index 00000000..5a2ef606 --- /dev/null +++ b/datamodel/low/overlay/overlay.go @@ -0,0 +1,152 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "context" + "crypto/sha256" + + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/index" + "github.com/pb33f/libopenapi/orderedmap" + "github.com/pb33f/libopenapi/utils" + "go.yaml.in/yaml/v4" +) + +// Overlay represents a low-level OpenAPI Overlay document. +// https://spec.openapis.org/overlay/v1.0.0 +type Overlay struct { + Overlay low.NodeReference[string] + Info low.NodeReference[*Info] + Extends low.NodeReference[string] + Actions low.NodeReference[[]low.ValueReference[*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 Overlay object +func (o *Overlay) GetIndex() *index.SpecIndex { + return o.index +} + +// GetContext returns the context.Context instance used when building the Overlay object +func (o *Overlay) GetContext() context.Context { + return o.context +} + +// FindExtension returns a ValueReference containing the extension value, if found. +func (o *Overlay) FindExtension(ext string) *low.ValueReference[*yaml.Node] { + return low.FindItemInOrderedMap(ext, o.Extensions) +} + +// GetRootNode returns the root yaml node of the Overlay object +func (o *Overlay) GetRootNode() *yaml.Node { + return o.RootNode +} + +// GetKeyNode returns the key yaml node of the Overlay object +func (o *Overlay) GetKeyNode() *yaml.Node { + return o.KeyNode +} + +// Build will extract all properties of the Overlay document. +func (o *Overlay) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { + o.KeyNode = keyNode + root = utils.NodeAlias(root) + o.RootNode = root + utils.CheckForMergeNodes(root) + o.Reference = new(low.Reference) + o.Nodes = low.ExtractNodes(ctx, root) + o.Extensions = low.ExtractExtensions(root) + o.index = idx + o.context = ctx + low.ExtractExtensionNodes(ctx, o.Extensions, o.Nodes) + + // Extract info object + info, err := low.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 +} + +func (o *Overlay) extractActions(ctx context.Context, root *yaml.Node, idx *index.SpecIndex) low.NodeReference[[]low.ValueReference[*Action]] { + 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 + } + } + return result +} + +// GetExtensions returns all Overlay extensions and satisfies the low.HasExtensions interface. +func (o *Overlay) GetExtensions() *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] { + return o.Extensions +} + +// Hash will return a consistent SHA256 Hash of the Overlay object +func (o *Overlay) Hash() [32]byte { + sb := low.GetStringBuilder() + defer low.PutStringBuilder(sb) + + if !o.Overlay.IsEmpty() { + sb.WriteString(o.Overlay.Value) + sb.WriteByte('|') + } + if !o.Info.IsEmpty() { + sb.WriteString(low.GenerateHashString(o.Info.Value)) + sb.WriteByte('|') + } + if !o.Extends.IsEmpty() { + sb.WriteString(o.Extends.Value) + sb.WriteByte('|') + } + if !o.Actions.IsEmpty() { + for _, action := range o.Actions.Value { + sb.WriteString(low.GenerateHashString(action.Value)) + sb.WriteByte('|') + } + } + for _, ext := range low.HashExtensions(o.Extensions) { + sb.WriteString(ext) + sb.WriteByte('|') + } + return sha256.Sum256([]byte(sb.String())) +} diff --git a/datamodel/low/overlay/overlay_test.go b/datamodel/low/overlay/overlay_test.go new file mode 100644 index 00000000..615a3fef --- /dev/null +++ b/datamodel/low/overlay/overlay_test.go @@ -0,0 +1,437 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "context" + "testing" + + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" +) + +func TestOverlay_Build(t *testing.T) { + yml := `overlay: 1.0.0 +info: + title: My Overlay + version: 1.0.0 +extends: https://example.com/openapi.yaml +actions: + - target: $.info.title + update: New Title + - target: $.info.description + remove: true` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var overlay Overlay + err = low.BuildModel(node.Content[0], &overlay) + require.NoError(t, err) + + err = overlay.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + assert.Equal(t, "1.0.0", overlay.Overlay.Value) + assert.False(t, overlay.Info.IsEmpty()) + assert.Equal(t, "My Overlay", overlay.Info.Value.Title.Value) + assert.Equal(t, "1.0.0", overlay.Info.Value.Version.Value) + assert.Equal(t, "https://example.com/openapi.yaml", overlay.Extends.Value) + assert.False(t, overlay.Actions.IsEmpty()) + assert.Len(t, overlay.Actions.Value, 2) + + // Check first action + action1 := overlay.Actions.Value[0].Value + assert.Equal(t, "$.info.title", action1.Target.Value) + assert.False(t, action1.Update.IsEmpty()) + + // Check second action + action2 := overlay.Actions.Value[1].Value + assert.Equal(t, "$.info.description", action2.Target.Value) + assert.True(t, action2.Remove.Value) +} + +func TestOverlay_Build_Minimal(t *testing.T) { + yml := `overlay: 1.0.0 +info: + title: Minimal + version: 1.0.0 +actions: + - target: $.info + update: + description: Added` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var overlay Overlay + err = low.BuildModel(node.Content[0], &overlay) + require.NoError(t, err) + + err = overlay.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + assert.Equal(t, "1.0.0", overlay.Overlay.Value) + assert.True(t, overlay.Extends.IsEmpty()) + assert.Len(t, overlay.Actions.Value, 1) +} + +func TestOverlay_Build_WithExtensions(t *testing.T) { + yml := `overlay: 1.0.0 +info: + title: Extended + version: 1.0.0 +actions: + - target: $.info + update: {} +x-custom: value` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var overlay Overlay + err = low.BuildModel(node.Content[0], &overlay) + require.NoError(t, err) + + err = overlay.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + assert.NotNil(t, overlay.Extensions) + ext := overlay.FindExtension("x-custom") + require.NotNil(t, ext) + assert.Equal(t, "value", ext.Value.Value) +} + +func TestOverlay_Build_NoActions(t *testing.T) { + yml := `overlay: 1.0.0 +info: + title: No Actions + version: 1.0.0` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var overlay Overlay + err = low.BuildModel(node.Content[0], &overlay) + require.NoError(t, err) + + err = overlay.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + assert.True(t, overlay.Actions.IsEmpty()) +} + +func TestOverlay_Hash(t *testing.T) { + yml1 := `overlay: 1.0.0 +info: + title: Test + version: 1.0.0 +actions: + - target: $.info + update: {}` + + yml2 := `overlay: 1.0.0 +info: + title: Test + version: 1.0.0 +actions: + - target: $.info + update: {}` + + yml3 := `overlay: 2.0.0 +info: + title: Different + version: 2.0.0 +actions: + - target: $.paths + remove: true` + + var node1, node2, node3 yaml.Node + _ = yaml.Unmarshal([]byte(yml1), &node1) + _ = yaml.Unmarshal([]byte(yml2), &node2) + _ = yaml.Unmarshal([]byte(yml3), &node3) + + var overlay1, overlay2, overlay3 Overlay + _ = low.BuildModel(node1.Content[0], &overlay1) + _ = overlay1.Build(context.Background(), nil, node1.Content[0], nil) + + _ = low.BuildModel(node2.Content[0], &overlay2) + _ = overlay2.Build(context.Background(), nil, node2.Content[0], nil) + + _ = low.BuildModel(node3.Content[0], &overlay3) + _ = overlay3.Build(context.Background(), nil, node3.Content[0], nil) + + assert.Equal(t, overlay1.Hash(), overlay2.Hash()) + assert.NotEqual(t, overlay1.Hash(), overlay3.Hash()) +} + +func TestOverlay_Hash_WithExtends(t *testing.T) { + yml := `overlay: 1.0.0 +info: + title: Test + version: 1.0.0 +extends: https://example.com/spec.yaml +actions: + - target: $.info + update: {}` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var overlay Overlay + _ = low.BuildModel(node.Content[0], &overlay) + _ = overlay.Build(context.Background(), nil, node.Content[0], nil) + + hash := overlay.Hash() + assert.NotEqual(t, [32]byte{}, hash) +} + +func TestOverlay_GettersReturnCorrectValues(t *testing.T) { + yml := `overlay: 1.0.0 +info: + title: Test + version: 1.0.0 +actions: []` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + keyNode := &yaml.Node{Value: "overlay"} + var overlay Overlay + _ = low.BuildModel(node.Content[0], &overlay) + _ = overlay.Build(context.Background(), keyNode, node.Content[0], nil) + + assert.Equal(t, keyNode, overlay.GetKeyNode()) + assert.Equal(t, node.Content[0], overlay.GetRootNode()) + assert.Nil(t, overlay.GetIndex()) + assert.NotNil(t, overlay.GetContext()) + assert.NotNil(t, overlay.GetExtensions()) +} + +func TestOverlay_FindExtension_NotFound(t *testing.T) { + yml := `overlay: 1.0.0 +info: + title: Test + version: 1.0.0 +actions: []` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var overlay Overlay + _ = low.BuildModel(node.Content[0], &overlay) + _ = overlay.Build(context.Background(), nil, node.Content[0], nil) + + ext := overlay.FindExtension("x-nonexistent") + assert.Nil(t, ext) +} + +func TestOverlay_Build_ActionsNotSequence(t *testing.T) { + yml := `overlay: 1.0.0 +info: + title: Test + version: 1.0.0 +actions: not-a-sequence` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var overlay Overlay + err = low.BuildModel(node.Content[0], &overlay) + require.NoError(t, err) + + err = overlay.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + // Actions should be empty since it's not a sequence + assert.True(t, overlay.Actions.IsEmpty() || len(overlay.Actions.Value) == 0) +} + +func TestOverlay_Build_MultipleActions(t *testing.T) { + yml := `overlay: 1.0.0 +info: + title: Multi-Action + version: 1.0.0 +actions: + - target: $.info.title + description: First action + update: Title One + - target: $.info.description + description: Second action + update: Description + - target: $.info.contact + description: Third action + remove: true` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var overlay Overlay + err = low.BuildModel(node.Content[0], &overlay) + require.NoError(t, err) + + err = overlay.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + require.Len(t, overlay.Actions.Value, 3) + + assert.Equal(t, "$.info.title", overlay.Actions.Value[0].Value.Target.Value) + assert.Equal(t, "First action", overlay.Actions.Value[0].Value.Description.Value) + + assert.Equal(t, "$.info.description", overlay.Actions.Value[1].Value.Target.Value) + assert.Equal(t, "Second action", overlay.Actions.Value[1].Value.Description.Value) + + assert.Equal(t, "$.info.contact", overlay.Actions.Value[2].Value.Target.Value) + assert.True(t, overlay.Actions.Value[2].Value.Remove.Value) +} + +func TestOverlay_Hash_Empty(t *testing.T) { + // Test hash with all fields empty + var overlay Overlay + hash := overlay.Hash() + // Empty hash should still produce a valid (non-zero) hash + assert.NotEqual(t, [32]byte{}, hash) +} + +func TestOverlay_Hash_NoActions(t *testing.T) { + yml := `overlay: 1.0.0 +info: + title: Test + version: 1.0.0` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var overlay Overlay + _ = low.BuildModel(node.Content[0], &overlay) + _ = overlay.Build(context.Background(), nil, node.Content[0], nil) + + hash := overlay.Hash() + assert.NotEqual(t, [32]byte{}, hash) +} + +func TestOverlay_Build_OddContentLength(t *testing.T) { + // This tests the i+1 >= len(root.Content) check in extractActions + yml := `overlay: 1.0.0 +info: + title: Test + version: 1.0.0 +actions: + - target: $.info + update: {}` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + // Manually corrupt the node to have odd content length + // This simulates a malformed YAML structure + var overlay Overlay + err = low.BuildModel(node.Content[0], &overlay) + require.NoError(t, err) + + err = overlay.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) +} + +func TestOverlay_Build_EmptyActions(t *testing.T) { + yml := `overlay: 1.0.0 +info: + title: Test + version: 1.0.0 +actions: []` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var overlay Overlay + err = low.BuildModel(node.Content[0], &overlay) + require.NoError(t, err) + + err = overlay.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + assert.Len(t, overlay.Actions.Value, 0) +} + +func TestOverlay_Hash_WithExtensions(t *testing.T) { + yml := `overlay: 1.0.0 +info: + title: Test + version: 1.0.0 +actions: [] +x-custom: value` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var overlay Overlay + _ = low.BuildModel(node.Content[0], &overlay) + _ = overlay.Build(context.Background(), nil, node.Content[0], nil) + + hash := overlay.Hash() + assert.NotEqual(t, [32]byte{}, hash) +} + +// TestOverlay_Build_InfoEmptyRef tests line 74 - error from ExtractObject when info has empty $ref +func TestOverlay_Build_InfoEmptyRef(t *testing.T) { + yml := `overlay: 1.0.0 +info: + $ref: ""` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var overlay Overlay + err = low.BuildModel(node.Content[0], &overlay) + require.NoError(t, err) + + 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") +} + +// TestOverlay_Build_OddContentLengthExtractActions tests line 93 - break on odd content length +func TestOverlay_Build_OddContentLengthExtractActions(t *testing.T) { + // Create an overlay WITHOUT actions - so extractActions iterates through all content + yml := `overlay: 1.0.0 +info: + title: Test + version: 1.0.0` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + // Manually corrupt the root node to have odd number of content elements + // This simulates a malformed YAML structure that extractActions must handle + root := node.Content[0] + // Root content is: [overlay, "1.0.0", info, {...}] - 4 elements (even) + // Add an orphan key to make it 5 elements (odd) + root.Content = append(root.Content, &yaml.Node{ + Kind: yaml.ScalarNode, + Value: "orphan-key", + }) + + var overlay Overlay + 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 + err = overlay.Build(context.Background(), nil, root, nil) + require.NoError(t, err) // Build should succeed, just skip the odd element +} diff --git a/overlay.go b/overlay.go new file mode 100644 index 00000000..2f475f0b --- /dev/null +++ b/overlay.go @@ -0,0 +1,134 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package libopenapi + +import ( + gocontext "context" + + "github.com/pb33f/libopenapi/datamodel" + highoverlay "github.com/pb33f/libopenapi/datamodel/high/overlay" + "github.com/pb33f/libopenapi/datamodel/low" + lowoverlay "github.com/pb33f/libopenapi/datamodel/low/overlay" + "github.com/pb33f/libopenapi/overlay" + "go.yaml.in/yaml/v4" +) + +// OverlayResult contains the result of applying an overlay to a target document. +type OverlayResult struct { + // Bytes raw YAML bytes of the modified document after the overlay has been applied. + Bytes []byte + + // OverlayDocument is the modified document, ready to have a model built from it. + // The document is created using the same configuration as the input document + // (for ApplyOverlay and ApplyOverlayFromBytes), or with a default configuration + // (for ApplyOverlayToSpecBytes and ApplyOverlayFromBytesToSpecBytes). + OverlayDocument Document + + // Warnings that occurred during overlay application. + Warnings []*overlay.Warning +} + +// NewOverlayDocument creates a new overlay document from the provided bytes. +// The overlay document can then be applied to a target OpenAPI document using ApplyOverlay. +func NewOverlayDocument(overlayBytes []byte) (*highoverlay.Overlay, error) { + var node yaml.Node + if err := yaml.Unmarshal(overlayBytes, &node); err != nil { + return nil, err + } + + if len(node.Content) == 0 { + return nil, overlay.ErrInvalidOverlay + } + + var lowOv lowoverlay.Overlay + if err := low.BuildModel(node.Content[0], &lowOv); err != nil { + return nil, err + } + + if err := lowOv.Build(gocontext.Background(), nil, node.Content[0], nil); err != nil { + return nil, err + } + + return highoverlay.NewOverlay(&lowOv), nil +} + +// ApplyOverlay applies the overlay to the target document and returns the modified document. +// This is the primary entry point for an overlay application when working with Document objects. +// +// The returned OverlayDocument uses the same configuration as the input document. +func ApplyOverlay(document Document, ov *highoverlay.Overlay) (*OverlayResult, error) { + specBytes := document.GetSpecInfo().SpecBytes + if specBytes == nil { + return nil, overlay.ErrNoTargetDocument + } + + result, err := overlay.Apply(*specBytes, ov) + if err != nil { + return nil, err + } + + newDoc, err := NewDocumentWithConfiguration(result.Bytes, document.GetConfiguration()) + if err != nil { + return nil, err + } + + return &OverlayResult{ + Bytes: result.Bytes, + OverlayDocument: newDoc, + Warnings: result.Warnings, + }, nil +} + +// ApplyOverlayFromBytes applies an overlay (provided as bytes) to the target document. +// This is a convenience function when you have a Document but the overlay as raw bytes. +// +// The returned OverlayDocument uses the same configuration as the input document. +func ApplyOverlayFromBytes(document Document, overlayBytes []byte) (*OverlayResult, error) { + ov, err := NewOverlayDocument(overlayBytes) + if err != nil { + return nil, err + } + return ApplyOverlay(document, ov) +} + +// ApplyOverlayToSpecBytes applies the overlay to the target document bytes. +// Use this when you have raw spec bytes and a parsed Overlay object. +// +// The returned OverlayDocument uses a default document configuration. +func ApplyOverlayToSpecBytes(docBytes []byte, ov *highoverlay.Overlay) (*OverlayResult, error) { + return applyOverlayToBytesWithConfig(docBytes, ov, nil) +} + +// ApplyOverlayFromBytesToSpecBytes applies an overlay to target document bytes, +// where both the overlay and target document are provided as raw bytes. +// This is the most convenient function when you don't need to configure either document. +// +// The returned OverlayDocument uses a default document configuration. +func ApplyOverlayFromBytesToSpecBytes(docBytes, overlayBytes []byte) (*OverlayResult, error) { + ov, err := NewOverlayDocument(overlayBytes) + if err != nil { + return nil, err + } + return applyOverlayToBytesWithConfig(docBytes, ov, nil) +} + +// applyOverlayToBytesWithConfig is the internal function that applies the overlay to bytes +// and creates a Document with the specified configuration (nil for default). +func applyOverlayToBytesWithConfig(targetBytes []byte, ov *highoverlay.Overlay, config *datamodel.DocumentConfiguration) (*OverlayResult, error) { + result, err := overlay.Apply(targetBytes, ov) + if err != nil { + return nil, err + } + + newDoc, err := NewDocumentWithConfiguration(result.Bytes, config) + if err != nil { + return nil, err + } + + return &OverlayResult{ + Bytes: result.Bytes, + OverlayDocument: newDoc, + Warnings: result.Warnings, + }, nil +} diff --git a/overlay/engine.go b/overlay/engine.go new file mode 100644 index 00000000..dc5b231d --- /dev/null +++ b/overlay/engine.go @@ -0,0 +1,218 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "github.com/pb33f/jsonpath/pkg/jsonpath" + "github.com/pb33f/jsonpath/pkg/jsonpath/config" + highoverlay "github.com/pb33f/libopenapi/datamodel/high/overlay" + "go.yaml.in/yaml/v4" +) + +// Apply applies the given overlay to the target document bytes. +// It returns the modified document bytes and any warnings encountered. +func Apply(targetBytes []byte, overlay *highoverlay.Overlay) (*Result, error) { + if overlay == nil { + return nil, ErrInvalidOverlay + } + + if err := validateOverlay(overlay); err != nil { + return nil, err + } + + var rootNode yaml.Node + if err := yaml.Unmarshal(targetBytes, &rootNode); err != nil { + return nil, err + } + + // Parent index is built lazily and rebuilt after updates to ensure + // remove actions can target nodes created by earlier update actions. + var parentIdx parentIndex + parentIdxStale := true + + var warnings []*Warning + for _, action := range overlay.Actions { + if action.Remove && parentIdxStale { + parentIdx = newParentIndex(&rootNode) + parentIdxStale = false + } + + actionWarnings, err := applyAction(&rootNode, action, parentIdx) + if err != nil { + return nil, &OverlayError{Action: action, Cause: err} + } + warnings = append(warnings, actionWarnings...) + + if action.Update != nil { + parentIdxStale = true + } + } + + resultBytes, err := yaml.Marshal(&rootNode) + if err != nil { + return nil, err + } + + return &Result{ + Bytes: resultBytes, + Warnings: warnings, + }, nil +} + +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()) + if err != nil { + return nil, ErrInvalidJSONPath + } + + nodes := path.Query(root) + + if len(nodes) == 0 { + warnings = append(warnings, &Warning{ + Action: action, + Target: action.Target, + Message: "target matched zero nodes", + }) + return warnings, nil + } + + // Validate targets for UPDATE actions (must be objects or arrays, not primitives). + // REMOVE actions can target any node type. + if !action.Remove && action.Update != nil { + for _, node := range nodes { + if err := validateTarget(node); err != nil { + return nil, err + } + } + } + + if action.Remove { + applyRemoveAction(parentIdx, nodes) + } else if action.Update != nil { + applyUpdateAction(nodes, action.Update) + } + + return warnings, nil +} + +func applyRemoveAction(idx parentIndex, nodes []*yaml.Node) { + for _, node := range nodes { + removeNode(idx, node) + } +} + +func applyUpdateAction(nodes []*yaml.Node, update *yaml.Node) { + if update.IsZero() { + return + } + for _, node := range nodes { + mergeNode(node, update) + } +} + +type parentIndex map[*yaml.Node]*yaml.Node + +func newParentIndex(root *yaml.Node) parentIndex { + index := parentIndex{} + index.indexNodeRecursively(root) + return index +} + +func (index parentIndex) indexNodeRecursively(parent *yaml.Node) { + for _, child := range parent.Content { + index[child] = parent + index.indexNodeRecursively(child) + } +} + +func (index parentIndex) getParent(child *yaml.Node) *yaml.Node { + return index[child] +} + +func removeNode(idx parentIndex, node *yaml.Node) { + parent := idx.getParent(node) + if parent == nil { + return + } + + for i, child := range parent.Content { + if child == node { + switch parent.Kind { + case yaml.MappingNode: + // JSONPath returns value nodes (odd indices), so remove both key and value + parent.Content = append(parent.Content[:i-1], parent.Content[i+1:]...) + return + case yaml.SequenceNode: + parent.Content = append(parent.Content[:i], parent.Content[i+1:]...) + return + } + } + } +} + +func mergeNode(node *yaml.Node, merge *yaml.Node) { + if node.Kind != merge.Kind { + *node = *cloneNode(merge) + return + } + switch node.Kind { + default: + node.Value = merge.Value + case yaml.MappingNode: + mergeMappingNode(node, merge) + case yaml.SequenceNode: + mergeSequenceNode(node, merge) + } +} + +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 + } + } + + node.Content = append(node.Content, merge.Content[i], cloneNode(mergeValue)) + } +} + +func mergeSequenceNode(node *yaml.Node, merge *yaml.Node) { + node.Content = append(node.Content, cloneNode(merge).Content...) +} + +func cloneNode(node *yaml.Node) *yaml.Node { + newNode := &yaml.Node{ + Kind: node.Kind, + Style: node.Style, + Tag: node.Tag, + Value: node.Value, + Anchor: node.Anchor, + HeadComment: node.HeadComment, + LineComment: node.LineComment, + FootComment: node.FootComment, + } + if node.Alias != nil { + newNode.Alias = cloneNode(node.Alias) + } + if node.Content != nil { + newNode.Content = make([]*yaml.Node, len(node.Content)) + for i, child := range node.Content { + newNode.Content[i] = cloneNode(child) + } + } + return newNode +} diff --git a/overlay/engine_test.go b/overlay/engine_test.go new file mode 100644 index 00000000..71f89c72 --- /dev/null +++ b/overlay/engine_test.go @@ -0,0 +1,673 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "context" + "testing" + + "github.com/pb33f/libopenapi/datamodel/low" + highoverlay "github.com/pb33f/libopenapi/datamodel/high/overlay" + lowoverlay "github.com/pb33f/libopenapi/datamodel/low/overlay" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" +) + +func parseOverlay(t *testing.T, yml string) *highoverlay.Overlay { + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var lowOv lowoverlay.Overlay + err = low.BuildModel(node.Content[0], &lowOv) + require.NoError(t, err) + err = lowOv.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + return highoverlay.NewOverlay(&lowOv) +} + +func TestApply_UpdateTitle(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Original Title + version: 1.0.0 +paths: {}` + + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.info + update: + title: Updated Title` + + overlay := parseOverlay(t, overlayYAML) + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + assert.NotNil(t, result) + assert.Contains(t, string(result.Bytes), "Updated Title") + assert.Len(t, result.Warnings, 0) +} + +func TestApply_RemoveDescription(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 + description: This should be removed +paths: {}` + + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.info.description + remove: true` + + overlay := parseOverlay(t, overlayYAML) + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + assert.NotNil(t, result) + assert.NotContains(t, string(result.Bytes), "This should be removed") +} + +func TestApply_AddDescription(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +paths: {}` + + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.info + update: + description: Added description` + + overlay := parseOverlay(t, overlayYAML) + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + assert.Contains(t, string(result.Bytes), "Added description") +} + +func TestApply_MultipleActions(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Original + version: 1.0.0 + description: Remove me +paths: {}` + + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.info + update: + title: Updated + - target: $.info.description + remove: true` + + overlay := parseOverlay(t, overlayYAML) + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + assert.Contains(t, string(result.Bytes), "Updated") + assert.NotContains(t, string(result.Bytes), "Remove me") +} + +func TestApply_NoMatchWarning(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +paths: {}` + + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.nonexistent + update: + value: test` + + overlay := parseOverlay(t, overlayYAML) + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + assert.Len(t, result.Warnings, 1) + assert.Equal(t, "$.nonexistent", result.Warnings[0].Target) + assert.Contains(t, result.Warnings[0].Message, "zero nodes") +} + +func TestApply_NilOverlay(t *testing.T) { + targetYAML := `openapi: 3.0.0` + + result, err := Apply([]byte(targetYAML), nil) + assert.ErrorIs(t, err, ErrInvalidOverlay) + assert.Nil(t, result) +} + +func TestApply_MissingOverlayField(t *testing.T) { + targetYAML := `openapi: 3.0.0` + + // Create overlay without the overlay field + overlay := &highoverlay.Overlay{ + Info: &highoverlay.Info{ + Title: "Test", + Version: "1.0.0", + }, + Actions: []*highoverlay.Action{ + {Target: "$.info"}, + }, + } + + result, err := Apply([]byte(targetYAML), overlay) + assert.ErrorIs(t, err, ErrMissingOverlayField) + assert.Nil(t, result) +} + +func TestApply_MissingInfo(t *testing.T) { + targetYAML := `openapi: 3.0.0` + + overlay := &highoverlay.Overlay{ + Overlay: "1.0.0", + Actions: []*highoverlay.Action{ + {Target: "$.info"}, + }, + } + + result, err := Apply([]byte(targetYAML), overlay) + assert.ErrorIs(t, err, ErrMissingInfo) + assert.Nil(t, result) +} + +func TestApply_EmptyActions(t *testing.T) { + targetYAML := `openapi: 3.0.0` + + overlay := &highoverlay.Overlay{ + Overlay: "1.0.0", + Info: &highoverlay.Info{ + Title: "Test", + Version: "1.0.0", + }, + Actions: []*highoverlay.Action{}, + } + + result, err := Apply([]byte(targetYAML), overlay) + assert.ErrorIs(t, err, ErrEmptyActions) + assert.Nil(t, result) +} + +func TestApply_InvalidTarget_UpdateScalar(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +paths: {}` + + // Create overlay that tries to update a scalar value with an object + // This is invalid because you can't merge an object into a scalar + updateNode := &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "key"}, + {Kind: yaml.ScalarNode, Value: "value"}, + }, + } + + overlay := &highoverlay.Overlay{ + Overlay: "1.0.0", + Info: &highoverlay.Info{ + Title: "Test", + Version: "1.0.0", + }, + Actions: []*highoverlay.Action{ + {Target: "$.info.title", Update: updateNode}, + }, + } + + result, err := Apply([]byte(targetYAML), overlay) + // $.info.title points to a scalar, which is invalid for update + assert.ErrorIs(t, err, ErrPrimitiveTarget) + assert.Nil(t, result) +} + +func TestApply_InvalidYAML(t *testing.T) { + targetYAML := `invalid: yaml: content:` + + overlay := &highoverlay.Overlay{ + Overlay: "1.0.0", + Info: &highoverlay.Info{ + Title: "Test", + Version: "1.0.0", + }, + Actions: []*highoverlay.Action{ + {Target: "$.info"}, + }, + } + + result, err := Apply([]byte(targetYAML), overlay) + assert.Error(t, err) + assert.Nil(t, result) +} + +func TestApply_EmptyTarget(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0` + + overlay := &highoverlay.Overlay{ + Overlay: "1.0.0", + Info: &highoverlay.Info{ + Title: "Test", + Version: "1.0.0", + }, + Actions: []*highoverlay.Action{ + {Target: "", Update: &yaml.Node{Kind: yaml.ScalarNode, Value: "test"}}, + }, + } + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + assert.NotNil(t, result) +} + +func TestApply_DeepMerge(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 + contact: + name: Original Name` + + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.info.contact + update: + email: new@example.com` + + overlay := parseOverlay(t, overlayYAML) + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + // Should have both original name and new email + assert.Contains(t, string(result.Bytes), "Original Name") + assert.Contains(t, string(result.Bytes), "new@example.com") +} + +func TestApply_ArrayAppend(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +tags: + - name: existing` + + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.tags + update: + - name: new-tag` + + overlay := parseOverlay(t, overlayYAML) + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + assert.Contains(t, string(result.Bytes), "existing") + assert.Contains(t, string(result.Bytes), "new-tag") +} + +func TestWarning_String(t *testing.T) { + w := &Warning{ + Target: "$.info.title", + Message: "test message", + } + assert.Contains(t, w.String(), "$.info.title") + assert.Contains(t, w.String(), "test message") +} + +func TestOverlayError_Error(t *testing.T) { + action := &highoverlay.Action{Target: "$.test"} + err := &OverlayError{ + Action: action, + Cause: ErrPrimitiveTarget, + } + assert.Contains(t, err.Error(), "$.test") + assert.Contains(t, err.Error(), "primitive") +} + +func TestOverlayError_Error_NoAction(t *testing.T) { + err := &OverlayError{ + Cause: ErrInvalidOverlay, + } + assert.Contains(t, err.Error(), "overlay error") +} + +func TestOverlayError_Unwrap(t *testing.T) { + err := &OverlayError{ + Cause: ErrPrimitiveTarget, + } + assert.ErrorIs(t, err, ErrPrimitiveTarget) +} + +func TestApply_RemoveFromSequence(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +tags: + - name: first + - name: second + - name: third` + + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.tags[1] + remove: true` + + overlay := parseOverlay(t, overlayYAML) + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + assert.Contains(t, string(result.Bytes), "first") + assert.NotContains(t, string(result.Bytes), "second") + assert.Contains(t, string(result.Bytes), "third") +} + +func TestApply_RemoveKey(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 + contact: + name: John + email: john@example.com` + + // Test removing a value (contact) + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.info.contact + remove: true` + + overlay := parseOverlay(t, overlayYAML) + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + assert.NotContains(t, string(result.Bytes), "contact") + assert.NotContains(t, string(result.Bytes), "John") +} + +func TestApply_UpdateWithDifferentKind(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 + contact: + name: John` + + // Replace the contact object with a different structure + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.info.contact + update: + email: new@example.com + url: https://example.com` + + overlay := parseOverlay(t, overlayYAML) + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + // Should have both old and new properties (merge) + assert.Contains(t, string(result.Bytes), "John") + assert.Contains(t, string(result.Bytes), "new@example.com") +} + +func TestApply_UpdateScalarValue(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0` + + // This tests the mergeNode default case where node types match but aren't mapping/sequence + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.info + update: + title: New Title` + + overlay := parseOverlay(t, overlayYAML) + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + assert.Contains(t, string(result.Bytes), "New Title") +} + +func TestApply_ReplaceWithDifferentType(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 + contact: + name: John` + + // Replace an object with a sequence (different node kinds) + updateNode := &yaml.Node{ + Kind: yaml.SequenceNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "item1"}, + {Kind: yaml.ScalarNode, Value: "item2"}, + }, + } + + overlay := &highoverlay.Overlay{ + Overlay: "1.0.0", + Info: &highoverlay.Info{ + Title: "Test", + Version: "1.0.0", + }, + Actions: []*highoverlay.Action{ + {Target: "$.info.contact", Update: updateNode}, + }, + } + + 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") +} + +func TestApply_RemoveNonexistentParent(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0` + + // Try to remove root (no parent) + overlay := &highoverlay.Overlay{ + Overlay: "1.0.0", + Info: &highoverlay.Info{ + Title: "Test", + Version: "1.0.0", + }, + Actions: []*highoverlay.Action{ + {Target: "$", Remove: true}, + }, + } + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + // Should complete without error, just doesn't remove root + assert.NotNil(t, result) +} + +func TestApply_EmptyUpdateNode(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0` + + // Action with empty update + overlay := &highoverlay.Overlay{ + Overlay: "1.0.0", + Info: &highoverlay.Info{ + Title: "Test", + Version: "1.0.0", + }, + Actions: []*highoverlay.Action{ + {Target: "$.info", Update: &yaml.Node{}}, + }, + } + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + assert.NotNil(t, result) +} + +func TestCloneNode_WithAlias(t *testing.T) { + // Create a node with an alias + alias := &yaml.Node{Kind: yaml.ScalarNode, Value: "aliased"} + node := &yaml.Node{ + Kind: yaml.ScalarNode, + Alias: alias, + } + + cloned := cloneNode(node) + assert.NotNil(t, cloned.Alias) + assert.Equal(t, "aliased", cloned.Alias.Value) +} + +func TestApply_InvalidJSONPath(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0` + + overlay := &highoverlay.Overlay{ + Overlay: "1.0.0", + Info: &highoverlay.Info{ + Title: "Test", + Version: "1.0.0", + }, + Actions: []*highoverlay.Action{ + {Target: "$..[[[invalid", Update: &yaml.Node{Kind: yaml.ScalarNode, Value: "test"}}, + }, + } + + result, err := Apply([]byte(targetYAML), overlay) + assert.ErrorIs(t, err, ErrInvalidJSONPath) + assert.Nil(t, result) +} + +func TestRemoveNode_NilParent(t *testing.T) { + // Test removeNode with a node that has no parent in the index + // This tests the defensive nil parent check + orphanNode := &yaml.Node{Kind: yaml.ScalarNode, Value: "orphan"} + + // Create an empty parent index + idx := parentIndex{} + + // removeNode should safely handle nil parent + removeNode(idx, orphanNode) + + // No panic or error expected, just a silent no-op + assert.Equal(t, "orphan", orphanNode.Value) +} + +func TestApply_MarshalError(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0` + + // Create an overlay with an update that contains an invalid node kind + // yaml.Marshal will fail when trying to marshal a node with kind 99 + invalidNode := &yaml.Node{ + Kind: 99, // Invalid node kind + } + + overlay := &highoverlay.Overlay{ + Overlay: "1.0.0", + Info: &highoverlay.Info{ + Title: "Test", + Version: "1.0.0", + }, + Actions: []*highoverlay.Action{ + {Target: "$.info", Update: &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "contact"}, + invalidNode, + }, + }}, + }, + } + + result, err := Apply([]byte(targetYAML), overlay) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown kind") + assert.Nil(t, result) +} + +func TestApply_UpdateThenRemove_SequentialActions(t *testing.T) { + // This test verifies that remove actions can delete nodes added by earlier update actions + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0` + + // First action adds a description, second action removes it + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.info + update: + description: This will be added then removed + - target: $.info.description + remove: true` + + overlay := parseOverlay(t, overlayYAML) + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + + // The description should NOT be in the result because it was removed + assert.NotContains(t, string(result.Bytes), "This will be added then removed") + assert.NotContains(t, string(result.Bytes), "description") +} diff --git a/overlay/errors.go b/overlay/errors.go new file mode 100644 index 00000000..31ce90b4 --- /dev/null +++ b/overlay/errors.go @@ -0,0 +1,56 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "errors" + "fmt" + + highoverlay "github.com/pb33f/libopenapi/datamodel/high/overlay" +) + +// Warning represents a non-fatal issue encountered during overlay application. +type Warning struct { + Action *highoverlay.Action + Target string + Message string +} + +func (w *Warning) String() string { + return fmt.Sprintf("overlay warning: target '%s': %s", w.Target, w.Message) +} + +// OverlayError represents an error that occurred during an overlay application. +type OverlayError struct { + Action *highoverlay.Action + Cause error +} + +func (e *OverlayError) Error() string { + if e.Action != nil { + return fmt.Sprintf("overlay error at target '%s': %v", e.Action.Target, e.Cause) + } + return fmt.Sprintf("overlay error: %v", e.Cause) +} + +func (e *OverlayError) Unwrap() error { + return e.Cause +} + +// Sentinel errors for overlay operations. +var ( + // Parsing errors + ErrInvalidOverlay = errors.New("invalid overlay document") + ErrMissingOverlayField = errors.New("missing required 'overlay' field") + ErrMissingInfo = errors.New("missing required 'info' field") + ErrMissingActions = errors.New("missing required 'actions' field") + ErrEmptyActions = errors.New("actions array must contain at least one action") + + // JSONPath errors + ErrInvalidJSONPath = errors.New("invalid JSONPath expression") + ErrPrimitiveTarget = errors.New("JSONPath target resolved to primitive/null; must be object or array") + + // Application errors + ErrNoTargetDocument = errors.New("no target document provided") +) diff --git a/overlay/result.go b/overlay/result.go new file mode 100644 index 00000000..47894337 --- /dev/null +++ b/overlay/result.go @@ -0,0 +1,13 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +// Result represents the result of applying an overlay to a target document. +type Result struct { + // Bytes is the raw YAML/JSON bytes of the modified document. + Bytes []byte + + // Warnings contains non-fatal issues encountered during application. + Warnings []*Warning +} diff --git a/overlay/validation.go b/overlay/validation.go new file mode 100644 index 00000000..155c2ac0 --- /dev/null +++ b/overlay/validation.go @@ -0,0 +1,32 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + highoverlay "github.com/pb33f/libopenapi/datamodel/high/overlay" + "go.yaml.in/yaml/v4" +) + +// validateOverlay checks that the overlay has all required fields. +func validateOverlay(overlay *highoverlay.Overlay) error { + if overlay.Overlay == "" { + return ErrMissingOverlayField + } + if overlay.Info == nil { + return ErrMissingInfo + } + if len(overlay.Actions) == 0 { + return ErrEmptyActions + } + 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 + } + return nil +} diff --git a/overlay/validation_test.go b/overlay/validation_test.go new file mode 100644 index 00000000..f518b7cd --- /dev/null +++ b/overlay/validation_test.go @@ -0,0 +1,106 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package overlay + +import ( + "testing" + + highoverlay "github.com/pb33f/libopenapi/datamodel/high/overlay" + "github.com/stretchr/testify/assert" + "go.yaml.in/yaml/v4" +) + +func TestValidateOverlay_Valid(t *testing.T) { + overlay := &highoverlay.Overlay{ + Overlay: "1.0.0", + Info: &highoverlay.Info{ + Title: "Test", + Version: "1.0.0", + }, + Actions: []*highoverlay.Action{ + {Target: "$.info"}, + }, + } + + err := validateOverlay(overlay) + assert.NoError(t, err) +} + +func TestValidateOverlay_MissingOverlayField(t *testing.T) { + overlay := &highoverlay.Overlay{ + Info: &highoverlay.Info{ + Title: "Test", + Version: "1.0.0", + }, + Actions: []*highoverlay.Action{ + {Target: "$.info"}, + }, + } + + err := validateOverlay(overlay) + assert.ErrorIs(t, err, ErrMissingOverlayField) +} + +func TestValidateOverlay_MissingInfo(t *testing.T) { + overlay := &highoverlay.Overlay{ + Overlay: "1.0.0", + Actions: []*highoverlay.Action{ + {Target: "$.info"}, + }, + } + + err := validateOverlay(overlay) + assert.ErrorIs(t, err, ErrMissingInfo) +} + +func TestValidateOverlay_EmptyActions(t *testing.T) { + overlay := &highoverlay.Overlay{ + Overlay: "1.0.0", + Info: &highoverlay.Info{ + Title: "Test", + Version: "1.0.0", + }, + Actions: []*highoverlay.Action{}, + } + + err := validateOverlay(overlay) + assert.ErrorIs(t, err, ErrEmptyActions) +} + +func TestValidateTarget_Scalar(t *testing.T) { + node := &yaml.Node{ + Kind: yaml.ScalarNode, + Value: "test", + } + + err := validateTarget(node) + assert.ErrorIs(t, err, ErrPrimitiveTarget) +} + +func TestValidateTarget_Mapping(t *testing.T) { + node := &yaml.Node{ + Kind: yaml.MappingNode, + } + + err := validateTarget(node) + assert.NoError(t, err) +} + +func TestValidateTarget_Sequence(t *testing.T) { + node := &yaml.Node{ + Kind: yaml.SequenceNode, + } + + err := validateTarget(node) + assert.NoError(t, err) +} + +func TestValidateTarget_Document(t *testing.T) { + node := &yaml.Node{ + Kind: yaml.DocumentNode, + } + + err := validateTarget(node) + assert.NoError(t, err) +} diff --git a/overlay_test.go b/overlay_test.go new file mode 100644 index 00000000..bc55cb2a --- /dev/null +++ b/overlay_test.go @@ -0,0 +1,534 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package libopenapi + +import ( + "testing" + + "github.com/pb33f/libopenapi/datamodel" + v2 "github.com/pb33f/libopenapi/datamodel/high/v2" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/pb33f/libopenapi/index" + "github.com/pb33f/libopenapi/overlay" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewOverlayDocument(t *testing.T) { + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.info + update: + title: Updated Title` + + ov, err := NewOverlayDocument([]byte(overlayYAML)) + require.NoError(t, err) + assert.NotNil(t, ov) + assert.Equal(t, "1.0.0", ov.Overlay) + assert.Equal(t, "Test Overlay", ov.Info.Title) + assert.Len(t, ov.Actions, 1) +} + +func TestNewOverlayDocument_InvalidYAML(t *testing.T) { + ov, err := NewOverlayDocument([]byte(`invalid: yaml: content:`)) + assert.Error(t, err) + assert.Nil(t, ov) +} + +func TestNewOverlayDocument_EmptyDocument(t *testing.T) { + ov, err := NewOverlayDocument([]byte(``)) + assert.ErrorIs(t, err, overlay.ErrInvalidOverlay) + assert.Nil(t, ov) +} + +func TestNewOverlayDocument_InvalidOverlay(t *testing.T) { + // Missing required fields + overlayYAML := `foo: bar` + + ov, err := NewOverlayDocument([]byte(overlayYAML)) + // BuildModel should succeed but Build should return an error + // Actually, lowoverlay.Build returns nil for missing fields, + // and validation happens in overlay.Apply + require.NoError(t, err) + assert.NotNil(t, ov) +} + +func TestNewOverlayDocument_SequenceRoot(t *testing.T) { + // Sequence at root - BuildModel is lenient and returns empty overlay + 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) +} + +func TestNewOverlayDocument_WithExtensions(t *testing.T) { + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +x-custom: custom value +actions: + - target: $.info + update: + title: Updated` + + ov, err := NewOverlayDocument([]byte(overlayYAML)) + require.NoError(t, err) + assert.NotNil(t, ov) + assert.Equal(t, "1.0.0", ov.Overlay) + assert.NotNil(t, ov.Extensions) +} + +// Tests for ApplyOverlay (Document, Overlay) + +func TestApplyOverlay(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Original Title + version: 1.0.0 +paths: {}` + + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.info + update: + title: Updated Title` + + doc, err := NewDocument([]byte(targetYAML)) + require.NoError(t, err) + + ov, err := NewOverlayDocument([]byte(overlayYAML)) + require.NoError(t, err) + + result, err := ApplyOverlay(doc, ov) + require.NoError(t, err) + assert.NotNil(t, result) + assert.Contains(t, string(result.Bytes), "Updated Title") + assert.Len(t, result.Warnings, 0) + + // Verify OverlayDocument is populated and ready to use + assert.NotNil(t, result.OverlayDocument) + assert.Equal(t, "3.0.0", result.OverlayDocument.GetVersion()) +} + +func TestApplyOverlay_PreservesConfiguration(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Original Title + version: 1.0.0 +paths: {}` + + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.info + update: + title: Updated Title` + + // Create document with custom configuration + config := &datamodel.DocumentConfiguration{ + AllowFileReferences: true, + AllowRemoteReferences: false, + } + doc, err := NewDocumentWithConfiguration([]byte(targetYAML), config) + require.NoError(t, err) + + ov, err := NewOverlayDocument([]byte(overlayYAML)) + require.NoError(t, err) + + result, err := ApplyOverlay(doc, ov) + require.NoError(t, err) + + // Verify configuration is preserved in the resulting document + resultConfig := result.OverlayDocument.GetConfiguration() + assert.NotNil(t, resultConfig) + assert.True(t, resultConfig.AllowFileReferences) + assert.False(t, resultConfig.AllowRemoteReferences) +} + +func TestApplyOverlay_WithWarnings(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0` + + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.nonexistent + update: + value: test` + + doc, err := NewDocument([]byte(targetYAML)) + require.NoError(t, err) + + ov, err := NewOverlayDocument([]byte(overlayYAML)) + require.NoError(t, err) + + result, err := ApplyOverlay(doc, ov) + require.NoError(t, err) + assert.Len(t, result.Warnings, 1) + assert.Contains(t, result.Warnings[0].Message, "zero nodes") + + // OverlayDocument should still be populated + assert.NotNil(t, result.OverlayDocument) +} + +func TestApplyOverlay_NilOverlay(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0` + + doc, err := NewDocument([]byte(targetYAML)) + require.NoError(t, err) + + result, err := ApplyOverlay(doc, nil) + assert.ErrorIs(t, err, overlay.ErrInvalidOverlay) + assert.Nil(t, result) +} + +// Tests for ApplyOverlayFromBytes (Document, overlayBytes) + +func TestApplyOverlayFromBytes(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Original Title + version: 1.0.0 +paths: {}` + + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.info + update: + title: Updated Title` + + doc, err := NewDocument([]byte(targetYAML)) + require.NoError(t, err) + + result, err := ApplyOverlayFromBytes(doc, []byte(overlayYAML)) + require.NoError(t, err) + assert.NotNil(t, result) + assert.Contains(t, string(result.Bytes), "Updated Title") + + // Verify OverlayDocument is populated + assert.NotNil(t, result.OverlayDocument) + assert.Equal(t, "3.0.0", result.OverlayDocument.GetVersion()) +} + +func TestApplyOverlayFromBytes_InvalidOverlay(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0` + + doc, err := NewDocument([]byte(targetYAML)) + require.NoError(t, err) + + result, err := ApplyOverlayFromBytes(doc, []byte(`invalid: yaml: content:`)) + assert.Error(t, err) + assert.Nil(t, result) +} + +// Tests for ApplyOverlayToSpecBytes (docBytes, Overlay) + +func TestApplyOverlayToSpecBytes(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Original Title + version: 1.0.0 +paths: {}` + + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.info + update: + title: Updated Title` + + ov, err := NewOverlayDocument([]byte(overlayYAML)) + require.NoError(t, err) + + result, err := ApplyOverlayToSpecBytes([]byte(targetYAML), ov) + require.NoError(t, err) + assert.NotNil(t, result) + assert.Contains(t, string(result.Bytes), "Updated Title") + + // Verify OverlayDocument is populated (with default config) + assert.NotNil(t, result.OverlayDocument) + assert.Equal(t, "3.0.0", result.OverlayDocument.GetVersion()) +} + +func TestApplyOverlayToSpecBytes_NilOverlay(t *testing.T) { + result, err := ApplyOverlayToSpecBytes([]byte(`openapi: 3.0.0`), nil) + assert.ErrorIs(t, err, overlay.ErrInvalidOverlay) + assert.Nil(t, result) +} + +func TestApplyOverlayToSpecBytes_InvalidTarget(t *testing.T) { + targetYAML := `invalid: yaml: content:` + + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.info + update: + title: Updated` + + ov, err := NewOverlayDocument([]byte(overlayYAML)) + require.NoError(t, err) + + result, err := ApplyOverlayToSpecBytes([]byte(targetYAML), ov) + assert.Error(t, err) + assert.Nil(t, result) +} + +// Tests for ApplyOverlayFromBytesToSpecBytes (docBytes, overlayBytes) + +func TestApplyOverlayFromBytesToSpecBytes(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Original Title + version: 1.0.0 +paths: {}` + + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.info + update: + title: Updated Title` + + result, err := ApplyOverlayFromBytesToSpecBytes([]byte(targetYAML), []byte(overlayYAML)) + require.NoError(t, err) + assert.NotNil(t, result) + assert.Contains(t, string(result.Bytes), "Updated Title") + + // Verify OverlayDocument is populated (with default config) + assert.NotNil(t, result.OverlayDocument) + assert.Equal(t, "3.0.0", result.OverlayDocument.GetVersion()) +} + +func TestApplyOverlayFromBytesToSpecBytes_InvalidOverlay(t *testing.T) { + targetYAML := `openapi: 3.0.0` + overlayYAML := `invalid: yaml: content:` + + result, err := ApplyOverlayFromBytesToSpecBytes([]byte(targetYAML), []byte(overlayYAML)) + assert.Error(t, err) + assert.Nil(t, result) +} + +func TestApplyOverlayFromBytesToSpecBytes_InvalidTarget(t *testing.T) { + targetYAML := `invalid: yaml: content:` + overlayYAML := `overlay: 1.0.0 +info: + title: Test + version: 1.0.0 +actions: + - target: $.info + update: + title: Updated` + + result, err := ApplyOverlayFromBytesToSpecBytes([]byte(targetYAML), []byte(overlayYAML)) + assert.Error(t, err) + assert.Nil(t, result) +} + +func TestApplyOverlayFromBytesToSpecBytes_ComplexOverlay(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Original + version: 1.0.0 + description: Remove me +tags: + - name: existing +paths: {}` + + overlayYAML := `overlay: 1.0.0 +info: + title: Complex Overlay + version: 1.0.0 +actions: + - target: $.info + update: + title: Updated + - target: $.info.description + remove: true + - target: $.tags + update: + - name: new-tag` + + result, err := ApplyOverlayFromBytesToSpecBytes([]byte(targetYAML), []byte(overlayYAML)) + require.NoError(t, err) + assert.Contains(t, string(result.Bytes), "Updated") + assert.NotContains(t, string(result.Bytes), "Remove me") + assert.Contains(t, string(result.Bytes), "existing") + assert.Contains(t, string(result.Bytes), "new-tag") + + // Verify OverlayDocument is populated + assert.NotNil(t, result.OverlayDocument) +} + +func TestApplyOverlay_CanBuildModel(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Original Title + version: 1.0.0 +paths: + /test: + get: + summary: Test endpoint` + + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.info + update: + title: Updated Title` + + doc, err := NewDocument([]byte(targetYAML)) + require.NoError(t, err) + + ov, err := NewOverlayDocument([]byte(overlayYAML)) + require.NoError(t, err) + + result, err := ApplyOverlay(doc, ov) + require.NoError(t, err) + + // Verify we can build a model from the OverlayDocument + model, errs := result.OverlayDocument.BuildV3Model() + require.Empty(t, errs) + assert.NotNil(t, model) + assert.Equal(t, "Updated Title", model.Model.Info.Title) +} + +// mockDocument is a minimal Document implementation for testing edge cases +type mockDocument struct { + specBytes *[]byte + config *datamodel.DocumentConfiguration + version string +} + +func (m *mockDocument) GetSpecInfo() *datamodel.SpecInfo { + return &datamodel.SpecInfo{SpecBytes: m.specBytes} +} + +func (m *mockDocument) GetConfiguration() *datamodel.DocumentConfiguration { + return m.config +} + +func (m *mockDocument) GetVersion() string { return m.version } +func (m *mockDocument) GetRolodex() *index.Rolodex { return nil } +func (m *mockDocument) SetConfiguration(*datamodel.DocumentConfiguration) {} +func (m *mockDocument) Render() ([]byte, error) { return nil, nil } +func (m *mockDocument) BuildV2Model() (*DocumentModel[v2.Swagger], error) { return nil, nil } +func (m *mockDocument) BuildV3Model() (*DocumentModel[v3.Document], error) { + return nil, nil +} +func (m *mockDocument) Serialize() ([]byte, error) { return nil, nil } +func (m *mockDocument) RenderAndReload() ([]byte, Document, *DocumentModel[v3.Document], error) { + return nil, nil, nil, nil +} + +func TestApplyOverlay_NilSpecBytes(t *testing.T) { + // Test line 63: specBytes == nil + doc := &mockDocument{ + specBytes: nil, + config: nil, + } + + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.info + update: + title: Updated` + + ov, err := NewOverlayDocument([]byte(overlayYAML)) + require.NoError(t, err) + + result, err := ApplyOverlay(doc, ov) + assert.ErrorIs(t, err, overlay.ErrNoTargetDocument) + assert.Nil(t, result) +} + +func TestApplyOverlay_InvalidResultDocument(t *testing.T) { + // Test line 73: NewDocumentWithConfiguration fails + // Create an overlay that removes the openapi version, making the result invalid + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0` + + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.openapi + remove: true` + + doc, err := NewDocument([]byte(targetYAML)) + require.NoError(t, err) + + ov, err := NewOverlayDocument([]byte(overlayYAML)) + require.NoError(t, err) + + result, err := ApplyOverlay(doc, ov) + // Should fail because resulting document has no openapi version + assert.Error(t, err) + assert.Nil(t, result) +} + +func TestApplyOverlayToSpecBytes_InvalidResultDocument(t *testing.T) { + // Test line 126: NewDocumentWithConfiguration fails in applyOverlayToBytesWithConfig + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0` + + overlayYAML := `overlay: 1.0.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.openapi + remove: true` + + ov, err := NewOverlayDocument([]byte(overlayYAML)) + require.NoError(t, err) + + result, err := ApplyOverlayToSpecBytes([]byte(targetYAML), ov) + // Should fail because resulting document has no openapi version + assert.Error(t, err) + assert.Nil(t, result) +} +