diff --git a/config/get.go b/config/get.go index 1210544..2d40f87 100644 --- a/config/get.go +++ b/config/get.go @@ -43,6 +43,19 @@ func GetAsString(name string, fallback string) func() string { } } +// GetAsStringArray returns a function that returns the wanted string with high performance. +func GetAsStringArray(name string, fallback []string) func() []string { + valid := getValidityFlag() + value := findStringArrayValue(name, fallback) + return func() []string { + if !valid.IsSet() { + valid = getValidityFlag() + value = findStringArrayValue(name, fallback) + } + return value + } +} + // GetAsInt returns a function that returns the wanted int with high performance. func GetAsInt(name string, fallback int64) func() int64 { valid := getValidityFlag() @@ -55,3 +68,16 @@ func GetAsInt(name string, fallback int64) func() int64 { return value } } + +// GetAsBool returns a function that returns the wanted int with high performance. +func GetAsBool(name string, fallback bool) func() bool { + valid := getValidityFlag() + value := findBoolValue(name, fallback) + return func() bool { + if !valid.IsSet() { + valid = getValidityFlag() + value = findBoolValue(name, fallback) + } + return value + } +} diff --git a/config/get_test.go b/config/get_test.go index 3e39e9f..008c678 100644 --- a/config/get_test.go +++ b/config/get_test.go @@ -9,7 +9,10 @@ func TestGet(t *testing.T) { err := SetConfig(` { "monkey": "1", - "elephant": 2 + "zebra": ["black", "white"], + "elephant": 2, + "hot": true, + "cold": false } `) if err != nil { @@ -28,14 +31,30 @@ func TestGet(t *testing.T) { } monkey := GetAsString("monkey", "none") - elephant := GetAsInt("elephant", -1) if monkey() != "1" { t.Fatalf("monkey should be 1, is %s", monkey()) } + + zebra := GetAsStringArray("zebra", []string{}) + if len(zebra()) != 2 || zebra()[0] != "black" || zebra()[1] != "white" { + t.Fatalf("zebra should be [\"black\", \"white\"], is %v", zebra()) + } + + elephant := GetAsInt("elephant", -1) if elephant() != 2 { t.Fatalf("elephant should be 2, is %d", elephant()) } + hot := GetAsBool("hot", false) + if !hot() { + t.Fatalf("hot should be true, is %v", hot()) + } + + cold := GetAsBool("cold", true) + if cold() { + t.Fatalf("cold should be false, is %v", cold()) + } + err = SetConfig(` { "monkey": "3" @@ -48,10 +67,14 @@ func TestGet(t *testing.T) { if monkey() != "3" { t.Fatalf("monkey should be 0, is %s", monkey()) } + if elephant() != 0 { t.Fatalf("elephant should be 0, is %d", elephant()) } + zebra() + hot() + } func BenchmarkGetAsStringCached(b *testing.B) { diff --git a/config/layers.go b/config/layers.go index cb84407..67db9aa 100644 --- a/config/layers.go +++ b/config/layers.go @@ -3,8 +3,10 @@ package config import ( "errors" "sync" + "fmt" "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) var ( @@ -15,6 +17,9 @@ var ( // ErrInvalidJSON is returned by SetConfig and SetDefaultConfig if they receive invalid json. ErrInvalidJSON = errors.New("json string invalid") + + // ErrInvalidOptionType is returned by SetConfigOption and SetDefaultConfigOption if given an unsupported option type. + ErrInvalidOptionType = errors.New("invalid option value type") ) // SetConfig sets the (prioritized) user defined config. @@ -45,7 +50,115 @@ func SetDefaultConfig(json string) error { return nil } -// findValue find the correct value in the user or default config +func validateValue(name string, value interface{}) error { + optionsLock.RLock() + defer optionsLock.RUnlock() + + option, ok := options[name] + if !ok { + switch value.(type) { + case string: + return nil + case []string: + return nil + case int: + return nil + case bool: + return nil + default: + return ErrInvalidOptionType + } + } + + switch v := value.(type) { + case string: + if option.OptType != OptTypeString { + return fmt.Errorf("expected type string for option %s, got type %T", name, v) + } + if option.compiledRegex != nil { + if !option.compiledRegex.MatchString(v) { + return fmt.Errorf("validation failed: string \"%s\" did not match regex for option %s", v, name) + } + } + return nil + case []string: + if option.OptType != OptTypeStringArray { + return fmt.Errorf("expected type string for option %s, got type %T", name, v) + } + if option.compiledRegex != nil { + for pos, entry := range v { + if !option.compiledRegex.MatchString(entry) { + return fmt.Errorf("validation failed: string \"%s\" at index %d did not match regex for option %s", entry, pos, name) + } + } + } + return nil + case int: + if option.OptType != OptTypeInt { + return fmt.Errorf("expected type int for option %s, got type %T", name, v) + } + return nil + case bool: + if option.OptType != OptTypeBool { + return fmt.Errorf("expected type bool for option %s, got type %T", name, v) + } + return nil + default: + return ErrInvalidOptionType + } +} + +// SetConfigOption sets a single value in the (prioritized) user defined config. +func SetConfigOption(name string, value interface{}) error { + configLock.Lock() + defer configLock.Unlock() + + var err error + var newConfig string + + if value == nil { + newConfig, err = sjson.Delete(userConfig, name) + } else { + err = validateValue(name, value) + if err == nil { + newConfig, err = sjson.Set(userConfig, name, value) + } + } + + if err == nil { + userConfig = newConfig + resetValidityFlag() + } + + return err +} + +// SetDefaultConfigOption sets a single value in the (fallback) default config. +func SetDefaultConfigOption(name string, value interface{}) error { + configLock.Lock() + defer configLock.Unlock() + + var err error + var newConfig string + + if value == nil { + newConfig, err = sjson.Delete(defaultConfig, name) + } else { + err = validateValue(name, value) + if err == nil { + newConfig, err = sjson.Set(defaultConfig, name, value) + } + } + + if err == nil { + defaultConfig = newConfig + resetValidityFlag() + } + + return err +} + +// findValue find the correct value in the user or default config. func findValue(name string) (result gjson.Result) { configLock.RLock() defer configLock.RUnlock() @@ -57,7 +170,7 @@ func findValue(name string) (result gjson.Result) { return result } -// findStringValue validates and return the value with the given name +// findStringValue validates and returns the value with the given name. func findStringValue(name string, fallback string) (value string) { result := findValue(name) if !result.Exists() { @@ -69,7 +182,26 @@ func findStringValue(name string, fallback string) (value string) { return result.String() } -// findIntValue validates and return the value with the given name +// findStringArrayValue validates and returns the value with the given name. +func findStringArrayValue(name string, fallback []string) (value []string) { + result := findValue(name) + if !result.Exists() { + return fallback + } + if !result.IsArray() { + return fallback + } + results := result.Array() + for _, r := range results { + if r.Type != gjson.String { + return fallback + } + value = append(value, r.String()) + } + return value +} + +// findIntValue validates and returns the value with the given name. func findIntValue(name string, fallback int64) (value int64) { result := findValue(name) if !result.Exists() { @@ -80,3 +212,19 @@ func findIntValue(name string, fallback int64) (value int64) { } return result.Int() } + +// findBoolValue validates and returns the value with the given name. +func findBoolValue(name string, fallback bool) (value bool) { + result := findValue(name) + if !result.Exists() { + return fallback + } + switch result.Type { + case gjson.True: + return true + case gjson.False: + return false + default: + return fallback + } +} diff --git a/config/layers_test.go b/config/layers_test.go index 90a3ec5..e4d7b28 100644 --- a/config/layers_test.go +++ b/config/layers_test.go @@ -2,50 +2,186 @@ package config import "testing" -func TestLayers(t *testing.T) { +func TestLayersGetters(t *testing.T) { err := SetConfig("{invalid json") if err == nil { - t.Fatal("expected error") + t.Error("expected error") } err = SetDefaultConfig("{invalid json") if err == nil { - t.Fatal("expected error") + t.Error("expected error") } err = SetConfig(` - { - "monkey": "banana", - "elephant": 3 - } + { + "monkey": "1", + "zebra": ["black", "white"], + "weird_zebra": ["black", -1], + "elephant": 2, + "hot": true + } `) if err != nil { - t.Fatal(err) + t.Error(err) } // Test missing values missingString := GetAsString("missing", "fallback") if missingString() != "fallback" { - t.Fatal("expected fallback value: fallback") + t.Error("expected fallback value: fallback") + } + + missingStringArray := GetAsStringArray("missing", []string{"fallback"}) + if len(missingStringArray()) != 1 || missingStringArray()[0] != "fallback" { + t.Error("expected fallback value: [fallback]") } missingInt := GetAsInt("missing", -1) if missingInt() != -1 { - t.Fatal("expected fallback value: -1") + t.Error("expected fallback value: -1") + } + + missingBool := GetAsBool("missing", false) + if missingBool() { + t.Error("expected fallback value: false") } // Test value mismatch notString := GetAsString("elephant", "fallback") if notString() != "fallback" { - t.Fatal("expected fallback value: fallback") + t.Error("expected fallback value: fallback") + } + + notStringArray := GetAsStringArray("elephant", []string{"fallback"}) + if len(notStringArray()) != 1 || notStringArray()[0] != "fallback" { + t.Error("expected fallback value: [fallback]") + } + + mixedStringArray := GetAsStringArray("weird_zebra", []string{"fallback"}) + if len(mixedStringArray()) != 1 || mixedStringArray()[0] != "fallback" { + t.Error("expected fallback value: [fallback]") } notInt := GetAsInt("monkey", -1) if notInt() != -1 { - t.Fatal("expected fallback value: -1") + t.Error("expected fallback value: -1") + } + + notBool := GetAsBool("monkey", false) + if notBool() { + t.Error("expected fallback value: false") + } + +} + +func TestLayersSetters(t *testing.T) { + + Register(&Option{ + Name: "name", + Key: "monkey", + Description: "description", + ExpertiseLevel: 1, + OptType: OptTypeString, + DefaultValue: "banana", + ValidationRegex: "^(banana|water)$", + }) + Register(&Option{ + Name: "name", + Key: "zebra", + Description: "description", + ExpertiseLevel: 1, + OptType: OptTypeStringArray, + DefaultValue: []string{"black", "white"}, + ValidationRegex: "^[a-z]+$", + }) + Register(&Option{ + Name: "name", + Key: "elephant", + Description: "description", + ExpertiseLevel: 1, + OptType: OptTypeInt, + DefaultValue: 2, + ValidationRegex: "", + }) + Register(&Option{ + Name: "name", + Key: "hot", + Description: "description", + ExpertiseLevel: 1, + OptType: OptTypeBool, + DefaultValue: true, + ValidationRegex: "", + }) + + // correct types + if err := SetConfigOption("monkey", "banana"); err != nil { + t.Error(err) + } + if err := SetConfigOption("zebra", []string{"black", "white"}); err != nil { + t.Error(err) + } + if err := SetDefaultConfigOption("elephant", 2); err != nil { + t.Error(err) + } + if err := SetDefaultConfigOption("hot", true); err != nil { + t.Error(err) + } + + // incorrect types + if err := SetConfigOption("monkey", []string{"black", "white"}); err == nil { + t.Error("should fail") + } + if err := SetConfigOption("zebra", 2); err == nil { + t.Error("should fail") + } + if err := SetDefaultConfigOption("elephant", true); err == nil { + t.Error("should fail") + } + if err := SetDefaultConfigOption("hot", "banana"); err == nil { + t.Error("should fail") + } + if err := SetDefaultConfigOption("hot", []byte{0}); err == nil { + t.Error("should fail") + } + + // validation fail + if err := SetConfigOption("monkey", "dirt"); err == nil { + t.Error("should fail") + } + if err := SetConfigOption("zebra", []string{"Element649"}); err == nil { + t.Error("should fail") + } + + // unregistered checking + if err := SetConfigOption("invalid", "banana"); err != nil { + t.Error(err) + } + if err := SetConfigOption("invalid", []string{"black", "white"}); err != nil { + t.Error(err) + } + if err := SetConfigOption("invalid", 2); err != nil { + t.Error(err) + } + if err := SetConfigOption("invalid", true); err != nil { + t.Error(err) + } + if err := SetConfigOption("invalid", []byte{0}); err != ErrInvalidOptionType { + t.Error("should fail with ErrInvalidOptionType") + } + + // delete + if err := SetConfigOption("monkey", nil); err != nil { + t.Error(err) + } + if err := SetDefaultConfigOption("elephant", nil); err != nil { + t.Error(err) + } + if err := SetDefaultConfigOption("invalid_delete", nil); err != nil { + t.Error(err) } } diff --git a/config/registry.go b/config/registry.go new file mode 100644 index 0000000..da5ff5f --- /dev/null +++ b/config/registry.go @@ -0,0 +1,67 @@ +package config + +import ( + "errors" + "fmt" + "regexp" + "sync" +) + +// Variable Type IDs for frontend Identification. Values over 100 are free for custom use. +const ( + OptTypeString uint8 = 1 + OptTypeStringArray uint8 = 2 + OptTypeInt uint8 = 3 + OptTypeBool uint8 = 4 + + ExpertiseLevelUser int8 = 1 + ExpertiseLevelExpert int8 = 2 + ExpertiseLevelDeveloper int8 = 3 +) + +var ( + optionsLock sync.RWMutex + options = make(map[string]*Option) + + // ErrIncompleteCall is return when RegisterOption is called with empty mandatory values. + ErrIncompleteCall = errors.New("could not register config option: all fields, except for the validationRegex are mandatory") +) + +// Option describes a configuration option. +type Option struct { + Name string + Key string + Description string + ExpertiseLevel uint8 + OptType uint8 + DefaultValue interface{} + ValidationRegex string + compiledRegex *regexp.Regexp +} + +// Register registers a new configuration option. +func Register(option *Option) error { + + if option.Name == "" || + option.Key == "" || + option.Description == "" || + option.ExpertiseLevel == 0 || + option.OptType == 0 { + return ErrIncompleteCall + } + + if option.ValidationRegex != "" { + var err error + option.compiledRegex, err = regexp.Compile(option.ValidationRegex) + if err != nil { + return fmt.Errorf("config: could not compile option.ValidationRegex: %s", err) + } + } + + optionsLock.Lock() + defer optionsLock.Unlock() + + options[option.Key] = option + + return nil +} diff --git a/config/registry_test.go b/config/registry_test.go new file mode 100644 index 0000000..99504bb --- /dev/null +++ b/config/registry_test.go @@ -0,0 +1,45 @@ +package config + +import ( + "testing" +) + +func TestRegistry(t *testing.T) { + + if err := Register(&Option{ + Name: "name", + Key: "key", + Description: "description", + ExpertiseLevel: 1, + OptType: OptTypeString, + DefaultValue: "default", + ValidationRegex: "^(banana|water)$", + }); err != nil { + t.Error(err) + } + + if err := Register(&Option{ + Name: "name", + Key: "key", + Description: "description", + ExpertiseLevel: 1, + OptType: 0, + DefaultValue: "default", + ValidationRegex: "^[A-Z][a-z]+$", + }); err == nil { + t.Error("should fail") + } + + if err := Register(&Option{ + Name: "name", + Key: "key", + Description: "description", + ExpertiseLevel: 1, + OptType: OptTypeString, + DefaultValue: "default", + ValidationRegex: "[", + }); err == nil { + t.Error("should fail") + } + +}