diff --git a/bucketing/bucketing.go b/bucketing/bucketing.go index f4d1bd19..a170adf9 100644 --- a/bucketing/bucketing.go +++ b/bucketing/bucketing.go @@ -2,10 +2,12 @@ package bucketing import ( "errors" + "reflect" "time" "github.com/devcyclehq/go-server-sdk/v2/api" "github.com/devcyclehq/go-server-sdk/v2/util" + "github.com/devcyclehq/go-server-sdk/v2/variable-utils" ) // Max value of an unsigned 32-bit integer, which is what murmurhash returns @@ -212,7 +214,17 @@ func GenerateBucketedConfig(sdkKey string, user api.PopulatedUser, clientCustomD }, nil } -func VariableForUser(sdkKey string, user api.PopulatedUser, variableKey string, expectedVariableType string, eventQueue *EventQueue, clientCustomData map[string]interface{}) (variableType string, variableValue any, err error) { +func VariableForUser(sdkKey string, user api.PopulatedUser, variableKey string, defaultValue interface{}, eventQueue *EventQueue, clientCustomData map[string]interface{}) (variableType string, variableValue any, err error) { + expectedVariableType, err := variable_utils.VariableTypeFromValue(variableKey, defaultValue, true) + + if err != nil { + eventErr := eventQueue.QueueVariableDefaultedEvent(variableKey, BucketResultErrorToDefaultReason(variable_utils.ErrInvalidDefaultValue)) + if eventErr != nil { + util.Warnf("Failed to queue variable defaulted event: %s", eventErr) + } + return "", nil, err + } + variableType, variableValue, featureId, variationId, err := generateBucketedVariableForUser(sdkKey, user, variableKey, clientCustomData) if err != nil { eventErr := eventQueue.QueueVariableDefaultedEvent(variableKey, BucketResultErrorToDefaultReason(err)) @@ -222,9 +234,17 @@ func VariableForUser(sdkKey string, user api.PopulatedUser, variableKey string, return "", nil, err } - if !isVariableTypeValid(variableType, expectedVariableType) && expectedVariableType != "" { + typeFieldMismatch := !isVariableTypeValid(variableType, expectedVariableType) && expectedVariableType != "" + valueFieldMismatch := defaultValue != nil && !variable_utils.CompareTypes(variableValue, defaultValue) + + if typeFieldMismatch || valueFieldMismatch { err = ErrInvalidVariableType eventErr := eventQueue.QueueVariableDefaultedEvent(variableKey, BucketResultErrorToDefaultReason(err)) + util.Warnf("Type mismatch for variable %s. Expected type %s, got %s", + variableKey, + reflect.TypeOf(defaultValue).String(), + reflect.TypeOf(variableValue).String(), + ) if eventErr != nil { util.Warnf("Failed to queue variable defaulted event: %s", eventErr) } @@ -303,6 +323,8 @@ func BucketResultErrorToDefaultReason(err error) (defaultReason string) { return "USER_NOT_TARGETED" case ErrInvalidVariableType: return "INVALID_VARIABLE_TYPE" + case variable_utils.ErrInvalidDefaultValue: + return "INVALID_DEFAULT_VALUE" default: return "Unknown" } diff --git a/client.go b/client.go index 43380c74..a1c6926b 100644 --- a/client.go +++ b/client.go @@ -6,8 +6,8 @@ import ( "fmt" "github.com/devcyclehq/go-server-sdk/v2/api" "github.com/devcyclehq/go-server-sdk/v2/util" + "github.com/devcyclehq/go-server-sdk/v2/variable-utils" "os" - "reflect" "regexp" "runtime" "strings" @@ -232,8 +232,8 @@ func (c *Client) Variable(userdata User, key string, defaultValue interface{}) ( return Variable{}, errors.New("invalid key provided for call to Variable") } - convertedDefaultValue := convertDefaultValueType(defaultValue) - variableType, err := variableTypeFromValue(key, convertedDefaultValue, c.IsLocalBucketing()) + convertedDefaultValue := variable_utils.ConvertDefaultValueType(defaultValue) + variableType, err := variable_utils.VariableTypeFromValue(key, convertedDefaultValue, c.IsLocalBucketing()) if err != nil { return Variable{}, err @@ -255,23 +255,7 @@ func (c *Client) Variable(userdata User, key string, defaultValue interface{}) ( return c.cloudClient.Variable(userdata, key, defaultValue) } - bucketedVariable, err := c.localBucketing.Variable(userdata, key, variableType) - - sameTypeAsDefault := compareTypes(bucketedVariable.Value, convertedDefaultValue) - if bucketedVariable.Value != nil && (sameTypeAsDefault || defaultValue == nil) { - variable.Type_ = bucketedVariable.Type_ - variable.Value = bucketedVariable.Value - variable.IsDefaulted = false - } else { - if !sameTypeAsDefault && bucketedVariable.Value != nil { - util.Warnf("Type mismatch for variable %s. Expected type %s, got %s", - key, - reflect.TypeOf(defaultValue).String(), - reflect.TypeOf(bucketedVariable.Value).String(), - ) - } - } - return variable, err + return c.localBucketing.Variable(userdata, key, convertedDefaultValue) } func (c *Client) AllVariables(user User) (map[string]ReadOnlyVariable, error) { @@ -393,60 +377,6 @@ func (c *Client) hasConfig() bool { return c.configManager.HasConfig() } -func compareTypes(value1 interface{}, value2 interface{}) bool { - return reflect.TypeOf(value1) == reflect.TypeOf(value2) -} - -func convertDefaultValueType(value interface{}) interface{} { - switch value := value.(type) { - case int: - return float64(value) - case int8: - return float64(value) - case int16: - return float64(value) - case int32: - return float64(value) - case int64: - return float64(value) - case uint: - return float64(value) - case uint8: - return float64(value) - case uint16: - return float64(value) - case uint32: - return float64(value) - case uint64: - return float64(value) - case float32: - return float64(value) - default: - return value - } -} - -var ErrInvalidDefaultValue = errors.New("the default value for variable is not of type Boolean, Number, String, or JSON") - -func variableTypeFromValue(key string, value interface{}, allowNil bool) (varType string, err error) { - switch value.(type) { - case float64: - return "Number", nil - case string: - return "String", nil - case bool: - return "Boolean", nil - case map[string]any: - return "JSON", nil - case nil: - if allowNil { - return "", nil - } - } - - return "", fmt.Errorf("%w: %s", ErrInvalidDefaultValue, key) -} - // Change base path to allow switching to mocks func (c *Client) ChangeBasePath(path string) { c.cfg.BasePath = path diff --git a/client_cloud_bucketing.go b/client_cloud_bucketing.go index 3bb200ec..47dc530e 100644 --- a/client_cloud_bucketing.go +++ b/client_cloud_bucketing.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "github.com/devcyclehq/go-server-sdk/v2/util" + "github.com/devcyclehq/go-server-sdk/v2/variable-utils" "github.com/matryer/try" "io" "math" @@ -80,8 +81,8 @@ func (c *cloudClient) Variable(userdata User, key string, defaultValue interface return Variable{}, errors.New("invalid key provided for call to Variable") } - convertedDefaultValue := convertDefaultValueType(defaultValue) - variableType, err := variableTypeFromValue(key, convertedDefaultValue, false) + convertedDefaultValue := variable_utils.ConvertDefaultValueType(defaultValue) + variableType, err := variable_utils.VariableTypeFromValue(key, convertedDefaultValue, false) if err != nil { return Variable{}, err @@ -127,7 +128,7 @@ func (c *cloudClient) Variable(userdata User, key string, defaultValue interface // If we succeed, return the data, otherwise pass on to decode error. err = decode(&localVarReturnValue, body, r.Header.Get("Content-Type")) if err == nil && localVarReturnValue.Value != nil { - if compareTypes(localVarReturnValue.Value, convertedDefaultValue) { + if variable_utils.CompareTypes(localVarReturnValue.Value, convertedDefaultValue) { variable.Value = localVarReturnValue.Value variable.IsDefaulted = false } else { diff --git a/client_native_bucketing.go b/client_native_bucketing.go index f46ba4c3..f16f7b39 100644 --- a/client_native_bucketing.go +++ b/client_native_bucketing.go @@ -2,6 +2,7 @@ package devcycle import ( "fmt" + "github.com/devcyclehq/go-server-sdk/v2/variable-utils" "sync" "time" @@ -83,20 +84,27 @@ func (n *NativeLocalBucketing) SetClientCustomData(customData map[string]interfa return nil } -func (n *NativeLocalBucketing) Variable(user User, variableKey string, variableType string) (Variable, error) { +func (n *NativeLocalBucketing) Variable(user User, variableKey string, defaultValue interface{}) (Variable, error) { + variableType, err := variable_utils.VariableTypeFromValue(variableKey, defaultValue, true) + + if err != nil { + return Variable{}, err + } defaultVar := Variable{ BaseVariable: api.BaseVariable{ Key: variableKey, Type_: variableType, - Value: nil, + Value: defaultValue, }, - DefaultValue: nil, + DefaultValue: defaultValue, IsDefaulted: true, } + clientCustomData := bucketing.GetClientCustomData(n.sdkKey) populatedUser := user.GetPopulatedUserWithTime(n.platformData, DEFAULT_USER_TIME) - resultVariableType, resultValue, err := bucketing.VariableForUser(n.sdkKey, populatedUser, variableKey, variableType, n.eventQueue, clientCustomData) + resultVariableType, resultValue, err := bucketing.VariableForUser(n.sdkKey, populatedUser, variableKey, defaultValue, n.eventQueue, clientCustomData) + if err != nil { return defaultVar, nil } diff --git a/openfeature_provider.go b/openfeature_provider.go index a79c781c..c4c46ebb 100644 --- a/openfeature_provider.go +++ b/openfeature_provider.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + variable_utils "github.com/devcyclehq/go-server-sdk/v2/variable-utils" "github.com/devcyclehq/go-server-sdk/v2/util" "github.com/open-feature/go-sdk/pkg/openfeature" @@ -307,7 +308,7 @@ func (p DevCycleProvider) Hooks() []openfeature.Hook { } func toOpenFeatureError(err error) openfeature.ResolutionError { - if errors.Is(err, ErrInvalidDefaultValue) { + if errors.Is(err, variable_utils.ErrInvalidDefaultValue) { return openfeature.NewTypeMismatchResolutionError(err.Error()) } return openfeature.NewGeneralResolutionError(err.Error()) diff --git a/variable-utils/variable_utils.go b/variable-utils/variable_utils.go new file mode 100644 index 00000000..2b4807b4 --- /dev/null +++ b/variable-utils/variable_utils.go @@ -0,0 +1,61 @@ +package variable_utils + +import ( + "errors" + "fmt" + "reflect" +) + +var ErrInvalidDefaultValue = errors.New("the default value for variable is not of type Boolean, Number, String, or JSON") + +func ConvertDefaultValueType(value interface{}) interface{} { + switch value := value.(type) { + case int: + return float64(value) + case int8: + return float64(value) + case int16: + return float64(value) + case int32: + return float64(value) + case int64: + return float64(value) + case uint: + return float64(value) + case uint8: + return float64(value) + case uint16: + return float64(value) + case uint32: + return float64(value) + case uint64: + return float64(value) + case float32: + return float64(value) + default: + return value + } +} + +func VariableTypeFromValue(key string, value interface{}, allowNil bool) (varType string, err error) { + switch value.(type) { + case float64: + return "Number", nil + case string: + return "String", nil + case bool: + return "Boolean", nil + case map[string]any: + return "JSON", nil + case nil: + if allowNil { + return "", nil + } + } + + return "", fmt.Errorf("%w: %s", ErrInvalidDefaultValue, key) +} + +func CompareTypes(value1 interface{}, value2 interface{}) bool { + return reflect.TypeOf(value1) == reflect.TypeOf(value2) +}