From d8e3d65b61172dca6c5f034c98a78da8be788aed Mon Sep 17 00:00:00 2001 From: Ryan Schumacher Date: Sun, 5 Jan 2025 10:28:43 -0600 Subject: [PATCH] feat: use go-playground/validator Closes #5 --- config/config.go | 156 ++++++++++++------------------------------ config/config_test.go | 119 ++++++++++++++++---------------- go.mod | 9 +++ go.sum | 19 +++++ main.go | 5 +- 5 files changed, 136 insertions(+), 172 deletions(-) diff --git a/config/config.go b/config/config.go index fb6699b..f7ab210 100644 --- a/config/config.go +++ b/config/config.go @@ -2,18 +2,17 @@ package config import ( "fmt" - "net/url" "os" - "slices" "strings" + "github.com/go-playground/validator/v10" "gopkg.in/yaml.v3" ) type Rule struct { Name string `yaml:"name,omitempty"` Component string `yaml:"component,omitempty"` - Check string `yaml:"check"` + Check string `yaml:"check" validate:"required"` CaseSensitive bool `yaml:"case"` Data []string `yaml:"data,omitempty"` } @@ -42,19 +41,40 @@ const ( ) type Modifier struct { - Name string `yaml:"name"` + Name string `yaml:"name" validate:"required"` Component string `yaml:"component,omitempty"` - Action Action `yaml:"action"` - Data string `yaml:"data"` - Filters []Rule `yaml:"rules,omitempty"` + Action Action `yaml:"action" validate:"required,oneof=APPEND REPLACE PREPEND ALARM"` + Data string `yaml:"data" validate:"required"` + Filters []Rule `yaml:"rules,omitempty" validate:"omitempty,dive"` } type Config struct { - Hostname string `yaml:"hostname"` - Port string `yaml:"port"` + Hostname string `yaml:"hostname" validate:"omitempty,hostname"` + Port string `yaml:"port" validate:"number"` - Notification Notification `yaml:"notification"` - Sources []Source `yaml:"sources"` + Notification Notification `yaml:"notification" validate:"omitempty"` + + // Unique endpoints will not return a very useful error message + Sources []Source `yaml:"sources" validate:"required,unique=EndPoint,dive"` +} + +type Notification struct { + Url string `yaml:"url" validate:"required,url"` + Service string `yaml:"service" validate:"required,oneof=DISCORD"` +} + +type Source struct { + EndPoint string `yaml:"end_point" validate:"required"` + Heartbeat int `yaml:"heartbeat" validate:"required,number,min=1"` + Name string `yaml:"xwr_name" validate:"required"` + Info []SourceInfo `yaml:"info" validate:"required,dive"` +} + +type SourceInfo struct { + Name string `yaml:"name" validate:"required"` + Url string `yaml:"url" validate:"required,url"` + Rules []Rule `yaml:"rules,omitempty" validate:"omitempty,dive"` + Modifiers []Modifier `yaml:"modifiers,omitempty" validate:"omitempty,dive"` } var defaultConfig = Config{ @@ -81,108 +101,22 @@ func LoadConfig(filePath string) (*Config, error) { config.Port = defaultConfig.Port } - if err := config.Validate(); err != nil { - return nil, err - } - - return config, nil -} - -func (c *Config) Validate() error { - var endpoints []string - - // Validate notification if set - not required - if c.Notification != (Notification{}) { - if err := c.Notification.Validate(); err != nil { - return fmt.Errorf(".Notification: %s", err) - } - } - - for i, source := range c.Sources { - // Ensure that the endpoint is unique - if slices.Contains(endpoints, source.EndPoint) { - return fmt.Errorf(".Source.%d: EndPoint is not unique", i) - } - endpoints = append(endpoints, source.EndPoint) - - if err := source.Validate(); err != nil { - return fmt.Errorf(".Source.%d: %s", i, err) - } - } - - return nil -} - -type Notification struct { - Url string `yaml:"url"` - Service string `yaml:"service"` -} - -func (n *Notification) Validate() error { - if n.Url == "" { - return fmt.Errorf("url is missing") - } - - if n.Service == "" { - return fmt.Errorf("service is missing") - } - - n.Service = strings.ToUpper(n.Service) - switch NotificationService(n.Service) { - case NotifyDiscord: - break - default: - return fmt.Errorf("service is invalid") - } - - return nil -} - -type Source struct { - EndPoint string `yaml:"end_point"` - Heartbeat int `yaml:"heartbeat"` - Name string `yaml:"xwr_name"` - Info []SourceInfo `yaml:"info"` -} - -func (c *Source) Validate() error { - if c.Heartbeat <= 0 { - return fmt.Errorf("heartbeat must be greater than 0") - } + validate := validator.New(validator.WithRequiredStructEnabled()) + if err := validate.Struct(config); err != nil { + verr := err.(validator.ValidationErrors) - for i, info := range c.Info { - if err := info.Validate(); err != nil { - return fmt.Errorf(".Info.%d: %s", i, err) + errs := make([]string, len(verr)) + for i, e := range verr { + if e.StructNamespace() == "Config.Sources" { + if e.Tag() == "unique" { + errs[i] = fmt.Sprintf("%s.%s is not unique", e.Namespace(), e.Param()) + } + } else { + errs[i] = fmt.Sprintf("%s is %s", e.Namespace(), e.Tag()) + } } + return nil, fmt.Errorf("config validation errors:\n%v", strings.Join(errs, "\n")) } - return nil -} - -type SourceInfo struct { - Name string `yaml:"name"` - Url string `yaml:"url"` - Rules []Rule `yaml:"rules,omitempty"` - Modifiers []Modifier `yaml:"modifiers,omitempty"` -} - -func (c *SourceInfo) Validate() error { - if c.Name == "" { - return fmt.Errorf("name is missing") - } - - if c.Url == "" { - return fmt.Errorf("URL is missing") - } - - u, err := url.Parse(c.Url) - if err != nil { - return fmt.Errorf("URL is invalid") - } - - if u.Hostname() == "" { - return fmt.Errorf("URL is invalid (hostname)") - } - - return nil + return config, nil } diff --git a/config/config_test.go b/config/config_test.go index aaede1a..fb69eb6 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -6,7 +6,9 @@ import ( "testing" "github.com/Fesaa/ical-merger/config" + "github.com/go-playground/validator/v10" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestLoadConfig(t *testing.T) { @@ -19,6 +21,13 @@ func TestLoadConfig(t *testing.T) { content := strings.Join([]string{ "hostname: example.com", "port: 4040", + "sources:", + " - xwr_name: Source1", + " end_point: /endpoint1", + " heartbeat: 60", + " info:", + " - name: Info1", + " url: http://example.com/info1", }, "\n") if _, err := tempFile.Write([]byte(content)); err != nil { @@ -33,9 +42,10 @@ func TestLoadConfig(t *testing.T) { func TestConfigValidation(t *testing.T) { cfg := &config.Config{ + Port: "4040", Sources: []config.Source{ { - EndPoint: "http://example.com/endpoint1", + EndPoint: "/endpoint1", Heartbeat: 60, Name: "Source1", Info: []config.SourceInfo{ @@ -46,7 +56,7 @@ func TestConfigValidation(t *testing.T) { }, }, { - EndPoint: "http://example.com/endpoint2", + EndPoint: "/endpoint2", Heartbeat: 60, Name: "Source2", Info: []config.SourceInfo{ @@ -58,15 +68,26 @@ func TestConfigValidation(t *testing.T) { }, }, } - - err := cfg.Validate() + validate := validator.New() + err := validate.Struct(cfg) assert.NoError(t, err) + // Test port validation + cfg.Port = "" + err = validate.Struct(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "'Port' failed on the 'number'") + // Test unique source endpoint + cfg.Port = "4040" + cfg.Sources[1].EndPoint = "/endpoint1" + err = validate.Struct(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "'Sources' failed on the 'unique'") } func TestSourceValidation(t *testing.T) { source := &config.Source{ - EndPoint: "http://example.com/endpoint", - Heartbeat: 60, + EndPoint: "/endpoint", + Heartbeat: 1, Name: "Source", Info: []config.SourceInfo{ { @@ -75,27 +96,25 @@ func TestSourceValidation(t *testing.T) { }, }, } - - err := source.Validate() - assert.NoError(t, err) -} - -func TestSourceValidationHeartbeat(t *testing.T) { - source := &config.Source{ - EndPoint: "http://example.com/endpoint", - Heartbeat: 0, - Name: "Source", - Info: []config.SourceInfo{ - { - Name: "Info", - Url: "http://example.com/info", - }, - }, - } - - err := source.Validate() - assert.Error(t, err) - assert.Equal(t, "heartbeat must be greater than 0", err.Error()) + validate := validator.New() + err := validate.Struct(source) + require.NoError(t, err) + // Test endpoint validation + source.EndPoint = "" + err = validate.Struct(source) + require.Error(t, err) + assert.Contains(t, err.Error(), "'EndPoint' failed on the 'required'") + // Test heartbeat validation (0 is the same as missing) + source.EndPoint = "/endpoint" + source.Heartbeat = 0 + err = validate.Struct(source) + require.Error(t, err) + assert.Contains(t, err.Error(), "'Heartbeat' failed on the 'required'") + // Test heartbeat validation + source.Heartbeat = -1 + err = validate.Struct(source) + require.Error(t, err) + assert.Contains(t, err.Error(), "'Heartbeat' failed on the 'min'") } func TestSourceInfoValidation(t *testing.T) { @@ -103,38 +122,18 @@ func TestSourceInfoValidation(t *testing.T) { Name: "Info", Url: "http://example.com/info", } - - err := info.Validate() + validate := validator.New() + err := validate.Struct(info) assert.NoError(t, err) -} - -func TestSourceInfoValidationMissingName(t *testing.T) { - info := &config.SourceInfo{ - Url: "http://example.com/info", - } - - err := info.Validate() - assert.Error(t, err) - assert.Equal(t, "name is missing", err.Error()) -} - -func TestSourceInfoValidationMissingUrl(t *testing.T) { - info := &config.SourceInfo{ - Name: "Info", - } - - err := info.Validate() - assert.Error(t, err) - assert.Equal(t, "URL is missing", err.Error()) -} - -func TestSourceInfoValidationInvalidUrl(t *testing.T) { - info := &config.SourceInfo{ - Name: "Info", - Url: "invalid-url", - } - - err := info.Validate() - assert.Error(t, err) - assert.Equal(t, "URL is invalid (hostname)", err.Error()) + // Test URL validation + info.Url = "invalid-url" + err = validate.Struct(info) + require.Error(t, err) + assert.Contains(t, err.Error(), "'Url' failed on the 'url'") + // Test Name Missing + info.Url = "http://example.com/info" + info.Name = "" + err = validate.Struct(info) + require.Error(t, err) + assert.Contains(t, err.Error(), "'Name' failed on the 'required'") } diff --git a/go.mod b/go.mod index c851c64..c1a1f86 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,20 @@ go 1.18 require ( github.com/arran4/golang-ical v0.2.8 + github.com/go-playground/validator/v10 v10.23.0 github.com/stretchr/testify v1.10.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/crypto v0.19.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/text v0.14.0 // indirect ) diff --git a/go.sum b/go.sum index 7b208fa..b698d5f 100644 --- a/go.sum +++ b/go.sum @@ -4,10 +4,21 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -16,6 +27,14 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/main.go b/main.go index 0d0b760..6e8904a 100644 --- a/main.go +++ b/main.go @@ -51,7 +51,10 @@ func main() { c, e := config.LoadConfig(configFile) if e != nil { - slog.Error("Error loading config", "error", e) + slog.Error("Error loading config") + for _, l := range strings.Split(e.Error(), "\n") { + slog.Error(l) + } panic(e) } host := c.Hostname + ":" + c.Port