diff --git a/config-resolver-go/go.mod b/config-resolver-go/go.mod new file mode 100644 index 0000000..647e11a --- /dev/null +++ b/config-resolver-go/go.mod @@ -0,0 +1,3 @@ +module configresolver + +go 1.23.8 diff --git a/config-resolver-go/resolver/dto/config.go b/config-resolver-go/resolver/dto/config.go new file mode 100644 index 0000000..e8e3cab --- /dev/null +++ b/config-resolver-go/resolver/dto/config.go @@ -0,0 +1,8 @@ +package dto + +// Config describes the configuration input for Json resolver + +type Config struct { + OverrideRules []OverrideRule `json:"override-rules"` + DefaultProperties map[string]interface{} `json:"default-properties"` +} diff --git a/config-resolver-go/resolver/dto/override_rule.go b/config-resolver-go/resolver/dto/override_rule.go new file mode 100644 index 0000000..50aa6ab --- /dev/null +++ b/config-resolver-go/resolver/dto/override_rule.go @@ -0,0 +1,11 @@ +package dto + +// OverrideRule defines the conditions and properties for override + +type OverrideRule struct { + UserIsInAllGroups []string `json:"user-is-in-all-groups"` + UserIsInAnyGroup []string `json:"user-is-in-any-group"` + UserIsInNoneOfTheGroups []string `json:"user-is-none-of-the-groups"` + CustomExpression string `json:"custom-expression"` + Override map[string]interface{} `json:"override"` +} diff --git a/config-resolver-go/resolver/json_service.go b/config-resolver-go/resolver/json_service.go new file mode 100644 index 0000000..29550e1 --- /dev/null +++ b/config-resolver-go/resolver/json_service.go @@ -0,0 +1,223 @@ +package resolver + +import ( + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "regexp" + "strings" + + "configresolver/resolver/dto" +) + +// JsonConfigResolverService is an implementation of ConfigResolver for JSON configuration. +type JsonConfigResolverService struct { + configToResolve string +} + +func NewJsonConfigResolverService() *JsonConfigResolverService { + return &JsonConfigResolverService{} +} + +func (s *JsonConfigResolverService) SetConfigToResolve(config string) { + s.configToResolve = config +} + +func (s *JsonConfigResolverService) ResolveConfig(userGroups []string) (string, error) { + if s.configToResolve == "" { + return "", ErrConfigNotSet + } + return s.ResolveConfigFrom(s.configToResolve, userGroups) +} + +func (s *JsonConfigResolverService) ResolveConfigAs(userGroups []string, result interface{}) error { + if s.configToResolve == "" { + return ErrConfigNotSet + } + return s.ResolveConfigFromAs(s.configToResolve, userGroups, result) +} + +func (s *JsonConfigResolverService) ResolveConfigFrom(config string, userGroups []string) (string, error) { + var out map[string]interface{} + if err := s.resolve(config, userGroups, &out); err != nil { + return "", err + } + data, err := json.Marshal(out) + return string(data), err +} + +func (s *JsonConfigResolverService) ResolveConfigFromAs(config string, userGroups []string, result interface{}) error { + var out map[string]interface{} + if err := s.resolve(config, userGroups, &out); err != nil { + return err + } + data, err := json.Marshal(out) + if err != nil { + return err + } + return json.Unmarshal(data, result) +} + +func (s *JsonConfigResolverService) resolve(config string, userGroups []string, out *map[string]interface{}) error { + var cfg dto.Config + if err := json.Unmarshal([]byte(config), &cfg); err != nil { + return err + } + groups := make(map[string]bool) + if cfg.DefaultProperties == nil { + return fmt.Errorf("invalid config: missing default-properties") + } + for _, g := range userGroups { + groups[g] = true + } + for _, rule := range cfg.OverrideRules { + applies, err := ruleApplies(groups, rule) + if err != nil { + return err + } + if applies { + applyOverride(cfg.DefaultProperties, rule.Override) + } + } + *out = cfg.DefaultProperties + return nil +} + +func ruleApplies(groups map[string]bool, rule dto.OverrideRule) (bool, error) { + if len(rule.UserIsInAllGroups) > 0 { + match := true + for _, g := range rule.UserIsInAllGroups { + if !groups[g] { + match = false + break + } + } + if match { + return true, nil + } + } + if len(rule.UserIsInAnyGroup) > 0 { + for _, g := range rule.UserIsInAnyGroup { + if groups[g] { + return true, nil + } + } + } + if len(rule.UserIsInNoneOfTheGroups) > 0 { + none := true + for _, g := range rule.UserIsInNoneOfTheGroups { + if groups[g] { + none = false + break + } + } + if none { + return true, nil + } + } + if rule.CustomExpression != "" { + val, err := evaluateExpression(rule.CustomExpression, groups) + if err != nil { + return false, err + } + if val { + return true, nil + } + } + return false, nil +} + +func applyOverride(base map[string]interface{}, override map[string]interface{}) { + for k, v := range override { + overrideProperty(base, k, v) + } +} + +func overrideProperty(node map[string]interface{}, path string, value interface{}) { + parts := strings.Split(path, ".") + for i, p := range parts { + if i == len(parts)-1 { + node[p] = value + return + } + next, ok := node[p] + if !ok { + newMap := make(map[string]interface{}) + node[p] = newMap + node = newMap + continue + } + m, ok := next.(map[string]interface{}) + if !ok { + m = make(map[string]interface{}) + node[p] = m + } + node = m + } +} + +var containsRe = regexp.MustCompile(`#user\.contains\('([^']+)'\)`) // pattern for user.contains('group') + +func evaluateExpression(expr string, groups map[string]bool) (bool, error) { + replaced := containsRe.ReplaceAllStringFunc(expr, func(s string) string { + m := containsRe.FindStringSubmatch(s) + if len(m) == 2 && groups[m[1]] { + return "true" + } + return "false" + }) + replaced = strings.ReplaceAll(replaced, " and ", " && ") + replaced = strings.ReplaceAll(replaced, " or ", " || ") + replaced = strings.ReplaceAll(replaced, " not ", " ! ") + + parsed, err := parser.ParseExpr(replaced) + if err != nil { + return false, err + } + return evalBoolAST(parsed) +} + +func evalBoolAST(e ast.Expr) (bool, error) { + switch v := e.(type) { + case *ast.ParenExpr: + return evalBoolAST(v.X) + case *ast.UnaryExpr: + if v.Op != token.NOT { + return false, fmt.Errorf("unsupported unary op") + } + val, err := evalBoolAST(v.X) + if err != nil { + return false, err + } + return !val, nil + case *ast.BinaryExpr: + left, err := evalBoolAST(v.X) + if err != nil { + return false, err + } + right, err := evalBoolAST(v.Y) + if err != nil { + return false, err + } + switch v.Op { + case token.LAND: + return left && right, nil + case token.LOR: + return left || right, nil + default: + return false, fmt.Errorf("unsupported binary op") + } + case *ast.Ident: + if v.Name == "true" { + return true, nil + } + if v.Name == "false" { + return false, nil + } + return false, fmt.Errorf("unknown identifier %s", v.Name) + default: + return false, fmt.Errorf("unsupported expr") + } +} diff --git a/config-resolver-go/resolver/json_service_test.go b/config-resolver-go/resolver/json_service_test.go new file mode 100644 index 0000000..199c421 --- /dev/null +++ b/config-resolver-go/resolver/json_service_test.go @@ -0,0 +1,107 @@ +package resolver + +import ( + "path/filepath" + "strings" + "testing" +) + +type TestDTOProperty3dash1 struct { + Property3Dash1Dash1 bool `json:"property3-1-1"` +} + +type TestDTOProperty3 struct { + Property3Dash1 TestDTOProperty3dash1 `json:"property3-1"` +} + +type TestDTOProperty2 struct { + Property2Dash1 bool `json:"property2-1"` +} + +type TestDTO struct { + Property1 int `json:"property1"` + Property2 TestDTOProperty2 `json:"property2"` + Property3 TestDTOProperty3 `json:"property3"` +} + +func buildPath(parts ...string) string { + return filepath.Join(append([]string{"testdata"}, parts...)...) +} + +func prepareService() *JsonConfigResolverService { + return NewJsonConfigResolverService() +} + +func TestResolveConfigScenarios(t *testing.T) { + tests := []struct { + groups []string + inFile string + outFile string + }{ + {[]string{"group-a", "group-b"}, buildPath("user-in-all-groups", "input.json"), buildPath("user-in-all-groups", "output.json")}, + {[]string{"group-d"}, buildPath("user-in-any-groups", "input.json"), buildPath("user-in-any-groups", "output.json")}, + {[]string{"group-c"}, buildPath("user-in-no-groups", "input.json"), buildPath("user-in-no-groups", "output.json")}, + {[]string{"group-a", "group-b", "group-c"}, buildPath("user-in-different-groups", "input.json"), buildPath("user-in-different-groups", "output.json")}, + {[]string{"group-a", "group-b", "group-c"}, buildPath("custom-user-groups", "input.json"), buildPath("custom-user-groups", "output.json")}, + } + + svc := prepareService() + + for _, tt := range tests { + inputCfg, err := readFile(tt.inFile) + if err != nil { + t.Fatal(err) + } + var expected TestDTO + if err := readFileIntoObject(tt.outFile, &expected); err != nil { + t.Fatal(err) + } + + var actual TestDTO + if err := svc.ResolveConfigFromAs(inputCfg, tt.groups, &actual); err != nil { + t.Fatal(err) + } + if expected != actual { + t.Errorf("unexpected result for %v", tt) + } + outStr, err := svc.ResolveConfigFrom(inputCfg, tt.groups) + if err != nil { + t.Fatal(err) + } + expectedStr, err := readFile(tt.outFile) + if err != nil { + t.Fatal(err) + } + if normalize(outStr) != normalize(expectedStr) { + t.Errorf("string result mismatch for %v", tt) + } + } +} + +func normalize(s string) string { + s = strings.ReplaceAll(s, "\n", "") + s = strings.ReplaceAll(s, " ", "") + return s +} + +func TestInvalidConfig(t *testing.T) { + svc := prepareService() + groups := []string{"group-a", "group-b"} + input, err := readFile(buildPath("invalid-config", "input.json")) + if err != nil { + t.Fatal(err) + } + if _, err := svc.ResolveConfigFrom(input, groups); err == nil { + t.Errorf("expected error") + } + if err := svc.ResolveConfigFromAs(input, groups, &TestDTO{}); err == nil { + t.Errorf("expected error") + } + svc.SetConfigToResolve(input) + if _, err := svc.ResolveConfig(groups); err == nil { + t.Errorf("expected error") + } + if err := svc.ResolveConfigAs(groups, &TestDTO{}); err == nil { + t.Errorf("expected error") + } +} diff --git a/config-resolver-go/resolver/resolver.go b/config-resolver-go/resolver/resolver.go new file mode 100644 index 0000000..ee143fd --- /dev/null +++ b/config-resolver-go/resolver/resolver.go @@ -0,0 +1,14 @@ +package resolver + +import "errors" + +// ConfigResolver defines methods for resolving configuration for user groups. +type ConfigResolver interface { + SetConfigToResolve(config string) + ResolveConfig(userGroups []string) (string, error) + ResolveConfigAs(userGroups []string, result interface{}) error + ResolveConfigFrom(config string, userGroups []string) (string, error) + ResolveConfigFromAs(config string, userGroups []string, result interface{}) error +} + +var ErrConfigNotSet = errors.New("config to resolve is null. Use SetConfigToResolve() method to set the config") diff --git a/config-resolver-go/resolver/testdata/custom-user-groups/input.json b/config-resolver-go/resolver/testdata/custom-user-groups/input.json new file mode 100644 index 0000000..c07e77d --- /dev/null +++ b/config-resolver-go/resolver/testdata/custom-user-groups/input.json @@ -0,0 +1,21 @@ +{ + "override-rules": [ + { + "custom-expression": "#user.contains('group-a') or #user.contains('group-b') or #user.contains('group-c')", + "override": { + "property3.property3-1.property3-1-1": true + } + } + ], + "default-properties": { + "property1": 1, + "property2": { + "property2-1": true + }, + "property3": { + "property3-1": { + "property3-1-1": false + } + } + } +} diff --git a/config-resolver-go/resolver/testdata/custom-user-groups/output.json b/config-resolver-go/resolver/testdata/custom-user-groups/output.json new file mode 100644 index 0000000..4833969 --- /dev/null +++ b/config-resolver-go/resolver/testdata/custom-user-groups/output.json @@ -0,0 +1,11 @@ +{ + "property1": 1, + "property2": { + "property2-1": true + }, + "property3": { + "property3-1": { + "property3-1-1": true + } + } +} diff --git a/config-resolver-go/resolver/testdata/invalid-config/input.json b/config-resolver-go/resolver/testdata/invalid-config/input.json new file mode 100644 index 0000000..98ee6a6 --- /dev/null +++ b/config-resolver-go/resolver/testdata/invalid-config/input.json @@ -0,0 +1,6 @@ +{ + "override-rules": [ + ], + "default-propertiesssssssss": { + } +} diff --git a/config-resolver-go/resolver/testdata/user-in-all-groups/input.json b/config-resolver-go/resolver/testdata/user-in-all-groups/input.json new file mode 100644 index 0000000..3015432 --- /dev/null +++ b/config-resolver-go/resolver/testdata/user-in-all-groups/input.json @@ -0,0 +1,21 @@ +{ + "override-rules": [ + { + "user-is-in-all-groups": ["group-a","group-b"], + "override": { + "property1": 2 + } + } + ], + "default-properties": { + "property1": 1, + "property2": { + "property2-1": true + }, + "property3": { + "property3-1": { + "property3-1-1": false + } + } + } +} diff --git a/config-resolver-go/resolver/testdata/user-in-all-groups/output.json b/config-resolver-go/resolver/testdata/user-in-all-groups/output.json new file mode 100644 index 0000000..fce12cf --- /dev/null +++ b/config-resolver-go/resolver/testdata/user-in-all-groups/output.json @@ -0,0 +1,11 @@ +{ + "property1": 2, + "property2": { + "property2-1": true + }, + "property3": { + "property3-1": { + "property3-1-1": false + } + } +} diff --git a/config-resolver-go/resolver/testdata/user-in-any-groups/input.json b/config-resolver-go/resolver/testdata/user-in-any-groups/input.json new file mode 100644 index 0000000..8b9a0b6 --- /dev/null +++ b/config-resolver-go/resolver/testdata/user-in-any-groups/input.json @@ -0,0 +1,24 @@ +{ + "override-rules": [ + { + "user-is-in-any-group": [ + "group-c", + "group-d" + ], + "override": { + "property2.property2-1": false + } + } + ], + "default-properties": { + "property1": 1, + "property2": { + "property2-1": true + }, + "property3": { + "property3-1": { + "property3-1-1": false + } + } + } +} diff --git a/config-resolver-go/resolver/testdata/user-in-any-groups/output.json b/config-resolver-go/resolver/testdata/user-in-any-groups/output.json new file mode 100644 index 0000000..d57bbb4 --- /dev/null +++ b/config-resolver-go/resolver/testdata/user-in-any-groups/output.json @@ -0,0 +1,11 @@ +{ + "property1": 1, + "property2": { + "property2-1": false + }, + "property3": { + "property3-1": { + "property3-1-1": false + } + } +} diff --git a/config-resolver-go/resolver/testdata/user-in-different-groups/input.json b/config-resolver-go/resolver/testdata/user-in-different-groups/input.json new file mode 100644 index 0000000..486742d --- /dev/null +++ b/config-resolver-go/resolver/testdata/user-in-different-groups/input.json @@ -0,0 +1,48 @@ +{ + "override-rules": [ + { + "user-is-in-all-groups": [ + "group-a", + "group-b" + ], + "override": { + "property1": 2 + } + }, + { + "user-is-in-any-group": [ + "group-c", + "group-d" + ], + "override": { + "property2.property2-1": false + } + }, + { + "user-is-none-of-the-groups": [ + "group-e", + "group-f" + ], + "override": { + "property2.property2-1": false + } + }, + { + "custom-expression": "#user.contains('group-a') or #user.contains('group-b') or #user.contains('group-c')", + "override": { + "property3.property3-1.property3-1-1": true + } + } + ], + "default-properties": { + "property1": 1, + "property2": { + "property2-1": true + }, + "property3": { + "property3-1": { + "property3-1-1": false + } + } + } +} diff --git a/config-resolver-go/resolver/testdata/user-in-different-groups/output.json b/config-resolver-go/resolver/testdata/user-in-different-groups/output.json new file mode 100644 index 0000000..f0034fb --- /dev/null +++ b/config-resolver-go/resolver/testdata/user-in-different-groups/output.json @@ -0,0 +1,11 @@ +{ + "property1": 2, + "property2": { + "property2-1": false + }, + "property3": { + "property3-1": { + "property3-1-1": true + } + } +} diff --git a/config-resolver-go/resolver/testdata/user-in-no-groups/input.json b/config-resolver-go/resolver/testdata/user-in-no-groups/input.json new file mode 100644 index 0000000..94b329b --- /dev/null +++ b/config-resolver-go/resolver/testdata/user-in-no-groups/input.json @@ -0,0 +1,21 @@ +{ + "override-rules": [ + { + "user-is-none-of-the-groups": ["group-e","group-f"], + "override": { + "property2.property2-1": false + } + } + ], + "default-properties": { + "property1": 1, + "property2": { + "property2-1": true + }, + "property3": { + "property3-1": { + "property3-1-1": false + } + } + } +} diff --git a/config-resolver-go/resolver/testdata/user-in-no-groups/output.json b/config-resolver-go/resolver/testdata/user-in-no-groups/output.json new file mode 100644 index 0000000..d57bbb4 --- /dev/null +++ b/config-resolver-go/resolver/testdata/user-in-no-groups/output.json @@ -0,0 +1,11 @@ +{ + "property1": 1, + "property2": { + "property2-1": false + }, + "property3": { + "property3-1": { + "property3-1-1": false + } + } +} diff --git a/config-resolver-go/resolver/utils_test.go b/config-resolver-go/resolver/utils_test.go new file mode 100644 index 0000000..51ee8e4 --- /dev/null +++ b/config-resolver-go/resolver/utils_test.go @@ -0,0 +1,22 @@ +package resolver + +import ( + "encoding/json" + "io/ioutil" +) + +func readFile(p string) (string, error) { + b, err := ioutil.ReadFile(p) + if err != nil { + return "", err + } + return string(b), nil +} + +func readFileIntoObject(p string, out interface{}) error { + data, err := ioutil.ReadFile(p) + if err != nil { + return err + } + return json.Unmarshal(data, out) +}