Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions config-resolver-go/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module configresolver

go 1.23.8
8 changes: 8 additions & 0 deletions config-resolver-go/resolver/dto/config.go
Original file line number Diff line number Diff line change
@@ -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"`
}
11 changes: 11 additions & 0 deletions config-resolver-go/resolver/dto/override_rule.go
Original file line number Diff line number Diff line change
@@ -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"`
}
223 changes: 223 additions & 0 deletions config-resolver-go/resolver/json_service.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
107 changes: 107 additions & 0 deletions config-resolver-go/resolver/json_service_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
14 changes: 14 additions & 0 deletions config-resolver-go/resolver/resolver.go
Original file line number Diff line number Diff line change
@@ -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")
21 changes: 21 additions & 0 deletions config-resolver-go/resolver/testdata/custom-user-groups/input.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
Loading