From a3581e55f590457306e575e459b363fef7eb7e68 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 17 May 2021 10:25:09 -0700 Subject: [PATCH] Add regexp.Regexp support It's useful to support validated regular expressions in configuration files. Previously, we could represent these fields as raw pattern strings and compile them in application code. By supporting Regexp as a first-class field type, we get a smaller application code and more immediate error reporting without complicating the API. --- README.md | 9 ++++--- doc.go | 10 +++++--- examples/config/config.yaml | 1 + examples/config/config_test.go | 6 ++++- fig.go | 24 ++++++++++++++++++ fig_test.go | 45 ++++++++++++++++++++++++++++++++-- util_test.go | 17 +++++++++++++ 7 files changed, 101 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c1e5b15..bddecb5 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ fig is a tiny library for loading an application's config file and its environme - Define your **configuration**, **validations** and **defaults** in a single location - Optionally **load from the environment** as well - Only **3** external dependencies -- Full support for`time.Time` & `time.Duration` +- Full support for`time.Time`, `time.Duration` & `regexp.Regexp` - Tiny API - Decoders for `.yaml`, `.json` and `.toml` files @@ -64,8 +64,9 @@ type Config struct { Cleanup time.Duration `fig:"cleanup" default:"30m"` } Logger struct { - Level string `fig:"level" default:"info"` - Trace bool `fig:"trace"` + Level string `fig:"level" default:"info"` + Pattern *regexp.Regexp `fig:"pattern" default:".*"` + Trace bool `fig:"trace"` } } @@ -75,7 +76,7 @@ func main() { // handle your err fmt.Printf("%+v\n", cfg) - // Output: {Build:2019-12-25 00:00:00 +0000 UTC Server:{Host:127.0.0.1 Ports:[8080] Cleanup:1h0m0s} Logger:{Level:warn Trace:true}} + // Output: {Build:2019-12-25 00:00:00 +0000 UTC Server:{Host:127.0.0.1 Ports:[8080] Cleanup:1h0m0s} Logger:{Level:warn Pattern:.* Trace:true}} } ``` diff --git a/doc.go b/doc.go index 6a4ec4d..45e2631 100644 --- a/doc.go +++ b/doc.go @@ -188,9 +188,10 @@ See example below to help understand: I interface{} `validate:"required"` J interface{} `validate:"required"` } `validate:"required"` - K *[]bool `validate:"required"` - L []uint `validate:"required"` - M *time.Time `validate:"required"` + K *[]bool `validate:"required"` + L []uint `validate:"required"` + M *time.Time `validate:"required"` + N *regexp.Regexp `validate:"required"` } var cfg Config @@ -206,7 +207,7 @@ See example below to help understand: err := fig.Load(&cfg) fmt.Print(err) - // A: required, B: required, C: required, D: required, E: required, G: required, H.J: required, K: required, M: required + // A: required validation failed, B: required validation failed, C: required validation failed, D: required validation failed, E: required validation failed, G: required validation failed, H.J: required validation failed, K: required validation failed, M: required validation failed, N: required validation failed Default @@ -224,6 +225,7 @@ A default value can be set for the following types: all basic types except bool and complex time.Time time.Duration + *regexp.Regexp slices (of above types) Successive elements of slice defaults should be separated by a comma. The entire slice can optionally be enclosed in square brackets: diff --git a/examples/config/config.yaml b/examples/config/config.yaml index 0e6d0ab..100ef2b 100644 --- a/examples/config/config.yaml +++ b/examples/config/config.yaml @@ -7,6 +7,7 @@ server: logger: level: debug + pattern: '[a-z]+' certificate: version: 1 diff --git a/examples/config/config_test.go b/examples/config/config_test.go index aba1bd1..24d18ec 100644 --- a/examples/config/config_test.go +++ b/examples/config/config_test.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "regexp" "time" "github.com/kkyr/fig" @@ -18,7 +19,8 @@ type Config struct { WriteTimeout time.Duration `fig:"write_timeout" default:"30s"` } `fig:"server"` Logger struct { - Level string `fig:"level" default:"info"` + Level string `fig:"level" default:"info"` + Pattern *regexp.Regexp `fig:"pattern" default:".*"` } `fig:"logger"` Certificate struct { Version int `fig:"version"` @@ -40,6 +42,7 @@ func ExampleLoad() { fmt.Println(cfg.Server.ReadTimeout) fmt.Println(cfg.Server.WriteTimeout) fmt.Println(cfg.Logger.Level) + fmt.Println(cfg.Logger.Pattern) fmt.Println(cfg.Certificate.Version) fmt.Println(cfg.Certificate.DNSNames) fmt.Println(cfg.Certificate.Expiration.Format("2006-01-02")) @@ -51,6 +54,7 @@ func ExampleLoad() { // 1m0s // 30s // debug + // [a-z]+ // 1 // [kkyr kkyr.io] // 2020-12-01 diff --git a/fig.go b/fig.go index 510a4f4..d690c3a 100644 --- a/fig.go +++ b/fig.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "reflect" + "regexp" "strconv" "strings" "time" @@ -153,6 +154,7 @@ func (f *fig) decodeMap(m map[string]interface{}, result interface{}) error { DecodeHook: mapstructure.ComposeDecodeHookFunc( mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToTimeHookFunc(f.timeLayout), + stringToRegexpHookFunc(), ), }) if err != nil { @@ -161,6 +163,22 @@ func (f *fig) decodeMap(m map[string]interface{}, result interface{}) error { return dec.Decode(m) } +// stringToRegexpHookFunc returns a DecodeHookFunc that converts strings to regexp.Regexp. +func stringToRegexpHookFunc() mapstructure.DecodeHookFunc { + return func( + f reflect.Type, + t reflect.Type, + data interface{}) (interface{}, error) { + if f.Kind() != reflect.String { + return data, nil + } + if t != reflect.TypeOf(®exp.Regexp{}) { + return data, nil + } + return regexp.Compile(data.(string)) + } +} + // processCfg processes a cfg struct after it has been loaded from // the config file, by validating required fields and setting defaults // where applicable. @@ -289,6 +307,12 @@ func (f *fig) setValue(fv reflect.Value, val string) error { return err } fv.Set(reflect.ValueOf(t)) + } else if _, ok := fv.Interface().(regexp.Regexp); ok { + re, err := regexp.Compile(val) + if err != nil { + return err + } + fv.Set(reflect.ValueOf(*re)) } else { return fmt.Errorf("unsupported type %s", fv.Kind()) } diff --git a/fig_test.go b/fig_test.go index 35c3826..8d53849 100644 --- a/fig_test.go +++ b/fig_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "reflect" + "regexp" "strings" "testing" "time" @@ -221,8 +222,9 @@ func Test_fig_Load_Defaults(t *testing.T) { Host string `fig:"host" default:"127.0.0.1"` Ports []int `fig:"ports" default:"[80,443]"` Logger struct { - LogLevel string `fig:"log_level" default:"info"` - Production bool `fig:"production"` + LogLevel string `fig:"log_level" default:"info"` + Pattern *regexp.Regexp `fig:"pattern" default:".*"` + Production bool `fig:"production"` Metadata struct { Keys []string `fig:"keys" default:"[ts]"` } @@ -236,6 +238,7 @@ func Test_fig_Load_Defaults(t *testing.T) { want.Host = "0.0.0.0" want.Ports = []int{80, 443} want.Logger.LogLevel = "debug" + want.Logger.Pattern = regexp.MustCompile(".*") want.Logger.Production = false want.Logger.Metadata.Keys = []string{"ts"} want.Application.BuildDate = time.Date(2020, 1, 1, 12, 0, 0, 0, time.UTC) @@ -1014,6 +1017,35 @@ func Test_fig_setValue(t *testing.T) { } }) + t.Run("regexp", func(t *testing.T) { + var re regexp.Regexp + fv := reflect.ValueOf(&re).Elem() + + err := fig.setValue(fv, "[a-z]+") + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + + want, err := regexp.Compile("[a-z]+") + if err != nil { + t.Fatalf("error parsing time: %v", err) + } + + if re.String() != want.String() { + t.Fatalf("want %v, got %v", want, re) + } + }) + + t.Run("bad regexp", func(t *testing.T) { + var re regexp.Regexp + fv := reflect.ValueOf(&re).Elem() + + err := fig.setValue(fv, "[a-") + if err == nil { + t.Fatalf("expected err") + } + }) + t.Run("interface returns error", func(t *testing.T) { var i interface{} fv := reflect.ValueOf(i) @@ -1089,6 +1121,15 @@ func Test_fig_setSlice(t *testing.T) { }, Val: "[2019-12-25T10:30:30Z,2020-01-01T00:00:00Z]", }, + { + Name: "regexps", + InSlice: &[]*regexp.Regexp{}, + WantSlice: &[]*regexp.Regexp{ + regexp.MustCompile("[a-z]+"), + regexp.MustCompile(".*"), + }, + Val: "[[a-z]+,.*]", + }, } { t.Run(tc.Val, func(t *testing.T) { in := reflect.ValueOf(tc.InSlice).Elem() diff --git a/util_test.go b/util_test.go index 5c994b2..5b6f660 100644 --- a/util_test.go +++ b/util_test.go @@ -3,6 +3,7 @@ package fig import ( "path/filepath" "reflect" + "regexp" "testing" "time" ) @@ -137,6 +138,22 @@ func Test_isZero(t *testing.T) { } }) + t.Run("zero regexp is zero", func(t *testing.T) { + var re *regexp.Regexp + + if isZero(reflect.ValueOf(re)) == false { + t.Fatalf("isZero == false") + } + }) + + t.Run("non-zero regexp is not zero", func(t *testing.T) { + re := regexp.MustCompile(".*") + + if isZero(reflect.ValueOf(re)) == true { + t.Fatalf("isZero == true") + } + }) + t.Run("reflect invalid is zero", func(t *testing.T) { var x interface{}