diff --git a/core/go.mod b/core/go.mod index bab1af71d..7c6da2321 100644 --- a/core/go.mod +++ b/core/go.mod @@ -31,6 +31,7 @@ require ( go.opentelemetry.io/otel/trace v1.16.0 go.uber.org/zap v1.24.0 golang.org/x/crypto v0.8.0 + golang.org/x/mod v0.9.0 golang.org/x/net v0.10.0 golang.org/x/sync v0.2.0 google.golang.org/grpc v1.55.0 diff --git a/core/go.sum b/core/go.sum index 3be7d2d58..da19b2863 100644 --- a/core/go.sum +++ b/core/go.sum @@ -693,8 +693,6 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= @@ -815,6 +813,8 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs= +golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= diff --git a/core/pkg/eval/json_evaluator.go b/core/pkg/eval/json_evaluator.go index 239910382..a5b538a4f 100644 --- a/core/pkg/eval/json_evaluator.go +++ b/core/pkg/eval/json_evaluator.go @@ -65,6 +65,10 @@ func NewJSONEvaluator(logger *logger.Logger, s *store.Flags) *JSONEvaluator { } jsonlogic.AddOperator("starts_with", sce.StartsWithEvaluation) jsonlogic.AddOperator("ends_with", sce.EndsWithEvaluation) + + sve := SemVerComparisonEvaluator{Logger: ev.Logger} + jsonlogic.AddOperator("sem_ver", sve.SemVerEvaluation) + return &ev } diff --git a/core/pkg/eval/semver_evaluation.go b/core/pkg/eval/semver_evaluation.go new file mode 100644 index 000000000..b8c26d262 --- /dev/null +++ b/core/pkg/eval/semver_evaluation.go @@ -0,0 +1,144 @@ +package eval + +import ( + "errors" + "fmt" + "strings" + + "github.com/open-feature/flagd/core/pkg/logger" + "golang.org/x/mod/semver" +) + +type SemVerOperator string + +const ( + Equals SemVerOperator = "=" + NotEqual SemVerOperator = "!=" + Less SemVerOperator = "<" + LessOrEqual SemVerOperator = "<=" + GreaterOrEqual SemVerOperator = ">=" + Greater SemVerOperator = ">" + MatchMajor SemVerOperator = "^" + MatchMinor SemVerOperator = "~" +) + +func (svo SemVerOperator) compare(v1, v2 string) (bool, error) { + cmpRes := semver.Compare(v1, v2) + switch svo { + case Less: + return cmpRes == -1, nil + case Equals: + return cmpRes == 0, nil + case NotEqual: + return cmpRes != 0, nil + case LessOrEqual: + return cmpRes == -1 || cmpRes == 0, nil + case GreaterOrEqual: + return cmpRes == +1 || cmpRes == 0, nil + case Greater: + return cmpRes == +1, nil + case MatchMinor: + v1MajorMinor := semver.MajorMinor(v1) + v2MajorMinor := semver.MajorMinor(v2) + return semver.Compare(v1MajorMinor, v2MajorMinor) == 0, nil + case MatchMajor: + v1Major := semver.Major(v1) + v2Major := semver.Major(v2) + return semver.Compare(v1Major, v2Major) == 0, nil + default: + return false, errors.New("invalid operator") + } +} + +type SemVerComparisonEvaluator struct { + Logger *logger.Logger +} + +// SemVerEvaluation checks if the given property matches a semantic versioning condition. +// It returns 'true', if the value of the given property meets the condition, 'false' if not. +// As an example, it can be used in the following way inside an 'if' evaluation: +// +// { +// "if": [ +// { +// "sem_ver": [{"var": "version"}, ">=", "1.0.0"] +// }, +// "red", null +// ] +// } +// +// This rule can be applied to the following data object, where the evaluation will resolve to 'true': +// +// { "version": "2.0.0" } +// +// Note that the 'sem_ver' evaluation rule must contain exactly three items: +// 1. Target property: this needs which both resolve to a semantic versioning string +// 2. Operator: One of the following: '=', '!=', '>', '<', '>=', '<=', '~', '^' +// 3. Target value: this needs which both resolve to a semantic versioning string +func (je *SemVerComparisonEvaluator) SemVerEvaluation(values, _ interface{}) interface{} { + actualVersion, targetVersion, operator, err := parseSemverEvaluationData(values) + if err != nil { + je.Logger.Error(fmt.Sprintf("parse sem_ver evaluation data: %v", err)) + return nil + } + res, err := operator.compare(actualVersion, targetVersion) + if err != nil { + je.Logger.Error(fmt.Sprintf("sem_ver evaluation: %v", err)) + return nil + } + return res +} + +func parseSemverEvaluationData(values interface{}) (string, string, SemVerOperator, error) { + parsed, ok := values.([]interface{}) + if !ok { + return "", "", "", errors.New("sem_ver evaluation is not an array") + } + + if len(parsed) != 3 { + return "", "", "", errors.New("sem_ver evaluation must contain a value, an operator and a comparison target") + } + + actualVersion, err := parseSemanticVersion(parsed[0]) + if err != nil { + return "", "", "", fmt.Errorf("sem_ver evaluation: could not parse target property value: %w", err) + } + + operator, err := parseOperator(parsed[1]) + if err != nil { + return "", "", "", fmt.Errorf("sem_ver evaluation: could not parse operator: %w", err) + } + + targetVersion, err := parseSemanticVersion(parsed[2]) + if err != nil { + return "", "", "", fmt.Errorf("sem_ver evaluation: could not parse target value: %w", err) + } + return actualVersion, targetVersion, operator, nil +} + +func parseSemanticVersion(v interface{}) (string, error) { + version, ok := v.(string) + if !ok { + return "", errors.New("sem_ver evaluation: property did not resolve to a string value") + } + // version strings are only valid in the semver package if they start with a 'v' + // if it's not present in the given value, we prepend it + if !strings.HasPrefix(version, "v") { + version = "v" + version + } + + if !semver.IsValid(version) { + return "", errors.New("not a valid semantic version string") + } + + return version, nil +} + +func parseOperator(o interface{}) (SemVerOperator, error) { + operatorString, ok := o.(string) + if !ok { + return "", errors.New("could not parse operator") + } + + return SemVerOperator(operatorString), nil +} diff --git a/core/pkg/eval/semver_evaluation_test.go b/core/pkg/eval/semver_evaluation_test.go new file mode 100644 index 000000000..55770e4dc --- /dev/null +++ b/core/pkg/eval/semver_evaluation_test.go @@ -0,0 +1,754 @@ +package eval + +import ( + "testing" + + "github.com/open-feature/flagd/core/pkg/logger" + "github.com/open-feature/flagd/core/pkg/model" + "github.com/open-feature/flagd/core/pkg/store" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestSemVerOperator_Compare(t *testing.T) { + type args struct { + v1 string + v2 string + } + tests := []struct { + name string + svo SemVerOperator + args args + want bool + wantErr bool + }{ + { + name: "invalid operator", + svo: "", + args: args{ + v1: "v0.0.1", + v2: "v0.0.2", + }, + want: false, + wantErr: true, + }, + { + name: "less", + svo: Less, + args: args{ + v1: "v0.0.1", + v2: "v0.0.2", + }, + want: true, + wantErr: false, + }, + { + name: "not less", + svo: Less, + args: args{ + v1: "v0.0.1", + v2: "v0.0.1", + }, + want: false, + wantErr: false, + }, + { + name: "less or equal", + svo: LessOrEqual, + args: args{ + v1: "v0.0.1", + v2: "v0.0.2", + }, + want: true, + wantErr: false, + }, + { + name: "less or equal 2", + svo: LessOrEqual, + args: args{ + v1: "v0.0.1", + v2: "v0.0.1", + }, + want: true, + wantErr: false, + }, + { + name: "equal", + svo: Equals, + args: args{ + v1: "v0.0.1", + v2: "v0.0.1", + }, + want: true, + wantErr: false, + }, + { + name: "not equal", + svo: Equals, + args: args{ + v1: "v0.0.2", + v2: "v0.0.1", + }, + want: false, + wantErr: false, + }, + { + name: "unequal", + svo: NotEqual, + args: args{ + v1: "v0.0.2", + v2: "v0.0.1", + }, + want: true, + wantErr: false, + }, + { + name: "not unequal", + svo: NotEqual, + args: args{ + v1: "v0.0.1", + v2: "v0.0.1", + }, + want: false, + wantErr: false, + }, + { + name: "greater or equal 1", + svo: GreaterOrEqual, + args: args{ + v1: "v0.0.2", + v2: "v0.0.1", + }, + want: true, + wantErr: false, + }, + { + name: "greater or equal 2", + svo: GreaterOrEqual, + args: args{ + v1: "v0.0.1", + v2: "v0.0.1", + }, + want: true, + wantErr: false, + }, + { + name: "not greater or equal", + svo: GreaterOrEqual, + args: args{ + v1: "v0.0.1", + v2: "v0.0.2", + }, + want: false, + wantErr: false, + }, + { + name: "greater", + svo: Greater, + args: args{ + v1: "v0.0.2", + v2: "v0.0.1", + }, + want: true, + wantErr: false, + }, + { + name: "not greater", + svo: Greater, + args: args{ + v1: "v0.0.1", + v2: "v0.0.1", + }, + want: false, + wantErr: false, + }, + { + name: "matching major version", + svo: MatchMajor, + args: args{ + v1: "v1.3.4", + v2: "v1.5.3", + }, + want: true, + wantErr: false, + }, + { + name: "not matching major version", + svo: MatchMajor, + args: args{ + v1: "v2.1.1", + v2: "v1.1.1", + }, + want: false, + wantErr: false, + }, + { + name: "matching minor version", + svo: MatchMinor, + args: args{ + v1: "v1.3.4", + v2: "v1.3.1", + }, + want: true, + wantErr: false, + }, + { + name: "not matching minor version", + svo: MatchMinor, + args: args{ + v1: "v2.2.1", + v2: "v2.1.1", + }, + want: false, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.svo.compare(tt.args.v1, tt.args.v2) + + if tt.wantErr { + require.NotNil(t, err) + } else { + require.Nil(t, err) + require.Equalf(t, tt.want, got, "compare(%v, %v)", tt.args.v1, tt.args.v2) + } + }) + } +} + +func TestJSONEvaluator_semVerEvaluation(t *testing.T) { + tests := map[string]struct { + flags Flags + flagKey string + context *structpb.Struct + expectedValue string + expectedVariant string + expectedReason string + expectedError error + }{ + "versions and operator provided - match": { + flags: Flags{ + Flags: map[string]model.Flag{ + "headerColor": { + State: "ENABLED", + DefaultVariant: "red", + Variants: map[string]any{ + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00", + "yellow": "#FFFF00", + }, + Targeting: []byte(`{ + "if": [ + { + "sem_ver": ["1.0.0", ">", "0.1.0"] + }, + "red", null + ] + }`), + }, + }, + }, + flagKey: "headerColor", + context: &structpb.Struct{Fields: map[string]*structpb.Value{ + "version": {Kind: &structpb.Value_StringValue{ + StringValue: "1.0.0", + }}, + }}, + expectedVariant: "red", + expectedValue: "#FF0000", + expectedReason: model.TargetingMatchReason, + }, + "resolve target property using nested operation - match": { + flags: Flags{ + Flags: map[string]model.Flag{ + "headerColor": { + State: "ENABLED", + DefaultVariant: "red", + Variants: map[string]any{ + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00", + "yellow": "#FFFF00", + }, + Targeting: []byte(`{ + "if": [ + { + "sem_ver": [{"var": "version"}, ">", "1.0.0"] + }, + "red", null + ] + }`), + }, + }, + }, + flagKey: "headerColor", + context: &structpb.Struct{Fields: map[string]*structpb.Value{ + "version": {Kind: &structpb.Value_StringValue{ + StringValue: "1.0.1", + }}, + }}, + expectedVariant: "red", + expectedValue: "#FF0000", + expectedReason: model.TargetingMatchReason, + }, + "versions and operator provided - no match": { + flags: Flags{ + Flags: map[string]model.Flag{ + "headerColor": { + State: "ENABLED", + DefaultVariant: "red", + Variants: map[string]any{ + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00", + "yellow": "#FFFF00", + }, + Targeting: []byte(`{ + "if": [ + { + "sem_ver": ["1.0.0", ">", "1.0.0"] + }, + "red", "green" + ] + }`), + }, + }, + }, + flagKey: "headerColor", + context: &structpb.Struct{Fields: map[string]*structpb.Value{ + "version": {Kind: &structpb.Value_StringValue{ + StringValue: "1.0.0", + }}, + }}, + expectedVariant: "green", + expectedValue: "#00FF00", + expectedReason: model.TargetingMatchReason, + }, + "versions and major-version operator provided - match": { + flags: Flags{ + Flags: map[string]model.Flag{ + "headerColor": { + State: "ENABLED", + DefaultVariant: "red", + Variants: map[string]any{ + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00", + "yellow": "#FFFF00", + }, + Targeting: []byte(`{ + "if": [ + { + "sem_ver": ["1.2.3", "^", "1.5.6"] + }, + "red", "green" + ] + }`), + }, + }, + }, + flagKey: "headerColor", + context: &structpb.Struct{Fields: map[string]*structpb.Value{ + "version": {Kind: &structpb.Value_StringValue{ + StringValue: "1.0.0", + }}, + }}, + expectedVariant: "red", + expectedValue: "#FF0000", + expectedReason: model.TargetingMatchReason, + }, + "versions and minor-version operator provided - match": { + flags: Flags{ + Flags: map[string]model.Flag{ + "headerColor": { + State: "ENABLED", + DefaultVariant: "red", + Variants: map[string]any{ + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00", + "yellow": "#FFFF00", + }, + Targeting: []byte(`{ + "if": [ + { + "sem_ver": ["1.2.3", "~", "1.2.6"] + }, + "red", "green" + ] + }`), + }, + }, + }, + flagKey: "headerColor", + context: &structpb.Struct{Fields: map[string]*structpb.Value{ + "version": {Kind: &structpb.Value_StringValue{ + StringValue: "1.0.0", + }}, + }}, + expectedVariant: "red", + expectedValue: "#FF0000", + expectedReason: model.TargetingMatchReason, + }, + "versions and major-version operator provided - no match": { + flags: Flags{ + Flags: map[string]model.Flag{ + "headerColor": { + State: "ENABLED", + DefaultVariant: "red", + Variants: map[string]any{ + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00", + "yellow": "#FFFF00", + }, + Targeting: []byte(`{ + "if": [ + { + "sem_ver": ["2.2.3", "^", "1.2.3"] + }, + "red", "green" + ] + }`), + }, + }, + }, + flagKey: "headerColor", + context: &structpb.Struct{Fields: map[string]*structpb.Value{ + "version": {Kind: &structpb.Value_StringValue{ + StringValue: "1.0.0", + }}, + }}, + expectedVariant: "green", + expectedValue: "#00FF00", + expectedReason: model.TargetingMatchReason, + }, + "versions and minor-version operator provided - no match": { + flags: Flags{ + Flags: map[string]model.Flag{ + "headerColor": { + State: "ENABLED", + DefaultVariant: "red", + Variants: map[string]any{ + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00", + "yellow": "#FFFF00", + }, + Targeting: []byte(`{ + "if": [ + { + "sem_ver": ["1.3.3", "~", "1.2.6"] + }, + "red", "green" + ] + }`), + }, + }, + }, + flagKey: "headerColor", + context: &structpb.Struct{Fields: map[string]*structpb.Value{ + "version": {Kind: &structpb.Value_StringValue{ + StringValue: "1.0.0", + }}, + }}, + expectedVariant: "green", + expectedValue: "#00FF00", + expectedReason: model.TargetingMatchReason, + }, + "resolve target property using nested operation - no match": { + flags: Flags{ + Flags: map[string]model.Flag{ + "headerColor": { + State: "ENABLED", + DefaultVariant: "red", + Variants: map[string]any{ + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00", + "yellow": "#FFFF00", + }, + Targeting: []byte(`{ + "if": [ + { + "sem_ver": [{"var": "version"}, ">", "1.0.0"] + }, + "red", "green" + ] + }`), + }, + }, + }, + flagKey: "headerColor", + context: &structpb.Struct{Fields: map[string]*structpb.Value{ + "version": {Kind: &structpb.Value_StringValue{ + StringValue: "0.0.1", + }}, + }}, + expectedVariant: "green", + expectedValue: "#00FF00", + expectedReason: model.TargetingMatchReason, + }, + "error during parsing (not an array) - return default": { + flags: Flags{ + Flags: map[string]model.Flag{ + "headerColor": { + State: "ENABLED", + DefaultVariant: "red", + Variants: map[string]any{ + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00", + "yellow": "#FFFF00", + }, + Targeting: []byte(`{ + "if": [ + { + "sem_ver": "not an array" + }, + "red", "green" + ] + }`), + }, + }, + }, + flagKey: "headerColor", + context: &structpb.Struct{Fields: map[string]*structpb.Value{ + "email": {Kind: &structpb.Value_StringValue{ + StringValue: "user@faas.com", + }}, + }}, + expectedVariant: "green", + expectedValue: "#00FF00", + expectedReason: model.TargetingMatchReason, + }, + "error during parsing (wrong number of items in array) - return default": { + flags: Flags{ + Flags: map[string]model.Flag{ + "headerColor": { + State: "ENABLED", + DefaultVariant: "red", + Variants: map[string]any{ + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00", + "yellow": "#FFFF00", + }, + Targeting: []byte(`{ + "if": [ + { + "sem_ver": ["not", "enough"] + }, + "red", "green" + ] + }`), + }, + }, + }, + flagKey: "headerColor", + context: &structpb.Struct{Fields: map[string]*structpb.Value{ + "email": {Kind: &structpb.Value_StringValue{ + StringValue: "user@faas.com", + }}, + }}, + expectedVariant: "green", + expectedValue: "#00FF00", + expectedReason: model.TargetingMatchReason, + }, + "error during parsing (invalid property value) - return default": { + flags: Flags{ + Flags: map[string]model.Flag{ + "headerColor": { + State: "ENABLED", + DefaultVariant: "red", + Variants: map[string]any{ + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00", + "yellow": "#FFFF00", + }, + Targeting: []byte(`{ + "if": [ + { + "sem_ver": ["invalid", ">", "1.0.0"] + }, + "red", "green" + ] + }`), + }, + }, + }, + flagKey: "headerColor", + context: &structpb.Struct{Fields: map[string]*structpb.Value{ + "email": {Kind: &structpb.Value_StringValue{ + StringValue: "user@faas.com", + }}, + }}, + expectedVariant: "green", + expectedValue: "#00FF00", + expectedReason: model.TargetingMatchReason, + }, + "error during parsing (invalid property type) - return default": { + flags: Flags{ + Flags: map[string]model.Flag{ + "headerColor": { + State: "ENABLED", + DefaultVariant: "red", + Variants: map[string]any{ + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00", + "yellow": "#FFFF00", + }, + Targeting: []byte(`{ + "if": [ + { + "sem_ver": [1.0, ">", "1.0.0"] + }, + "red", "green" + ] + }`), + }, + }, + }, + flagKey: "headerColor", + context: &structpb.Struct{Fields: map[string]*structpb.Value{ + "email": {Kind: &structpb.Value_StringValue{ + StringValue: "user@faas.com", + }}, + }}, + expectedVariant: "green", + expectedValue: "#00FF00", + expectedReason: model.TargetingMatchReason, + }, + "error during parsing (invalid operator) - return default": { + flags: Flags{ + Flags: map[string]model.Flag{ + "headerColor": { + State: "ENABLED", + DefaultVariant: "red", + Variants: map[string]any{ + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00", + "yellow": "#FFFF00", + }, + Targeting: []byte(`{ + "if": [ + { + "sem_ver": ["1.0.0", "invalid", "1.0.0"] + }, + "red", "green" + ] + }`), + }, + }, + }, + flagKey: "headerColor", + context: &structpb.Struct{Fields: map[string]*structpb.Value{ + "email": {Kind: &structpb.Value_StringValue{ + StringValue: "user@faas.com", + }}, + }}, + expectedVariant: "green", + expectedValue: "#00FF00", + expectedReason: model.TargetingMatchReason, + }, + "error during parsing (invalid operator type) - return default": { + flags: Flags{ + Flags: map[string]model.Flag{ + "headerColor": { + State: "ENABLED", + DefaultVariant: "red", + Variants: map[string]any{ + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00", + "yellow": "#FFFF00", + }, + Targeting: []byte(`{ + "if": [ + { + "sem_ver": ["1.0.0", 1, "1.0.0"] + }, + "red", "green" + ] + }`), + }, + }, + }, + flagKey: "headerColor", + context: &structpb.Struct{Fields: map[string]*structpb.Value{ + "email": {Kind: &structpb.Value_StringValue{ + StringValue: "user@faas.com", + }}, + }}, + expectedVariant: "green", + expectedValue: "#00FF00", + expectedReason: model.TargetingMatchReason, + }, + "error during parsing (invalid target version) - return default": { + flags: Flags{ + Flags: map[string]model.Flag{ + "headerColor": { + State: "ENABLED", + DefaultVariant: "red", + Variants: map[string]any{ + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00", + "yellow": "#FFFF00", + }, + Targeting: []byte(`{ + "if": [ + { + "sem_ver": ["1.0.0", ">", "invalid"] + }, + "red", "green" + ] + }`), + }, + }, + }, + flagKey: "headerColor", + context: &structpb.Struct{Fields: map[string]*structpb.Value{ + "email": {Kind: &structpb.Value_StringValue{ + StringValue: "user@faas.com", + }}, + }}, + expectedVariant: "green", + expectedValue: "#00FF00", + expectedReason: model.TargetingMatchReason, + }, + } + + const reqID = "default" + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + je := NewJSONEvaluator(logger.NewLogger(nil, false), store.NewFlags()) + je.store.Flags = tt.flags.Flags + + value, variant, reason, err := resolve[string]( + reqID, tt.flagKey, tt.context, je.evaluateVariant, je.store.Flags[tt.flagKey].Variants, + ) + + if value != tt.expectedValue { + t.Errorf("expected value '%s', got '%s'", tt.expectedValue, value) + } + + if variant != tt.expectedVariant { + t.Errorf("expected variant '%s', got '%s'", tt.expectedVariant, variant) + } + + if reason != tt.expectedReason { + t.Errorf("expected reason '%s', got '%s'", tt.expectedReason, reason) + } + + if err != tt.expectedError { + t.Errorf("expected err '%v', got '%v'", tt.expectedError, err) + } + }) + } +} diff --git a/docs/README.md b/docs/README.md index 5e137be5b..0e60502a1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -36,12 +36,22 @@ flagd offers a functionality called targeting rules which rely on the incoming c flagd supports [fractional evaluation](configuration/fractional_evaluation.md) meaning an incoming property in the context can be sub-divided at "evaluation time" into "buckets". +[This page](configuration/fractional_evaluation.md) explains the concept and describes the technical implementation in detail. + ## Starts/Ends With Evaluation flagd supports [starts/ends_with evaluation](configuration/string_comparison_evaluation.md) meaning an incoming property in the context can be used to determine whether a certain variant should be returned based on if its value starts or ends with a certain string. -[This page](configuration/fractional_evaluation.md) explains the concept and describes the technical implementation in detail. +[This page](configuration/string_comparison_evaluation.md) explains the concept and describes the technical implementation in detail. + +## SemVer Evaluation + +flagd supports [sem_ver evaluation](configuration/sem_ver_evaluation.md) meaning an incoming property +representing a semantic version in the context can be used to determine whether a certain variant should be returned +based on if the version meets a certain criteria. + +[This page](configuration/sem_ver_evaluation.md) explains the concept and describes the technical implementation in detail. ## Flag Merging diff --git a/docs/configuration/sem_ver_evaluation.md b/docs/configuration/sem_ver_evaluation.md new file mode 100644 index 000000000..aaab7ace2 --- /dev/null +++ b/docs/configuration/sem_ver_evaluation.md @@ -0,0 +1,86 @@ +# SemVer Evaluation + +OpenFeature allows clients to pass contextual information which can then be used during a flag evaluation. For example, a client could pass the email address of the user. + +In some scenarios, it is desirable to use that contextual information to segment the user population further and thus return dynamic values. + +## SemVer Evaluation: Technical Description + +The `sem_ver` evaluation checks if the given property matches a semantic versioning condition. +It returns 'true', if the value of the given property meets the condition, 'false' if not. + +## SemVer Evaluation Configuration + +The `sem_ver` evaluation can be added as part of a targeting definition. +Note that the 'sem_ver' evaluation rule must contain exactly three items: + +1. Target property: this needs which both resolve to a semantic versioning string +2. Operator: One of the following: `=`, `!=`, `>`, `<`, `>=`, `<=`, `~` (match minor version), `^` (match major version) +3. Target value: this needs which both resolve to a semantic versioning string + +The `sem_ver` evaluation returns a boolean, indicating whether the condition has been met. + +```js +{ + "if": [ + { + "sem_ver": [{"var": "version"}, ">=", "1.0.0"] + }, + "red", null + ] +} +``` + +## Example for 'starts_with' Evaluation + +Flags defined as such: + +```json +{ + "flags": { + "headerColor": { + "variants": { + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00" + }, + "defaultVariant": "blue", + "state": "ENABLED", + "targeting": { + "if": [ + { + "sem_ver": [{"var": "version"}, ">=", "1.0.0"] + }, + "red", "green" + ] + } + } + } +} +``` + +will return variant `red`, if the value of the `version` is a semantic version that is greater than or equal to `1.0.0`. + +Command: + +```shell +curl -X POST "localhost:8013/schema.v1.Service/ResolveString" -d '{"flagKey":"headerColor","context":{"version": "1.0.1"}}' -H "Content-Type: application/json" +``` + +Result: + +```json +{"value":"#00FF00","reason":"TARGETING_MATCH","variant":"red"} +``` + +Command: + +```shell +curl -X POST "localhost:8013/schema.v1.Service/ResolveString" -d '{"flagKey":"headerColor","context":{"version": "0.1.0"}}' -H "Content-Type: application/json" +``` + +Result: + +```shell +{"value":"#0000FF","reason":"TARGETING_MATCH","variant":"green"} +```