From 16dc829fd54c67d82ccfca903aeaf281a0ba511a43cd92c43c88d8079fd51aab Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Tue, 15 Jul 2025 12:57:46 -0400 Subject: [PATCH] e3.1.0 Validator added. --- builder.go | 72 ++++++++++++++++++++++++++++++++++++++----------- builder_test.go | 68 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 16 deletions(-) diff --git a/builder.go b/builder.go index a9dbf2b..6da150d 100644 --- a/builder.go +++ b/builder.go @@ -7,23 +7,29 @@ import ( "os" ) +// ValidatorFunc defines the signature for a function that can validate a Config instance. +// It receives the fully loaded *Config object and should return an error if validation fails. +type ValidatorFunc func(c *Config) error + // Builder provides a fluent interface for building configurations type Builder struct { - cfg *Config - opts LoadOptions - defaults any - prefix string - file string - args []string - err error + cfg *Config + opts LoadOptions + defaults any + prefix string + file string + args []string + err error + validators []ValidatorFunc } // NewBuilder creates a new configuration builder func NewBuilder() *Builder { return &Builder{ - cfg: New(), - opts: DefaultLoadOptions(), - args: os.Args[1:], + cfg: New(), + opts: DefaultLoadOptions(), + args: os.Args[1:], + validators: make([]ValidatorFunc, 0), } } @@ -57,7 +63,7 @@ func (b *Builder) WithArgs(args []string) *Builder { return b } -// WithSources sets the precedence order for configuration sources +// WithSources sets the precedent order for configuration sources func (b *Builder) WithSources(sources ...Source) *Builder { b.opts.Sources = sources return b @@ -80,6 +86,15 @@ func (b *Builder) WithEnvWhitelist(paths ...string) *Builder { return b } +// WithValidator adds a validation function that runs at the end of the build process +// Multiple validators can be added and are executed in the order they are added +func (b *Builder) WithValidator(fn ValidatorFunc) *Builder { + if fn != nil { + b.validators = append(b.validators, fn) + } + return b +} + // Build creates the Config instance with all specified options func (b *Builder) Build() (*Config, error) { if b.err != nil { @@ -94,13 +109,21 @@ func (b *Builder) Build() (*Config, error) { } // Load configuration - if err := b.cfg.LoadWithOptions(b.file, b.args, b.opts); err != nil { - // The error might be non-fatal (e.g., file not found). - // Return the config object so it can be used with other sources. - return b.cfg, err + loadErr := b.cfg.LoadWithOptions(b.file, b.args, b.opts) + if loadErr != nil && !errors.Is(loadErr, ErrConfigNotFound) { + // Return on fatal load errors. ErrConfigNotFound is not fatal. + return nil, loadErr } - return b.cfg, nil + // Run validators + for _, validator := range b.validators { + if err := validator(b.cfg); err != nil { + return nil, fmt.Errorf("configuration validation failed: %w", err) + } + } + + // ErrConfigNotFound or nil + return b.cfg, loadErr } // MustBuild is like Build but panics on error @@ -114,4 +137,21 @@ func (b *Builder) MustBuild() *Config { } } return cfg +} + +// BuildAndScan builds and unmarshals the final configuration into the provided target struct pointer +func (b *Builder) BuildAndScan(target any) error { + cfg, err := b.Build() + if err != nil && !errors.Is(err, ErrConfigNotFound) { + return err + } + + // Use Scan to populate the target struct. + // The prefix used during registration is the base path for scanning. + if err := cfg.Scan(b.prefix, target); err != nil { + return fmt.Errorf("failed to scan final config into target: %w", err) + } + + // ErrConfigNotFound or nil + return err } \ No newline at end of file diff --git a/builder_test.go b/builder_test.go index c5acc35..a22b542 100644 --- a/builder_test.go +++ b/builder_test.go @@ -2,6 +2,7 @@ package config_test import ( + "errors" "os" "testing" @@ -150,6 +151,73 @@ func TestBuilder(t *testing.T) { MustBuild() }) }) + + t.Run("Builder with Validator", func(t *testing.T) { + type Config struct { + Server struct { + Host string `toml:"host"` + Port int `toml:"port"` + } `toml:"server"` + MaxConns int `toml:"max_conns"` + } + + defaults := Config{} + defaults.Server.Host = "localhost" + defaults.Server.Port = 8080 + defaults.MaxConns = 100 + + // Validator that fails + failingValidator := func(c *config.Config) error { + port, err := c.Int64("server.port") + if err != nil { + return err + } + if port == 8080 { + return errors.New("port 8080 is not allowed") + } + return nil + } + + // Validator that succeeds + passingValidator := func(c *config.Config) error { + host, err := c.String("server.host") + if err != nil { + return err + } + if host == "" { + return errors.New("host cannot be empty") + } + return nil + } + + // Test case 1: Validator fails + _, err := config.NewBuilder(). + WithDefaults(defaults). + WithValidator(failingValidator). + Build() + require.Error(t, err) + assert.Contains(t, err.Error(), "port 8080 is not allowed") + + // Test case 2: Validator passes + cfg, err := config.NewBuilder(). + WithDefaults(defaults). + WithArgs([]string{"--server.port=9000"}). // Change the port so it passes + WithValidator(failingValidator). + WithValidator(passingValidator). + Build() + require.NoError(t, err) + assert.NotNil(t, cfg) + port, _ := cfg.Int64("server.port") + assert.Equal(t, int64(9000), port) + + // Test case 3: MustBuild panics on validation failure + assert.PanicsWithError(t, "configuration validation failed: port 8080 is not allowed", func() { + config.NewBuilder(). + WithDefaults(defaults). + WithValidator(failingValidator). + MustBuild() + }) + }) } func TestQuickFunctions(t *testing.T) {