From 52936810d34fb7a1b287052b75d88eeda12486c8 Mon Sep 17 00:00:00 2001 From: vahid torkaman Date: Fri, 17 May 2024 12:23:18 +0200 Subject: [PATCH] flag api total confidence --- confidence/.fleet/run.json | 5 + confidence/confidence.go | 170 +++++++++ confidence/confidence_internal_test.go | 260 +++++++++++++ confidence/go.mod | 11 + confidence/go.sum | 11 + .../http_resolve_client.go | 20 +- {pkg/provider => confidence}/models.go | 63 ++- confidence/resolution_details.go | 84 ++++ confidence/utils.go | 360 ++++++++++++++++++ {pkg/provider => confidence}/utils_test.go | 262 +++++++------ demo/GoDemoApp.go | 9 +- demo/go.mod | 9 +- demo/go.sum | 8 +- go.sum | 20 - pkg/provider/provider.go | 110 ------ pkg/provider/utils.go | 291 -------------- provider/.gitignore | 8 + go.mod => provider/go.mod | 10 +- provider/go.sum | 32 ++ provider/google-java-format.xml | 6 + provider/misc.xml | 6 + provider/modules.xml | 8 + provider/provider.go | 133 +++++++ provider/provider.iml | 10 + .../provider_internal_test.go | 149 ++++---- provider/vcs.xml | 6 + 26 files changed, 1417 insertions(+), 644 deletions(-) create mode 100644 confidence/.fleet/run.json create mode 100644 confidence/confidence.go create mode 100644 confidence/confidence_internal_test.go create mode 100644 confidence/go.mod create mode 100644 confidence/go.sum rename {pkg/provider => confidence}/http_resolve_client.go (71%) rename {pkg/provider => confidence}/models.go (53%) create mode 100644 confidence/resolution_details.go create mode 100644 confidence/utils.go rename {pkg/provider => confidence}/utils_test.go (59%) delete mode 100644 go.sum delete mode 100755 pkg/provider/provider.go delete mode 100644 pkg/provider/utils.go create mode 100644 provider/.gitignore rename go.mod => provider/go.mod (51%) create mode 100644 provider/go.sum create mode 100644 provider/google-java-format.xml create mode 100644 provider/misc.xml create mode 100644 provider/modules.xml create mode 100644 provider/provider.go create mode 100644 provider/provider.iml rename {pkg/provider => provider}/provider_internal_test.go (77%) create mode 100644 provider/vcs.xml diff --git a/confidence/.fleet/run.json b/confidence/.fleet/run.json new file mode 100644 index 0000000..12303d3 --- /dev/null +++ b/confidence/.fleet/run.json @@ -0,0 +1,5 @@ +{ + "configurations": [ + + ] +} \ No newline at end of file diff --git a/confidence/confidence.go b/confidence/confidence.go new file mode 100644 index 0000000..7c39d71 --- /dev/null +++ b/confidence/confidence.go @@ -0,0 +1,170 @@ +package confidence + +import ( + "context" + "fmt" + "net/http" + "reflect" + "strings" +) + +type FlagResolver interface { + resolveFlag(ctx context.Context, flag string, defaultValue interface{}, + evalCtx map[string]interface{}, expectedKind reflect.Kind) InterfaceResolutionDetail +} + +type ContextProvider interface { + GetContext() map[string]interface{} +} + +var ( + SDK_ID = "SDK_ID_GO_PROVIDER" + SDK_VERSION = "0.1.8" // x-release-please-version +) + +type Confidence struct { + parent ContextProvider + contextMap map[string]interface{} + Config APIConfig + ResolveClient ResolveClient +} + +func (e Confidence) GetContext() map[string]interface{} { + currentMap := e.contextMap + parentMap := make(map[string]interface{}) + if e.parent != nil { + parentMap = e.parent.GetContext() + } + for key, value := range parentMap { + currentMap[key] = value + } + return currentMap +} + +type ConfidenceBuilder struct { + confidence Confidence +} + +func (e ConfidenceBuilder) SetAPIConfig(config APIConfig) ConfidenceBuilder { + e.confidence.Config = config + return e +} + +func (e ConfidenceBuilder) SetResolveClient(client ResolveClient) ConfidenceBuilder { + e.confidence.ResolveClient = client + return e +} + +func (e ConfidenceBuilder) Build() Confidence { + if e.confidence.ResolveClient == nil { + e.confidence.ResolveClient = HttpResolveClient{Client: &http.Client{}, Config: e.confidence.Config} + } + e.confidence.contextMap = make(map[string]interface{}) + return e.confidence +} + +func NewConfidenceBuilder() ConfidenceBuilder { + return ConfidenceBuilder{ + confidence: Confidence{}, + } +} + +func (e Confidence) putContext(key string, value interface{}) { + e.contextMap[key] = value +} + +func (e Confidence) WithContext(context map[string]interface{}) Confidence { + return Confidence{ + parent: &e, + contextMap: context, + Config: e.Config, + ResolveClient: e.ResolveClient, + } +} + +func (e Confidence) GetBoolFlag(ctx context.Context, flag string, defaultValue bool) BoolResolutionDetail { + resp := e.ResolveFlag(ctx, flag, defaultValue, reflect.Bool) + return ToBoolResolutionDetail(resp, defaultValue) +} + +func (e Confidence) GetBoolValue(ctx context.Context, flag string, defaultValue bool) bool { + return e.GetBoolFlag(ctx, flag, defaultValue).Value +} + +func (e Confidence) GetIntFlag(ctx context.Context, flag string, defaultValue int64) IntResolutionDetail { + resp := e.ResolveFlag(ctx, flag, defaultValue, reflect.Int64) + return ToIntResolutionDetail(resp, defaultValue) +} + +func (e Confidence) GetIntValue(ctx context.Context, flag string, defaultValue int64) int64 { + return e.GetIntFlag(ctx, flag, defaultValue).Value +} + +func (e Confidence) GetDoubleFlag(ctx context.Context, flag string, defaultValue float64) FloatResolutionDetail { + resp := e.ResolveFlag(ctx, flag, defaultValue, reflect.Float64) + return ToFloatResolutionDetail(resp, defaultValue) +} + +func (e Confidence) GetDoubleValue(ctx context.Context, flag string, defaultValue float64) float64 { + return e.GetDoubleFlag(ctx, flag, defaultValue).Value +} + +func (e Confidence) GetStringFlag(ctx context.Context, flag string, defaultValue string) StringResolutionDetail { + resp := e.ResolveFlag(ctx, flag, defaultValue, reflect.String) + return ToStringResolutionDetail(resp, defaultValue) +} + +func (e Confidence) GetStringValue(ctx context.Context, flag string, defaultValue string) string { + return e.GetStringFlag(ctx, flag, defaultValue).Value +} + +func (e Confidence) GetObjectFlag(ctx context.Context, flag string, defaultValue string) InterfaceResolutionDetail { + resp := e.ResolveFlag(ctx, flag, defaultValue, reflect.Map) + return resp +} + +func (e Confidence) GetObjectValue(ctx context.Context, flag string, defaultValue string) interface{} { + return e.GetObjectFlag(ctx, flag, defaultValue).Value +} + +func (e Confidence) ResolveFlag(ctx context.Context, flag string, defaultValue interface{}, expectedKind reflect.Kind) InterfaceResolutionDetail { + flagName, propertyPath := splitFlagString(flag) + + requestFlagName := fmt.Sprintf("flags/%s", flagName) + resp, err := e.ResolveClient.SendResolveRequest(ctx, + ResolveRequest{ClientSecret: e.Config.APIKey, + Flags: []string{requestFlagName}, Apply: true, EvaluationContext: e.contextMap, + Sdk: sdk{Id: SDK_ID, Version: SDK_VERSION}}) + + if err != nil { + return processResolveError(err, defaultValue) + } + if len(resp.ResolvedFlags) == 0 { + return InterfaceResolutionDetail{ + Value: defaultValue, + ResolutionDetail: ResolutionDetail{ + Variant: "", + Reason: ErrorReason, + ErrorCode: FlagNotFoundCode, + ErrorMessage: "Flag not found", + FlagMetadata: nil, + }, + } + } + + resolvedFlag := resp.ResolvedFlags[0] + if resolvedFlag.Flag != requestFlagName { + return InterfaceResolutionDetail{ + Value: defaultValue, + ResolutionDetail: ResolutionDetail{ + Variant: "", + Reason: ErrorReason, + ErrorCode: FlagNotFoundCode, + ErrorMessage: fmt.Sprintf("unexpected flag '%s' from remote", strings.TrimPrefix(resolvedFlag.Flag, "flags/")), + FlagMetadata: nil, + }, + } + } + + return processResolvedFlag(resolvedFlag, defaultValue, expectedKind, propertyPath) +} diff --git a/confidence/confidence_internal_test.go b/confidence/confidence_internal_test.go new file mode 100644 index 0000000..9b71b0f --- /dev/null +++ b/confidence/confidence_internal_test.go @@ -0,0 +1,260 @@ +package confidence + +import ( + "bytes" + "context" + "encoding/json" + "reflect" + "fmt" + "testing" + "github.com/stretchr/testify/assert" +) + +type MockResolveClient struct { + MockedResponse ResolveResponse + MockedError error + TestingT *testing.T +} + +func (r MockResolveClient) SendResolveRequest(_ context.Context, + request ResolveRequest) (ResolveResponse, error) { + assert.Equal(r.TestingT, "user1", request.EvaluationContext["targeting_key"]) + return r.MockedResponse, r.MockedError +} + +func TestResolveBoolValue(t *testing.T) { + client := client(t, templateResponse(), nil) + client.putContext("targeting_key", "user1") + evalDetails := client.GetBoolFlag(context.Background(), "test-flag.boolean-key", false) + + assert.Equal(t, true, evalDetails.Value) + assert.Equal(t, TargetingMatchReason, evalDetails.Reason) + assert.Equal(t, "flags/test-flag/variants/treatment", evalDetails.Variant) +} + +func TestResolveIntValue(t *testing.T) { + client := client(t, templateResponse(), nil) + client.putContext("targeting_key", "user1") + + evalDetails := client.GetIntFlag(context.Background(), "test-flag.integer-key", 99) + + assert.Equal(t, int64(40), evalDetails.Value) +} + +func TestResolveDoubleValue(t *testing.T) { + client := client(t, templateResponse(), nil) + client.putContext("targeting_key", "user1") + + evalDetails := client.GetDoubleFlag(context.Background(), "test-flag.double-key", 99.99) + + assert.Equal(t, 20.203, evalDetails.Value) +} + +func TestResolveStringValue(t *testing.T) { + client := client(t, templateResponse(), nil) + client.putContext("targeting_key", "user1") + + evalDetails := client.GetStringFlag(context.Background(), "test-flag.string-key", "default") + + assert.Equal(t, "treatment", evalDetails.Value) +} + +func TestResolveObjectValue(t *testing.T) { + client := client(t, templateResponse(), nil) + client.putContext("targeting_key", "user1") + + evalDetails := client.GetObjectFlag(context.Background(), "test-flag.struct-key", "default") + _, ok := evalDetails.Value.(map[string]interface{}) + assert.True(t, ok) +} + +func TestResolveNestedValue(t *testing.T) { + client := client(t, templateResponse(), nil) + client.putContext("targeting_key", "user1") + + evalDetails := client.GetBoolFlag(context.Background(), "test-flag.struct-key.boolean-key", true) + assert.Equal(t, false, evalDetails.Value) +} + +func TestResolveDoubleNestedValue(t *testing.T) { + client := client(t, templateResponse(), nil) + client.putContext("targeting_key", "user1") + + evalDetails := client.GetBoolFlag(context.Background(), "test-flag.struct-key.nested-struct-key.nested-boolean-key", true) + assert.Equal(t, false, evalDetails.Value) +} + +func TestResolveWholeFlagAsObject(t *testing.T) { + client := client(t, templateResponse(), nil) + client.putContext("targeting_key", "user1") + + evalDetails := client.GetObjectFlag(context.Background(), "test-flag", "default") + _, ok := evalDetails.Value.(map[string]interface{}) + assert.True(t, ok) +} + +func TestResolveWholeFlagAsObjectWithInts(t *testing.T) { + client := client(t, templateResponse(), nil) + client.putContext("targeting_key", "user1") + + evalDetails := client.GetObjectFlag(context.Background(), "test-flag", "default") + + value, _ := evalDetails.Value.(map[string]interface{}) + rootIntValue := value["integer-key"] + + assert.Equal(t, reflect.Int64, reflect.ValueOf(rootIntValue).Kind()) + assert.Equal(t, int64(40), rootIntValue) + + nestedIntValue := value["struct-key"].(map[string]interface{})["integer-key"] + + assert.Equal(t, reflect.Int64, reflect.ValueOf(nestedIntValue).Kind()) + assert.Equal(t, int64(23), nestedIntValue) +} + +func TestResolveWithWrongType(t *testing.T) { + client := client(t, templateResponse(), nil) + client.putContext("targeting_key", "user1") + + evalDetails := client.GetBoolFlag(context.Background(), "test-flag.integer-key", false) + + assert.Equal(t, false, evalDetails.Value) + assert.Equal(t, ErrorReason, evalDetails.Reason) + assert.Equal(t, TypeMismatchCode, evalDetails.ErrorCode) +} + +func TestResolveWithUnexpectedFlag(t *testing.T) { + client := client(t, templateResponseWithFlagName("wrong-flag"), nil) + client.putContext("targeting_key", "user1") + + evalDetails := client.GetBoolFlag(context.Background(), "test-flag.boolean-key", true) + + assert.Equal(t, true, evalDetails.Value) + assert.Equal(t, ErrorReason, evalDetails.Reason) + assert.Equal(t, FlagNotFoundCode, evalDetails.ErrorCode) + assert.Equal(t, "unexpected flag 'wrong-flag' from remote", evalDetails.ErrorMessage) +} + +func TestResolveWithNonExistingFlag(t *testing.T) { + client := client(t, emptyResponse(), nil) + client.putContext("targeting_key", "user1") + + evalDetails := client.GetBoolFlag(context.Background(), "test-flag.boolean-key", true) + + assert.Equal(t, true, evalDetails.Value) + assert.Equal(t, ErrorReason, evalDetails.Reason) + assert.Equal(t, FlagNotFoundCode, evalDetails.ErrorCode) + assert.Equal(t, "Flag not found", evalDetails.ErrorMessage) +} + +func client(t *testing.T, response ResolveResponse, errorToReturn error) *Confidence { + confidence := newConfidence("apiKey", MockResolveClient{MockedResponse: response, MockedError: errorToReturn, TestingT: t}) + return confidence +} + +func templateResponse() ResolveResponse { + return templateResponseWithFlagName("test-flag") +} + +func templateResponseWithFlagName(flagName string) ResolveResponse { + templateResolveResponse := fmt.Sprintf(` +{ + "resolvedFlags": [ + { + "flag": "flags/%[1]s", + "variant": "flags/%[1]s/variants/treatment", + "value": { + "struct-key": { + "boolean-key": false, + "string-key": "treatment-struct", + "double-key": 123.23, + "integer-key": 23, + "nested-struct-key": { + "nested-boolean-key": false + } + }, + "boolean-key": true, + "string-key": "treatment", + "double-key": 20.203, + "integer-key": 40 + }, + "flagSchema": { + "schema": { + "struct-key": { + "structSchema": { + "schema": { + "boolean-key": { + "boolSchema": {} + }, + "string-key": { + "stringSchema": {} + }, + "double-key": { + "doubleSchema": {} + }, + "integer-key": { + "intSchema": {} + }, + "nested-struct-key": { + "structSchema": { + "schema": { + "nested-boolean-key": { + "boolSchema": {} + } + } + } + } + } + } + }, + "boolean-key": { + "boolSchema": {} + }, + "string-key": { + "stringSchema": {} + }, + "double-key": { + "doubleSchema": {} + }, + "integer-key": { + "intSchema": {} + } + } + }, + "reason": "RESOLVE_REASON_MATCH" + }], + "resolveToken": "" +} +`, flagName) + var result ResolveResponse + decoder := json.NewDecoder(bytes.NewBuffer([]byte(templateResolveResponse))) + decoder.UseNumber() + _ = decoder.Decode(&result) + return result +} + +func emptyResponse() ResolveResponse { + templateResolveResponse := + ` +{ + "resolvedFlags": [], + "resolveToken": "" +} +` + var result ResolveResponse + decoder := json.NewDecoder(bytes.NewBuffer([]byte(templateResolveResponse))) + decoder.UseNumber() + _ = decoder.Decode(&result) + return result +} + +func newConfidence(apiKey string, client ResolveClient) *Confidence { + config := APIConfig{ + APIKey: apiKey, + Region: APIRegionGlobal, + } + return &Confidence{ + Config: config, + ResolveClient: client, + contextMap: make(map[string]interface{}), + } +} diff --git a/confidence/go.mod b/confidence/go.mod new file mode 100644 index 0000000..463dc1c --- /dev/null +++ b/confidence/go.mod @@ -0,0 +1,11 @@ +module github.com/spotify/confidence-openfeature-provider-go/confidence + +go 1.22.2 + +require github.com/stretchr/testify v1.9.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/confidence/go.sum b/confidence/go.sum new file mode 100644 index 0000000..266857e --- /dev/null +++ b/confidence/go.sum @@ -0,0 +1,11 @@ +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/provider/http_resolve_client.go b/confidence/http_resolve_client.go similarity index 71% rename from pkg/provider/http_resolve_client.go rename to confidence/http_resolve_client.go index c283cfe..45a36bd 100644 --- a/pkg/provider/http_resolve_client.go +++ b/confidence/http_resolve_client.go @@ -1,4 +1,4 @@ -package provider +package confidence import ( "bytes" @@ -9,7 +9,7 @@ import ( "net/http" ) -type httpResolveClient struct { +type HttpResolveClient struct { Client *http.Client Config APIConfig } @@ -25,37 +25,37 @@ func parseErrorMessage(body io.ReadCloser) string { return resolveError.Message } -func (client httpResolveClient) sendResolveRequest(ctx context.Context, - request resolveRequest) (resolveResponse, error) { +func (client HttpResolveClient) SendResolveRequest(ctx context.Context, + request ResolveRequest) (ResolveResponse, error) { jsonRequest, err := json.Marshal(request) if err != nil { - return resolveResponse{}, fmt.Errorf("error when serializing request to the resolver service: %w", err) + return ResolveResponse{}, fmt.Errorf("error when serializing request to the resolver service: %w", err) } payload := bytes.NewBuffer(jsonRequest) req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("%s/flags:resolve", client.Config.Region.apiURL()), payload) if err != nil { - return resolveResponse{}, err + return ResolveResponse{}, err } resp, err := client.Client.Do(req) if err != nil { - return resolveResponse{}, fmt.Errorf("error when calling the resolver service: %w", err) + return ResolveResponse{}, fmt.Errorf("error when calling the resolver service: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return resolveResponse{}, + return ResolveResponse{}, fmt.Errorf("got '%s' error from the resolver service: %s", resp.Status, parseErrorMessage(resp.Body)) } - var result resolveResponse + var result ResolveResponse decoder := json.NewDecoder(resp.Body) decoder.UseNumber() err = decoder.Decode(&result) if err != nil { - return resolveResponse{}, fmt.Errorf("error when deserializing response from the resolver service: %w", err) + return ResolveResponse{}, fmt.Errorf("error when deserializing response from the resolver service: %w", err) } return result, nil } diff --git a/pkg/provider/models.go b/confidence/models.go similarity index 53% rename from pkg/provider/models.go rename to confidence/models.go index 912704e..6cb4d25 100644 --- a/pkg/provider/models.go +++ b/confidence/models.go @@ -1,4 +1,4 @@ -package provider +package confidence import ( "context" @@ -7,6 +7,57 @@ import ( type APIRegion int64 +func NewFlagNotFoundResolutionError(msg string) ResolutionError { + return ResolutionError{ + code: FlagNotFoundCode, + message: msg, + } +} + +func NewParseErrorResolutionError(msg string) ResolutionError { + return ResolutionError{ + code: ParseErrorCode, + message: msg, + } +} + +// NewTypeMismatchResolutionError constructs a resolution error with code TYPE_MISMATCH +// +// Explanation - The type of the flag value does not match the expected type. +func NewTypeMismatchResolutionError(msg string) ResolutionError { + return ResolutionError{ + code: TypeMismatchCode, + message: msg, + } +} + +// NewTargetingKeyMissingResolutionError constructs a resolution error with code TARGETING_KEY_MISSING +// +// Explanation - The provider requires a targeting key and one was not provided in the evaluation context. +func NewTargetingKeyMissingResolutionError(msg string) ResolutionError { + return ResolutionError{ + code: TargetingKeyMissingCode, + message: msg, + } +} + +func NewInvalidContextResolutionError(msg string) ResolutionError { + return ResolutionError{ + code: InvalidContextCode, + message: msg, + } +} + +// NewGeneralResolutionError constructs a resolution error with code GENERAL +// +// Explanation - The error was for a reason not enumerated above. +func NewGeneralResolutionError(msg string) ResolutionError { + return ResolutionError{ + code: GeneralCode, + message: msg, + } +} + type APIConfig struct { APIKey string Region APIRegion @@ -42,7 +93,7 @@ func (r APIRegion) apiURL() string { return "" } -func (c APIConfig) validate() error { +func (c APIConfig) Validate() error { if c.APIKey == "" { return errors.New("api key needs to be set") } @@ -52,13 +103,13 @@ func (c APIConfig) validate() error { return nil } -type resolveClient interface { - sendResolveRequest(ctx context.Context, request resolveRequest) (resolveResponse, error) +type ResolveClient interface { + SendResolveRequest(ctx context.Context, request ResolveRequest) (ResolveResponse, error) } var errFlagNotFound = errors.New("flag not found") -type resolveRequest struct { +type ResolveRequest struct { ClientSecret string `json:"client_secret"` Apply bool `json:"apply"` EvaluationContext map[string]interface{} `json:"evaluation_context"` @@ -71,7 +122,7 @@ type sdk struct { Version string `json:"version"` } -type resolveResponse struct { +type ResolveResponse struct { ResolvedFlags []resolvedFlag `json:"resolvedFlags"` ResolveToken string `json:"resolveToken"` } diff --git a/confidence/resolution_details.go b/confidence/resolution_details.go new file mode 100644 index 0000000..244294a --- /dev/null +++ b/confidence/resolution_details.go @@ -0,0 +1,84 @@ +package confidence + +import ( + "fmt" +) + +type ResolutionError struct { + // fields are unexported, this means providers are forced to create structs of this type using one of the constructors below. + // this effectively emulates an enum + code ErrorCode + message string +} + +func (r ResolutionError) Error() string { + return fmt.Sprintf("%s: %s", r.code, r.message) +} + +type ResolutionDetail struct { + Variant string + Reason Reason + ErrorCode ErrorCode + ErrorMessage string + FlagMetadata FlagMetadata +} + +// FlagMetadata is a structure which supports definition of arbitrary properties, with keys of type string, and values +// of type boolean, string, int64 or float64. This structure is populated by a provider for use by an Application +// Author (via the Evaluation API) or an Application Integrator (via hooks). +type FlagMetadata map[string]interface{} +type Reason string + +type ErrorCode string + +const ( + // ProviderNotReadyCode - the value was resolved before the provider was ready. + ProviderNotReadyCode ErrorCode = "PROVIDER_NOT_READY" + // FlagNotFoundCode - the flag could not be found. + FlagNotFoundCode ErrorCode = "FLAG_NOT_FOUND" + // ParseErrorCode - an error was encountered parsing data, such as a flag configuration. + ParseErrorCode ErrorCode = "PARSE_ERROR" + // TypeMismatchCode - the type of the flag value does not match the expected type. + TypeMismatchCode ErrorCode = "TYPE_MISMATCH" + // TargetingKeyMissingCode - the provider requires a targeting key and one was not provided in the evaluation context. + TargetingKeyMissingCode ErrorCode = "TARGETING_KEY_MISSING" + // InvalidContextCode - the evaluation context does not meet provider requirements. + InvalidContextCode ErrorCode = "INVALID_CONTEXT" + // GeneralCode - the error was for a reason not enumerated above. + GeneralCode ErrorCode = "GENERAL" +) + +// BoolResolutionDetail provides a resolution detail with boolean type +type BoolResolutionDetail struct { + Value bool + ResolutionDetail +} + +// StringResolutionDetail provides a resolution detail with string type +type StringResolutionDetail struct { + Value string + ResolutionDetail +} + +// FloatResolutionDetail provides a resolution detail with float64 type +type FloatResolutionDetail struct { + Value float64 + ResolutionDetail +} + +// IntResolutionDetail provides a resolution detail with int64 type +type IntResolutionDetail struct { + Value int64 + ResolutionDetail +} + +// InterfaceResolutionDetail provides a resolution detail with interface{} type +type InterfaceResolutionDetail struct { + Value interface{} + ResolutionDetail +} + +// Metadata provides provider name +type Metadata struct { + Name string +} diff --git a/confidence/utils.go b/confidence/utils.go new file mode 100644 index 0000000..0ec3b0d --- /dev/null +++ b/confidence/utils.go @@ -0,0 +1,360 @@ +package confidence + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" +) + +const ErrorReason Reason = "ERROR" +const TargetingMatchReason Reason = "TARGETING_MATCH" +const DefaultReason Reason = "DEFAULT" + +func splitFlagString(flag string) (string, string) { + splittedFlag := strings.SplitN(flag, ".", 2) + if len(splittedFlag) == 2 { + return splittedFlag[0], splittedFlag[1] + } + + return splittedFlag[0], "" +} + +func extractPropertyValue(path string, values map[string]interface{}) (interface{}, error) { + if path == "" { + return values, nil + } + + firstPartAndRest := strings.SplitN(path, ".", 2) + if len(firstPartAndRest) == 1 { + value := values[firstPartAndRest[0]] + return value, nil + } + + childMap, ok := values[firstPartAndRest[0]].(map[string]interface{}) + if ok { + return extractPropertyValue(firstPartAndRest[1], childMap) + } + + return false, fmt.Errorf("unable to find property in path %s", path) +} + +func getTypeForPath(schema map[string]interface{}, path string) (reflect.Kind, error) { + if path == "" { + return reflect.Map, nil + } + + 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") + } + + if _, isBool := value["boolSchema"]; isBool { + return reflect.Bool, nil + } else if _, isString := value["stringSchema"]; isString { + return reflect.String, nil + } else if _, isInt := value["intSchema"]; isInt { + return reflect.Int64, nil + } else if _, isFloat := value["doubleSchema"]; isFloat { + return reflect.Float64, nil + } else if _, isMap := value["structSchema"]; isMap { + return reflect.Map, nil + } + + return 0, fmt.Errorf("unable to find property type in schema %s", path) + } + + // If we are here, the property path contains multiple entries -> this must be a struct -> recurse down the tree. + childMap, ok := schema[firstPartAndRest[0]].(map[string]interface{}) + if !ok { + return 0, fmt.Errorf("unexpected error when parsing resolve response schema") + } + + if structMap, isStruct := childMap["structSchema"]; isStruct { + 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) +} + +func processResolveError(err error, defaultValue interface{}) InterfaceResolutionDetail { + switch { + case errors.Is(err, errFlagNotFound): + return InterfaceResolutionDetail{ + Value: defaultValue, + ResolutionDetail: ResolutionDetail{ + Variant: "", + Reason: ErrorReason, + ErrorCode: FlagNotFoundCode, + ErrorMessage: "error when resolving, flag not found", + FlagMetadata: nil, + }, + } + default: + return InterfaceResolutionDetail{ + Value: defaultValue, + ResolutionDetail: ResolutionDetail{ + Variant: "", + Reason: ErrorReason, + ErrorCode: GeneralCode, + ErrorMessage: "error when resolving, returning default value", + FlagMetadata: nil, + }} + } +} + +func processResolvedFlag(resolvedFlag resolvedFlag, defaultValue interface{}, + expectedKind reflect.Kind, propertyPath string) InterfaceResolutionDetail { + if len(resolvedFlag.Value) == 0 { + return InterfaceResolutionDetail{ + Value: defaultValue, + ResolutionDetail: ResolutionDetail{ + Reason: DefaultReason}} + } + + actualKind, schemaErr := getTypeForPath(resolvedFlag.FlagSchema.Schema, propertyPath) + if schemaErr != nil || actualKind != expectedKind { + err := NewTypeMismatchResolutionError( + fmt.Sprintf("schema for property %s does not match the expected type", + propertyPath)) + return InterfaceResolutionDetail{ + Value: defaultValue, + ResolutionDetail: ResolutionDetail{ + Variant: "", + Reason: ErrorReason, + ErrorCode: err.code, + ErrorMessage: err.message, + FlagMetadata: nil, + }} + } + + updatedMap, err := replaceNumbers("", resolvedFlag.Value, resolvedFlag.FlagSchema.Schema) + if err != nil { + return typeMismatchError(defaultValue) + } + + extractedValue, extractValueError := extractPropertyValue(propertyPath, updatedMap) + if extractValueError != nil { + return typeMismatchError(defaultValue) + } + + return InterfaceResolutionDetail{ + Value: extractedValue, + ResolutionDetail: ResolutionDetail{ + Reason: TargetingMatchReason, + Variant: resolvedFlag.Variant}} +} + +func replaceNumbers(basePath string, input map[string]interface{}, + schema map[string]interface{}) (map[string]interface{}, error) { + 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 + case reflect.Int64: + 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%s.", basePath, key), asMap, schema) + if err != nil { + return updatedMap, fmt.Errorf("unable to convert map") + } + + updatedMap[key] = childMap + default: + updatedMap[key] = value + } + } + + return updatedMap, nil +} + +func typeMismatchError(defaultValue interface{}) InterfaceResolutionDetail { + err := NewTypeMismatchResolutionError( + "Unable to extract property value from resolve response") + return InterfaceResolutionDetail{ + Value: defaultValue, + ResolutionDetail: ResolutionDetail{ + Variant: "", + Reason: ErrorReason, + ErrorCode: err.code, + ErrorMessage: err.message, + FlagMetadata: nil, + }} +} + +func ToBoolResolutionDetail(res InterfaceResolutionDetail, + defaultValue bool) BoolResolutionDetail { + if res.ResolutionDetail.Reason == TargetingMatchReason { + v, ok := res.Value.(bool) + if ok { + return BoolResolutionDetail{ + Value: v, + ResolutionDetail: res.ResolutionDetail, + } + } + + err := NewTypeMismatchResolutionError("Unable to convert response property to boolean") + + return BoolResolutionDetail{ + Value: defaultValue, + ResolutionDetail: ResolutionDetail{ + Variant: "", + Reason: ErrorReason, + ErrorCode: err.code, + ErrorMessage: err.message, + FlagMetadata: nil}} + } + + return BoolResolutionDetail{ + Value: defaultValue, + ResolutionDetail: res.ResolutionDetail, + } +} + +func ToStringResolutionDetail(res InterfaceResolutionDetail, + defaultValue string) StringResolutionDetail { + if res.ResolutionDetail.Reason == TargetingMatchReason { + v, ok := res.Value.(string) + if ok { + return StringResolutionDetail{ + Value: v, + ResolutionDetail: res.ResolutionDetail, + } + } + + err := NewTypeMismatchResolutionError("Unable to convert response property to boolean") + + return StringResolutionDetail{ + Value: defaultValue, + ResolutionDetail: ResolutionDetail{ + Variant: "", + Reason: ErrorReason, + ErrorCode: err.code, + ErrorMessage: err.message, + FlagMetadata: nil, + }, + } + } + + return StringResolutionDetail{ + Value: defaultValue, + ResolutionDetail: res.ResolutionDetail, + } +} + +func ToFloatResolutionDetail(res InterfaceResolutionDetail, + defaultValue float64) FloatResolutionDetail { + if res.ResolutionDetail.Reason == TargetingMatchReason { + v, ok := res.Value.(float64) + if ok { + return FloatResolutionDetail{ + Value: v, + ResolutionDetail: res.ResolutionDetail, + } + } + + err := NewTypeMismatchResolutionError("Unable to convert response property to float") + + return FloatResolutionDetail{ + Value: defaultValue, + ResolutionDetail: ResolutionDetail{ + Variant: "", + Reason: ErrorReason, + ErrorCode: err.code, + ErrorMessage: err.message, + FlagMetadata: nil, + }, + } + } + + return FloatResolutionDetail{ + Value: defaultValue, + ResolutionDetail: res.ResolutionDetail, + } +} + +func ToObjectResolutionDetail(res InterfaceResolutionDetail, defaultValue interface{}) InterfaceResolutionDetail { + if res.ResolutionDetail.Reason == TargetingMatchReason { + v, ok := res.Value.(interface{}) + if ok { + return InterfaceResolutionDetail{ + Value: v, + ResolutionDetail: res.ResolutionDetail, + } + } + + err := NewTypeMismatchResolutionError("Unable to convert response property to float") + + return InterfaceResolutionDetail{ + Value: defaultValue, + ResolutionDetail: ResolutionDetail{ + Variant: "", + Reason: ErrorReason, + ErrorCode: err.code, + ErrorMessage: err.message, + FlagMetadata: nil, + }, + } + } + + return InterfaceResolutionDetail{ + Value: defaultValue, + ResolutionDetail: res.ResolutionDetail, + } +} + +func ToIntResolutionDetail(res InterfaceResolutionDetail, + defaultValue int64) IntResolutionDetail { + if res.ResolutionDetail.Reason == TargetingMatchReason { + v, ok := res.Value.(int64) + if ok { + return IntResolutionDetail{ + Value: v, + ResolutionDetail: res.ResolutionDetail, + } + } + + err := NewTypeMismatchResolutionError("Unable to convert response property to int") + + return IntResolutionDetail{ + Value: defaultValue, + ResolutionDetail: ResolutionDetail{ + Variant: "", + Reason: ErrorReason, + ErrorCode: err.code, + ErrorMessage: err.message, + FlagMetadata: nil, + }, + } + } + + return IntResolutionDetail{ + Value: defaultValue, + ResolutionDetail: res.ResolutionDetail, + } +} diff --git a/pkg/provider/utils_test.go b/confidence/utils_test.go similarity index 59% rename from pkg/provider/utils_test.go rename to confidence/utils_test.go index 35acd0b..38b2f90 100644 --- a/pkg/provider/utils_test.go +++ b/confidence/utils_test.go @@ -1,9 +1,8 @@ -package provider +package confidence import ( "encoding/json" "errors" - "github.com/open-feature/go-sdk/openfeature" "github.com/stretchr/testify/assert" "reflect" "testing" @@ -161,22 +160,20 @@ func TestProcessResolveError(t *testing.T) { 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) - resDetails := res.ProviderResolutionDetail.ResolutionDetail() - assert.Equal(t, openfeature.FlagNotFoundCode, resDetails.ErrorCode) - assert.Equal(t, openfeature.ErrorReason, resDetails.Reason) + resDetails := res.ResolutionDetail + assert.Equal(t, FlagNotFoundCode, resDetails.ErrorCode) + assert.Equal(t, ErrorReason, resDetails.Reason) }) t.Run("GeneralError", func(t *testing.T) { err := errors.New("unknown error") res := processResolveError(err, defaultValue) assert.Equal(t, defaultValue, res.Value) - assert.IsType(t, openfeature.ResolutionError{}, res.ProviderResolutionDetail.ResolutionError) - resDetails := res.ProviderResolutionDetail.ResolutionDetail() - assert.Equal(t, openfeature.GeneralCode, resDetails.ErrorCode) - assert.Equal(t, openfeature.ErrorReason, resDetails.Reason) + resDetails := res.ResolutionDetail + assert.Equal(t, GeneralCode, resDetails.ErrorCode) + assert.Equal(t, ErrorReason, resDetails.Reason) }) } @@ -188,10 +185,10 @@ func TestProcessResolvedFlag(t *testing.T) { FlagSchema: flagSchema{Schema: map[string]interface{}{}}, } - expected := openfeature.InterfaceResolutionDetail{ + expected := InterfaceResolutionDetail{ Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.DefaultReason, + ResolutionDetail: ResolutionDetail{ + Reason: DefaultReason, }, } @@ -205,12 +202,14 @@ func TestProcessResolvedFlag(t *testing.T) { FlagSchema: flagSchema{Schema: map[string]interface{}{"key": "wrongType"}}, } - expected := openfeature.InterfaceResolutionDetail{ + expected := InterfaceResolutionDetail{ Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - ResolutionError: openfeature.NewTypeMismatchResolutionError( - "schema for property key does not match the expected type"), - Reason: openfeature.ErrorReason, + ResolutionDetail: ResolutionDetail{ + Variant: "", + Reason: ErrorReason, + ErrorCode: TypeMismatchCode, + ErrorMessage: "schema for property key does not match the expected type", + FlagMetadata: nil, }, } @@ -225,8 +224,13 @@ func TestProcessResolvedFlag(t *testing.T) { } expected := typeMismatchError(defaultValue) - expected.ProviderResolutionDetail.ResolutionError = - openfeature.NewTypeMismatchResolutionError("schema for property key.missing does not match the expected type") + expected.ResolutionDetail = ResolutionDetail{ + Variant: "", + Reason: ErrorReason, + ErrorCode: TypeMismatchCode, + ErrorMessage: "schema for property key.missing does not match the expected type", + FlagMetadata: nil, + } assert.Equal(t, expected, processResolvedFlag(rf, defaultValue, reflect.String, "key.missing")) }) @@ -238,10 +242,10 @@ func TestProcessResolvedFlag(t *testing.T) { FlagSchema: flagSchema{Schema: map[string]interface{}{"key": map[string]interface{}{"stringSchema": "value"}}}, } - expected := openfeature.InterfaceResolutionDetail{ + expected := InterfaceResolutionDetail{ Value: "value", // Success case excludes default value - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.TargetingMatchReason, + ResolutionDetail: ResolutionDetail{ + Reason: TargetingMatchReason, }, } assert.Equal(t, expected, processResolvedFlag(rf, defaultValue, reflect.String, "key")) @@ -349,12 +353,14 @@ func TestReplaceNumbers(t *testing.T) { func TestTypeMismatchError(t *testing.T) { t.Run("WithStringValue", func(t *testing.T) { defaultValue := "my default value" - expected := openfeature.InterfaceResolutionDetail{ + expected := InterfaceResolutionDetail{ Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - ResolutionError: openfeature.NewTypeMismatchResolutionError( - "Unable to extract property value from resolve response"), - Reason: openfeature.ErrorReason, + ResolutionDetail: ResolutionDetail{ + Variant: "", + Reason: ErrorReason, + ErrorCode: TypeMismatchCode, + ErrorMessage: "Unable to extract property value from resolve response", + FlagMetadata: nil, }, } @@ -363,12 +369,14 @@ func TestTypeMismatchError(t *testing.T) { t.Run("WithIntValue", func(t *testing.T) { defaultValue := 123 - expected := openfeature.InterfaceResolutionDetail{ + expected := InterfaceResolutionDetail{ Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - ResolutionError: openfeature.NewTypeMismatchResolutionError( - "Unable to extract property value from resolve response"), - Reason: openfeature.ErrorReason, + ResolutionDetail: ResolutionDetail{ + Variant: "", + Reason: ErrorReason, + ErrorCode: TypeMismatchCode, + ErrorMessage: "Unable to extract property value from resolve response", + FlagMetadata: nil, }, } @@ -380,58 +388,61 @@ func TestToBoolResolutionDetail(t *testing.T) { defaultValue := false t.Run("WhenValueIsBool", func(t *testing.T) { - res := openfeature.InterfaceResolutionDetail{ + res := InterfaceResolutionDetail{ Value: true, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.TargetingMatchReason, + ResolutionDetail: ResolutionDetail{ + Reason: TargetingMatchReason, }, } - expected := openfeature.BoolResolutionDetail{ + expected := BoolResolutionDetail{ Value: true, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.TargetingMatchReason, + ResolutionDetail: ResolutionDetail{ + Reason: TargetingMatchReason, }, } - assert.Equal(t, expected, toBoolResolutionDetail(res, defaultValue)) + assert.Equal(t, expected, ToBoolResolutionDetail(res, defaultValue)) }) t.Run("WhenValueIsNotBool", func(t *testing.T) { - res := openfeature.InterfaceResolutionDetail{ + res := InterfaceResolutionDetail{ Value: "not a bool", - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.TargetingMatchReason, + ResolutionDetail: ResolutionDetail{ + Reason: TargetingMatchReason, }, } - expected := openfeature.BoolResolutionDetail{ + expected := BoolResolutionDetail{ Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - ResolutionError: openfeature.NewTypeMismatchResolutionError("Unable to convert response property to boolean"), - Reason: openfeature.ErrorReason, + ResolutionDetail: ResolutionDetail{ + Variant: "", + Reason: ErrorReason, + ErrorCode: TypeMismatchCode, + ErrorMessage: "Unable to convert response property to boolean", + FlagMetadata: nil, }, } - assert.Equal(t, expected, toBoolResolutionDetail(res, defaultValue)) + assert.Equal(t, expected, ToBoolResolutionDetail(res, defaultValue)) }) t.Run("WhenReasonIsNotTargetingMatchReason", func(t *testing.T) { - res := openfeature.InterfaceResolutionDetail{ + res := InterfaceResolutionDetail{ Value: true, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.ErrorReason, + ResolutionDetail: ResolutionDetail{ + Reason: ErrorReason, }, } - expected := openfeature.BoolResolutionDetail{ + expected := BoolResolutionDetail{ Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.ErrorReason, + ResolutionDetail: ResolutionDetail{ + Reason: ErrorReason, }, } - assert.Equal(t, expected, toBoolResolutionDetail(res, defaultValue)) + assert.Equal(t, expected, ToBoolResolutionDetail(res, defaultValue)) }) } @@ -439,58 +450,61 @@ func TestToStringResolutionDetail(t *testing.T) { defaultValue := "default" t.Run("WhenValueIsString", func(t *testing.T) { - res := openfeature.InterfaceResolutionDetail{ + res := InterfaceResolutionDetail{ Value: "hello", - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.TargetingMatchReason, + ResolutionDetail: ResolutionDetail{ + Reason: TargetingMatchReason, }, } - expected := openfeature.StringResolutionDetail{ + expected := StringResolutionDetail{ Value: "hello", - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.TargetingMatchReason, + ResolutionDetail: ResolutionDetail{ + Reason: TargetingMatchReason, }, } - assert.Equal(t, expected, toStringResolutionDetail(res, defaultValue)) + assert.Equal(t, expected, ToStringResolutionDetail(res, defaultValue)) }) t.Run("WhenValueIsNotString", func(t *testing.T) { - res := openfeature.InterfaceResolutionDetail{ + res := InterfaceResolutionDetail{ Value: 123, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.TargetingMatchReason, + ResolutionDetail: ResolutionDetail{ + Reason: TargetingMatchReason, }, } - expected := openfeature.StringResolutionDetail{ + expected := StringResolutionDetail{ Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - ResolutionError: openfeature.NewTypeMismatchResolutionError("Unable to convert response property to boolean"), - Reason: openfeature.ErrorReason, + ResolutionDetail: ResolutionDetail{ + Variant: "", + Reason: ErrorReason, + ErrorCode: TypeMismatchCode, + ErrorMessage: "Unable to convert response property to boolean", + FlagMetadata: nil, }, } - assert.Equal(t, expected, toStringResolutionDetail(res, defaultValue)) + assert.Equal(t, expected, ToStringResolutionDetail(res, defaultValue)) }) t.Run("WhenReasonIsNotTargetingMatchReason", func(t *testing.T) { - res := openfeature.InterfaceResolutionDetail{ + res := InterfaceResolutionDetail{ Value: "hello", - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.ErrorReason, + ResolutionDetail: ResolutionDetail{ + Reason: ErrorReason, }, } - expected := openfeature.StringResolutionDetail{ + expected := StringResolutionDetail{ Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.ErrorReason, + ResolutionDetail: ResolutionDetail{ + Reason: ErrorReason, }, } - assert.Equal(t, expected, toStringResolutionDetail(res, defaultValue)) + assert.Equal(t, expected, ToStringResolutionDetail(res, defaultValue)) }) } @@ -498,115 +512,121 @@ func TestToFloatResolutionDetail(t *testing.T) { defaultValue := 42.0 t.Run("WhenValueIsFloat", func(t *testing.T) { - res := openfeature.InterfaceResolutionDetail{ + res := InterfaceResolutionDetail{ Value: 24.0, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.TargetingMatchReason, + ResolutionDetail: ResolutionDetail{ + Reason: TargetingMatchReason, }, } - expected := openfeature.FloatResolutionDetail{ + expected := FloatResolutionDetail{ Value: 24.0, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.TargetingMatchReason, + ResolutionDetail: ResolutionDetail{ + Reason: TargetingMatchReason, }, } - assert.Equal(t, expected, toFloatResolutionDetail(res, defaultValue)) + assert.Equal(t, expected, ToFloatResolutionDetail(res, defaultValue)) }) t.Run("WhenValueIsNotFloat", func(t *testing.T) { - res := openfeature.InterfaceResolutionDetail{ + res := InterfaceResolutionDetail{ Value: "not a float", - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.TargetingMatchReason, + ResolutionDetail: ResolutionDetail{ + Reason: TargetingMatchReason, }, } - expected := openfeature.FloatResolutionDetail{ + expected := FloatResolutionDetail{ Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - ResolutionError: openfeature.NewTypeMismatchResolutionError("Unable to convert response property to float"), - Reason: openfeature.ErrorReason, + ResolutionDetail: ResolutionDetail{ + Variant: "", + Reason: ErrorReason, + ErrorCode: TypeMismatchCode, + ErrorMessage: "Unable to convert response property to float", + FlagMetadata: nil, }, } - assert.Equal(t, expected, toFloatResolutionDetail(res, defaultValue)) + assert.Equal(t, expected, ToFloatResolutionDetail(res, defaultValue)) }) t.Run("WhenReasonIsNotTargetingMatchReason", func(t *testing.T) { - res := openfeature.InterfaceResolutionDetail{ + res := InterfaceResolutionDetail{ Value: 24.0, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.ErrorReason, + ResolutionDetail: ResolutionDetail{ + Reason: ErrorReason, }, } - expected := openfeature.FloatResolutionDetail{ + expected := FloatResolutionDetail{ Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.ErrorReason, + ResolutionDetail: ResolutionDetail{ + Reason: ErrorReason, }, } - assert.Equal(t, expected, toFloatResolutionDetail(res, defaultValue)) + 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{ + res := InterfaceResolutionDetail{ Value: int64(456), - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.TargetingMatchReason, + ResolutionDetail: ResolutionDetail{ + Reason: TargetingMatchReason, }, } - expected := openfeature.IntResolutionDetail{ + expected := IntResolutionDetail{ Value: int64(456), - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.TargetingMatchReason, + ResolutionDetail: ResolutionDetail{ + Reason: TargetingMatchReason, }, } - assert.Equal(t, expected, toIntResolutionDetail(res, defaultValue)) + assert.Equal(t, expected, ToIntResolutionDetail(res, defaultValue)) }) t.Run("WhenValueIsNotInt", func(t *testing.T) { - res := openfeature.InterfaceResolutionDetail{ + res := InterfaceResolutionDetail{ Value: "not an int", - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.TargetingMatchReason, + ResolutionDetail: ResolutionDetail{ + Reason: TargetingMatchReason, }, } - expected := openfeature.IntResolutionDetail{ + expected := IntResolutionDetail{ Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - ResolutionError: openfeature.NewTypeMismatchResolutionError("Unable to convert response property to int"), - Reason: openfeature.ErrorReason, + ResolutionDetail: ResolutionDetail{ + Variant: "", + Reason: ErrorReason, + ErrorCode: TypeMismatchCode, + ErrorMessage: "Unable to convert response property to int", + FlagMetadata: nil, }, } - assert.Equal(t, expected, toIntResolutionDetail(res, defaultValue)) + assert.Equal(t, expected, ToIntResolutionDetail(res, defaultValue)) }) t.Run("WhenReasonIsNotTargetingMatchReason", func(t *testing.T) { - res := openfeature.InterfaceResolutionDetail{ + res := InterfaceResolutionDetail{ Value: int64(456), - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.ErrorReason, + ResolutionDetail: ResolutionDetail{ + Reason: ErrorReason, }, } - expected := openfeature.IntResolutionDetail{ + expected := IntResolutionDetail{ Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.ErrorReason, + ResolutionDetail: ResolutionDetail{ + Reason: ErrorReason, }, } - assert.Equal(t, expected, toIntResolutionDetail(res, defaultValue)) + assert.Equal(t, expected, ToIntResolutionDetail(res, defaultValue)) }) } diff --git a/demo/GoDemoApp.go b/demo/GoDemoApp.go index b0e9c2f..253842e 100644 --- a/demo/GoDemoApp.go +++ b/demo/GoDemoApp.go @@ -6,18 +6,17 @@ import ( "github.com/google/uuid" "github.com/open-feature/go-sdk/openfeature" - confidence "github.com/spotify/confidence-openfeature-provider-go/pkg/provider" + c "github.com/spotify/confidence-openfeature-provider-go/confidence" + p "github.com/spotify/confidence-openfeature-provider-go/provider" ) func main() { clientSecret := "CLIENT_SECRET" fmt.Println("Fetching the flags...") - provider, err := confidence.NewFlagProvider(*confidence.NewAPIConfig(clientSecret)) + confidence := c.NewConfidenceBuilder().SetAPIConfig(c.APIConfig{APIKey: clientSecret}).Build() - if err != nil { - // handle error - } + provider := p.NewFlagProvider(confidence) openfeature.SetProvider(provider) client := openfeature.NewClient("testApp") diff --git a/demo/go.mod b/demo/go.mod index 6d9a544..1c53a5d 100644 --- a/demo/go.mod +++ b/demo/go.mod @@ -1,14 +1,17 @@ module demo -go 1.19 +go 1.22.2 require ( github.com/google/uuid v1.6.0 github.com/open-feature/go-sdk v1.10.0 - github.com/spotify/confidence-openfeature-provider-go v0.1.7 + github.com/spotify/confidence-openfeature-provider-go/confidence v1.0.0 + github.com/spotify/confidence-openfeature-provider-go/provider v0.1.7 ) -replace github.com/spotify/confidence-openfeature-provider-go v0.1.7 => ../ +replace github.com/spotify/confidence-openfeature-provider-go/provider v0.1.7 => ../provider + +replace github.com/spotify/confidence-openfeature-provider-go/confidence v1.0.0 => ../confidence require ( github.com/go-logr/logr v1.4.1 // indirect diff --git a/demo/go.sum b/demo/go.sum index dfd5b5f..39c4ad6 100644 --- a/demo/go.sum +++ b/demo/go.sum @@ -1,14 +1,20 @@ 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/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/open-feature/go-sdk v1.10.0 h1:druQtYOrN+gyz3rMsXp0F2jW1oBXJb0V26PVQnUGLbM= github.com/open-feature/go-sdk v1.10.0/go.mod h1:+rkJhLBtYsJ5PZNddAgFILhRAAxwrJ32aU7UEUm4zQI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/exp v0.0.0-20240213143201-ec583247a57a h1:HinSgX1tJRX3KsL//Gxynpw5CTOAIPhgL4W8PNiIpVE= golang.org/x/exp v0.0.0-20240213143201-ec583247a57a/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go.sum b/go.sum deleted file mode 100644 index a2c86c1..0000000 --- a/go.sum +++ /dev/null @@ -1,20 +0,0 @@ -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/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= -github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/open-feature/go-sdk v1.10.0 h1:druQtYOrN+gyz3rMsXp0F2jW1oBXJb0V26PVQnUGLbM= -github.com/open-feature/go-sdk v1.10.0/go.mod h1:+rkJhLBtYsJ5PZNddAgFILhRAAxwrJ32aU7UEUm4zQI= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -golang.org/x/exp v0.0.0-20240213143201-ec583247a57a h1:HinSgX1tJRX3KsL//Gxynpw5CTOAIPhgL4W8PNiIpVE= -golang.org/x/exp v0.0.0-20240213143201-ec583247a57a/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go deleted file mode 100755 index 5634a09..0000000 --- a/pkg/provider/provider.go +++ /dev/null @@ -1,110 +0,0 @@ -package provider - -import ( - "context" - "fmt" - "net/http" - "reflect" - "strings" - - "github.com/open-feature/go-sdk/openfeature" -) - -type FlagProvider struct { - Config APIConfig - ResolveClient resolveClient -} - -var ( - SDK_ID = "SDK_ID_GO_PROVIDER" - SDK_VERSION = "0.1.7" // x-release-please-version -) - -func NewFlagProvider(config APIConfig) (*FlagProvider, error) { - validationError := config.validate() - if validationError != nil { - return nil, validationError - } - return &FlagProvider{Config: config, - ResolveClient: httpResolveClient{Client: &http.Client{}, Config: config}}, nil -} - -func (e FlagProvider) Metadata() openfeature.Metadata { - return openfeature.Metadata{Name: "ConfidenceFlagProvider"} -} - -func (e FlagProvider) BooleanEvaluation(ctx context.Context, flag string, defaultValue bool, - evalCtx openfeature.FlattenedContext) openfeature.BoolResolutionDetail { - res := e.resolveFlag(ctx, flag, defaultValue, evalCtx, reflect.Bool) - return toBoolResolutionDetail(res, defaultValue) -} - -func (e FlagProvider) StringEvaluation(ctx context.Context, flag string, defaultValue string, - evalCtx openfeature.FlattenedContext) openfeature.StringResolutionDetail { - res := e.resolveFlag(ctx, flag, defaultValue, evalCtx, reflect.String) - return toStringResolutionDetail(res, defaultValue) -} - -func (e FlagProvider) FloatEvaluation(ctx context.Context, flag string, defaultValue float64, - evalCtx openfeature.FlattenedContext) openfeature.FloatResolutionDetail { - res := e.resolveFlag(ctx, flag, defaultValue, evalCtx, reflect.Float64) - return toFloatResolutionDetail(res, defaultValue) -} - -func (e FlagProvider) IntEvaluation(ctx context.Context, flag string, defaultValue int64, - evalCtx openfeature.FlattenedContext) openfeature.IntResolutionDetail { - res := e.resolveFlag(ctx, flag, defaultValue, evalCtx, reflect.Int64) - return toIntResolutionDetail(res, defaultValue) -} - -func (e FlagProvider) ObjectEvaluation(ctx context.Context, flag string, defaultValue interface{}, - evalCtx openfeature.FlattenedContext) openfeature.InterfaceResolutionDetail { - return e.resolveFlag(ctx, flag, defaultValue, evalCtx, reflect.Map) -} - -func (e FlagProvider) resolveFlag(ctx context.Context, flag string, defaultValue interface{}, - evalCtx openfeature.FlattenedContext, expectedKind reflect.Kind) openfeature.InterfaceResolutionDetail { - flagName, propertyPath := splitFlagString(flag) - - requestFlagName := fmt.Sprintf("flags/%s", flagName) - resp, err := e.ResolveClient.sendResolveRequest(ctx, - resolveRequest{ClientSecret: e.Config.APIKey, - Flags: []string{requestFlagName}, Apply: true, EvaluationContext: processTargetingKey(evalCtx), - Sdk: sdk{Id: SDK_ID, Version: SDK_VERSION}}) - - if err != nil { - return processResolveError(err, defaultValue) - } - - if len(resp.ResolvedFlags) == 0 { - return openfeature.InterfaceResolutionDetail{ - Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - ResolutionError: openfeature.NewFlagNotFoundResolutionError(fmt.Sprintf("no active flag '%s' was found", flagName)), - Reason: openfeature.ErrorReason}} - } - - resolvedFlag := resp.ResolvedFlags[0] - if resolvedFlag.Flag != requestFlagName { - return openfeature.InterfaceResolutionDetail{ - Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - ResolutionError: openfeature.NewFlagNotFoundResolutionError(fmt.Sprintf("unexpected flag '%s' from remote", strings.TrimPrefix(resolvedFlag.Flag, "flags/"))), - Reason: openfeature.ErrorReason}} - } - - return processResolvedFlag(resolvedFlag, defaultValue, expectedKind, propertyPath) -} - -func processTargetingKey(evalCtx openfeature.FlattenedContext) openfeature.FlattenedContext { - newEvalContext := openfeature.FlattenedContext{} - newEvalContext = evalCtx - if targetingKey, exists := evalCtx["targetingKey"]; exists { - newEvalContext["targeting_key"] = targetingKey - } - return newEvalContext -} - -func (e FlagProvider) Hooks() []openfeature.Hook { - return []openfeature.Hook{} -} diff --git a/pkg/provider/utils.go b/pkg/provider/utils.go deleted file mode 100644 index 5138e1c..0000000 --- a/pkg/provider/utils.go +++ /dev/null @@ -1,291 +0,0 @@ -package provider - -import ( - "encoding/json" - "errors" - "fmt" - "reflect" - "strings" - - "github.com/open-feature/go-sdk/openfeature" -) - -func splitFlagString(flag string) (string, string) { - splittedFlag := strings.SplitN(flag, ".", 2) - if len(splittedFlag) == 2 { - return splittedFlag[0], splittedFlag[1] - } - - return splittedFlag[0], "" -} - -func extractPropertyValue(path string, values map[string]interface{}) (interface{}, error) { - if path == "" { - return values, nil - } - - firstPartAndRest := strings.SplitN(path, ".", 2) - if len(firstPartAndRest) == 1 { - value := values[firstPartAndRest[0]] - return value, nil - } - - childMap, ok := values[firstPartAndRest[0]].(map[string]interface{}) - if ok { - return extractPropertyValue(firstPartAndRest[1], childMap) - } - - return false, fmt.Errorf("unable to find property in path %s", path) -} - -func getTypeForPath(schema map[string]interface{}, path string) (reflect.Kind, error) { - if path == "" { - return reflect.Map, nil - } - - 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") - } - - if _, isBool := value["boolSchema"]; isBool { - return reflect.Bool, nil - } else if _, isString := value["stringSchema"]; isString { - return reflect.String, nil - } else if _, isInt := value["intSchema"]; isInt { - return reflect.Int64, nil - } else if _, isFloat := value["doubleSchema"]; isFloat { - return reflect.Float64, nil - } else if _, isMap := value["structSchema"]; isMap { - return reflect.Map, nil - } - - return 0, fmt.Errorf("unable to find property type in schema %s", path) - } - - // If we are here, the property path contains multiple entries -> this must be a struct -> recurse down the tree. - childMap, ok := schema[firstPartAndRest[0]].(map[string]interface{}) - if !ok { - return 0, fmt.Errorf("unexpected error when parsing resolve response schema") - } - - if structMap, isStruct := childMap["structSchema"]; isStruct { - 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) -} - -func processResolveError(err error, defaultValue interface{}) openfeature.InterfaceResolutionDetail { - switch { - case errors.Is(err, errFlagNotFound): - return openfeature.InterfaceResolutionDetail{ - Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - ResolutionError: openfeature.NewFlagNotFoundResolutionError("error when resolving, flag not found"), - Reason: openfeature.ErrorReason}} - default: - return openfeature.InterfaceResolutionDetail{ - Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - ResolutionError: openfeature.NewGeneralResolutionError("error when resolving, returning default value"), - Reason: openfeature.ErrorReason}} - } -} - -func processResolvedFlag(resolvedFlag resolvedFlag, defaultValue interface{}, - expectedKind reflect.Kind, propertyPath string) openfeature.InterfaceResolutionDetail { - if len(resolvedFlag.Value) == 0 { - return openfeature.InterfaceResolutionDetail{ - Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.DefaultReason}} - } - - actualKind, schemaErr := getTypeForPath(resolvedFlag.FlagSchema.Schema, propertyPath) - if schemaErr != nil || actualKind != expectedKind { - return openfeature.InterfaceResolutionDetail{ - Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - ResolutionError: openfeature.NewTypeMismatchResolutionError( - fmt.Sprintf("schema for property %s does not match the expected type", - propertyPath)), - Reason: openfeature.ErrorReason}} - } - - updatedMap, err := replaceNumbers("", resolvedFlag.Value, resolvedFlag.FlagSchema.Schema) - if err != nil { - return typeMismatchError(defaultValue) - } - - extractedValue, extractValueError := extractPropertyValue(propertyPath, updatedMap) - if extractValueError != nil { - return typeMismatchError(defaultValue) - } - - return openfeature.InterfaceResolutionDetail{ - Value: extractedValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - Reason: openfeature.TargetingMatchReason, - Variant: resolvedFlag.Variant}} -} - -func replaceNumbers(basePath string, input map[string]interface{}, - schema map[string]interface{}) (map[string]interface{}, error) { - 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 - case reflect.Int64: - 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%s.", basePath, key), asMap, schema) - if err != nil { - return updatedMap, fmt.Errorf("unable to convert map") - } - - updatedMap[key] = childMap - default: - updatedMap[key] = value - } - } - - return updatedMap, nil -} - -func typeMismatchError(defaultValue interface{}) openfeature.InterfaceResolutionDetail { - return openfeature.InterfaceResolutionDetail{ - Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - ResolutionError: openfeature.NewTypeMismatchResolutionError( - "Unable to extract property value from resolve response"), - Reason: openfeature.ErrorReason}} -} - -func toBoolResolutionDetail(res openfeature.InterfaceResolutionDetail, - defaultValue bool) openfeature.BoolResolutionDetail { - if res.ProviderResolutionDetail.Reason == openfeature.TargetingMatchReason { - v, ok := res.Value.(bool) - if ok { - return openfeature.BoolResolutionDetail{ - Value: v, - ProviderResolutionDetail: res.ProviderResolutionDetail, - } - } - - return openfeature.BoolResolutionDetail{ - Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - ResolutionError: openfeature.NewTypeMismatchResolutionError("Unable to convert response property to boolean"), - Reason: openfeature.ErrorReason, - }, - } - } - - return openfeature.BoolResolutionDetail{ - Value: defaultValue, - ProviderResolutionDetail: res.ProviderResolutionDetail, - } -} - -func toStringResolutionDetail(res openfeature.InterfaceResolutionDetail, - defaultValue string) openfeature.StringResolutionDetail { - if res.ProviderResolutionDetail.Reason == openfeature.TargetingMatchReason { - v, ok := res.Value.(string) - if ok { - return openfeature.StringResolutionDetail{ - Value: v, - ProviderResolutionDetail: res.ProviderResolutionDetail, - } - } - - return openfeature.StringResolutionDetail{ - Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - ResolutionError: openfeature.NewTypeMismatchResolutionError("Unable to convert response property to boolean"), - Reason: openfeature.ErrorReason, - }, - } - } - - return openfeature.StringResolutionDetail{ - Value: defaultValue, - ProviderResolutionDetail: res.ProviderResolutionDetail, - } -} - -func toFloatResolutionDetail(res openfeature.InterfaceResolutionDetail, - defaultValue float64) openfeature.FloatResolutionDetail { - if res.ProviderResolutionDetail.Reason == openfeature.TargetingMatchReason { - v, ok := res.Value.(float64) - if ok { - return openfeature.FloatResolutionDetail{ - Value: v, - ProviderResolutionDetail: res.ProviderResolutionDetail, - } - } - - return openfeature.FloatResolutionDetail{ - Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - ResolutionError: openfeature.NewTypeMismatchResolutionError("Unable to convert response property to float"), - Reason: openfeature.ErrorReason, - }, - } - } - - return openfeature.FloatResolutionDetail{ - Value: defaultValue, - ProviderResolutionDetail: res.ProviderResolutionDetail, - } -} - -func toIntResolutionDetail(res openfeature.InterfaceResolutionDetail, - defaultValue int64) openfeature.IntResolutionDetail { - if res.ProviderResolutionDetail.Reason == openfeature.TargetingMatchReason { - v, ok := res.Value.(int64) - if ok { - return openfeature.IntResolutionDetail{ - Value: v, - ProviderResolutionDetail: res.ProviderResolutionDetail, - } - } - - return openfeature.IntResolutionDetail{ - Value: defaultValue, - ProviderResolutionDetail: openfeature.ProviderResolutionDetail{ - ResolutionError: openfeature.NewTypeMismatchResolutionError("Unable to convert response property to int"), - Reason: openfeature.ErrorReason, - }, - } - } - - return openfeature.IntResolutionDetail{ - Value: defaultValue, - ProviderResolutionDetail: res.ProviderResolutionDetail, - } -} diff --git a/provider/.gitignore b/provider/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/provider/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/go.mod b/provider/go.mod similarity index 51% rename from go.mod rename to provider/go.mod index cea4a4e..625b8b9 100644 --- a/go.mod +++ b/provider/go.mod @@ -1,16 +1,18 @@ -module github.com/spotify/confidence-openfeature-provider-go +module github.com/spotify/confidence-openfeature-provider-go/provider -go 1.19 +go 1.22.2 + +replace github.com/spotify/confidence-openfeature-provider-go/confidence v1.0.0 => ../confidence require ( github.com/open-feature/go-sdk v1.10.0 - github.com/stretchr/testify v1.8.4 + github.com/spotify/confidence-openfeature-provider-go/confidence v1.0.0 + github.com/stretchr/testify v1.9.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-logr/logr v1.4.1 // indirect - github.com/google/uuid v1.6.0 github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/exp v0.0.0-20240213143201-ec583247a57a // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/provider/go.sum b/provider/go.sum new file mode 100644 index 0000000..ad92d30 --- /dev/null +++ b/provider/go.sum @@ -0,0 +1,32 @@ +github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0= +github.com/cucumber/godog v0.14.0/go.mod h1:FX3rzIDybWABU4kuIXLZ/qtqEe1Ac5RdXmqvACJOces= +github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s= +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/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/open-feature/go-sdk v1.10.0 h1:druQtYOrN+gyz3rMsXp0F2jW1oBXJb0V26PVQnUGLbM= +github.com/open-feature/go-sdk v1.10.0/go.mod h1:+rkJhLBtYsJ5PZNddAgFILhRAAxwrJ32aU7UEUm4zQI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/exp v0.0.0-20240213143201-ec583247a57a h1:HinSgX1tJRX3KsL//Gxynpw5CTOAIPhgL4W8PNiIpVE= +golang.org/x/exp v0.0.0-20240213143201-ec583247a57a/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/provider/google-java-format.xml b/provider/google-java-format.xml new file mode 100644 index 0000000..2aa056d --- /dev/null +++ b/provider/google-java-format.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/provider/misc.xml b/provider/misc.xml new file mode 100644 index 0000000..3d3ab27 --- /dev/null +++ b/provider/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/provider/modules.xml b/provider/modules.xml new file mode 100644 index 0000000..1f2e99d --- /dev/null +++ b/provider/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/provider/provider.go b/provider/provider.go new file mode 100644 index 0000000..c71579f --- /dev/null +++ b/provider/provider.go @@ -0,0 +1,133 @@ +package provider + +import ( + "context" + "github.com/open-feature/go-sdk/openfeature" + c "github.com/spotify/confidence-openfeature-provider-go/confidence" + "reflect" +) + +type FlagProvider struct { + confidence c.Confidence +} + +func NewFlagProvider(confidence c.Confidence) *FlagProvider { + return &FlagProvider{ + confidence: confidence, + } +} + +func (e FlagProvider) Metadata() openfeature.Metadata { + return openfeature.Metadata{Name: "ConfidenceFlagProvider"} +} + +func (e FlagProvider) BooleanEvaluation(ctx context.Context, flag string, defaultValue bool, + evalCtx openfeature.FlattenedContext) openfeature.BoolResolutionDetail { + confidence := e.confidence.WithContext(processTargetingKey(evalCtx)) + res := confidence.ResolveFlag(ctx, flag, defaultValue, reflect.Bool) + boolDetail := c.ToBoolResolutionDetail(res, defaultValue) + return openfeature.BoolResolutionDetail{ + Value: boolDetail.Value, + ProviderResolutionDetail: toOFResolutionDetail(boolDetail.ResolutionDetail), + } +} + +func (e FlagProvider) StringEvaluation(ctx context.Context, flag string, defaultValue string, + evalCtx openfeature.FlattenedContext) openfeature.StringResolutionDetail { + confidence := e.confidence.WithContext(processTargetingKey(evalCtx)) + res := confidence.ResolveFlag(ctx, flag, defaultValue, reflect.String) + detail := c.ToStringResolutionDetail(res, defaultValue) + return openfeature.StringResolutionDetail{ + Value: detail.Value, + ProviderResolutionDetail: toOFResolutionDetail(detail.ResolutionDetail), + } +} + +func (e FlagProvider) FloatEvaluation(ctx context.Context, flag string, defaultValue float64, + evalCtx openfeature.FlattenedContext) openfeature.FloatResolutionDetail { + confidence := e.confidence.WithContext(processTargetingKey(evalCtx)) + res := confidence.ResolveFlag(ctx, flag, defaultValue, reflect.Float64) + detail := c.ToFloatResolutionDetail(res, defaultValue) + return openfeature.FloatResolutionDetail{ + Value: detail.Value, + ProviderResolutionDetail: toOFResolutionDetail(detail.ResolutionDetail), + } +} + +func (e FlagProvider) IntEvaluation(ctx context.Context, flag string, defaultValue int64, + evalCtx openfeature.FlattenedContext) openfeature.IntResolutionDetail { + confidence := e.confidence.WithContext(processTargetingKey(evalCtx)) + res := confidence.ResolveFlag(ctx, flag, defaultValue, reflect.Int64) + detail := c.ToIntResolutionDetail(res, defaultValue) + return openfeature.IntResolutionDetail{ + Value: detail.Value, + ProviderResolutionDetail: toOFResolutionDetail(detail.ResolutionDetail), + } +} + +func (e FlagProvider) ObjectEvaluation(ctx context.Context, flag string, defaultValue interface{}, + evalCtx openfeature.FlattenedContext) openfeature.InterfaceResolutionDetail { + confidence := e.confidence.WithContext(processTargetingKey(evalCtx)) + res := confidence.ResolveFlag(ctx, flag, defaultValue, reflect.Map) + detail := c.ToObjectResolutionDetail(res, defaultValue) + return openfeature.InterfaceResolutionDetail{ + Value: detail.Value, + ProviderResolutionDetail: toOFResolutionDetail(detail.ResolutionDetail), + } +} + +func (e FlagProvider) Hooks() []openfeature.Hook { + return []openfeature.Hook{} +} + +func toOFResolutionDetail(detail c.ResolutionDetail) openfeature.ProviderResolutionDetail { + return openfeature.ProviderResolutionDetail{ + ResolutionError: toOFResolutionError(detail.ErrorCode, detail.ErrorMessage), + Reason: toOFReason(detail.Reason), + Variant: detail.Variant, + FlagMetadata: toOFFlagMetadata(detail.FlagMetadata), + } +} + +func toOFResolutionError(code c.ErrorCode, message string) openfeature.ResolutionError { + switch code { + case c.TypeMismatchCode: + return openfeature.NewTypeMismatchResolutionError(message) + case c.FlagNotFoundCode: + return openfeature.NewFlagNotFoundResolutionError(message) + case c.GeneralCode: + return openfeature.NewGeneralResolutionError(message) + case c.InvalidContextCode: + return openfeature.NewInvalidContextResolutionError(message) + case c.ProviderNotReadyCode: + return openfeature.NewProviderNotReadyResolutionError(message) + case c.ParseErrorCode: + return openfeature.NewParseErrorResolutionError(message) + } + return openfeature.ResolutionError{} +} + +func processTargetingKey(evalCtx openfeature.FlattenedContext) openfeature.FlattenedContext { + newEvalContext := openfeature.FlattenedContext{} + newEvalContext = evalCtx + if targetingKey, exists := evalCtx["targetingKey"]; exists { + newEvalContext["targeting_key"] = targetingKey + } + delete(newEvalContext, "targetingKey") + return newEvalContext +} + +func toOFFlagMetadata(metadata c.FlagMetadata) map[string]interface{} { + return metadata +} + +func toOFReason(reason c.Reason) openfeature.Reason { + switch reason { + case c.TargetingMatchReason: + return openfeature.TargetingMatchReason + case c.DefaultReason: + return openfeature.DefaultReason + default: + return openfeature.ErrorReason + } +} diff --git a/provider/provider.iml b/provider/provider.iml new file mode 100644 index 0000000..25ed3f6 --- /dev/null +++ b/provider/provider.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/pkg/provider/provider_internal_test.go b/provider/provider_internal_test.go similarity index 77% rename from pkg/provider/provider_internal_test.go rename to provider/provider_internal_test.go index 7a51fda..9a77e44 100644 --- a/pkg/provider/provider_internal_test.go +++ b/provider/provider_internal_test.go @@ -5,23 +5,23 @@ import ( "context" "encoding/json" "fmt" - "reflect" - "testing" - "github.com/open-feature/go-sdk/openfeature" + confidence "github.com/spotify/confidence-openfeature-provider-go/confidence" "github.com/stretchr/testify/assert" + "reflect" + "testing" ) type MockResolveClient struct { - MockedResponse resolveResponse + MockedResponse confidence.ResolveResponse MockedError error TestingT *testing.T } -func (r MockResolveClient) sendResolveRequest(_ context.Context, - request resolveRequest) (resolveResponse, error) { +func (r MockResolveClient) SendResolveRequest(_ context.Context, + request confidence.ResolveRequest) (confidence.ResolveResponse, error) { assert.Equal(r.TestingT, "user1", request.EvaluationContext["targeting_key"]) - return r.MockedResponse, r.MockedError + return r.MockedResponse, r.MockedError } func TestResolveBoolValue(t *testing.T) { @@ -187,60 +187,63 @@ func TestResolveWithNonExistingFlag(t *testing.T) { assert.Equal(t, true, evalDetails.Value) assert.Equal(t, openfeature.ErrorReason, evalDetails.Reason) assert.Equal(t, openfeature.FlagNotFoundCode, evalDetails.ErrorCode) - assert.Equal(t, "no active flag 'test-flag' was found", evalDetails.ErrorMessage) + assert.Equal(t, "Flag not found", evalDetails.ErrorMessage) } -func client(t *testing.T, response resolveResponse, errorToReturn error) *openfeature.Client { - provider := FlagProvider{Config: APIConfig{APIKey: "apikey", - Region: APIRegionGlobal}, ResolveClient: MockResolveClient{MockedResponse: response, MockedError: errorToReturn, TestingT: t}} +func client(t *testing.T, response confidence.ResolveResponse, errorToReturn error) *openfeature.Client { + resolveClient := MockResolveClient{MockedResponse: response, MockedError: errorToReturn, TestingT: t} + conf := confidence.NewConfidenceBuilder().SetAPIConfig(confidence.APIConfig{APIKey: "apiKey"}).SetResolveClient(resolveClient).Build() + provider := FlagProvider{ + confidence: conf, + } openfeature.SetProvider(provider) return openfeature.NewClient("testApp") } -func templateResponse() resolveResponse { +func templateResponse() confidence.ResolveResponse { return templateResponseWithFlagName("test-flag") } -func templateResponseWithFlagName(flagName string) resolveResponse { +func templateResponseWithFlagName(flagName string) confidence.ResolveResponse { templateResolveResponse := fmt.Sprintf(` { - "resolvedFlags": [ - { - "flag": "flags/%[1]s", - "variant": "flags/%[1]s/variants/treatment", - "value": { - "struct-key": { - "boolean-key": false, - "string-key": "treatment-struct", - "double-key": 123.23, - "integer-key": 23, +"resolvedFlags": [ +{ +"flag": "flags/%[1]s", +"variant": "flags/%[1]s/variants/treatment", +"value": { +"struct-key": { +"boolean-key": false, +"string-key": "treatment-struct", +"double-key": 123.23, +"integer-key": 23, "nested-struct-key": { "nested-boolean-key": false } - }, - "boolean-key": true, - "string-key": "treatment", - "double-key": 20.203, - "integer-key": 40 - }, - "flagSchema": { - "schema": { - "struct-key": { - "structSchema": { - "schema": { - "boolean-key": { - "boolSchema": {} - }, - "string-key": { - "stringSchema": {} - }, - "double-key": { - "doubleSchema": {} - }, - "integer-key": { - "intSchema": {} - }, - "nested-struct-key": { +}, +"boolean-key": true, +"string-key": "treatment", +"double-key": 20.203, +"integer-key": 40 +}, +"flagSchema": { +"schema": { +"struct-key": { +"structSchema": { +"schema": { +"boolean-key": { +"boolSchema": {} +}, +"string-key": { +"stringSchema": {} +}, +"double-key": { +"doubleSchema": {} +}, +"integer-key": { +"intSchema": {} +}, + "nested-struct-key": { "structSchema": { "schema": { "nested-boolean-key": { @@ -248,45 +251,45 @@ func templateResponseWithFlagName(flagName string) resolveResponse { } } } - } - } - } - }, - "boolean-key": { - "boolSchema": {} - }, - "string-key": { - "stringSchema": {} - }, - "double-key": { - "doubleSchema": {} - }, - "integer-key": { - "intSchema": {} - } - } - }, - "reason": "RESOLVE_REASON_MATCH" - }], - "resolveToken": "" + } +} +} +}, +"boolean-key": { +"boolSchema": {} +}, +"string-key": { +"stringSchema": {} +}, +"double-key": { +"doubleSchema": {} +}, +"integer-key": { +"intSchema": {} +} +} +}, +"reason": "RESOLVE_REASON_MATCH" +}], +"resolveToken": "" } `, flagName) - var result resolveResponse + var result confidence.ResolveResponse decoder := json.NewDecoder(bytes.NewBuffer([]byte(templateResolveResponse))) decoder.UseNumber() _ = decoder.Decode(&result) return result } -func emptyResponse() resolveResponse { +func emptyResponse() confidence.ResolveResponse { templateResolveResponse := ` { - "resolvedFlags": [], - "resolveToken": "" +"resolvedFlags": [], +"resolveToken": "" } ` - var result resolveResponse + var result confidence.ResolveResponse decoder := json.NewDecoder(bytes.NewBuffer([]byte(templateResolveResponse))) decoder.UseNumber() _ = decoder.Decode(&result) diff --git a/provider/vcs.xml b/provider/vcs.xml new file mode 100644 index 0000000..b2bdec2 --- /dev/null +++ b/provider/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file