diff --git a/internal/sliceutil/sliceutil.go b/internal/sliceutil/sliceutil.go new file mode 100644 index 0000000..42d8065 --- /dev/null +++ b/internal/sliceutil/sliceutil.go @@ -0,0 +1,9 @@ +package sliceutil + +func Map[T any, U any](slice []T, fn func(T) U) []U { + mapped := make([]U, len(slice)) + for i, elem := range slice { + mapped[i] = fn(elem) + } + return mapped +} diff --git a/internal/sliceutil/sliceutil_test.go b/internal/sliceutil/sliceutil_test.go new file mode 100644 index 0000000..07758fa --- /dev/null +++ b/internal/sliceutil/sliceutil_test.go @@ -0,0 +1,44 @@ +package sliceutil_test + +import ( + "testing" + + "github.com/speakeasy-api/openapi/internal/sliceutil" + "github.com/stretchr/testify/assert" +) + +func TestMap(t *testing.T) { + t.Parallel() + tests := []struct { + name string + slice []int + fn func(int) int + expected []int + }{ + { + name: "empty slice", + slice: []int{}, + fn: func(i int) int { return i }, + expected: []int{}, + }, + { + name: "single element", + slice: []int{1}, + fn: func(i int) int { return i }, + expected: []int{1}, + }, + { + name: "multiple elements", + slice: []int{1, 2, 3}, + fn: func(i int) int { return i }, + expected: []int{1, 2, 3}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := sliceutil.Map(tt.slice, tt.fn) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/version/version.go b/internal/version/version.go index 4005ee7..6054f41 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -50,6 +50,18 @@ func (v Version) LessThan(other Version) bool { return !v.Equal(other) && !v.GreaterThan(other) } +func (v Version) IsOneOf(versions []*Version) bool { + for _, ver := range versions { + if ver == nil { + continue + } + if v.Equal(*ver) { + return true + } + } + return false +} + func Parse(version string) (*Version, error) { parts := strings.Split(version, ".") if len(parts) != 3 { diff --git a/internal/version/version_test.go b/internal/version/version_test.go index 7e2a196..00c306c 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -137,3 +137,94 @@ func Test_ParseVersion_Error(t *testing.T) { }) } } + +func Test_Version_IsOneOf(t *testing.T) { + t.Parallel() + tests := []struct { + name string + version Version + versions []*Version + expected bool + }{ + { + name: "version is in list", + version: Version{Major: 1, Minor: 2, Patch: 3}, + versions: []*Version{ + {Major: 1, Minor: 0, Patch: 0}, + {Major: 1, Minor: 2, Patch: 3}, + {Major: 2, Minor: 0, Patch: 0}, + }, + expected: true, + }, + { + name: "version is not in list", + version: Version{Major: 1, Minor: 2, Patch: 3}, + versions: []*Version{ + {Major: 1, Minor: 0, Patch: 0}, + {Major: 1, Minor: 2, Patch: 4}, + {Major: 2, Minor: 0, Patch: 0}, + }, + expected: false, + }, + { + name: "empty list", + version: Version{Major: 1, Minor: 2, Patch: 3}, + versions: []*Version{}, + expected: false, + }, + { + name: "nil list", + version: Version{Major: 1, Minor: 2, Patch: 3}, + versions: nil, + expected: false, + }, + { + name: "list with nil values", + version: Version{Major: 1, Minor: 2, Patch: 3}, + versions: []*Version{ + nil, + {Major: 1, Minor: 2, Patch: 3}, + nil, + }, + expected: true, + }, + { + name: "version is first in list", + version: Version{Major: 1, Minor: 0, Patch: 0}, + versions: []*Version{ + {Major: 1, Minor: 0, Patch: 0}, + {Major: 1, Minor: 2, Patch: 3}, + {Major: 2, Minor: 0, Patch: 0}, + }, + expected: true, + }, + { + name: "version is last in list", + version: Version{Major: 2, Minor: 0, Patch: 0}, + versions: []*Version{ + {Major: 1, Minor: 0, Patch: 0}, + {Major: 1, Minor: 2, Patch: 3}, + {Major: 2, Minor: 0, Patch: 0}, + }, + expected: true, + }, + { + name: "similar but different versions", + version: Version{Major: 1, Minor: 2, Patch: 3}, + versions: []*Version{ + {Major: 1, Minor: 2, Patch: 2}, + {Major: 1, Minor: 2, Patch: 4}, + {Major: 1, Minor: 3, Patch: 3}, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := tt.version.IsOneOf(tt.versions) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/overlay/validate.go b/overlay/validate.go index a2be9ab..af1f5d5 100644 --- a/overlay/validate.go +++ b/overlay/validate.go @@ -5,6 +5,26 @@ import ( "fmt" "net/url" "strings" + + "github.com/speakeasy-api/openapi/internal/sliceutil" + "github.com/speakeasy-api/openapi/internal/version" +) + +var ( + SupportedVersions = []*version.Version{version.MustParse("1.0.0"), version.MustParse("1.1.0")} +) + +// Errors +var ( + ErrOverlayVersionInvalid = errors.New("overlay version is invalid") + ErrOverlayVersionNotSupported = fmt.Errorf("overlay version must be one of: %s", strings.Join(sliceutil.Map(SupportedVersions, func(v *version.Version) string { return v.String() }), ", ")) + ErrOverlayVersionMustBeDefined = errors.New("overlay version must be defined") + ErrOverlayInfoTitleMustBeDefined = errors.New("overlay info title must be defined") + ErrOverlayInfoVersionMustBeDefined = errors.New("overlay info version must be defined") + ErrOverlayExtendsMustBeAValidURL = errors.New("overlay extends must be a valid URL") + ErrOverlayMustDefineAtLeastOneAction = errors.New("overlay must define at least one action") + ErrOverlayActionTargetMustBeDefined = errors.New("overlay action target must be defined") + ErrOverlayActionRemoveAndUpdateCannotBeSet = errors.New("overlay action remove and update cannot be set") ) type ValidationErrors []error @@ -24,18 +44,31 @@ func (v ValidationErrors) Return() error { return nil } +func (o *Overlay) ValidateVersion() []error { + errs := make(ValidationErrors, 0) + overlayVersion, err := version.Parse(o.Version) + switch { + case err != nil || overlayVersion == nil: + errs = append(errs, ErrOverlayVersionInvalid) + case !overlayVersion.IsOneOf(SupportedVersions): + errs = append(errs, ErrOverlayVersionNotSupported) + } + + return errs +} + func (o *Overlay) Validate() error { errs := make(ValidationErrors, 0) - if o.Version != "1.0.0" { - errs = append(errs, errors.New("overlay version must be 1.0.0")) + + errs = append(errs, o.ValidateVersion()...) + + if o.Info.Version == "" { + errs = append(errs, errors.New("overlay info version must be defined")) } if o.Info.Title == "" { errs = append(errs, errors.New("overlay info title must be defined")) } - if o.Info.Version == "" { - errs = append(errs, errors.New("overlay info version must be defined")) - } if o.Extends != "" { _, err := url.Parse(o.Extends) diff --git a/overlay/validate_test.go b/overlay/validate_test.go new file mode 100644 index 0000000..c5d95a9 --- /dev/null +++ b/overlay/validate_test.go @@ -0,0 +1,306 @@ +package overlay_test + +import ( + "testing" + + "github.com/speakeasy-api/openapi/overlay" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestOverlay_Validate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + overlay *overlay.Overlay + expectedErrors []string + }{ + { + name: "valid overlay", + overlay: &overlay.Overlay{ + Version: "1.0.0", + Info: overlay.Info{ + Title: "Test Overlay", + Version: "1.0.0", + }, + Extends: "https://example.com/openapi.yaml", + Actions: []overlay.Action{ + { + Target: "$.info.title", + Update: yaml.Node{Kind: yaml.ScalarNode, Value: "New Title"}, + }, + }, + }, + expectedErrors: nil, + }, + { + name: "invalid overlay version format", + overlay: &overlay.Overlay{ + Version: "invalid", + Info: overlay.Info{ + Title: "Test Overlay", + Version: "1.0.0", + }, + Actions: []overlay.Action{ + { + Target: "$.info.title", + Update: yaml.Node{Kind: yaml.ScalarNode, Value: "New Title"}, + }, + }, + }, + expectedErrors: []string{"overlay version is invalid"}, + }, + { + name: "unsupported overlay version", + overlay: &overlay.Overlay{ + Version: "2.0.0", + Info: overlay.Info{ + Title: "Test Overlay", + Version: "1.0.0", + }, + Actions: []overlay.Action{ + { + Target: "$.info.title", + Update: yaml.Node{Kind: yaml.ScalarNode, Value: "New Title"}, + }, + }, + }, + expectedErrors: []string{"overlay version must be one of: 1.0.0, 1.1.0"}, + }, + { + name: "empty overlay version", + overlay: &overlay.Overlay{ + Version: "", + Info: overlay.Info{ + Title: "Test Overlay", + Version: "1.0.0", + }, + Actions: []overlay.Action{ + { + Target: "$.info.title", + Update: yaml.Node{Kind: yaml.ScalarNode, Value: "New Title"}, + }, + }, + }, + expectedErrors: []string{"overlay version is invalid"}, + }, + { + name: "missing info version", + overlay: &overlay.Overlay{ + Version: "1.0.0", + Info: overlay.Info{ + Title: "Test Overlay", + Version: "", + }, + Actions: []overlay.Action{ + { + Target: "$.info.title", + Update: yaml.Node{Kind: yaml.ScalarNode, Value: "New Title"}, + }, + }, + }, + expectedErrors: []string{"overlay info version must be defined"}, + }, + { + name: "missing info version and invalid overlay version", + overlay: &overlay.Overlay{ + Version: "invalid", + Info: overlay.Info{ + Title: "Test Overlay", + Version: "", + }, + Actions: []overlay.Action{ + { + Target: "$.info.title", + Update: yaml.Node{Kind: yaml.ScalarNode, Value: "New Title"}, + }, + }, + }, + expectedErrors: []string{ + "overlay info version must be defined", + "overlay version is invalid", + }, + }, + { + name: "valid overlay without extends", + overlay: &overlay.Overlay{ + Version: "1.0.0", + Info: overlay.Info{ + Title: "Test Overlay", + Version: "1.0.0", + }, + Actions: []overlay.Action{ + { + Target: "$.info.title", + Update: yaml.Node{Kind: yaml.ScalarNode, Value: "New Title"}, + }, + }, + }, + expectedErrors: nil, + }, + { + name: "valid overlay with remove action", + overlay: &overlay.Overlay{ + Version: "1.0.0", + Info: overlay.Info{ + Title: "Test Overlay", + Version: "1.0.0", + }, + Actions: []overlay.Action{ + { + Target: "$.info.description", + Remove: true, + }, + }, + }, + expectedErrors: nil, + }, + { + name: "missing title", + overlay: &overlay.Overlay{ + Version: "1.0.0", + Info: overlay.Info{ + Title: "", + Version: "1.0.0", + }, + Actions: []overlay.Action{ + { + Target: "$.info.title", + Update: yaml.Node{Kind: yaml.ScalarNode, Value: "New Title"}, + }, + }, + }, + expectedErrors: []string{"overlay info title must be defined"}, + }, + { + name: "invalid extends URL", + overlay: &overlay.Overlay{ + Version: "1.0.0", + Info: overlay.Info{ + Title: "Test Overlay", + Version: "1.0.0", + }, + Extends: "://invalid-url", + Actions: []overlay.Action{ + { + Target: "$.info.title", + Update: yaml.Node{Kind: yaml.ScalarNode, Value: "New Title"}, + }, + }, + }, + expectedErrors: []string{"overlay extends must be a valid URL"}, + }, + { + name: "no actions", + overlay: &overlay.Overlay{ + Version: "1.0.0", + Info: overlay.Info{ + Title: "Test Overlay", + Version: "1.0.0", + }, + Actions: []overlay.Action{}, + }, + expectedErrors: []string{"overlay must define at least one action"}, + }, + { + name: "action without target", + overlay: &overlay.Overlay{ + Version: "1.0.0", + Info: overlay.Info{ + Title: "Test Overlay", + Version: "1.0.0", + }, + Actions: []overlay.Action{ + { + Target: "", + Update: yaml.Node{Kind: yaml.ScalarNode, Value: "New Title"}, + }, + }, + }, + expectedErrors: []string{"overlay action at index 0 target must be defined"}, + }, + { + name: "action with both remove and update", + overlay: &overlay.Overlay{ + Version: "1.0.0", + Info: overlay.Info{ + Title: "Test Overlay", + Version: "1.0.0", + }, + Actions: []overlay.Action{ + { + Target: "$.info.title", + Remove: true, + Update: yaml.Node{Kind: yaml.ScalarNode, Value: "New Title"}, + }, + }, + }, + expectedErrors: []string{"overlay action at index 0 should not both set remove and define update"}, + }, + { + name: "multiple actions with errors at different indices", + overlay: &overlay.Overlay{ + Version: "1.0.0", + Info: overlay.Info{ + Title: "Test Overlay", + Version: "1.0.0", + }, + Actions: []overlay.Action{ + { + Target: "$.info.title", + Update: yaml.Node{Kind: yaml.ScalarNode, Value: "New Title"}, + }, + { + Target: "", + Update: yaml.Node{Kind: yaml.ScalarNode, Value: "New Title"}, + }, + { + Target: "$.info.description", + Remove: true, + Update: yaml.Node{Kind: yaml.ScalarNode, Value: "Description"}, + }, + }, + }, + expectedErrors: []string{ + "overlay action at index 1 target must be defined", + "overlay action at index 2 should not both set remove and define update", + }, + }, + { + name: "all validation errors combined", + overlay: &overlay.Overlay{ + Version: "invalid", + Info: overlay.Info{ + Title: "", + Version: "", + }, + Extends: "://invalid-url", + Actions: []overlay.Action{}, + }, + expectedErrors: []string{ + "overlay info version must be defined", + "overlay version is invalid", + "overlay info title must be defined", + "overlay extends must be a valid URL", + "overlay must define at least one action", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := tt.overlay.Validate() + if tt.expectedErrors == nil { + assert.NoError(t, err) + } else { + require.Error(t, err) + for _, expectedErr := range tt.expectedErrors { + assert.Contains(t, err.Error(), expectedErr) + } + } + }) + } +}