From ed833a0eb40ccdcb840362f4f4d71b78d74a3863 Mon Sep 17 00:00:00 2001 From: Anton Prokhorov Date: Mon, 15 Jun 2026 22:17:52 +0000 Subject: [PATCH 1/2] Add nested struct validation to pkg/valid Collapse Field and Validatable into a single Validatable interface returning Problems, so leaf fields and domain types share one shape. Add Struct(name, Validatable) which folds a nested value's problems into the flat map with namespaced keys (field.nested.field), composing to any depth. A nil value is skipped for optional nested structs. --- pkg/valid/number.go | 2 +- pkg/valid/slice.go | 2 +- pkg/valid/string.go | 4 +-- pkg/valid/struct.go | 25 +++++++++++++++++++ pkg/valid/valid.go | 46 +++++++++++++++++++++++----------- pkg/valid/valid_test.go | 55 ++++++++++++++++++++++++++++++++++++++++- 6 files changed, 115 insertions(+), 19 deletions(-) create mode 100644 pkg/valid/struct.go diff --git a/pkg/valid/number.go b/pkg/valid/number.go index c77a94a..2ff2f21 100644 --- a/pkg/valid/number.go +++ b/pkg/valid/number.go @@ -12,7 +12,7 @@ type number interface { } // Number validates a numeric field. -func Number[T number](name string, value T, rules ...Rule[T]) Field { +func Number[T number](name string, value T, rules ...Rule[T]) Validatable { return newField(name, value, rules) } diff --git a/pkg/valid/slice.go b/pkg/valid/slice.go index b0e5d47..45e9bad 100644 --- a/pkg/valid/slice.go +++ b/pkg/valid/slice.go @@ -7,7 +7,7 @@ import ( // Slice validates a slice field. Rules operate on the whole slice (e.g. // NotEmpty, MinItems); use Each to apply per-element rules. -func Slice[T any](name string, value []T, rules ...Rule[[]T]) Field { +func Slice[T any](name string, value []T, rules ...Rule[[]T]) Validatable { return newField(name, value, rules) } diff --git a/pkg/valid/string.go b/pkg/valid/string.go index c608f41..2fc4005 100644 --- a/pkg/valid/string.go +++ b/pkg/valid/string.go @@ -10,13 +10,13 @@ import ( ) // String validates a string (or ~string, e.g. an enum) field. -func String[T ~string](name string, value T, rules ...Rule[T]) Field { +func String[T ~string](name string, value T, rules ...Rule[T]) Validatable { return newField(name, value, rules) } // OptionalString validates an optional string field addressed by a pointer. When // the pointer is nil the field is treated as absent (no rules run). -func OptionalString[T ~string](name string, value *T, rules ...Rule[T]) Field { +func OptionalString[T ~string](name string, value *T, rules ...Rule[T]) Validatable { if value == nil { var zero T return newField(name, zero, nil) diff --git a/pkg/valid/struct.go b/pkg/valid/struct.go new file mode 100644 index 0000000..7e0d4ab --- /dev/null +++ b/pkg/valid/struct.go @@ -0,0 +1,25 @@ +package valid + +// Struct validates a nested Validatable, namespacing its problems under name +// (name.), so nesting composes to arbitrary depth (a.b.c). Pass a +// value whose Validate() satisfies Validatable — e.g. &in.Inner when Validate +// has a pointer receiver. A nil value is skipped, so an optional nested struct +// can be passed directly. Note: a typed nil pointer is not a nil interface — a +// caller with an optional *Inner field should pass a genuine nil. +func Struct(name string, value Validatable) Validatable { + return structField{name: name, value: value} +} + +// structField adapts a nested Validatable, prefixing its keys with the field +// name. +type structField struct { + name string + value Validatable +} + +func (f structField) Validate() Problems { + if f.value == nil { + return nil + } + return prefixed(f.name, f.value.Validate()) +} diff --git a/pkg/valid/valid.go b/pkg/valid/valid.go index 4f1ce38..7aea1df 100644 --- a/pkg/valid/valid.go +++ b/pkg/valid/valid.go @@ -19,10 +19,15 @@ package valid import "errors" -// Field is a single validated struct field. Struct collects the problem reported -// by each field. -type Field interface { - Validate() (name string, err error) +// Validatable is anything that validates itself, returning a flat map of keyed +// problems (nil when ok). Both the leaf fields built by String/Number/Slice and +// domain types (e.g. service Input types exposing Validate() Problems) satisfy +// it, so a Validatable can be a single field in a Validate(...) call or a nested +// struct folded in via Struct/OptionalStruct, which namespace its keys. +type Validatable interface { + // Validate returns problems keyed relative to this value: a leaf returns + // {name: msg}, a nested struct returns {name.child: msg, ...}; nil when ok. + Validate() Problems } // Rule validates a value of type T, returning an error whose message becomes the @@ -38,17 +43,17 @@ type Problems map[string]string // Ok reports whether there are no problems. func (p Problems) Ok() bool { return len(p) == 0 } -// Validate runs the given fields and returns a map of field name -> problem. -// Within a field the first failing rule wins; if two fields share a name the -// first is kept. It returns nil when everything is valid. -func Validate(fields ...Field) Problems { +// Validate runs the given fields and merges their problems into a single flat +// map of field name -> problem. Within a field the first failing rule wins; if +// two fields produce the same key the first is kept. It returns nil when +// everything is valid. +func Validate(fields ...Validatable) Problems { problems := Problems{} for _, field := range fields { - name, err := field.Validate() - if err != nil { + for name, msg := range field.Validate() { if _, exists := problems[name]; !exists { - problems[name] = err.Error() + problems[name] = msg } } } @@ -60,6 +65,19 @@ func Validate(fields ...Field) Problems { return problems } +// prefixed returns child with each key prefixed by "prefix.", or nil when child +// has no problems. It is how nested validators namespace their keys. +func prefixed(prefix string, child Problems) Problems { + if child.Ok() { + return nil + } + out := make(Problems, len(child)) + for k, msg := range child { + out[prefix+"."+k] = msg + } + return out +} + // field is the shared implementation for all typed constructors: a name, a // value, and an ordered list of rules. type field[T any] struct { @@ -72,13 +90,13 @@ func newField[T any](name string, value T, rules []Rule[T]) field[T] { return field[T]{name: name, value: value, rules: rules} } -func (f field[T]) Validate() (string, error) { +func (f field[T]) Validate() Problems { for _, rule := range f.rules { if err := rule(f.value); err != nil { - return f.name, err + return Problems{f.name: err.Error()} } } - return f.name, nil + return nil } // Required asserts a comparable value is not its zero value (empty string, zero diff --git a/pkg/valid/valid_test.go b/pkg/valid/valid_test.go index e50bd13..bcd5ee4 100644 --- a/pkg/valid/valid_test.go +++ b/pkg/valid/valid_test.go @@ -50,7 +50,7 @@ func TestStruct_Valid(t *testing.T) { func TestStruct_Problems(t *testing.T) { tests := []struct { name string - field v.Field + field v.Validatable wantKey string wantMsg string }{ @@ -93,6 +93,59 @@ func TestNumber_NoCoercion(t *testing.T) { } } +// inner is a nested Validatable. +type inner struct { + Host string +} + +func (i inner) Validate() v.Problems { + return v.Validate(v.String("host", i.Host, v.Required)) +} + +// Three levels of nesting to prove keys compose as a.b.c. +type lvl3 struct{ C string } + +func (l lvl3) Validate() v.Problems { return v.Validate(v.String("c", l.C, v.Required)) } + +type lvl2 struct{ B lvl3 } + +func (l lvl2) Validate() v.Problems { return v.Validate(v.Struct("b", l.B)) } + +func TestStruct_Nested(t *testing.T) { + // Single nested struct: child key is namespaced under the field name. + got := v.Validate(v.Struct("inner", inner{Host: ""})) + if got["inner.host"] != "is required" { + t.Fatalf("got %v, want inner.host=is required", got) + } + + // Valid nested struct contributes nothing. + got = v.Validate(v.Struct("inner", inner{Host: "example.com"})) + if !got.Ok() { + t.Fatalf("expected ok, got %v", got) + } + + // Deep nesting composes: a.b.c. + got = v.Validate(v.Struct("a", lvl2{})) + if got["a.b.c"] != "is required" { + t.Fatalf("got %v, want a.b.c=is required", got) + } + + // A nil value is skipped (optional nested struct). + if got := v.Validate(v.Struct("inner", nil)); !got.Ok() { + t.Fatalf("nil value should be ok, got %v", got) + } +} + +func TestStruct_MixedLeafAndNested(t *testing.T) { + got := v.Validate( + v.String("name", "", v.Required), + v.Struct("inner", inner{Host: ""}), + ) + if got["name"] != "is required" || got["inner.host"] != "is required" { + t.Fatalf("got %v, want both name and inner.host", got) + } +} + func TestStruct_FirstProblemPerFieldWins(t *testing.T) { // Within a field, the first failing rule wins. got := v.Validate(v.String("name", "", v.Required, v.MinLength(5))) From d95e98cbeba91eecd0000c7e90e69c9d39e333e4 Mon Sep 17 00:00:00 2001 From: Anton Prokhorov Date: Mon, 15 Jun 2026 23:16:53 +0000 Subject: [PATCH 2/2] Replace ozzo-validation with pkg/valid for config Migrate all configuration validation (server, client, and module configs) from ozzo-validation to the in-house pkg/valid, dropping the ozzo dependency so the codebase has a single validation library. - pkg/valid: add IP/Domain/URL string rules and a Problems.Error() so a Problems can be returned where an error is expected. - Config Validate() methods now return valid.Problems; nested configs compose via valid.Struct (keys like tls.custom.key, modules.lark.app_id). - ozzo's When/dynamic FieldRules become plain-Go conditional field lists; the client's context-based cross-field check moves into Config.Validate. - File/Directory become local rules in internal/cmd/server; internal/utils/valid is removed. - Validate slack and api modules when enabled (previously skipped); make lark Mode required. - Drop the pkg/valid "v" import alias now that the utils/valid collision is gone. --- cmd/client/config.go | 58 +++----- cmd/client/main.go | 9 +- go.mod | 2 - go.sum | 5 - internal/cmd/server/config.go | 92 +++++-------- internal/cmd/server/modules.go | 22 +-- .../valid.go => cmd/server/validators.go} | 14 +- internal/cmd/util.go | 6 +- internal/modules/api/config.go | 11 ++ internal/modules/lark/config.go | 25 ++-- internal/modules/slack/config.go | 12 +- internal/modules/telegram/config.go | 11 +- internal/service/audit_records_get.go | 8 +- internal/service/dns_records_clear.go | 8 +- internal/service/dns_records_create.go | 16 +-- internal/service/dns_records_delete.go | 10 +- internal/service/dns_records_list.go | 8 +- internal/service/events_get.go | 10 +- internal/service/events_list.go | 8 +- internal/service/http_routes_clear.go | 8 +- internal/service/http_routes_create.go | 14 +- internal/service/http_routes_delete.go | 10 +- internal/service/http_routes_list.go | 8 +- internal/service/http_routes_update.go | 12 +- internal/service/payloads_create.go | 10 +- internal/service/payloads_delete.go | 8 +- internal/service/payloads_update.go | 10 +- internal/service/users_create.go | 8 +- internal/service/users_delete.go | 8 +- internal/service/validators.go | 4 +- pkg/valid/format.go | 40 ++++++ pkg/valid/valid.go | 24 +++- pkg/valid/valid_test.go | 127 ++++++++++++------ tests/config_test.go | 11 ++ 34 files changed, 356 insertions(+), 281 deletions(-) rename internal/{utils/valid/valid.go => cmd/server/validators.go} (56%) create mode 100644 pkg/valid/format.go diff --git a/cmd/client/config.go b/cmd/client/config.go index 8f5dae1..53a14dc 100644 --- a/cmd/client/config.go +++ b/cmd/client/config.go @@ -1,38 +1,32 @@ package main import ( - "context" "net/url" "regexp" - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/go-ozzo/ozzo-validation/v4/is" + "github.com/nt0xa/sonar/pkg/valid" ) -func init() { - validation.ErrorTag = "mapstructure" -} - type Config struct { Context Context `mapstructure:"context"` Servers map[string]Server `mapstructure:"servers"` } -type serversKey struct{} - -func (c Config) ValidateWithContext(ctx context.Context) error { - servers := make([]any, 0) - - for s := range c.Servers { - servers = append(servers, s) +func (c Config) Validate() valid.Problems { + servers := make([]string, 0, len(c.Servers)) + for name := range c.Servers { + servers = append(servers, name) } - ctx = context.WithValue(ctx, serversKey{}, servers) + fields := []valid.Validatable{ + valid.Slice("servers", servers, valid.NotEmpty), + valid.String("context.server", c.Context.Server, valid.Required, valid.In(servers...)), + } + for name, srv := range c.Servers { + fields = append(fields, valid.Struct("servers."+name, srv)) + } - return validation.ValidateStructWithContext(ctx, &c, - validation.Field(&c.Context), - validation.Field(&c.Servers, validation.Length(1, 0)), - ) + return valid.Validate(fields...) } func (c *Config) Server() *Server { @@ -47,17 +41,6 @@ type Context struct { Server string `mapstructure:"server"` } -func (c Context) ValidateWithContext(ctx context.Context) error { - servers, ok := ctx.Value(serversKey{}).([]any) - if !ok { - panic(`fail to find "servers" key in context`) - } - - return validation.ValidateStructWithContext(ctx, &c, - validation.Field(&c.Server, validation.Required, validation.In(servers...)), - ) -} - type Server struct { Token string `mapstructure:"token"` URL string `mapstructure:"url"` @@ -65,14 +48,13 @@ type Server struct { Insecure bool `mapstructure:"insecure"` } -func (c Server) ValidateWithContext(ctx context.Context) error { - return validation.ValidateStructWithContext(ctx, &c, - validation.Field(&c.Token, - validation.Required, - validation.Match(regexp.MustCompile("[a-f0-9]{32}")), - ), - validation.Field(&c.URL, validation.Required, is.URL), - validation.Field(&c.Proxy, is.URL), +var tokenRe = regexp.MustCompile("[a-f0-9]{32}") + +func (c Server) Validate() valid.Problems { + return valid.Validate( + valid.String("token", c.Token, valid.Required, valid.Match(tokenRe, "invalid token")), + valid.String("url", c.URL, valid.Required, valid.URL), + valid.OptionalString("proxy", c.Proxy, valid.URL), ) } diff --git a/cmd/client/main.go b/cmd/client/main.go index 1123ecc..b07ea07 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -13,7 +13,6 @@ import ( "strings" "github.com/adrg/xdg" - validation "github.com/go-ozzo/ozzo-validation/v4" "github.com/gookit/color" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -33,10 +32,6 @@ type lazyService struct { service.Service } -func init() { - validation.ErrorTag = "err" -} - func main() { var ( cfg Config @@ -175,8 +170,8 @@ func initConfig(cfgFile string, cfg *Config) error { return err } - if err := cfg.ValidateWithContext(context.Background()); err != nil { - return err + if p := cfg.Validate(); !p.Ok() { + return fmt.Errorf("config validation failed: %w", p) } return nil diff --git a/go.mod b/go.mod index 16534a7..d939a40 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( github.com/fsnotify/fsnotify v1.9.0 github.com/go-acme/lego/v3 v3.9.0 github.com/go-chi/chi/v5 v5.2.2 - github.com/go-ozzo/ozzo-validation/v4 v4.3.0 github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/go-testfixtures/testfixtures/v3 v3.9.0 github.com/golang-migrate/migrate/v4 v4.18.3 @@ -58,7 +57,6 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/andybalholm/brotli v1.0.5 // indirect - github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect diff --git a/go.sum b/go.sum index 778bfe2..8cee173 100644 --- a/go.sum +++ b/go.sum @@ -67,9 +67,6 @@ github.com/aliyun/alibaba-cloud-sdk-go v1.61.112/go.mod h1:pUKYbK5JQ+1Dfxk80P0qx github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= -github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= -github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= -github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aws/aws-sdk-go v1.30.20/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= @@ -146,8 +143,6 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es= -github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= diff --git a/internal/cmd/server/config.go b/internal/cmd/server/config.go index f2b1ac5..b255b16 100644 --- a/internal/cmd/server/config.go +++ b/internal/cmd/server/config.go @@ -6,15 +6,13 @@ import ( "os" "strings" - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/go-ozzo/ozzo-validation/v4/is" "github.com/knadh/koanf/parsers/toml" "github.com/knadh/koanf/providers/confmap" "github.com/knadh/koanf/providers/env/v2" fsprov "github.com/knadh/koanf/providers/fs" "github.com/knadh/koanf/v2" "github.com/nt0xa/sonar/internal/utils" - "github.com/nt0xa/sonar/internal/utils/valid" + "github.com/nt0xa/sonar/pkg/valid" ) var ConfigDefaults = map[string]any{ @@ -77,8 +75,8 @@ func LoadConfig( return nil, fmt.Errorf("unmarshal failed: %w", err) } - if err := cfg.Validate(); err != nil { - return nil, fmt.Errorf("validation failed: %w", err) + if p := cfg.Validate(); !p.Ok() { + return nil, fmt.Errorf("validation failed: %w", p) } return &cfg, nil @@ -96,16 +94,14 @@ type Config struct { Modules ModulesConfig } -func (c Config) Validate() error { - return validation.ValidateStruct(&c, - validation.Field(&c.Domain, validation.Required, is.Domain), - validation.Field(&c.IP, validation.Required, is.IP), - validation.Field(&c.DB, validation.Required), - validation.Field(&c.Audit), - validation.Field(&c.GeoIP), - validation.Field(&c.DNS), - validation.Field(&c.TLS), - validation.Field(&c.Modules), +func (c Config) Validate() valid.Problems { + return valid.Validate( + valid.String("domain", c.Domain, valid.Required, valid.Domain), + valid.String("ip", c.IP, valid.Required, valid.IP), + valid.Struct("db", c.DB), + valid.Struct("geoip", c.GeoIP), + valid.Struct("tls", c.TLS), + valid.Struct("modules", c.Modules), ) } @@ -113,12 +109,6 @@ type AuditConfig struct { Enabled bool } -func (c AuditConfig) Validate() error { - return validation.ValidateStruct(&c, - validation.Field(&c.Enabled), - ) -} - // // Telemetry // @@ -127,12 +117,6 @@ type TelemetryConfig struct { Enabled bool } -func (c TelemetryConfig) Validate() error { - return validation.ValidateStruct(&c, - validation.Field(&c.Enabled), - ) -} - // // DB // @@ -141,9 +125,10 @@ type DBConfig struct { DSN string } -func (c DBConfig) Validate() error { - return validation.ValidateStruct(&c, - validation.Field(&c.DSN, validation.Required)) +func (c DBConfig) Validate() valid.Problems { + return valid.Validate( + valid.String("dsn", c.DSN, valid.Required), + ) } // @@ -154,10 +139,6 @@ type DNSConfig struct { Zone string } -func (c DNSConfig) Validate() error { - return validation.ValidateStruct(&c) -} - // // TLS // @@ -168,20 +149,19 @@ type TLSConfig struct { LetsEncrypt TLSLetsEncryptConfig } -func (c TLSConfig) Validate() error { - rules := make([]*validation.FieldRules, 0) - - rules = append(rules, - validation.Field(&c.Type, validation.Required, validation.In("custom", "letsencrypt"))) +func (c TLSConfig) Validate() valid.Problems { + fields := []valid.Validatable{ + valid.String("type", c.Type, valid.Required, valid.In("custom", "letsencrypt")), + } switch c.Type { case "custom": - rules = append(rules, validation.Field(&c.Custom)) + fields = append(fields, valid.Struct("custom", c.Custom)) case "letsencrypt": - rules = append(rules, validation.Field(&c.LetsEncrypt)) + fields = append(fields, valid.Struct("letsencrypt", c.LetsEncrypt)) } - return validation.ValidateStruct(&c, rules...) + return valid.Validate(fields...) } // Custom @@ -191,10 +171,10 @@ type TLSCustomConfig struct { Cert string } -func (c TLSCustomConfig) Validate() error { - return validation.ValidateStruct(&c, - validation.Field(&c.Key, validation.Required, validation.By(valid.File)), - validation.Field(&c.Cert, validation.Required, validation.By(valid.File)), +func (c TLSCustomConfig) Validate() valid.Problems { + return valid.Validate( + valid.String("key", c.Key, valid.Required, file), + valid.String("cert", c.Cert, valid.Required, file), ) } @@ -207,10 +187,10 @@ type TLSLetsEncryptConfig struct { CAInsecure bool `koanf:"ca_insecure"` } -func (c TLSLetsEncryptConfig) Validate() error { - return validation.ValidateStruct(&c, - validation.Field(&c.Email, validation.Required), - validation.Field(&c.Directory, validation.Required, validation.By(valid.Directory)), +func (c TLSLetsEncryptConfig) Validate() valid.Problems { + return valid.Validate( + valid.String("email", c.Email, valid.Required), + valid.String("directory", c.Directory, valid.Required, directory), ) } @@ -224,10 +204,12 @@ type GeoIPConfig struct { ASN string } -func (c GeoIPConfig) Validate() error { - return validation.ValidateStruct(&c, - validation.Field(&c.Enabled), - validation.Field(&c.City, validation.When(c.Enabled, validation.Required, validation.By(valid.File))), - validation.Field(&c.ASN, validation.When(c.Enabled, validation.Required, validation.By(valid.File))), +func (c GeoIPConfig) Validate() valid.Problems { + if !c.Enabled { + return nil + } + return valid.Validate( + valid.String("city", c.City, valid.Required, file), + valid.String("asn", c.ASN, valid.Required, file), ) } diff --git a/internal/cmd/server/modules.go b/internal/cmd/server/modules.go index 9c4375c..167bdca 100644 --- a/internal/cmd/server/modules.go +++ b/internal/cmd/server/modules.go @@ -5,8 +5,6 @@ import ( "fmt" "log/slog" - validation "github.com/go-ozzo/ozzo-validation/v4" - "github.com/nt0xa/sonar/internal/modules" "github.com/nt0xa/sonar/internal/modules/api" "github.com/nt0xa/sonar/internal/modules/lark" @@ -14,6 +12,7 @@ import ( "github.com/nt0xa/sonar/internal/modules/telegram" "github.com/nt0xa/sonar/internal/service" "github.com/nt0xa/sonar/pkg/telemetry" + "github.com/nt0xa/sonar/pkg/valid" ) type Controller interface { @@ -31,27 +30,30 @@ type ModulesConfig struct { Slack slack.Config } -func (c ModulesConfig) Validate() error { - rules := make([]*validation.FieldRules, 0) - rules = append(rules, validation.Field(&c.Enabled, - validation.Each(validation.In("telegram", "api", "lark", "slack")))) +func (c ModulesConfig) Validate() valid.Problems { + fields := []valid.Validatable{ + valid.Slice("enabled", c.Enabled, valid.Each(valid.In("telegram", "api", "lark", "slack"))), + } // TODO: dynamic modules registration. Something like sql drivers for _, name := range c.Enabled { switch name { case "telegram": - rules = append(rules, validation.Field(&c.Telegram)) + fields = append(fields, valid.Struct("telegram", c.Telegram)) case "api": - rules = append(rules, validation.Field(&c.API)) + fields = append(fields, valid.Struct("api", c.API)) case "lark": - rules = append(rules, validation.Field(&c.Lark)) + fields = append(fields, valid.Struct("lark", c.Lark)) + + case "slack": + fields = append(fields, valid.Struct("slack", c.Slack)) } } - return validation.ValidateStruct(&c, rules...) + return valid.Validate(fields...) } func Modules( diff --git a/internal/utils/valid/valid.go b/internal/cmd/server/validators.go similarity index 56% rename from internal/utils/valid/valid.go rename to internal/cmd/server/validators.go index a99c180..f7e1ee9 100644 --- a/internal/utils/valid/valid.go +++ b/internal/cmd/server/validators.go @@ -1,28 +1,24 @@ -package valid +package server import ( "errors" "os" ) -func File(value any) error { - path, _ := value.(string) - +// file asserts the path exists on disk. +func file(path string) error { if _, err := os.Stat(path); os.IsNotExist(err) { return err } - return nil } -func Directory(value any) error { - path, _ := value.(string) - +// directory asserts the path is not a regular file (i.e. a directory or absent). +func directory(path string) error { if fi, err := os.Stat(path); os.IsNotExist(err) { return nil } else if fi.Mode().IsRegular() { return errors.New("must be directory") } - return nil } diff --git a/internal/cmd/util.go b/internal/cmd/util.go index 236d985..7a29c8e 100644 --- a/internal/cmd/util.go +++ b/internal/cmd/util.go @@ -6,12 +6,10 @@ import ( "strings" "github.com/nt0xa/sonar/internal/service" - v "github.com/nt0xa/sonar/pkg/valid" + "github.com/nt0xa/sonar/pkg/valid" ) -// validate runs an input's Validate method, wrapping any problems in a -// service.Error so it surfaces like a server-side validation error. -func validate(in interface{ Validate() v.Problems }) error { +func validate(in valid.Validatable) error { if p := in.Validate(); !p.Ok() { return service.Validation(p) } diff --git a/internal/modules/api/config.go b/internal/modules/api/config.go index 66f1707..2e02489 100644 --- a/internal/modules/api/config.go +++ b/internal/modules/api/config.go @@ -1,6 +1,17 @@ package api +import ( + "github.com/nt0xa/sonar/pkg/valid" +) + type Config struct { Admin string Port int } + +func (c Config) Validate() valid.Problems { + return valid.Validate( + valid.String("admin", c.Admin, valid.Required), + valid.Number("port", c.Port, valid.Required, valid.Min(1), valid.Max(65535)), + ) +} diff --git a/internal/modules/lark/config.go b/internal/modules/lark/config.go index 8203a40..086e9de 100644 --- a/internal/modules/lark/config.go +++ b/internal/modules/lark/config.go @@ -1,7 +1,7 @@ package lark import ( - validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/nt0xa/sonar/pkg/valid" ) type Config struct { @@ -21,13 +21,18 @@ const ( ModeWebsocket = "websocket" ) -func (c Config) Validate() error { - return validation.ValidateStruct(&c, - validation.Field(&c.Admin, validation.Required), - validation.Field(&c.AppID, validation.Required), - validation.Field(&c.AppSecret, validation.Required), - validation.Field(&c.Mode, validation.In(ModeWebhook, ModeWebsocket)), - validation.Field(&c.VerificationToken, validation.When(c.Mode == ModeWebhook, validation.Required)), - validation.Field(&c.EncryptKey, validation.When(c.Mode == ModeWebhook, validation.Required)), - ) +func (c Config) Validate() valid.Problems { + fields := []valid.Validatable{ + valid.String("admin", c.Admin, valid.Required), + valid.String("app_id", c.AppID, valid.Required), + valid.String("app_secret", c.AppSecret, valid.Required), + valid.String("mode", c.Mode, valid.Required, valid.In(ModeWebhook, ModeWebsocket)), + } + if c.Mode == ModeWebhook { + fields = append(fields, + valid.String("verification_token", c.VerificationToken, valid.Required), + valid.String("encrypt_key", c.EncryptKey, valid.Required), + ) + } + return valid.Validate(fields...) } diff --git a/internal/modules/slack/config.go b/internal/modules/slack/config.go index 3e490e4..4e0a92e 100644 --- a/internal/modules/slack/config.go +++ b/internal/modules/slack/config.go @@ -1,7 +1,7 @@ package slack import ( - validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/nt0xa/sonar/pkg/valid" ) type Config struct { @@ -10,10 +10,10 @@ type Config struct { AppToken string `koanf:"app_token"` } -func (c Config) Validate() error { - return validation.ValidateStruct(&c, - validation.Field(&c.Admin, validation.Required), - validation.Field(&c.AppToken, validation.Required), - validation.Field(&c.BotToken, validation.Required), +func (c Config) Validate() valid.Problems { + return valid.Validate( + valid.String("admin", c.Admin, valid.Required), + valid.String("app_token", c.AppToken, valid.Required), + valid.String("bot_token", c.BotToken, valid.Required), ) } diff --git a/internal/modules/telegram/config.go b/internal/modules/telegram/config.go index b64ac8d..7ef6960 100644 --- a/internal/modules/telegram/config.go +++ b/internal/modules/telegram/config.go @@ -1,7 +1,7 @@ package telegram import ( - validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/nt0xa/sonar/pkg/valid" ) type Config struct { @@ -10,10 +10,9 @@ type Config struct { Proxy string } -func (c Config) Validate() error { - return validation.ValidateStruct(&c, - validation.Field(&c.Admin, validation.Required), - validation.Field(&c.Token, validation.Required), - validation.Field(&c.Proxy), +func (c Config) Validate() valid.Problems { + return valid.Validate( + valid.Number("admin", c.Admin, valid.Required), + valid.String("token", c.Token, valid.Required), ) } diff --git a/internal/service/audit_records_get.go b/internal/service/audit_records_get.go index 431af05..08285dc 100644 --- a/internal/service/audit_records_get.go +++ b/internal/service/audit_records_get.go @@ -3,7 +3,7 @@ package service import ( "context" - v "github.com/nt0xa/sonar/pkg/valid" + "github.com/nt0xa/sonar/pkg/valid" ) type AuditRecordsGet interface { @@ -14,9 +14,9 @@ type AuditRecordsGetInput struct { ID int64 } -func (in AuditRecordsGetInput) Validate() v.Problems { - return v.Validate( - v.Number("id", in.ID, v.Required), +func (in AuditRecordsGetInput) Validate() valid.Problems { + return valid.Validate( + valid.Number("id", in.ID, valid.Required), ) } diff --git a/internal/service/dns_records_clear.go b/internal/service/dns_records_clear.go index 1170e8c..6e7e831 100644 --- a/internal/service/dns_records_clear.go +++ b/internal/service/dns_records_clear.go @@ -3,7 +3,7 @@ package service import ( "context" - v "github.com/nt0xa/sonar/pkg/valid" + "github.com/nt0xa/sonar/pkg/valid" ) type DNSRecordsClear interface { @@ -15,9 +15,9 @@ type DNSRecordsClearInput struct { Name string } -func (in DNSRecordsClearInput) Validate() v.Problems { - return v.Validate( - v.String("payloadName", in.PayloadName, v.Required), +func (in DNSRecordsClearInput) Validate() valid.Problems { + return valid.Validate( + valid.String("payloadName", in.PayloadName, valid.Required), ) } diff --git a/internal/service/dns_records_create.go b/internal/service/dns_records_create.go index a7826a6..31b72e8 100644 --- a/internal/service/dns_records_create.go +++ b/internal/service/dns_records_create.go @@ -3,7 +3,7 @@ package service import ( "context" - v "github.com/nt0xa/sonar/pkg/valid" + "github.com/nt0xa/sonar/pkg/valid" ) type DNSRecordsCreate interface { @@ -19,13 +19,13 @@ type DNSRecordsCreateInput struct { Strategy DNSRecordStrategy } -func (in DNSRecordsCreateInput) Validate() v.Problems { - return v.Validate( - v.String("payloadName", in.PayloadName, v.Required), - v.String("name", in.Name, v.Required, subdomain), - v.String("type", in.Type, v.Required, v.In(DNSRecordTypeValues()...)), - v.Slice("values", in.Values, v.NotEmpty, v.Each(dnsValueRule(in.Type))), - v.String("strategy", in.Strategy, v.Required, v.In(DNSRecordStrategyValues()...)), +func (in DNSRecordsCreateInput) Validate() valid.Problems { + return valid.Validate( + valid.String("payloadName", in.PayloadName, valid.Required), + valid.String("name", in.Name, valid.Required, subdomain), + valid.String("type", in.Type, valid.Required, valid.In(DNSRecordTypeValues()...)), + valid.Slice("values", in.Values, valid.NotEmpty, valid.Each(dnsValueRule(in.Type))), + valid.String("strategy", in.Strategy, valid.Required, valid.In(DNSRecordStrategyValues()...)), ) } diff --git a/internal/service/dns_records_delete.go b/internal/service/dns_records_delete.go index 64b83be..a76670c 100644 --- a/internal/service/dns_records_delete.go +++ b/internal/service/dns_records_delete.go @@ -3,7 +3,7 @@ package service import ( "context" - v "github.com/nt0xa/sonar/pkg/valid" + "github.com/nt0xa/sonar/pkg/valid" ) type DNSRecordsDelete interface { @@ -15,10 +15,10 @@ type DNSRecordsDeleteInput struct { Index int64 } -func (in DNSRecordsDeleteInput) Validate() v.Problems { - return v.Validate( - v.String("payloadName", in.PayloadName, v.Required), - v.Number("index", in.Index, v.Required), +func (in DNSRecordsDeleteInput) Validate() valid.Problems { + return valid.Validate( + valid.String("payloadName", in.PayloadName, valid.Required), + valid.Number("index", in.Index, valid.Required), ) } diff --git a/internal/service/dns_records_list.go b/internal/service/dns_records_list.go index ef071f1..b73f062 100644 --- a/internal/service/dns_records_list.go +++ b/internal/service/dns_records_list.go @@ -3,7 +3,7 @@ package service import ( "context" - v "github.com/nt0xa/sonar/pkg/valid" + "github.com/nt0xa/sonar/pkg/valid" ) type DNSRecordsList interface { @@ -14,9 +14,9 @@ type DNSRecordsListInput struct { PayloadName string } -func (in DNSRecordsListInput) Validate() v.Problems { - return v.Validate( - v.String("payloadName", in.PayloadName, v.Required), +func (in DNSRecordsListInput) Validate() valid.Problems { + return valid.Validate( + valid.String("payloadName", in.PayloadName, valid.Required), ) } diff --git a/internal/service/events_get.go b/internal/service/events_get.go index 9126705..1fe9eb7 100644 --- a/internal/service/events_get.go +++ b/internal/service/events_get.go @@ -3,7 +3,7 @@ package service import ( "context" - v "github.com/nt0xa/sonar/pkg/valid" + "github.com/nt0xa/sonar/pkg/valid" ) type EventsGet interface { @@ -15,10 +15,10 @@ type EventsGetInput struct { Index int64 } -func (in EventsGetInput) Validate() v.Problems { - return v.Validate( - v.String("payloadName", in.PayloadName, v.Required), - v.Number("index", in.Index, v.Required), +func (in EventsGetInput) Validate() valid.Problems { + return valid.Validate( + valid.String("payloadName", in.PayloadName, valid.Required), + valid.Number("index", in.Index, valid.Required), ) } diff --git a/internal/service/events_list.go b/internal/service/events_list.go index d414fa2..ad1c3c8 100644 --- a/internal/service/events_list.go +++ b/internal/service/events_list.go @@ -3,7 +3,7 @@ package service import ( "context" - v "github.com/nt0xa/sonar/pkg/valid" + "github.com/nt0xa/sonar/pkg/valid" ) type EventsList interface { @@ -16,9 +16,9 @@ type EventsListInput struct { Offset uint } -func (in EventsListInput) Validate() v.Problems { - return v.Validate( - v.String("payloadName", in.PayloadName, v.Required), +func (in EventsListInput) Validate() valid.Problems { + return valid.Validate( + valid.String("payloadName", in.PayloadName, valid.Required), ) } diff --git a/internal/service/http_routes_clear.go b/internal/service/http_routes_clear.go index 3bcef27..239b6e2 100644 --- a/internal/service/http_routes_clear.go +++ b/internal/service/http_routes_clear.go @@ -3,7 +3,7 @@ package service import ( "context" - v "github.com/nt0xa/sonar/pkg/valid" + "github.com/nt0xa/sonar/pkg/valid" ) type HTTPRoutesClear interface { @@ -15,9 +15,9 @@ type HTTPRoutesClearInput struct { Path string } -func (in HTTPRoutesClearInput) Validate() v.Problems { - return v.Validate( - v.String("payloadName", in.PayloadName, v.Required), +func (in HTTPRoutesClearInput) Validate() valid.Problems { + return valid.Validate( + valid.String("payloadName", in.PayloadName, valid.Required), ) } diff --git a/internal/service/http_routes_create.go b/internal/service/http_routes_create.go index 72f9599..b710192 100644 --- a/internal/service/http_routes_create.go +++ b/internal/service/http_routes_create.go @@ -3,7 +3,7 @@ package service import ( "context" - v "github.com/nt0xa/sonar/pkg/valid" + "github.com/nt0xa/sonar/pkg/valid" ) type HTTPRoutesCreate interface { @@ -20,12 +20,12 @@ type HTTPRoutesCreateInput struct { IsDynamic bool } -func (in HTTPRoutesCreateInput) Validate() v.Problems { - return v.Validate( - v.String("payloadName", in.PayloadName, v.Required), - v.String("method", in.Method, v.Required, v.In(HTTPMethodValues()...)), - v.String("path", in.Path, v.Required, v.Match(httpPathRegexp, `path must start with "/"`)), - v.Number("code", in.Code, v.Required), +func (in HTTPRoutesCreateInput) Validate() valid.Problems { + return valid.Validate( + valid.String("payloadName", in.PayloadName, valid.Required), + valid.String("method", in.Method, valid.Required, valid.In(HTTPMethodValues()...)), + valid.String("path", in.Path, valid.Required, valid.Match(httpPathRegexp, `path must start with "/"`)), + valid.Number("code", in.Code, valid.Required), ) } diff --git a/internal/service/http_routes_delete.go b/internal/service/http_routes_delete.go index 672da18..646b367 100644 --- a/internal/service/http_routes_delete.go +++ b/internal/service/http_routes_delete.go @@ -3,7 +3,7 @@ package service import ( "context" - v "github.com/nt0xa/sonar/pkg/valid" + "github.com/nt0xa/sonar/pkg/valid" ) type HTTPRoutesDelete interface { @@ -15,10 +15,10 @@ type HTTPRoutesDeleteInput struct { Index int64 } -func (in HTTPRoutesDeleteInput) Validate() v.Problems { - return v.Validate( - v.String("payloadName", in.PayloadName, v.Required), - v.Number("index", in.Index, v.Required), +func (in HTTPRoutesDeleteInput) Validate() valid.Problems { + return valid.Validate( + valid.String("payloadName", in.PayloadName, valid.Required), + valid.Number("index", in.Index, valid.Required), ) } diff --git a/internal/service/http_routes_list.go b/internal/service/http_routes_list.go index c666d90..01365d4 100644 --- a/internal/service/http_routes_list.go +++ b/internal/service/http_routes_list.go @@ -3,7 +3,7 @@ package service import ( "context" - v "github.com/nt0xa/sonar/pkg/valid" + "github.com/nt0xa/sonar/pkg/valid" ) type HTTPRoutesList interface { @@ -14,9 +14,9 @@ type HTTPRoutesListInput struct { PayloadName string } -func (in HTTPRoutesListInput) Validate() v.Problems { - return v.Validate( - v.String("payloadName", in.PayloadName, v.Required), +func (in HTTPRoutesListInput) Validate() valid.Problems { + return valid.Validate( + valid.String("payloadName", in.PayloadName, valid.Required), ) } diff --git a/internal/service/http_routes_update.go b/internal/service/http_routes_update.go index aabcbdb..2dba3fd 100644 --- a/internal/service/http_routes_update.go +++ b/internal/service/http_routes_update.go @@ -3,7 +3,7 @@ package service import ( "context" - v "github.com/nt0xa/sonar/pkg/valid" + "github.com/nt0xa/sonar/pkg/valid" ) type HTTPRoutesUpdate interface { @@ -21,11 +21,11 @@ type HTTPRoutesUpdateInput struct { IsDynamic *bool } -func (in HTTPRoutesUpdateInput) Validate() v.Problems { - return v.Validate( - v.String("payload", in.Payload, v.Required), - v.OptionalString("method", in.Method, v.In(HTTPMethodValues()...)), - v.OptionalString("path", in.Path, v.Match(httpPathRegexp, `path must start with "/"`)), +func (in HTTPRoutesUpdateInput) Validate() valid.Problems { + return valid.Validate( + valid.String("payload", in.Payload, valid.Required), + valid.OptionalString("method", in.Method, valid.In(HTTPMethodValues()...)), + valid.OptionalString("path", in.Path, valid.Match(httpPathRegexp, `path must start with "/"`)), ) } diff --git a/internal/service/payloads_create.go b/internal/service/payloads_create.go index bbd5770..412f4b2 100644 --- a/internal/service/payloads_create.go +++ b/internal/service/payloads_create.go @@ -3,7 +3,7 @@ package service import ( "context" - v "github.com/nt0xa/sonar/pkg/valid" + "github.com/nt0xa/sonar/pkg/valid" ) type PayloadsCreate interface { @@ -16,10 +16,10 @@ type PayloadsCreateInput struct { StoreEvents bool } -func (in PayloadsCreateInput) Validate() v.Problems { - return v.Validate( - v.String("name", in.Name, v.Required), - v.Slice("notifyProtocols", in.NotifyProtocols, v.Each(v.In(ProtoCategoryValues()...))), +func (in PayloadsCreateInput) Validate() valid.Problems { + return valid.Validate( + valid.String("name", in.Name, valid.Required), + valid.Slice("notifyProtocols", in.NotifyProtocols, valid.Each(valid.In(ProtoCategoryValues()...))), ) } diff --git a/internal/service/payloads_delete.go b/internal/service/payloads_delete.go index 2afd911..0c16e1f 100644 --- a/internal/service/payloads_delete.go +++ b/internal/service/payloads_delete.go @@ -3,7 +3,7 @@ package service import ( "context" - v "github.com/nt0xa/sonar/pkg/valid" + "github.com/nt0xa/sonar/pkg/valid" ) type PayloadsDelete interface { @@ -14,9 +14,9 @@ type PayloadsDeleteInput struct { Name string } -func (in PayloadsDeleteInput) Validate() v.Problems { - return v.Validate( - v.String("name", in.Name, v.Required), +func (in PayloadsDeleteInput) Validate() valid.Problems { + return valid.Validate( + valid.String("name", in.Name, valid.Required), ) } diff --git a/internal/service/payloads_update.go b/internal/service/payloads_update.go index b9d5716..7e7b876 100644 --- a/internal/service/payloads_update.go +++ b/internal/service/payloads_update.go @@ -3,7 +3,7 @@ package service import ( "context" - v "github.com/nt0xa/sonar/pkg/valid" + "github.com/nt0xa/sonar/pkg/valid" ) type PayloadsUpdate interface { @@ -19,10 +19,10 @@ type PayloadsUpdateInput struct { StoreEvents *bool } -func (in PayloadsUpdateInput) Validate() v.Problems { - return v.Validate( - v.String("name", in.Name, v.Required), - v.Slice("notifyProtocols", in.NotifyProtocols, v.Each(v.In(ProtoCategoryValues()...))), +func (in PayloadsUpdateInput) Validate() valid.Problems { + return valid.Validate( + valid.String("name", in.Name, valid.Required), + valid.Slice("notifyProtocols", in.NotifyProtocols, valid.Each(valid.In(ProtoCategoryValues()...))), ) } diff --git a/internal/service/users_create.go b/internal/service/users_create.go index 3423edc..3b430ed 100644 --- a/internal/service/users_create.go +++ b/internal/service/users_create.go @@ -3,7 +3,7 @@ package service import ( "context" - v "github.com/nt0xa/sonar/pkg/valid" + "github.com/nt0xa/sonar/pkg/valid" ) type UsersCreate interface { @@ -19,9 +19,9 @@ type UsersCreateInput struct { IsAdmin bool } -func (in UsersCreateInput) Validate() v.Problems { - return v.Validate( - v.String("name", in.Name, v.Required), +func (in UsersCreateInput) Validate() valid.Problems { + return valid.Validate( + valid.String("name", in.Name, valid.Required), ) } diff --git a/internal/service/users_delete.go b/internal/service/users_delete.go index 88dfc48..cdbed92 100644 --- a/internal/service/users_delete.go +++ b/internal/service/users_delete.go @@ -3,7 +3,7 @@ package service import ( "context" - v "github.com/nt0xa/sonar/pkg/valid" + "github.com/nt0xa/sonar/pkg/valid" ) type UsersDelete interface { @@ -14,9 +14,9 @@ type UsersDeleteInput struct { Name string } -func (in UsersDeleteInput) Validate() v.Problems { - return v.Validate( - v.String("name", in.Name, v.Required), +func (in UsersDeleteInput) Validate() valid.Problems { + return valid.Validate( + valid.String("name", in.Name, valid.Required), ) } diff --git a/internal/service/validators.go b/internal/service/validators.go index acceac4..334f0b8 100644 --- a/internal/service/validators.go +++ b/internal/service/validators.go @@ -8,7 +8,7 @@ import ( "strconv" "strings" - v "github.com/nt0xa/sonar/pkg/valid" + "github.com/nt0xa/sonar/pkg/valid" ) var ( @@ -84,7 +84,7 @@ func notEmpty(s string) error { } // dnsValueRule returns the per-value validation rule for a DNS record type. -func dnsValueRule(t DNSRecordType) v.Rule[string] { +func dnsValueRule(t DNSRecordType) valid.Rule[string] { switch t { case DNSRecordTypeA: return ip4 diff --git a/pkg/valid/format.go b/pkg/valid/format.go new file mode 100644 index 0000000..43a9cd4 --- /dev/null +++ b/pkg/valid/format.go @@ -0,0 +1,40 @@ +package valid + +import ( + "errors" + "net" + "net/url" + "regexp" +) + +// domainRegexp matches a DNS hostname like "example.com": dot-separated labels +// of letters/digits/hyphens (not starting or ending with a hyphen) and a +// letters-only TLD. +var domainRegexp = regexp.MustCompile( + `^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`, +) + +// IP asserts the string is a valid IPv4 or IPv6 address. +func IP(s string) error { + if net.ParseIP(s) == nil { + return errors.New("must be a valid IP address") + } + return nil +} + +// Domain asserts the string is a valid domain name (e.g. example.com). +func Domain(s string) error { + if len(s) > 255 || !domainRegexp.MatchString(s) { + return errors.New("must be a valid domain") + } + return nil +} + +// URL asserts the string is a valid absolute URL with a scheme and host. +func URL(s string) error { + u, err := url.Parse(s) + if err != nil || u.Scheme == "" || u.Host == "" { + return errors.New("must be a valid URL") + } + return nil +} diff --git a/pkg/valid/valid.go b/pkg/valid/valid.go index 7aea1df..7ad64b1 100644 --- a/pkg/valid/valid.go +++ b/pkg/valid/valid.go @@ -17,13 +17,17 @@ // keyed to the element type. package valid -import "errors" +import ( + "errors" + "sort" + "strings" +) // Validatable is anything that validates itself, returning a flat map of keyed // problems (nil when ok). Both the leaf fields built by String/Number/Slice and // domain types (e.g. service Input types exposing Validate() Problems) satisfy // it, so a Validatable can be a single field in a Validate(...) call or a nested -// struct folded in via Struct/OptionalStruct, which namespace its keys. +// struct folded in via Struct, which namespaces its keys. type Validatable interface { // Validate returns problems keyed relative to this value: a leaf returns // {name: msg}, a nested struct returns {name.child: msg, ...}; nil when ok. @@ -43,6 +47,22 @@ type Problems map[string]string // Ok reports whether there are no problems. func (p Problems) Ok() bool { return len(p) == 0 } +// Error renders problems as "key: msg; key2: msg2" with keys sorted, letting a +// non-empty Problems be returned where an error is expected. Guard with Ok() to +// return a nil error when there are no problems. +func (p Problems) Error() string { + keys := make([]string, 0, len(p)) + for k := range p { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, len(keys)) + for i, k := range keys { + parts[i] = k + ": " + p[k] + } + return strings.Join(parts, "; ") +} + // Validate runs the given fields and merges their problems into a single flat // map of field name -> problem. Within a field the first failing rule wins; if // two fields produce the same key the first is kept. It returns nil when diff --git a/pkg/valid/valid_test.go b/pkg/valid/valid_test.go index bcd5ee4..9558154 100644 --- a/pkg/valid/valid_test.go +++ b/pkg/valid/valid_test.go @@ -6,7 +6,7 @@ import ( "regexp" "testing" - v "github.com/nt0xa/sonar/pkg/valid" + "github.com/nt0xa/sonar/pkg/valid" ) type color string @@ -30,14 +30,14 @@ func notFoo(s string) error { func TestStruct_Valid(t *testing.T) { path := "/ok" - got := v.Validate( - v.String("name", "abc", v.Required, v.MinLength(2)), - v.String("color", colorRed, v.In(colorValues()...)), - v.Slice("tags", []string{"a", "b"}, v.NotEmpty, v.Each(notFoo)), - v.Slice("ports", []int{80, 443}, v.Each(v.Min(1), v.Max(65535))), - v.Number("count", 10, v.Required, v.Min(1), v.Max(100)), - v.OptionalString("path", &path, v.Match(pathRe, "bad path")), - v.OptionalString("missing", (*string)(nil), v.Required), + got := valid.Validate( + valid.String("name", "abc", valid.Required, valid.MinLength(2)), + valid.String("color", colorRed, valid.In(colorValues()...)), + valid.Slice("tags", []string{"a", "b"}, valid.NotEmpty, valid.Each(notFoo)), + valid.Slice("ports", []int{80, 443}, valid.Each(valid.Min(1), valid.Max(65535))), + valid.Number("count", 10, valid.Required, valid.Min(1), valid.Max(100)), + valid.OptionalString("path", &path, valid.Match(pathRe, "bad path")), + valid.OptionalString("missing", (*string)(nil), valid.Required), ) if !got.Ok() { t.Fatalf("expected ok, got %v", got) @@ -50,30 +50,30 @@ func TestStruct_Valid(t *testing.T) { func TestStruct_Problems(t *testing.T) { tests := []struct { name string - field v.Validatable + field valid.Validatable wantKey string wantMsg string }{ - {"required string", v.String("name", "", v.Required), "name", "is required"}, - {"min length", v.String("name", "a", v.MinLength(3)), "name", "must be at least 3 characters"}, - {"max length", v.String("name", "abcd", v.MaxLength(3)), "name", "must be at most 3 characters"}, - {"not blank", v.String("name", " ", v.NotBlank), "name", "must not be blank"}, - {"in", v.String("color", color("green"), v.In(colorValues()...)), "color", "must be one of: red, blue"}, - {"match", v.String("path", "x", v.Match(pathRe, "bad path")), "path", "bad path"}, - {"custom func", v.String("x", "foo", notFoo), "x", "must not be foo"}, - {"required number", v.Number("index", 0, v.Required), "index", "is required"}, - {"min", v.Number("n", 5, v.Min(10)), "n", "must be >= 10"}, - {"max", v.Number("n", 200, v.Max(100)), "n", "must be <= 100"}, - {"not empty slice", v.Slice("tags", []string(nil), v.NotEmpty), "tags", "is required"}, - {"each element", v.Slice("tags", []string{"ok", "foo"}, v.Each(notFoo)), "tags", "element #1: must not be foo"}, - {"each in", v.Slice("colors", []color{colorRed, "x"}, v.Each(v.In(colorValues()...))), "colors", "element #1: must be one of: red, blue"}, - {"each min", v.Slice("ports", []int{0, 5}, v.Each(v.Min(1))), "ports", "element #0: must be >= 1"}, - {"optional ptr", v.OptionalString("path", new("x"), v.Match(pathRe, "bad path")), "path", "bad path"}, + {"required string", valid.String("name", "", valid.Required), "name", "is required"}, + {"min length", valid.String("name", "a", valid.MinLength(3)), "name", "must be at least 3 characters"}, + {"max length", valid.String("name", "abcd", valid.MaxLength(3)), "name", "must be at most 3 characters"}, + {"not blank", valid.String("name", " ", valid.NotBlank), "name", "must not be blank"}, + {"in", valid.String("color", color("green"), valid.In(colorValues()...)), "color", "must be one of: red, blue"}, + {"match", valid.String("path", "x", valid.Match(pathRe, "bad path")), "path", "bad path"}, + {"custom func", valid.String("x", "foo", notFoo), "x", "must not be foo"}, + {"required number", valid.Number("index", 0, valid.Required), "index", "is required"}, + {"min", valid.Number("n", 5, valid.Min(10)), "n", "must be >= 10"}, + {"max", valid.Number("n", 200, valid.Max(100)), "n", "must be <= 100"}, + {"not empty slice", valid.Slice("tags", []string(nil), valid.NotEmpty), "tags", "is required"}, + {"each element", valid.Slice("tags", []string{"ok", "foo"}, valid.Each(notFoo)), "tags", "element #1: must not be foo"}, + {"each in", valid.Slice("colors", []color{colorRed, "x"}, valid.Each(valid.In(colorValues()...))), "colors", "element #1: must be one of: red, blue"}, + {"each min", valid.Slice("ports", []int{0, 5}, valid.Each(valid.Min(1))), "ports", "element #0: must be >= 1"}, + {"optional ptr", valid.OptionalString("path", new("x"), valid.Match(pathRe, "bad path")), "path", "bad path"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := v.Validate(tt.field) + got := valid.Validate(tt.field) if got[tt.wantKey] != tt.wantMsg { t.Fatalf("key %q: got %q, want %q (all: %v)", tt.wantKey, got[tt.wantKey], tt.wantMsg, got) } @@ -85,10 +85,10 @@ func TestNumber_NoCoercion(t *testing.T) { // A large uint64 must compare correctly (no int64 widening) and a float must // use float comparison. big := uint64(math.MaxUint64) - if got := v.Validate(v.Number("u", big, v.Required, v.Min(uint64(1)))); !got.Ok() { + if got := valid.Validate(valid.Number("u", big, valid.Required, valid.Min(uint64(1)))); !got.Ok() { t.Fatalf("large uint64 should pass Min(1), got %v", got) } - if got := v.Validate(v.Number("f", 1.5, v.Max(1.0))); got["f"] != "must be <= 1" { + if got := valid.Validate(valid.Number("f", 1.5, valid.Max(1.0))); got["f"] != "must be <= 1" { t.Fatalf("float max: got %v", got) } } @@ -98,48 +98,50 @@ type inner struct { Host string } -func (i inner) Validate() v.Problems { - return v.Validate(v.String("host", i.Host, v.Required)) +func (i inner) Validate() valid.Problems { + return valid.Validate(valid.String("host", i.Host, valid.Required)) } // Three levels of nesting to prove keys compose as a.b.c. type lvl3 struct{ C string } -func (l lvl3) Validate() v.Problems { return v.Validate(v.String("c", l.C, v.Required)) } +func (l lvl3) Validate() valid.Problems { + return valid.Validate(valid.String("c", l.C, valid.Required)) +} type lvl2 struct{ B lvl3 } -func (l lvl2) Validate() v.Problems { return v.Validate(v.Struct("b", l.B)) } +func (l lvl2) Validate() valid.Problems { return valid.Validate(valid.Struct("b", l.B)) } func TestStruct_Nested(t *testing.T) { // Single nested struct: child key is namespaced under the field name. - got := v.Validate(v.Struct("inner", inner{Host: ""})) + got := valid.Validate(valid.Struct("inner", inner{Host: ""})) if got["inner.host"] != "is required" { t.Fatalf("got %v, want inner.host=is required", got) } // Valid nested struct contributes nothing. - got = v.Validate(v.Struct("inner", inner{Host: "example.com"})) + got = valid.Validate(valid.Struct("inner", inner{Host: "example.com"})) if !got.Ok() { t.Fatalf("expected ok, got %v", got) } // Deep nesting composes: a.b.c. - got = v.Validate(v.Struct("a", lvl2{})) + got = valid.Validate(valid.Struct("a", lvl2{})) if got["a.b.c"] != "is required" { t.Fatalf("got %v, want a.b.c=is required", got) } // A nil value is skipped (optional nested struct). - if got := v.Validate(v.Struct("inner", nil)); !got.Ok() { + if got := valid.Validate(valid.Struct("inner", nil)); !got.Ok() { t.Fatalf("nil value should be ok, got %v", got) } } func TestStruct_MixedLeafAndNested(t *testing.T) { - got := v.Validate( - v.String("name", "", v.Required), - v.Struct("inner", inner{Host: ""}), + got := valid.Validate( + valid.String("name", "", valid.Required), + valid.Struct("inner", inner{Host: ""}), ) if got["name"] != "is required" || got["inner.host"] != "is required" { t.Fatalf("got %v, want both name and inner.host", got) @@ -148,17 +150,56 @@ func TestStruct_MixedLeafAndNested(t *testing.T) { func TestStruct_FirstProblemPerFieldWins(t *testing.T) { // Within a field, the first failing rule wins. - got := v.Validate(v.String("name", "", v.Required, v.MinLength(5))) + got := valid.Validate(valid.String("name", "", valid.Required, valid.MinLength(5))) if got["name"] != "is required" { t.Fatalf("got %q, want first rule's message", got["name"]) } // Across fields, the first occurrence of a name is kept. - got = v.Validate( - v.String("name", "", v.Required), - v.String("name", "a", v.MinLength(5)), + got = valid.Validate( + valid.String("name", "", valid.Required), + valid.String("name", "a", valid.MinLength(5)), ) if got["name"] != "is required" { t.Fatalf("got %q, want first field's message", got["name"]) } } + +func TestFormatRules(t *testing.T) { + tests := []struct { + name string + rule valid.Rule[string] + ok []string + bad []string + }{ + {"IP", valid.IP, []string{"127.0.0.1", "::1", "2001:db8::1"}, []string{"", "256.0.0.1", "example.com"}}, + {"Domain", valid.Domain, []string{"example.com", "a.b.example.org"}, []string{"", "example", "-bad.com", "http://x.com"}}, + {"URL", valid.URL, []string{"https://example.com", "http://x:8080/p"}, []string{"", "example.com", "/just/path"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, s := range tt.ok { + if err := tt.rule(s); err != nil { + t.Errorf("%q: expected ok, got %v", s, err) + } + } + for _, s := range tt.bad { + if err := tt.rule(s); err == nil { + t.Errorf("%q: expected error, got nil", s) + } + } + }) + } +} + +func TestProblems_Error(t *testing.T) { + p := valid.Validate( + valid.String("name", "", valid.Required), + valid.String("ip", "bad", valid.IP), + ) + // Keys are sorted: ip before name. + if got, want := p.Error(), "ip: must be a valid IP address; name: is required"; got != want { + t.Fatalf("got %q, want %q", got, want) + } +} diff --git a/tests/config_test.go b/tests/config_test.go index 33111f2..96d2981 100644 --- a/tests/config_test.go +++ b/tests/config_test.go @@ -108,6 +108,17 @@ verification_token = "" assert.Equal(t, "", cfg.Modules.Lark.VerificationToken) } +func TestConfig_Invalid(t *testing.T) { + _, err := server.LoadConfig( + fstest.MapFS{}, + func() []string { return nil }, + ) + require.Error(t, err) + // Missing required domain/ip surface as keyed problems in the message. + assert.Contains(t, err.Error(), "domain") + assert.Contains(t, err.Error(), "ip") +} + func TestConfig_Env(t *testing.T) { cfg, err := server.LoadConfig( fstest.MapFS{},