Repository: Unleash/unleash-client-go
Pull request #97 (2022-02-24 09:42:09)

feat: add new constraint operators


Patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index b1be7dd..344f26e 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -18,7 +18,7 @@ jobs:
         name: Checkout client specifications
         with:
           repository: Unleash/client-specification
-          ref: refs/tags/v3.3.1
+          ref: refs/tags/v4.1.0
           path: testdata/client-specification
       - uses: actions/setup-go@v2
         name: Setup go
diff --git a/.gitignore b/.gitignore
index 48abe9b..3090f81 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,8 @@ _testmain.go
 *.exe
 *.test
 *.prof
+*.cov
+
 ### Go template
 # Binaries for programs and plugins
 *.exe
diff --git a/api/constraint.go b/api/constraint.go
index 0d47f70..ce7e790 100644
--- a/api/constraint.go
+++ b/api/constraint.go
@@ -11,16 +11,77 @@ const (
 	// OperatorNotIn indicates that the context values must
 	// NOT be contained within those specified in the constraint.
 	OperatorNotIn Operator = "NOT_IN"
+
+	// OperatorStrContains indicates that the context value
+	// must contain the specified substring.
+	OperatorStrContains Operator = "STR_CONTAINS"
+
+	// OperatorStrStartsWith indicates that the context value
+	// must have the specified prefix.
+	OperatorStrStartsWith Operator = "STR_STARTS_WITH"
+
+	// OperatorStrEndsWith indicates that the context value
+	// must have the specified suffix.
+	OperatorStrEndsWith Operator = "STR_ENDS_WITH"
+
+	// OperatorNumEq indicates that the context value
+	// must be equal to the specified number.
+	OperatorNumEq Operator = "NUM_EQ"
+
+	// OperatorNumLt indicates that the context value
+	// must be less than the specified number.
+	OperatorNumLt Operator = "NUM_LT"
+
+	// OperatorNumLte indicates that the context value
+	// must be less than or equal to the specified number.
+	OperatorNumLte Operator = "NUM_LTE"
+
+	// OperatorNumGt indicates that the context value
+	// must be greater than the specified number.
+	OperatorNumGt Operator = "NUM_GT"
+
+	// OperatorNumGte indicates that the context value
+	// must be greater than or equal to the specified number.
+	OperatorNumGte Operator = "NUM_GTE"
+
+	// OperatorDateBefore indicates that the context value
+	// must be before the specified date.
+	OperatorDateBefore Operator = "DATE_BEFORE"
+
+	// OperatorDateAfter indicates that the context value
+	// must be after the specified date.
+	OperatorDateAfter Operator = "DATE_AFTER"
+
+	// OperatorSemverEq indicates that the context value
+	// must be equal to the specified SemVer version.
+	OperatorSemverEq Operator = "SEMVER_EQ"
+
+	// OperatorSemverLt indicates that the context value
+	// must be less than the specified SemVer version.
+	OperatorSemverLt Operator = "SEMVER_LT"
+
+	// OperatorSemverGt indicates that the context value
+	// must be greater than the specified SemVer version.
+	OperatorSemverGt Operator = "SEMVER_GT"
 )
 
 // Constraint represents a constraint on a particular context value.
 type Constraint struct {
 	// ContextName is the context name of the constraint.
-	ContextName string   `json:"contextName"`
+	ContextName string `json:"contextName"`
 
 	// Operator is the operator of the constraint.
-	Operator    Operator `json:"operator"`
+	Operator Operator `json:"operator"`
+
+	// Values is the list of target values for multi-valued constraints.
+	Values []string `json:"values"`
+
+	// Value is the target value single-value constraints.
+	Value string `json:"value"`
+
+	// CaseInsensitive makes the string operators case-insensitive.
+	CaseInsensitive bool `json:"caseInsensitive"`
 
-	// Values is the values of the constraint.
-	Values      []string `json:"values"`
+	// Inverted flips the constraint check result.
+	Inverted bool `json:"inverted"`
 }
diff --git a/client.go b/client.go
index 173ea90..7d13214 100644
--- a/client.go
+++ b/client.go
@@ -285,10 +285,13 @@ func (uc *Client) isEnabled(feature string, options ...FeatureOption) (enabled b
 			continue
 		}
 
-		if constraints.Check(ctx, s.Constraints) && foundStrategy.IsEnabled(s.Parameters, ctx) {
+		if ok, err := constraints.Check(ctx, s.Constraints); err != nil {
+			uc.errors <- err
+		} else if ok && foundStrategy.IsEnabled(s.Parameters, ctx) {
 			return true
 		}
 	}
+
 	return false
 }
 
diff --git a/context/context.go b/context/context.go
index 0c6526f..b2fc533 100644
--- a/context/context.go
+++ b/context/context.go
@@ -18,6 +18,9 @@ type Context struct {
 	// AppName is the application name.
 	AppName string
 
+	// CurrentTime is used for date constraints. Defaults to time.Now if not set.
+	CurrentTime string
+
 	// Properties is a map of additional properties.
 	Properties map[string]string
 }
