diff --git a/pkg/provider/utils.go b/pkg/provider/utils.go index 082d796..8c23d00 100644 --- a/pkg/provider/utils.go +++ b/pkg/provider/utils.go @@ -15,6 +15,7 @@ func splitFlagString(flag string) (string, string) { if len(splittedFlag) == 2 { return splittedFlag[0], splittedFlag[1] } + return splittedFlag[0], "" } @@ -24,7 +25,6 @@ func extractPropertyValue(path string, values map[string]interface{}) (interface } firstPartAndRest := strings.SplitN(path, ".", 2) - if len(firstPartAndRest) == 1 { value := values[firstPartAndRest[0]] return value, nil @@ -34,6 +34,7 @@ func extractPropertyValue(path string, values map[string]interface{}) (interface if ok { return extractPropertyValue(firstPartAndRest[1], childMap) } + return false, fmt.Errorf("unable to find property in path %s", path) } @@ -43,10 +44,8 @@ func getTypeForPath(schema map[string]interface{}, path string) (reflect.Kind, e } firstPartAndRest := strings.SplitN(path, ".", 2) - if len(firstPartAndRest) == 1 { value, ok := schema[firstPartAndRest[0]].(map[string]interface{}) - if !ok { return 0, fmt.Errorf("schema was not in the expected format") } @@ -76,6 +75,7 @@ func getTypeForPath(schema map[string]interface{}, path string) (reflect.Kind, e structSchema, _ := structMap.(map[string]interface{})["schema"].(map[string]interface{}) return getTypeForPath(structSchema, firstPartAndRest[1]) } + return 0, fmt.Errorf("unable to find property in schema %s", path) } @@ -106,7 +106,6 @@ func processResolvedFlag(resolvedFlag resolvedFlag, defaultValue interface{}, } actualKind, schemaErr := getTypeForPath(resolvedFlag.FlagSchema.Schema, propertyPath) - if schemaErr != nil || actualKind != expectedKind { return openfeature.InterfaceResolutionDetail{ Value: defaultValue, @@ -123,7 +122,6 @@ func processResolvedFlag(resolvedFlag resolvedFlag, defaultValue interface{}, } extractedValue, extractValueError := extractPropertyValue(propertyPath, updatedMap) - if extractValueError != nil { return typeMismatchError(defaultValue) } @@ -140,46 +138,42 @@ func replaceNumbers(basePath string, input map[string]interface{}, updatedMap := make(map[string]interface{}) for key, value := range input { kind, typeErr := getTypeForPath(schema, fmt.Sprintf("%s%s", basePath, key)) - if typeErr != nil { return updatedMap, fmt.Errorf("unable to get type for path %w", typeErr) } switch kind { case reflect.Float64: - { - floatValue, err := value.(json.Number).Float64() - if err != nil { - return updatedMap, fmt.Errorf("unable to convert to float") - } - updatedMap[key] = floatValue + floatValue, err := value.(json.Number).Float64() + if err != nil { + return updatedMap, fmt.Errorf("unable to convert to float") } + + updatedMap[key] = floatValue case reflect.Int64: - { - intValue, err := value.(json.Number).Int64() - if err != nil { - return updatedMap, fmt.Errorf("unable to convert to int") - } - updatedMap[key] = intValue + intValue, err := value.(json.Number).Int64() + if err != nil { + return updatedMap, fmt.Errorf("unable to convert to int") } + + updatedMap[key] = intValue case reflect.Map: - { - asMap, ok := value.(map[string]interface{}) - if !ok { - return updatedMap, fmt.Errorf("unable to convert map") - } - childMap, err := replaceNumbers(fmt.Sprintf("%s.", key), asMap, schema) - if err != nil { - return updatedMap, fmt.Errorf("unable to convert map") - } - updatedMap[key] = childMap + asMap, ok := value.(map[string]interface{}) + if !ok { + return updatedMap, fmt.Errorf("unable to convert map") } - default: - { - updatedMap[key] = value + + childMap, err := replaceNumbers(fmt.Sprintf("%s.", key), asMap, schema) + if err != nil { + return updatedMap, fmt.Errorf("unable to convert map") } + + updatedMap[key] = childMap + default: + updatedMap[key] = value } } + return updatedMap, nil } @@ -202,6 +196,7 @@ func toBoolResolutionDetail(res openfeature.InterfaceResolutionDetail, ProviderResolutionDetail: res.ProviderResolutionDetail, } } + return openfeature.BoolResolutionDetail{ Value: defaultValue, ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ @@ -210,6 +205,7 @@ func toBoolResolutionDetail(res openfeature.InterfaceResolutionDetail, }, } } + return openfeature.BoolResolutionDetail{ Value: defaultValue, ProviderResolutionDetail: res.ProviderResolutionDetail, @@ -226,6 +222,7 @@ func toStringResolutionDetail(res openfeature.InterfaceResolutionDetail, ProviderResolutionDetail: res.ProviderResolutionDetail, } } + return openfeature.StringResolutionDetail{ Value: defaultValue, ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ @@ -234,6 +231,7 @@ func toStringResolutionDetail(res openfeature.InterfaceResolutionDetail, }, } } + return openfeature.StringResolutionDetail{ Value: defaultValue, ProviderResolutionDetail: res.ProviderResolutionDetail, @@ -250,6 +248,7 @@ func toFloatResolutionDetail(res openfeature.InterfaceResolutionDetail, ProviderResolutionDetail: res.ProviderResolutionDetail, } } + return openfeature.FloatResolutionDetail{ Value: defaultValue, ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ @@ -258,6 +257,7 @@ func toFloatResolutionDetail(res openfeature.InterfaceResolutionDetail, }, } } + return openfeature.FloatResolutionDetail{ Value: defaultValue, ProviderResolutionDetail: res.ProviderResolutionDetail, @@ -274,6 +274,7 @@ func toIntResolutionDetail(res openfeature.InterfaceResolutionDetail, ProviderResolutionDetail: res.ProviderResolutionDetail, } } + return openfeature.IntResolutionDetail{ Value: defaultValue, ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ @@ -282,6 +283,7 @@ func toIntResolutionDetail(res openfeature.InterfaceResolutionDetail, }, } } + return openfeature.IntResolutionDetail{ Value: defaultValue, ProviderResolutionDetail: res.ProviderResolutionDetail, diff --git a/pkg/provider/utils_test.go b/pkg/provider/utils_test.go new file mode 100644 index 0000000..a9d9543 --- /dev/null +++ b/pkg/provider/utils_test.go @@ -0,0 +1,566 @@ +package provider + +import ( + "encoding/json" + "errors" + "github.com/open-feature/go-sdk/pkg/openfeature" + "github.com/stretchr/testify/assert" + "reflect" + "testing" +) + +func TestSpitFlagString(t *testing.T) { + t.Run("FlagWithValue", func(t *testing.T) { + val1, val2 := splitFlagString("name.value") + assert.Equal(t, "name", val1) + assert.Equal(t, "value", val2) + }) + + t.Run("FlagWithoutSecondPart", func(t *testing.T) { + val1, val2 := splitFlagString("novalue") + assert.Equal(t, "novalue", val1) + assert.Equal(t, "", val2) + }) + + t.Run("FlagWithMultipleDots", func(t *testing.T) { + val1, val2 := splitFlagString("double.dot.value") + assert.Equal(t, "double", val1) + assert.Equal(t, "dot.value", val2) + }) +} + +func TestExtractPropertyValue(t *testing.T) { + t.Run("PathFromMap", func(t *testing.T) { + values := map[string]interface{}{ + "child": map[string]interface{}{ + "key": "value", + }, + "key": "no-value", + } + + got, err := extractPropertyValue("child.key", values) + assert.NoError(t, err) + assert.Equal(t, "value", got) + }) + + t.Run("DirectPathFromMap", func(t *testing.T) { + values := map[string]interface{}{ + "key": "direct-value", + } + + got, err := extractPropertyValue("key", values) + assert.NoError(t, err) + assert.Equal(t, "direct-value", got) + }) + + t.Run("PathNotFound", func(t *testing.T) { + values := map[string]interface{}{ + "valid": map[string]interface{}{ + "path": "value", + }, + } + + got, err := extractPropertyValue("invalid.path", values) + assert.Error(t, err) + assert.Equal(t, false, got) + }) +} + +func TestGetTypeForPath(t *testing.T) { + t.Run("EmptyPath", func(t *testing.T) { + schema := map[string]interface{}{ + "key": "value", + } + + got, err := getTypeForPath(schema, "") + assert.NoError(t, err) + assert.Equal(t, reflect.Map, got) + }) + + t.Run("BoolSchema", func(t *testing.T) { + schema := map[string]interface{}{ + "key": map[string]interface{}{ + "boolSchema": true, + }, + } + + got, err := getTypeForPath(schema, "key") + assert.NoError(t, err) + assert.Equal(t, reflect.Bool, got) + }) + + t.Run("StringSchema", func(t *testing.T) { + schema := map[string]interface{}{ + "key": map[string]interface{}{ + "stringSchema": "value", + }, + } + + got, err := getTypeForPath(schema, "key") + assert.NoError(t, err) + assert.Equal(t, reflect.String, got) + }) + + t.Run("IntSchema", func(t *testing.T) { + schema := map[string]interface{}{ + "key": map[string]interface{}{ + "intSchema": 123, + }, + } + + got, err := getTypeForPath(schema, "key") + assert.NoError(t, err) + assert.Equal(t, reflect.Int64, got) + }) + + t.Run("FloatSchema", func(t *testing.T) { + schema := map[string]interface{}{ + "key": map[string]interface{}{ + "doubleSchema": 123.456, + }, + } + + got, err := getTypeForPath(schema, "key") + assert.NoError(t, err) + assert.Equal(t, reflect.Float64, got) + }) + + t.Run("MapSchema", func(t *testing.T) { + schema := map[string]interface{}{ + "key": map[string]interface{}{ + "structSchema": map[string]interface{}{ + "schema": map[string]interface{}{ + "nested": map[string]interface{}{ + "structSchema": map[string]interface{}{}, + }, + }, + }, + }, + } + + got, err := getTypeForPath(schema, "key.nested") + assert.NoError(t, err) + assert.Equal(t, reflect.Map, got) + }) + + t.Run("PropertyNotFound", func(t *testing.T) { + schema := map[string]interface{}{ + "valid": map[string]interface{}{ + "boolSchema": true, + }, + } + + _, err := getTypeForPath(schema, "invalid") + assert.Error(t, err) + }) +} + +func TestProcessResolveError(t *testing.T) { + defaultValue := "default" + + t.Run("FlagNotFoundError", func(t *testing.T) { + res := processResolveError(errFlagNotFound, defaultValue) + assert.Equal(t, defaultValue, res.Value) + assert.IsType(t, openfeature.ResolutionError{}, res.ProviderResolutionDetail.ResolutionError) + assert.Equal(t, openfeature.ErrorReason, res.ProviderResolutionDetail.Reason) + }) + + t.Run("GeneralError", func(t *testing.T) { + err := errors.New("different error") + res := processResolveError(err, defaultValue) + assert.Equal(t, defaultValue, res.Value) + assert.IsType(t, openfeature.ResolutionError{}, res.ProviderResolutionDetail.ResolutionError) + assert.Equal(t, openfeature.ErrorReason, res.ProviderResolutionDetail.Reason) + }) +} + +func TestProcessResolvedFlag(t *testing.T) { + t.Run("EmptyValue", func(t *testing.T) { + defaultValue := "default" + rf := resolvedFlag{ + Value: map[string]interface{}{}, + FlagSchema: flagSchema{Schema: map[string]interface{}{}}, + } + + expected := openfeature.InterfaceResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.DefaultReason, + }, + } + + assert.Equal(t, expected, processResolvedFlag(rf, defaultValue, reflect.String, "")) + }) + + t.Run("TypeMismatchError", func(t *testing.T) { + defaultValue := "default" + rf := resolvedFlag{ + Value: map[string]interface{}{"key": "value"}, + FlagSchema: flagSchema{Schema: map[string]interface{}{"key": "wrongType"}}, + } + + expected := openfeature.InterfaceResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.NewTypeMismatchResolutionError( + "schema for property key does not match the expected type"), + Reason: openfeature.ErrorReason, + }, + } + + assert.Equal(t, expected, processResolvedFlag(rf, defaultValue, reflect.String, "key")) + }) + + t.Run("ExtractValueError", func(t *testing.T) { + defaultValue := "default" + rf := resolvedFlag{ + Value: map[string]interface{}{"key": "value"}, + FlagSchema: flagSchema{Schema: map[string]interface{}{"key": "value"}}, + } + + expected := typeMismatchError(defaultValue) + expected.ProviderResolutionDetail.ResolutionError = + openfeature.NewTypeMismatchResolutionError("schema for property key.missing does not match the expected type") + + assert.Equal(t, expected, processResolvedFlag(rf, defaultValue, reflect.String, "key.missing")) + }) + + t.Run("Success", func(t *testing.T) { + defaultValue := "default" + rf := resolvedFlag{ + Value: map[string]interface{}{"key": "value"}, + FlagSchema: flagSchema{Schema: map[string]interface{}{"key": map[string]interface{}{"stringSchema": "value"}}}, + } + + expected := openfeature.InterfaceResolutionDetail{ + Value: "value", // Success case excludes default value + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.TargetingMatchReason, + }, + } + assert.Equal(t, expected, processResolvedFlag(rf, defaultValue, reflect.String, "key")) + }) +} + +func TestReplaceNumbers(t *testing.T) { + t.Run("SuccessfulFlowFloat64", func(t *testing.T) { + schema := map[string]interface{}{ + "key": map[string]interface{}{ + "doubleSchema": 123.45, + }, + } + input := map[string]interface{}{"key": json.Number("123.45")} + expected := map[string]interface{}{"key": float64(123.45)} + updatedMap, err := replaceNumbers("", input, schema) + + assert.NoError(t, err) + assert.Equal(t, expected, updatedMap) + }) + + t.Run("SuccessfulFlowInt64", func(t *testing.T) { + schema := map[string]interface{}{ + "key": map[string]interface{}{ + "intSchema": 123, + }, + } + input := map[string]interface{}{"key": json.Number("123")} + expected := map[string]interface{}{"key": int64(123)} + updatedMap, err := replaceNumbers("", input, schema) + + assert.NoError(t, err) + assert.Equal(t, expected, updatedMap) + }) + + t.Run("SuccessfulFlowMap", func(t *testing.T) { + schema := map[string]interface{}{ + "key": map[string]interface{}{ + "structSchema": map[string]interface{}{ + "schema": map[string]interface{}{ + "subKey": map[string]interface{}{ + "doubleSchema": 123.45, + }, + }, + }, + }, + } + input := map[string]interface{}{ + "key": map[string]interface{}{ + "subKey": json.Number("123.45"), + }, + } + expected := map[string]interface{}{ + "key": map[string]interface{}{ + "subKey": float64(123.45), + }, + } + updatedMap, err := replaceNumbers("", input, schema) + + assert.NoError(t, err) + assert.Equal(t, expected, updatedMap) + }) +} + +func TestTypeMismatchError(t *testing.T) { + t.Run("WithStringValue", func(t *testing.T) { + defaultValue := "my default value" + expected := openfeature.InterfaceResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.NewTypeMismatchResolutionError( + "Unable to extract property value from resolve response"), + Reason: openfeature.ErrorReason, + }, + } + + assert.Equal(t, expected, typeMismatchError(defaultValue)) + }) + + t.Run("WithIntValue", func(t *testing.T) { + defaultValue := 123 + expected := openfeature.InterfaceResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.NewTypeMismatchResolutionError( + "Unable to extract property value from resolve response"), + Reason: openfeature.ErrorReason, + }, + } + + assert.Equal(t, expected, typeMismatchError(defaultValue)) + }) +} + +func TestToBoolResolutionDetail(t *testing.T) { + defaultValue := false + + t.Run("WhenValueIsBool", func(t *testing.T) { + res := openfeature.InterfaceResolutionDetail{ + Value: true, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.TargetingMatchReason, + }, + } + + expected := openfeature.BoolResolutionDetail{ + Value: true, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.TargetingMatchReason, + }, + } + + assert.Equal(t, expected, toBoolResolutionDetail(res, defaultValue)) + }) + + t.Run("WhenValueIsNotBool", func(t *testing.T) { + res := openfeature.InterfaceResolutionDetail{ + Value: "not a bool", + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.TargetingMatchReason, + }, + } + + expected := openfeature.BoolResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.NewTypeMismatchResolutionError("Unable to convert response property to boolean"), + Reason: openfeature.ErrorReason, + }, + } + + assert.Equal(t, expected, toBoolResolutionDetail(res, defaultValue)) + }) + + t.Run("WhenReasonIsNotTargetingMatchReason", func(t *testing.T) { + res := openfeature.InterfaceResolutionDetail{ + Value: true, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.ErrorReason, + }, + } + + expected := openfeature.BoolResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.ErrorReason, + }, + } + + assert.Equal(t, expected, toBoolResolutionDetail(res, defaultValue)) + }) +} + +func TestToStringResolutionDetail(t *testing.T) { + defaultValue := "default" + + t.Run("WhenValueIsString", func(t *testing.T) { + res := openfeature.InterfaceResolutionDetail{ + Value: "hello", + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.TargetingMatchReason, + }, + } + + expected := openfeature.StringResolutionDetail{ + Value: "hello", + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.TargetingMatchReason, + }, + } + + assert.Equal(t, expected, toStringResolutionDetail(res, defaultValue)) + }) + + t.Run("WhenValueIsNotString", func(t *testing.T) { + res := openfeature.InterfaceResolutionDetail{ + Value: 123, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.TargetingMatchReason, + }, + } + + expected := openfeature.StringResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.NewTypeMismatchResolutionError("Unable to convert response property to boolean"), + Reason: openfeature.ErrorReason, + }, + } + + assert.Equal(t, expected, toStringResolutionDetail(res, defaultValue)) + }) + + t.Run("WhenReasonIsNotTargetingMatchReason", func(t *testing.T) { + res := openfeature.InterfaceResolutionDetail{ + Value: "hello", + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.ErrorReason, + }, + } + + expected := openfeature.StringResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.ErrorReason, + }, + } + + assert.Equal(t, expected, toStringResolutionDetail(res, defaultValue)) + }) +} + +func TestToFloatResolutionDetail(t *testing.T) { + defaultValue := 42.0 + + t.Run("WhenValueIsFloat", func(t *testing.T) { + res := openfeature.InterfaceResolutionDetail{ + Value: 24.0, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.TargetingMatchReason, + }, + } + + expected := openfeature.FloatResolutionDetail{ + Value: 24.0, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.TargetingMatchReason, + }, + } + + assert.Equal(t, expected, toFloatResolutionDetail(res, defaultValue)) + }) + + t.Run("WhenValueIsNotFloat", func(t *testing.T) { + res := openfeature.InterfaceResolutionDetail{ + Value: "not a float", + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.TargetingMatchReason, + }, + } + + expected := openfeature.FloatResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.NewTypeMismatchResolutionError("Unable to convert response property to float"), + Reason: openfeature.ErrorReason, + }, + } + + assert.Equal(t, expected, toFloatResolutionDetail(res, defaultValue)) + }) + + t.Run("WhenReasonIsNotTargetingMatchReason", func(t *testing.T) { + res := openfeature.InterfaceResolutionDetail{ + Value: 24.0, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.ErrorReason, + }, + } + + expected := openfeature.FloatResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.ErrorReason, + }, + } + + assert.Equal(t, expected, toFloatResolutionDetail(res, defaultValue)) + }) +} + +func TestToIntResolutionDetail(t *testing.T) { + defaultValue := int64(123) + t.Run("WhenValueIsInt", func(t *testing.T) { + res := openfeature.InterfaceResolutionDetail{ + Value: int64(456), + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.TargetingMatchReason, + }, + } + + expected := openfeature.IntResolutionDetail{ + Value: int64(456), + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.TargetingMatchReason, + }, + } + + assert.Equal(t, expected, toIntResolutionDetail(res, defaultValue)) + }) + + t.Run("WhenValueIsNotInt", func(t *testing.T) { + res := openfeature.InterfaceResolutionDetail{ + Value: "not an int", + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.TargetingMatchReason, + }, + } + + expected := openfeature.IntResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + ResolutionError: openfeature.NewTypeMismatchResolutionError("Unable to convert response property to int"), + Reason: openfeature.ErrorReason, + }, + } + + assert.Equal(t, expected, toIntResolutionDetail(res, defaultValue)) + }) + + t.Run("WhenReasonIsNotTargetingMatchReason", func(t *testing.T) { + res := openfeature.InterfaceResolutionDetail{ + Value: int64(456), + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.ErrorReason, + }, + } + + expected := openfeature.IntResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ + Reason: openfeature.ErrorReason, + }, + } + + assert.Equal(t, expected, toIntResolutionDetail(res, defaultValue)) + }) +}