Skip to content

Commit

Permalink
feat: add sem_ver jsonLogic evaluator (#675)
Browse files Browse the repository at this point in the history
Signed-off-by: Florian Bacher <[email protected]>
  • Loading branch information
bacherfl authored May 31, 2023
1 parent ca8c9d6 commit a8d8ab6
Show file tree
Hide file tree
Showing 7 changed files with 1,002 additions and 3 deletions.
1 change: 1 addition & 0 deletions core/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions core/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
4 changes: 4 additions & 0 deletions core/pkg/eval/json_evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
144 changes: 144 additions & 0 deletions core/pkg/eval/semver_evaluation.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit a8d8ab6

Please sign in to comment.