@@ -40,6 +43,9 @@ func (ctx Context) Override(src Context) *Context {
 	if src.AppName != "" {
 		ctx.AppName = src.AppName
 	}
+	if src.CurrentTime != "" {
+		ctx.CurrentTime = src.CurrentTime
+	}
 	if src.Properties != nil {
 		ctx.Properties = src.Properties
 	}
@@ -61,6 +67,8 @@ func (ctx Context) Field(name string) string {
 		return ctx.Environment
 	case "appName":
 		return ctx.AppName
+	case "currentTime":
+		return ctx.CurrentTime
 	default:
 		return ctx.Properties[name]
 	}
diff --git a/go.mod b/go.mod
index a7e52af..ef912ea 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,7 @@
 module github.com/Unleash/unleash-client-go/v3
 
 require (
+	github.com/Masterminds/semver v1.5.0
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
diff --git a/go.sum b/go.sum
index 991eb2b..239337f 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
+github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4=
diff --git a/internal/api/event.go b/internal/api/event.go
index d0adf7d..614d063 100644
--- a/internal/api/event.go
+++ b/internal/api/event.go
@@ -21,11 +21,11 @@ type Event struct {
 }
 
 type EventData struct {
-	Name        string     `json:"name"`
-	Description string     `json:"description"`
-	Enabled     bool       `json:"enabled"`
+	Name        string         `json:"name"`
+	Description string         `json:"description"`
+	Enabled     bool           `json:"enabled"`
 	Strategies  []api.Strategy `json:"strategies"`
-	CreatedAt   time.Time  `json:"createdAt"`
+	CreatedAt   time.Time      `json:"createdAt"`
 }
 
 type EventDiff struct {
diff --git a/internal/api/metrics.go b/internal/api/metrics.go
index 41cfcd2..66d6553 100644
--- a/internal/api/metrics.go
+++ b/internal/api/metrics.go
@@ -3,8 +3,8 @@ package api
 import "time"
 
 type ToggleCount struct {
-	Yes int32 `json:"yes"`
-	No  int32 `json:"no"`
+	Yes      int32            `json:"yes"`
+	No       int32            `json:"no"`
 	Variants map[string]int32 `json:"variants"`
 }
 
diff --git a/internal/constraints/check.go b/internal/constraints/check.go
new file mode 100644
index 0000000..536b0dc
--- /dev/null
+++ b/internal/constraints/check.go
@@ -0,0 +1,67 @@
+package constraints
+
+import (
+	"fmt"
+	"github.com/Unleash/unleash-client-go/v3/api"
+	"github.com/Unleash/unleash-client-go/v3/context"
+)
+
+// Check checks if all the constraints are fulfilled by the context.
+func Check(ctx *context.Context, constraints []api.Constraint) (bool, error) {
+	for _, c := range constraints {
+		if ok, err := checkConstraintInvertible(ctx, c); !ok || err != nil {
+			return false, err
+		}
+	}
+
+	return true, nil
+}
+
+// checkConstraintInvertible inverts the result from checkConstraint if required.
+func checkConstraintInvertible(ctx *context.Context, constraint api.Constraint) (bool, error) {
+	ok, err := checkConstraint(ctx, constraint)
+
+	if constraint.Inverted {
+		return !ok, err
+	}
+
+	return ok, err
+}
+
+// checkConstraint checks if a specific constraint is fulfilled by the context.
+func checkConstraint(ctx *context.Context, constraint api.Constraint) (bool, error) {
+	switch constraint.Operator {
+	case api.OperatorIn:
+		return operatorIn(ctx, constraint), nil
+	case api.OperatorNotIn:
+		return operatorNotIn(ctx, constraint), nil
+	case api.OperatorStrContains:
+		return operatorStrContains(ctx, constraint), nil
+	case api.OperatorStrStartsWith:
+		return operatorStrStartsWith(ctx, constraint), nil
+	case api.OperatorStrEndsWith:
+		return operatorStrEndsWith(ctx, constraint), nil
+	case api.OperatorNumEq:
+		return operatorNumEq(ctx, constraint)
+	case api.OperatorNumLt:
+		return operatorNumLt(ctx, constraint)
+	case api.OperatorNumLte:
+		return operatorNumLte(ctx, constraint)
+	case api.OperatorNumGt:
+		return operatorNumGt(ctx, constraint)
+	case api.OperatorNumGte:
+		return operatorNumGte(ctx, constraint)
+	case api.OperatorDateBefore:
+		return operatorDateBefore(ctx, constraint)
+	case api.OperatorDateAfter:
+		return operatorDateAfter(ctx, constraint)
+	case api.OperatorSemverEq:
+		return operatorSemverEq(ctx, constraint)
+	case api.OperatorSemverLt:
+		return operatorSemverLt(ctx, constraint)
+	case api.OperatorSemverGt:
+		return operatorSemverGt(ctx, constraint)
+	default:
+		return false, fmt.Errorf("unknown constraint operator: %s", constraint.Operator)
+	}
+}
diff --git a/internal/constraints/checker.go b/internal/constraints/checker.go
deleted file mode 100644
index d915da0..0000000
--- a/internal/constraints/checker.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package constraints
-
-import (
-	"github.com/Unleash/unleash-client-go/v3/api"
-	"github.com/Unleash/unleash-client-go/v3/context"
-)
-
-func checkConstraint(ctx *context.Context, constraint api.Constraint) bool {
-	field := constraint.ContextName
-	value := ctx.Field(field)
-	isIn := false
-	for _, s := range constraint.Values {
-		if s == value {
-			isIn = true
-			break
-		}
-	}
-	return (constraint.Operator == api.OperatorIn) == isIn
-}
-
-// Check validates all of the constraints and returns true if all constraints are satisfied.
-func Check(ctx *context.Context, constraints []api.Constraint) bool {
-	for _, c := range constraints {
-		if !checkConstraint(ctx, c) {
-			return false
-		}
-	}
-	return true
-}
diff --git a/internal/constraints/operator_date.go b/internal/constraints/operator_date.go
new file mode 100644
index 0000000..ea3fe9e
--- /dev/null
+++ b/internal/constraints/operator_date.go
@@ -0,0 +1,48 @@
+package constraints
+
+import (
+	"github.com/Unleash/unleash-client-go/v3/api"
+	"github.com/Unleash/unleash-client-go/v3/context"
+	"time"
+)
+
+func operatorDateBefore(ctx *context.Context, constraint api.Constraint) (bool, error) {
+	return operatorDate(ctx, constraint, func(context time.Time, constraint time.Time) bool {
+		return context.Before(constraint)
+	})
+}
+
+func operatorDateAfter(ctx *context.Context, constraint api.Constraint) (bool, error) {
+	return operatorDate(ctx, constraint, func(context time.Time, constraint time.Time) bool {
+		return context.After(constraint)
+	})
+}
+
+func operatorDate(
+	ctx *context.Context,
+	constraint api.Constraint,
+	check func(context time.Time, constraint time.Time) bool,
+) (bool, error) {
+	contextParsed, contextErr := contextDateValueOrNow(ctx, constraint)
+	constraintParsed, constraintErr := time.Parse(time.RFC3339, constraint.Value)
+
+	if contextErr != nil {
+		return false, contextErr
+	}
+
+	if constraintErr != nil {
+		return false, constraintErr
+	}
+
+	return check(contextParsed, constraintParsed), nil
+}
+
+func contextDateValueOrNow(ctx *context.Context, constraint api.Constraint) (time.Time, error) {
+	contextValue := ctx.Field(constraint.ContextName)
+
+	if contextValue != "" {
+		return time.Parse(time.RFC3339, contextValue)
+	}
+
+	return time.Now(), nil
+}
diff --git a/internal/constraints/operator_in.go b/internal/constraints/operator_in.go
new file mode 100644
index 0000000..a60dc98
--- /dev/null
+++ b/internal/constraints/operator_in.go
@@ -0,0 +1,22 @@
+package constraints
+
+import (
+	"github.com/Unleash/unleash-client-go/v3/api"
+	"github.com/Unleash/unleash-client-go/v3/context"
+)
+
+func operatorNotIn(ctx *context.Context, constraint api.Constraint) bool {
+	return !operatorIn(ctx, constraint)
+}
+
+func operatorIn(ctx *context.Context, constraint api.Constraint) bool {
+	contextValue := ctx.Field(constraint.ContextName)
+
+	for _, constraint := range constraint.Values {
+		if contextValue == constraint {
+			return true
+		}
+	}
+
+	return false
+}
diff --git a/internal/constraints/operator_num.go b/internal/constraints/operator_num.go
new file mode 100644
index 0000000..6b4df80
--- /dev/null
+++ b/internal/constraints/operator_num.go
@@ -0,0 +1,57 @@
+package constraints
+
+import (
+	"github.com/Unleash/unleash-client-go/v3/api"
+	"github.com/Unleash/unleash-client-go/v3/context"
+	"math/big"
+)
+
+func operatorNumEq(ctx *context.Context, constraint api.Constraint) (bool, error) {
+	return operatorNum(ctx, constraint, func(context *big.Float, constraint *big.Float) bool {
+		return context.Cmp(constraint) == 0
+	})
+}
+
+func operatorNumLt(ctx *context.Context, constraint api.Constraint) (bool, error) {
+	return operatorNum(ctx, constraint, func(context *big.Float, constraint *big.Float) bool {
+		return context.Cmp(constraint) < 0
+	})
+}
+
+func operatorNumLte(ctx *context.Context, constraint api.Constraint) (bool, error) {
+	return operatorNum(ctx, constraint, func(context *big.Float, constraint *big.Float) bool {
+		return context.Cmp(constraint) <= 0
+	})
+}
+
+func operatorNumGt(ctx *context.Context, constraint api.Constraint) (bool, error) {
+	return operatorNum(ctx, constraint, func(context *big.Float, constraint *big.Float) bool {
+		return context.Cmp(constraint) > 0
+	})
+}
+
+func operatorNumGte(ctx *context.Context, constraint api.Constraint) (bool, error) {
+	return operatorNum(ctx, constraint, func(context *big.Float, constraint *big.Float) bool {
+		return context.Cmp(constraint) >= 0
+	})
+}
+
+func operatorNum(
+	ctx *context.Context,
+	constraint api.Constraint,
+	check func(context *big.Float, constraint *big.Float) bool,
+) (bool, error) {
+	contextValue := ctx.Field(constraint.ContextName)
+	contextParsed, _, contextErr := big.ParseFloat(contextValue, 10, 0, big.ToNearestEven)
+	constraintParsed, _, constraintErr := big.ParseFloat(constraint.Value, 10, 0, big.ToNearestEven)
+
+	if contextErr != nil {
+		return false, contextErr
+	}
+
+	if constraintErr != nil {
+		return false, constraintErr
+	}
+
+	return check(contextParsed, constraintParsed), nil
+}
diff --git a/internal/constraints/operator_semver.go b/internal/constraints/operator_semver.go
new file mode 100644
index 0000000..d71008d
--- /dev/null
+++ b/internal/constraints/operator_semver.go
@@ -0,0 +1,45 @@
+package constraints
+
+import (
+	"github.com/Masterminds/semver"
+	"github.com/Unleash/unleash-client-go/v3/api"
+	"github.com/Unleash/unleash-client-go/v3/context"
+)
+
+func operatorSemverEq(ctx *context.Context, constraint api.Constraint) (bool, error) {
+	return operatorSemver(ctx, constraint, func(context *semver.Version, constraint *semver.Version) bool {
+		return context.Equal(constraint)
+	})
+}
+
+func operatorSemverLt(ctx *context.Context, constraint api.Constraint) (bool, error) {
+	return operatorSemver(ctx, constraint, func(context *semver.Version, constraint *semver.Version) bool {
+		return context.LessThan(constraint)
+	})
+}
+
+func operatorSemverGt(ctx *context.Context, constraint api.Constraint) (bool, error) {
+	return operatorSemver(ctx, constraint, func(context *semver.Version, constraint *semver.Version) bool {
+		return context.GreaterThan(constraint)
+	})
+}
+
+func operatorSemver(
+	ctx *context.Context,
+	constraint api.Constraint,
+	check func(context *semver.Version, constraint *semver.Version) bool,
+) (bool, error) {
+	contextValue := ctx.Field(constraint.ContextName)
+	contextParsed, contextErr := semver.NewVersion(contextValue)
+	constraintParsed, constraintErr := semver.NewVersion(constraint.Value)
+
+	if contextErr != nil {
+		return false, contextErr
+	}
+
+	if constraintErr != nil {
+		return false, constraintErr
+	}
+
+	return check(contextParsed, constraintParsed), nil
+}
diff --git a/internal/constraints/operator_str.go b/internal/constraints/operator_str.go
new file mode 100644
index 0000000..5213f9b
--- /dev/null
+++ b/internal/constraints/operator_str.go
@@ -0,0 +1,43 @@
+package constraints
+
+import (
+	"github.com/Unleash/unleash-client-go/v3/api"
+	"github.com/Unleash/unleash-client-go/v3/context"
+	"strings"
+)
+
+func operatorStrContains(ctx *context.Context, constraint api.Constraint) bool {
+	return operatorStr(ctx, constraint, strings.Contains)
+}
+
+func operatorStrStartsWith(ctx *context.Context, constraint api.Constraint) bool {
+	return operatorStr(ctx, constraint, strings.HasPrefix)
+}
+
+func operatorStrEndsWith(ctx *context.Context, constraint api.Constraint) bool {
+	return operatorStr(ctx, constraint, strings.HasSuffix)
+}
+
+func operatorStr(
+	ctx *context.Context,
+	constraint api.Constraint,
+	check func(context string, constraint string) bool,
+) bool {
+	contextValue := toLowerIfCaseInsensitive(constraint, ctx.Field(constraint.ContextName))
+
+	for _, constraintValue := range constraint.Values {
+		if check(contextValue, toLowerIfCaseInsensitive(constraint, constraintValue)) {
+			return true
+		}
+	}
+
+	return false
+}
+
+func toLowerIfCaseInsensitive(constraint api.Constraint, value string) string {
+	if constraint.CaseInsensitive {
+		return strings.ToLower(value)
+	} else {
+		return value
+	}
+}


Test patch:
diff --git a/internal/constraints/check_test.go b/internal/constraints/check_test.go
new file mode 100644
index 0000000..67c4e7f
--- /dev/null
+++ b/internal/constraints/check_test.go
@@ -0,0 +1,80 @@
+package constraints
+
+import (
+	"fmt"
+	"github.com/Unleash/unleash-client-go/v3/api"
+	"github.com/Unleash/unleash-client-go/v3/context"
+	"github.com/stretchr/testify/assert"
+	"testing"
+)
+
+type checkTestCase struct {
+	ctx         *context.Context
+	constraints []api.Constraint
+	expected    bool
+}
+
+func TestCheck(t *testing.T) {
+	testCases := []checkTestCase{
+		{
+			expected: true,
+		},
+		{
+			ctx:      &context.Context{},
+			expected: true,
+		},
+		{
+			ctx:         &context.Context{},
+			constraints: []api.Constraint{},
+			expected:    true,
+		},
+		{
+			constraints: []api.Constraint{},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{Environment: "dev"},
+			constraints: []api.Constraint{},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{},
+			constraints: []api.Constraint{{ContextName: "environment", Operator: "IN", Values: []string{}}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{},
+			constraints: []api.Constraint{{ContextName: "environment", Operator: "IN", Values: []string{}, Inverted: true}},
+			expected:    true,
+		},
+	}
+
+	for _, tc := range testCases {
+		if ok, err := Check(tc.ctx, tc.constraints); err != nil {
+			t.Fatal(err)
+		} else {
+			assert.Equal(t, tc.expected, ok)
+		}
+	}
+}
+
+func TestCheckUnknownOperator(t *testing.T) {
+	testCases := []checkTestCase{
+		{
+			ctx:         &context.Context{},
+			constraints: []api.Constraint{{ContextName: "environment", Operator: "UNKNOWN_OPERATOR"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{},
+			constraints: []api.Constraint{{ContextName: "environment", Operator: "UNKNOWN_OPERATOR", Inverted: true}},
+			expected:    false,
+		},
+	}
+
+	for _, tc := range testCases {
+		ok, err := Check(tc.ctx, tc.constraints)
+		assert.Equal(t, tc.expected, ok)
+		assert.Equal(t, err.Error(), fmt.Sprintf("unknown constraint operator: %s", tc.constraints[0].Operator))
+	}
+}
diff --git a/internal/constraints/checker_test.go b/internal/constraints/checker_test.go
deleted file mode 100644
index bbe1543..0000000
--- a/internal/constraints/checker_test.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package constraints
-
-import (
-	"github.com/Unleash/unleash-client-go/v3/api"
-	"github.com/Unleash/unleash-client-go/v3/context"
-	"github.com/stretchr/testify/assert"
-	"testing"
-)
-
-func TestCheck(t *testing.T) {
-	type testCase struct {
-		ctx         *context.Context
-		constraints []api.Constraint
-		expected    bool
-	}
-
-	testCases := []testCase{
-		{
-			ctx: &context.Context{
-				Environment: "prod",
-				Properties: map[string]string{
-					"country": "norway",
-				},
-			},
-			constraints: []api.Constraint{
-				{
-					ContextName: "environment",
-					Operator:    "IN",
-					Values:      []string{"prod"},
-				},
-			},
-			expected: true,
-		},
-		{
-			ctx: &context.Context{
-				Environment: "dev",
-				Properties: map[string]string{
-					"country": "norway",
-				},
-			},
-			constraints: []api.Constraint{
-				{
-					ContextName: "environment",
-					Operator:    "IN",
-					Values:      []string{"prod"},
-				},
-			},
-			expected: false,
-		},
-		{
-			ctx: &context.Context{
-				Environment: "prod",
-			},
-			constraints: []api.Constraint{
-				{
-					ContextName: "environment",
-					Operator:    "NOT_IN",
-					Values:      []string{"prod"},
-				},
-			},
-			expected: false,
-		},
-		{
-			ctx: &context.Context{
-				Environment: "dev",
-				AppName:     "test",
-			},
-			constraints: []api.Constraint{
-				{
-					ContextName: "environment",
-					Operator:    "NOT_IN",
-					Values:      []string{"prod"},
-				},
-				{
-					ContextName: "appName",
-					Operator:    "IN",
-					Values:      []string{"test"},
-				},
-			},
-			expected: true,
-		},
-		{
-			ctx: &context.Context{
-				Environment: "dev",
-			},
-			expected: true,
-		},
-	}
-
-	for _, tc := range testCases {
-		assert.Equal(t, tc.expected, Check(tc.ctx, tc.constraints))
-	}
-}
diff --git a/internal/constraints/operator_date_test.go b/internal/constraints/operator_date_test.go
new file mode 100644
index 0000000..9974b26
--- /dev/null
+++ b/internal/constraints/operator_date_test.go
@@ -0,0 +1,102 @@
+package constraints
+
+import (
+	"github.com/Unleash/unleash-client-go/v3/api"
+	"github.com/Unleash/unleash-client-go/v3/context"
+	"github.com/stretchr/testify/assert"
+	"testing"
+	"time"
+)
+
+func TestOperatorDate(t *testing.T) {
+	testCases := []checkTestCase{
+		{
+			ctx:         &context.Context{CurrentTime: "2006-01-02T15:04:04Z"},
+			constraints: []api.Constraint{{ContextName: "currentTime", Operator: "DATE_BEFORE", Value: "2006-01-02T15:04:05Z"}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{CurrentTime: "2006-01-02T15:04:05Z"},
+			constraints: []api.Constraint{{ContextName: "currentTime", Operator: "DATE_BEFORE", Value: "2006-01-02T15:04:05Z"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{CurrentTime: "2006-01-02T15:04:06Z"},
+			constraints: []api.Constraint{{ContextName: "currentTime", Operator: "DATE_BEFORE", Value: "2006-01-02T15:04:05Z"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{Properties: map[string]string{"date": "2006-01-02T15:04:04Z"}},
+			constraints: []api.Constraint{{ContextName: "date", Operator: "DATE_BEFORE", Value: "2006-01-02T15:04:05Z"}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{Properties: map[string]string{"date": "2006-01-02T15:04:06Z"}},
+			constraints: []api.Constraint{{ContextName: "date", Operator: "DATE_BEFORE", Value: "2006-01-02T15:04:05Z"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{},
+			constraints: []api.Constraint{{ContextName: "currentTime", Operator: "DATE_AFTER", Value: time.Now().Add(-time.Minute).Format(time.RFC3339)}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{},
+			constraints: []api.Constraint{{ContextName: "currentTime", Operator: "DATE_AFTER", Value: time.Now().Add(time.Minute).Format(time.RFC3339)}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{CurrentTime: "2006-01-02T15:04:04Z"},
+			constraints: []api.Constraint{{ContextName: "currentTime", Operator: "DATE_AFTER", Value: "2006-01-02T15:04:05Z"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{CurrentTime: "2006-01-02T15:04:05Z"},
+			constraints: []api.Constraint{{ContextName: "currentTime", Operator: "DATE_AFTER", Value: "2006-01-02T15:04:05Z"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{CurrentTime: "2006-01-02T15:04:06Z"},
+			constraints: []api.Constraint{{ContextName: "currentTime", Operator: "DATE_AFTER", Value: "2006-01-02T15:04:05Z"}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{Properties: map[string]string{"date": "2006-01-02T15:04:05.001Z"}},
+			constraints: []api.Constraint{{ContextName: "date", Operator: "DATE_AFTER", Value: "2006-01-02T15:04:05.002Z"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{Properties: map[string]string{"date": "2006-01-02T15:04:05.001Z"}},
+			constraints: []api.Constraint{{ContextName: "date", Operator: "DATE_AFTER", Value: "2006-01-02T15:04:05.001Z"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{Properties: map[string]string{"date": "2006-01-02T15:04:05.002Z"}},
+			constraints: []api.Constraint{{ContextName: "date", Operator: "DATE_AFTER", Value: "2006-01-02T15:04:05.001Z"}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{Properties: map[string]string{"date": "2006-01-02T15:04:05.999999999Z"}},
+			constraints: []api.Constraint{{ContextName: "date", Operator: "DATE_AFTER", Value: "2006-01-02T15:04:05.999999999Z"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{Properties: map[string]string{"date": "2006-01-02T15:04:05.999999998Z"}},
+			constraints: []api.Constraint{{ContextName: "date", Operator: "DATE_AFTER", Value: "2006-01-02T15:04:05.999999999Z"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{Properties: map[string]string{"date": "2006-01-02T15:04:05.999999999Z"}},
+			constraints: []api.Constraint{{ContextName: "date", Operator: "DATE_AFTER", Value: "2006-01-02T15:04:05.999999998Z"}},
+			expected:    true,
+		},
+	}
+
+	for _, tc := range testCases {
+		if ok, err := Check(tc.ctx, tc.constraints); err != nil {
+			t.Fatal(err)
+		} else {
+			assert.Equal(t, tc.expected, ok)
+		}
+	}
+}
diff --git a/internal/constraints/operator_in_test.go b/internal/constraints/operator_in_test.go
new file mode 100644
index 0000000..41f9fb0
--- /dev/null
+++ b/internal/constraints/operator_in_test.go
@@ -0,0 +1,56 @@
+package constraints
+
+import (
+	"github.com/Unleash/unleash-client-go/v3/api"
+	"github.com/Unleash/unleash-client-go/v3/context"
+	"github.com/stretchr/testify/assert"
+	"testing"
+)
+
+func TestOperatorIn(t *testing.T) {
+	testCases := []checkTestCase{
+		{
+			ctx:         &context.Context{Environment: "prod", Properties: map[string]string{"country": "norway"}},
+			constraints: []api.Constraint{{ContextName: "environment", Operator: "IN", Values: []string{"prod"}}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{Environment: "dev", Properties: map[string]string{"country": "norway"}},
+			constraints: []api.Constraint{{ContextName: "environment", Operator: "IN", Values: []string{"prod"}}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{Environment: "prod"},
+			constraints: []api.Constraint{{ContextName: "environment", Operator: "NOT_IN", Values: []string{"prod"}}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{Environment: "dev", AppName: "test"},
+			constraints: []api.Constraint{{ContextName: "environment", Operator: "NOT_IN", Values: []string{"prod"}}, {ContextName: "appName", Operator: "IN", Values: []string{"test"}}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{Environment: "dev", AppName: "test"},
+			constraints: []api.Constraint{{ContextName: "environment", Operator: "NOT_IN", Values: []string{"prod"}}, {ContextName: "appName", Operator: "IN", Values: []string{"test"}}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{Environment: "dev", AppName: "test"},
+			constraints: []api.Constraint{{ContextName: "environment", Operator: "NOT_IN", Values: []string{"dev"}}, {ContextName: "appName", Operator: "IN", Values: []string{"test"}}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{Environment: "dev", AppName: "test"},
+			constraints: []api.Constraint{{ContextName: "environment", Operator: "IN", Values: []string{"dev"}}, {ContextName: "appName", Operator: "IN", Values: []string{"test"}}},
+			expected:    true,
+		},
+	}
+
+	for _, tc := range testCases {
+		if ok, err := Check(tc.ctx, tc.constraints); err != nil {
+			t.Fatal(err)
+		} else {
+			assert.Equal(t, tc.expected, ok)
+		}
+	}
+}
diff --git a/internal/constraints/operator_num_test.go b/internal/constraints/operator_num_test.go
new file mode 100644
index 0000000..c4ded61
--- /dev/null
+++ b/internal/constraints/operator_num_test.go
@@ -0,0 +1,131 @@
+package constraints
+
+import (
+	"github.com/Unleash/unleash-client-go/v3/api"
+	"github.com/Unleash/unleash-client-go/v3/context"
+	"github.com/stretchr/testify/assert"
+	"testing"
+)
+
+func TestOperatorNum(t *testing.T) {
+	testCases := []checkTestCase{
+		{
+			ctx:         &context.Context{UserId: "123"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "NUM_EQ", Value: "321"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{UserId: "321"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "NUM_EQ", Value: "123"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{UserId: "123"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "NUM_EQ", Value: "123"}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{UserId: "123.321"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "NUM_EQ", Value: "123.321"}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{UserId: "123.321"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "NUM_EQ", Value: "321.123"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{UserId: "1.0000000000000001"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "NUM_EQ", Value: "1.0000000000000001"}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{UserId: "2.0000000000000001"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "NUM_EQ", Value: "2.0000000000000001"}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{UserId: "1.0000000000000002"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "NUM_EQ", Value: "1.0000000000000001"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{UserId: "1.0000000000000001"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "NUM_EQ", Value: "1.0000000000000002"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{UserId: "2.0000000000000001"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "NUM_EQ", Value: "2.0000000000000002"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{UserId: "123.321"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "NUM_LT", Value: "123.321"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{UserId: "321.123"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "NUM_LT", Value: "123.321"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{UserId: "123.321"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "NUM_LT", Value: "321.123"}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{UserId: "123.321"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "NUM_LTE", Value: "123.321"}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{UserId: "321.123"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "NUM_LTE", Value: "123.321"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{UserId: "123.321"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "NUM_LTE", Value: "321.123"}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{UserId: "123.321"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "NUM_GT", Value: "123.321"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{UserId: "321.123"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "NUM_GT", Value: "123.321"}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{UserId: "123.321"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "NUM_GT", Value: "321.123"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{UserId: "123.321"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "NUM_GTE", Value: "123.321"}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{UserId: "321.123"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "NUM_GTE", Value: "123.321"}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{UserId: "123.321"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "NUM_GTE", Value: "321.123"}},
+			expected:    false,
+		},
+	}
+
+	for _, tc := range testCases {
+		if ok, err := Check(tc.ctx, tc.constraints); err != nil {
+			t.Fatal(err)
+		} else {
+			assert.Equal(t, tc.expected, ok)
+		}
+	}
+}
diff --git a/internal/constraints/operator_semver_test.go b/internal/constraints/operator_semver_test.go
new file mode 100644
index 0000000..79d9a4a
--- /dev/null
+++ b/internal/constraints/operator_semver_test.go
@@ -0,0 +1,76 @@
+package constraints
+
+import (
+	"github.com/Unleash/unleash-client-go/v3/api"
+	"github.com/Unleash/unleash-client-go/v3/context"
+	"github.com/stretchr/testify/assert"
+	"testing"
+)
+
+func TestOperatorSemver(t *testing.T) {
+	testCases := []checkTestCase{
+		{
+			ctx:         &context.Context{Properties: map[string]string{"v": "1.2.3"}},
+			constraints: []api.Constraint{{ContextName: "v", Operator: "SEMVER_EQ", Value: "3.2.1"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{Properties: map[string]string{"v": "1.2.3"}},
+			constraints: []api.Constraint{{ContextName: "v", Operator: "SEMVER_EQ", Value: "1.2.3"}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{Properties: map[string]string{"v": "1.2.3"}},
+			constraints: []api.Constraint{{ContextName: "v", Operator: "SEMVER_EQ", Value: "v1.2.3"}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{Properties: map[string]string{"v": "v1.2.3"}},
+			constraints: []api.Constraint{{ContextName: "v", Operator: "SEMVER_EQ", Value: "v3.2.1"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{Properties: map[string]string{"v": "v1.2.3"}},
+			constraints: []api.Constraint{{ContextName: "v", Operator: "SEMVER_EQ", Value: "v1.2.3"}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{Properties: map[string]string{"v": "v1.2.3"}},
+			constraints: []api.Constraint{{ContextName: "v", Operator: "SEMVER_EQ", Value: "v1.2.3"}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{Properties: map[string]string{"v": "v1.2.3"}},
+			constraints: []api.Constraint{{ContextName: "v", Operator: "SEMVER_LT", Value: "v1.2.3"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{Properties: map[string]string{"v": "v3.2.1"}},
+			constraints: []api.Constraint{{ContextName: "v", Operator: "SEMVER_LT", Value: "v1.2.3"}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{Properties: map[string]string{"v": "v1.2.3"}},
+			constraints: []api.Constraint{{ContextName: "v", Operator: "SEMVER_LT", Value: "v3.2.1"}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{Properties: map[string]string{"v": "v3.2.1"}},
+			constraints: []api.Constraint{{ContextName: "v", Operator: "SEMVER_GT", Value: "v1.2.3"}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{Properties: map[string]string{"v": "v1.2.3"}},
+			constraints: []api.Constraint{{ContextName: "v", Operator: "SEMVER_GT", Value: "v3.2.1"}},
+			expected:    false,
+		},
+	}
+
+	for _, tc := range testCases {
+		if ok, err := Check(tc.ctx, tc.constraints); err != nil {
+			t.Fatal(err)
+		} else {
+			assert.Equal(t, tc.expected, ok)
+		}
+	}
+}
diff --git a/internal/constraints/operator_str_test.go b/internal/constraints/operator_str_test.go
new file mode 100644
index 0000000..f847207
--- /dev/null
+++ b/internal/constraints/operator_str_test.go
@@ -0,0 +1,81 @@
+package constraints
+
+import (
+	"github.com/Unleash/unleash-client-go/v3/api"
+	"github.com/Unleash/unleash-client-go/v3/context"
+	"github.com/stretchr/testify/assert"
+	"testing"
+)
+
+func TestOperatorStr(t *testing.T) {
+	testCases := []checkTestCase{
+		{
+			ctx:         &context.Context{UserId: "123"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "STR_CONTAINS", Values: []string{"abc"}}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{UserId: "abc"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "STR_CONTAINS", Values: []string{"abc"}}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{UserId: "abc123"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "STR_CONTAINS", Values: []string{"abc"}}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{UserId: "ABC"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "STR_CONTAINS", Values: []string{"abc"}}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{UserId: "ABC"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "STR_CONTAINS", Values: []string{"abc"}, CaseInsensitive: true}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{UserId: "123abc"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "STR_STARTS_WITH", Values: []string{"abc"}}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{UserId: "abc123"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "STR_STARTS_WITH", Values: []string{"abc"}}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{UserId: ""},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "STR_ENDS_WITH", Values: []string{"abc"}}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{UserId: "abc"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "STR_ENDS_WITH", Values: []string{""}}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{UserId: "abc"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "STR_ENDS_WITH", Values: []string{"abc"}}},
+			expected:    true,
+		},
+		{
+			ctx:         &context.Context{UserId: "abc123"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "STR_ENDS_WITH", Values: []string{"abc"}}},
+			expected:    false,
+		},
+		{
+			ctx:         &context.Context{UserId: "123abc"},
+			constraints: []api.Constraint{{ContextName: "userId", Operator: "STR_ENDS_WITH", Values: []string{"abc"}}},
+			expected:    true,
+		},
+	}
+
+	for _, tc := range testCases {
+		if ok, err := Check(tc.ctx, tc.constraints); err != nil {
+			t.Fatal(err)
+		} else {
+			assert.Equal(t, tc.expected, ok)
+		}
+	}
+}
diff --git a/spec_test.go b/spec_test.go
index 5a58c8e..feb7f5c 100644
--- a/spec_test.go
+++ b/spec_test.go
@@ -19,6 +19,7 @@ const mockHost = "http://unleash-apu"
 const specFolder = "./testdata/client-specification/specifications"
 
 var specIndex = filepath.Join(specFolder, "index.json")
+var specNotImplemented = []string{"12-custom-stickiness"}
 
 type TestState struct {
 	Version  int           `json:"version"`
@@ -148,6 +149,16 @@ func (td TestDefinition) Run(t *testing.T) {
 	}
 }
 
+func (td TestDefinition) IsImplemented() bool {
+	for _, name := range specNotImplemented {
+		if name == td.Name {
+			return false
+		}
+	}
+
+	return true
+}
+
 type ClientSpecificationSuite struct {
 	suite.Suite
 	definitions []TestDefinition
@@ -180,8 +191,10 @@ func (s *ClientSpecificationSuite) SetupTest() {
 }
 
 func (s ClientSpecificationSuite) TestClientSpecification() {
-	for _, def := range s.definitions {
-		s.T().Run(def.Name, def.Run)
+	for _, td := range s.definitions {
+		if td.IsImplemented() {
+			s.T().Run(td.Name, td.Run)
+		}
 	}
 }