From 8a713f425f2918ea8b3baf800c43d4f368900465 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 24 Dec 2024 18:03:05 +0100 Subject: [PATCH 1/3] feat: Support JSON logic rules Signed-off-by: Thomas Poignant --- go.mod | 2 + go.sum | 4 ++ internal/flag/rule.go | 68 +++++++++++++++++++++++++++---- internal/utils/json_check.go | 9 ++++ internal/utils/json_check_test.go | 44 ++++++++++++++++++++ internal/utils/str_trim.go | 11 +++++ internal/utils/str_trim_test.go | 54 ++++++++++++++++++++++++ 7 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 internal/utils/json_check.go create mode 100644 internal/utils/json_check_test.go create mode 100644 internal/utils/str_trim.go create mode 100644 internal/utils/str_trim_test.go diff --git a/go.mod b/go.mod index 2e343b947ac..177d9d92e66 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sqs v1.37.3 github.com/aws/smithy-go v1.22.1 github.com/awslabs/aws-lambda-go-api-proxy v0.16.2 + github.com/diegoholiveira/jsonlogic/v3 v3.6.1 github.com/fsouza/fake-gcs-server v1.50.2 github.com/golang/mock v1.6.0 github.com/google/go-cmp v0.6.0 @@ -111,6 +112,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.7 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.33.3 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/buger/jsonparser v1.1.1 // indirect diff --git a/go.sum b/go.sum index 5f70d73e3d2..0bbd99d1127 100644 --- a/go.sum +++ b/go.sum @@ -267,6 +267,8 @@ github.com/awslabs/aws-lambda-go-api-proxy v0.16.2 h1:CJyGEyO1CIwOnXTU40urf0mchf github.com/awslabs/aws-lambda-go-api-proxy v0.16.2/go.mod h1:vxxjwBHe/KbgFeNlAP/Tvp4SsVRL3WQamcWRxqVh0z0= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0= +github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df/go.mod h1:hiVxq5OP2bUGBRNS3Z/bt/reCLFNbdcST6gISi1fiOM= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= @@ -327,6 +329,8 @@ github.com/denisenkom/go-mssqldb v0.12.0/go.mod h1:iiK0YP1ZeepvmBQk/QpLEhhTNJgfz github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/diegoholiveira/jsonlogic/v3 v3.6.1 h1:EBHcGqqP7cJi1ygvNs7+APzyNBWzcVhHVwogCxQqj+w= +github.com/diegoholiveira/jsonlogic/v3 v3.6.1/go.mod h1:3nnfWovrlZq2rTpucrJ2KMIS8TMf6IoFneofmeqk/qk= github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8= github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= diff --git a/internal/flag/rule.go b/internal/flag/rule.go index b254dc8e5e8..f40e19b1c68 100644 --- a/internal/flag/rule.go +++ b/internal/flag/rule.go @@ -1,24 +1,35 @@ package flag import ( + "bytes" + "encoding/json" "fmt" + "log/slog" "sort" "strings" "time" + jsonlogic "github.com/diegoholiveira/jsonlogic/v3" "github.com/nikunjy/rules/parser" "github.com/thomaspoignant/go-feature-flag/ffcontext" "github.com/thomaspoignant/go-feature-flag/internal/internalerror" "github.com/thomaspoignant/go-feature-flag/internal/utils" ) +type QueryFormat = string + +const ( + NikunjyQueryFormat QueryFormat = "nikunjy" + JsonLogicQueryFormat QueryFormat = "json_logic" +) + // Rule represents a rule applied by the flag. type Rule struct { // Name is the name of the rule, this field is mandatory if you want // to update the rule during scheduled rollout Name *string `json:"name,omitempty" yaml:"name,omitempty" toml:"name,omitempty" jsonschema:"title=name,description=Name is the name of the rule. This field is mandatory if you want to update the rule during scheduled rollout."` // nolint: lll - // Query represents an antlr query in the nikunjy/rules format + // Query represents the query used to target the audience of the flag. Query *string `json:"query,omitempty" yaml:"query,omitempty" toml:"query,omitempty" jsonschema:"title=query,description=The query that allow to check in the evaluation context match. Note: in the defaultRule field query is ignored."` // nolint: lll // VariationResult represents the variation name to use if the rule apply for the user. @@ -54,7 +65,7 @@ func (r *Rule) Evaluate(key string, ctx ffcontext.Context, flagName string, isDe } // Check if the rule applies for this user - ruleApply := isDefault || r.GetQuery() == "" || parser.Evaluate(r.GetTrimmedQuery(), utils.ContextToMap(ctx)) + ruleApply := isDefault || evaluateRule(r.GetTrimmedQuery(), r.GetQueryFormat(), ctx) if !ruleApply || (!isDefault && r.IsDisable()) { return "", &internalerror.RuleNotApply{Context: ctx} } @@ -70,6 +81,33 @@ func (r *Rule) Evaluate(key string, ctx ffcontext.Context, flagName string, isDe return "", fmt.Errorf("error in the configuration, no variation available for this rule") } +func evaluateRule(query string, queryFormat QueryFormat, ctx ffcontext.Context) bool { + if query == "" { + return true + } + mapCtx := utils.ContextToMap(ctx) + switch queryFormat { + case JsonLogicQueryFormat: + strCtx, err := json.Marshal(mapCtx) + if err != nil { + slog.Error("error while marhsalling the context for the jsonlogic query", + slog.Any("mapCtx", mapCtx), slog.Any("error", err)) + return false + } + var result bytes.Buffer + err = jsonlogic.Apply(strings.NewReader(query), strings.NewReader(string(strCtx)), &result) + if err != nil { + slog.Error("error while evaluating the jsonlogic query", + slog.String("query", query), slog.Any("error", err)) + return false + } + return utils.StrTrim(result.String()) == "true" + default: + return parser.Evaluate(query, mapCtx) + } + +} + // EvaluateProgressiveRollout is evaluating the progressive rollout for the rule. func (r *Rule) EvaluateProgressiveRollout(key string, flagName string, evaluationDate time.Time) (string, error) { progressiveRolloutMaxPercentage := uint32(100 * PercentageMultiplier) @@ -294,7 +332,19 @@ func (r *Rule) isQueryValid(defaultRule bool) error { } // Validate the query with the parser - ev, err := parser.NewEvaluator(r.GetTrimmedQuery()) + switch r.GetQueryFormat() { + case JsonLogicQueryFormat: + if !jsonlogic.IsValid(strings.NewReader(r.GetTrimmedQuery())) { + return fmt.Errorf("invalid jsonlogic query: %s", r.GetTrimmedQuery()) + } + return nil + default: + return validateNikunjyQuery(r.GetTrimmedQuery()) + } +} + +func validateNikunjyQuery(query string) error { + ev, err := parser.NewEvaluator(query) if err != nil { return err } @@ -307,11 +357,15 @@ func (r *Rule) isQueryValid(defaultRule bool) error { // GetTrimmedQuery is removing the break lines and return func (r *Rule) GetTrimmedQuery() string { - splitQuery := strings.Split(r.GetQuery(), "\n") - for index, item := range splitQuery { - splitQuery[index] = strings.TrimLeft(item, " ") + return utils.StrTrim(r.GetQuery()) +} + +// GetQueryFormat is returning the format used for the query +func (r *Rule) GetQueryFormat() QueryFormat { + if utils.IsJSONObject(r.GetTrimmedQuery()) { + return JsonLogicQueryFormat } - return strings.Join(splitQuery, "") + return NikunjyQueryFormat } func (r *Rule) GetQuery() string { diff --git a/internal/utils/json_check.go b/internal/utils/json_check.go new file mode 100644 index 00000000000..740e308850a --- /dev/null +++ b/internal/utils/json_check.go @@ -0,0 +1,9 @@ +package utils + +import "encoding/json" + +// IsJSONObject checks if a string is a valid JSON +func IsJSONObject(s string) bool { + var js map[string]interface{} + return json.Unmarshal([]byte(s), &js) == nil +} diff --git a/internal/utils/json_check_test.go b/internal/utils/json_check_test.go new file mode 100644 index 00000000000..a48cfb9c8e6 --- /dev/null +++ b/internal/utils/json_check_test.go @@ -0,0 +1,44 @@ +package utils_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thomaspoignant/go-feature-flag/internal/utils" +) + +func TestIsJSONObject(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + { + name: "valid JSON object", + input: `{"key": "value"}`, + want: true, + }, + { + name: "invalid JSON", + input: `{"key": "value"`, + want: false, + }, + { + name: "empty string", + input: ``, + want: false, + }, + { + name: "non-JSON string", + input: `not a json`, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := utils.IsJSONObject(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/utils/str_trim.go b/internal/utils/str_trim.go new file mode 100644 index 00000000000..fed9bc6098a --- /dev/null +++ b/internal/utils/str_trim.go @@ -0,0 +1,11 @@ +package utils + +import "strings" + +func StrTrim(s string) string { + trimmed := strings.Split(s, "\n") + for index, item := range trimmed { + trimmed[index] = strings.TrimLeft(item, " ") + } + return strings.Join(trimmed, "") +} diff --git a/internal/utils/str_trim_test.go b/internal/utils/str_trim_test.go new file mode 100644 index 00000000000..154571f5066 --- /dev/null +++ b/internal/utils/str_trim_test.go @@ -0,0 +1,54 @@ +package utils_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thomaspoignant/go-feature-flag/internal/utils" +) + +func TestStrTrim(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "single line with leading spaces", + input: " hello", + want: "hello", + }, + { + name: "multiple lines with leading spaces", + input: " hello\n world", + want: "helloworld", + }, + { + name: "no leading spaces", + input: "hello\nworld", + want: "helloworld", + }, + { + name: "empty string", + input: "", + want: "", + }, + { + name: "only spaces", + input: " ", + want: "", + }, + { + name: "mixed leading spaces", + input: " hello\nworld", + want: "helloworld", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := utils.StrTrim(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} From d4d4738886cab6fe6254a2ed8edbbd319bd96a6f Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 24 Dec 2024 18:43:27 +0100 Subject: [PATCH 2/3] fix(lint): Rename enum with uppercase JSON Signed-off-by: Thomas Poignant --- internal/flag/rule.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/flag/rule.go b/internal/flag/rule.go index f40e19b1c68..615f532d64f 100644 --- a/internal/flag/rule.go +++ b/internal/flag/rule.go @@ -20,7 +20,7 @@ type QueryFormat = string const ( NikunjyQueryFormat QueryFormat = "nikunjy" - JsonLogicQueryFormat QueryFormat = "json_logic" + JSONLogicQueryFormat QueryFormat = "jsonlogic" ) // Rule represents a rule applied by the flag. @@ -87,7 +87,7 @@ func evaluateRule(query string, queryFormat QueryFormat, ctx ffcontext.Context) } mapCtx := utils.ContextToMap(ctx) switch queryFormat { - case JsonLogicQueryFormat: + case JSONLogicQueryFormat: strCtx, err := json.Marshal(mapCtx) if err != nil { slog.Error("error while marhsalling the context for the jsonlogic query", @@ -333,7 +333,7 @@ func (r *Rule) isQueryValid(defaultRule bool) error { // Validate the query with the parser switch r.GetQueryFormat() { - case JsonLogicQueryFormat: + case JSONLogicQueryFormat: if !jsonlogic.IsValid(strings.NewReader(r.GetTrimmedQuery())) { return fmt.Errorf("invalid jsonlogic query: %s", r.GetTrimmedQuery()) } @@ -363,7 +363,7 @@ func (r *Rule) GetTrimmedQuery() string { // GetQueryFormat is returning the format used for the query func (r *Rule) GetQueryFormat() QueryFormat { if utils.IsJSONObject(r.GetTrimmedQuery()) { - return JsonLogicQueryFormat + return JSONLogicQueryFormat } return NikunjyQueryFormat } From 24d3688375cb00dd30697c2713a635900fad2a24 Mon Sep 17 00:00:00 2001 From: Thomas Poignant Date: Tue, 24 Dec 2024 21:51:23 +0100 Subject: [PATCH 3/3] fix: linting issue Signed-off-by: Thomas Poignant --- internal/flag/rule.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/flag/rule.go b/internal/flag/rule.go index 615f532d64f..d4e26a468a4 100644 --- a/internal/flag/rule.go +++ b/internal/flag/rule.go @@ -105,7 +105,6 @@ func evaluateRule(query string, queryFormat QueryFormat, ctx ffcontext.Context) default: return parser.Evaluate(query, mapCtx) } - } // EvaluateProgressiveRollout is evaluating the progressive rollout for the rule.