Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support JSON logic rules #2863

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
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
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
67 changes: 60 additions & 7 deletions internal/flag/rule.go
Original file line number Diff line number Diff line change
@@ -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 = "jsonlogic"
)

// 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.
Expand Down Expand Up @@ -54,7 +65,7 @@
}

// 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}
}
Expand All @@ -70,6 +81,32 @@
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"

Check warning on line 104 in internal/flag/rule.go

View check run for this annotation

Codecov / codecov/patch

internal/flag/rule.go#L90-L104

Added lines #L90 - L104 were not covered by tests
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)
Expand Down Expand Up @@ -294,7 +331,19 @@
}

// 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

Check warning on line 339 in internal/flag/rule.go

View check run for this annotation

Codecov / codecov/patch

internal/flag/rule.go#L335-L339

Added lines #L335 - L339 were not covered by tests
default:
return validateNikunjyQuery(r.GetTrimmedQuery())
}
}

func validateNikunjyQuery(query string) error {
ev, err := parser.NewEvaluator(query)
if err != nil {
return err
}
Expand All @@ -307,11 +356,15 @@

// 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

Check warning on line 365 in internal/flag/rule.go

View check run for this annotation

Codecov / codecov/patch

internal/flag/rule.go#L365

Added line #L365 was not covered by tests
}
return strings.Join(splitQuery, "")
return NikunjyQueryFormat
}

func (r *Rule) GetQuery() string {
Expand Down
9 changes: 9 additions & 0 deletions internal/utils/json_check.go
Original file line number Diff line number Diff line change
@@ -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
}
44 changes: 44 additions & 0 deletions internal/utils/json_check_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
11 changes: 11 additions & 0 deletions internal/utils/str_trim.go
Original file line number Diff line number Diff line change
@@ -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, "")
}
54 changes: 54 additions & 0 deletions internal/utils/str_trim_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
Loading