diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a85f139..3265bb1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -19,8 +19,31 @@ jobs: with: go-version: '1.20' - - name: Build - run: cd pkg/provider && go build -v . + - name: Build Provider + run: cd provider && go build -v . - - name: Test - run: cd pkg/provider && go test -v \ No newline at end of file + - name: Build Confidence + run: cd confidence && go build -v . + + - name: Test Provider + run: cd provider && go test -v + + - name: Test Confidence + run: cd confidence && go test -v + - name: Run gofmt + run: | + modules=("confidence" "demo" "demo-open-feature" "provider") + fmt_issues="" + for module in "${modules[@]}"; do + fmt_output=$(gofmt -l "$module") + if [ -n "$fmt_output" ]; then + fmt_issues+="$fmt_output"$'\n' + fi + done + + if [ -n "$fmt_issues" ]; then + echo "The following files are not properly formatted:" + echo "$fmt_issues" + echo "Please run 'gofmt -w .' in the respective module directories to format your code." + exit 1 + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f23b875 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +*/shelf/ +*/workspace.xml +# Editor-based HTTP Client requests +*/httpRequests/ +# Datasource local storage ignored files +*/dataSources/ +*/dataSources.local.xml +*/vcs.xmlg +*/.fleet diff --git a/confidence/EventUploader.go b/confidence/EventUploader.go new file mode 100644 index 0000000..1f37c14 --- /dev/null +++ b/confidence/EventUploader.go @@ -0,0 +1,33 @@ +package confidence + +import ( + "bytes" + "context" + "encoding/json" + "net/http" +) + +type EventUploader struct { + client http.Client + config APIConfig +} + +func (e EventUploader) upload(ctx context.Context, request EventBatchRequest) { + jsonRequest, err := json.Marshal(request) + if err != nil { + return + } + + payload := bytes.NewBuffer(jsonRequest) + req, err := http.NewRequestWithContext(ctx, + http.MethodPost, "https://events.eu.confidence.dev/v1/events:publish", payload) + if err != nil { + return + } + + resp, err := e.client.Do(req) + if err != nil { + return + } + defer resp.Body.Close() +} diff --git a/confidence/confidence.go b/confidence/confidence.go new file mode 100644 index 0000000..d3a3766 --- /dev/null +++ b/confidence/confidence.go @@ -0,0 +1,218 @@ +package confidence + +import ( + "context" + "fmt" + "net/http" + "reflect" + "strings" + "sync" + "time" +) + +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_CONFIDENCE" + SDK_VERSION = "0.1.8" // x-release-please-version +) + +type Confidence struct { + parent ContextProvider + uploader EventUploader + contextMap map[string]interface{} + Config APIConfig + ResolveClient ResolveClient +} + +func (e Confidence) GetContext() map[string]interface{} { + currentMap := map[string]interface{}{} + parentMap := make(map[string]interface{}) + if e.parent != nil { + parentMap = e.parent.GetContext() + } + for key, value := range parentMap { + currentMap[key] = value + } + for key, value := range e.contextMap { + if value == nil { + delete(currentMap, key) + } else { + 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) Track(ctx context.Context, eventName string, message map[string]interface{}) *sync.WaitGroup { + newMap := e.GetContext() + + for key, value := range message { + newMap[key] = value + } + + var wg sync.WaitGroup + wg.Add(1) + go func() { + currentTime := time.Now() + iso8601Time := currentTime.Format(time.RFC3339) + event := Event{ + EventDefinition: fmt.Sprintf("eventDefinitions/%s", eventName), + EventTime: iso8601Time, + Payload: newMap, + } + batch := EventBatchRequest{ + CclientSecret: e.Config.APIKey, + Sdk: sdk{SDK_ID, SDK_VERSION}, + SendTime: iso8601Time, + Events: []Event{event}, + } + e.uploader.upload(ctx, batch) + wg.Done() + }() + return &wg +} + +func (e Confidence) WithContext(context map[string]interface{}) Confidence { + newMap := map[string]interface{}{} + for key, value := range e.GetContext() { + newMap[key] = value + } + + for key, value := range context { + newMap[key] = value + } + + return Confidence{ + parent: &e, + contextMap: newMap, + 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_context_test.go b/confidence/confidence_context_test.go new file mode 100644 index 0000000..3aa3f45 --- /dev/null +++ b/confidence/confidence_context_test.go @@ -0,0 +1,50 @@ +package confidence + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestContextIsInConfidenceObject(t *testing.T) { + client := create_confidence(t, templateResponse()) + client.PutContext("hello", "hey") + assert.Equal(t, client.GetContext(), map[string]interface{}{"hello": "hey"}) +} + +func TestWithContextIsInChildContext(t *testing.T) { + client := create_confidence(t, templateResponse()) + client.PutContext("hello", "hey") + child := client.WithContext(map[string]interface{}{"west": "world"}) + assert.Equal(t, child.GetContext(), map[string]interface{}{"hello": "hey", "west": "world"}) + client.PutContext("hello2", "hey2") + assert.Equal(t, child.GetContext(), map[string]interface{}{"hello": "hey", "west": "world", "hello2": "hey2"}) +} + +func TestChildContextOverrideParentContext(t *testing.T) { + client := create_confidence(t, templateResponse()) + client.PutContext("hello", "hey") + child := client.WithContext(map[string]interface{}{"hello": "boom"}) + assert.Equal(t, child.GetContext(), map[string]interface{}{"hello": "boom"}) + assert.Equal(t, client.GetContext(), map[string]interface{}{"hello": "hey"}) +} + +func TestChildContextRemoveParentContext(t *testing.T) { + client := create_confidence(t, templateResponse()) + client.PutContext("hello", "hey") + child := client.WithContext(map[string]interface{}{}) + child.PutContext("hello", nil) + assert.Equal(t, child.GetContext(), map[string]interface{}{}) +} + +func create_confidence(t *testing.T, response ResolveResponse) *Confidence { + config := APIConfig{ + APIKey: "apiKey", + Region: APIRegionGlobal, + } + return &Confidence{ + Config: config, + ResolveClient: MockResolveClient{MockedResponse: response, MockedError: nil, TestingT: t}, + contextMap: make(map[string]interface{}), + } +} diff --git a/confidence/confidence_internal_test.go b/confidence/confidence_internal_test.go new file mode 100644 index 0000000..cf3705c --- /dev/null +++ b/confidence/confidence_internal_test.go @@ -0,0 +1,260 @@ +package confidence + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "github.com/stretchr/testify/assert" + "reflect" + "testing" +) + +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..fa986ac --- /dev/null +++ b/confidence/go.mod @@ -0,0 +1,11 @@ +module github.com/spotify/confidence-openfeature-provider-go/confidence + +go 1.19 + +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..60ce688 --- /dev/null +++ b/confidence/go.sum @@ -0,0 +1,10 @@ +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/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/confidence/models.go b/confidence/models.go new file mode 100644 index 0000000..7c1a6d4 --- /dev/null +++ b/confidence/models.go @@ -0,0 +1,158 @@ +package confidence + +import ( + "context" + "errors" +) + +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 +} + +func NewAPIConfig(apiKey string) *APIConfig { + return &APIConfig{ + APIKey: apiKey, + Region: APIRegionGlobal, + } +} + +const ( + APIRegionEU = iota + APIRegionUS = iota + APIRegionGlobal = iota +) + +// Private types below + +const euAPIURL = "https://resolver.eu.confidence.dev/v1" +const usAPIURL = "https://resolver.us.confidence.dev/v1" +const globalAPIURL = "https://resolver.confidence.dev/v1" + +func (r APIRegion) apiURL() string { + if r == APIRegionEU { + return euAPIURL + } else if r == APIRegionUS { + return usAPIURL + } else if r == APIRegionGlobal { + return globalAPIURL + } + return "" +} + +func (c APIConfig) Validate() error { + if c.APIKey == "" { + return errors.New("api key needs to be set") + } + if c.Region.apiURL() == "" { + return errors.New("api region needs to be set") + } + return nil +} + +type ResolveClient interface { + SendResolveRequest(ctx context.Context, request ResolveRequest) (ResolveResponse, error) +} + +var errFlagNotFound = errors.New("flag not found") + +type EventBatchRequest struct { + CclientSecret string `json:"clientSecret"` + Sdk sdk `json:"sdk"` + SendTime string `json:"sendTime"` + Events []Event `json:"events"` +} + +type Event struct { + EventDefinition string `json:"eventDefinition"` + EventTime string `json:"eventTime"` + Payload map[string]interface{} `json:"payload"` +} + +type ResolveRequest struct { + ClientSecret string `json:"client_secret"` + Apply bool `json:"apply"` + EvaluationContext map[string]interface{} `json:"evaluation_context"` + Flags []string `json:"flags"` + Sdk sdk `json:"sdk"` +} + +type sdk struct { + Id string `json:"id"` + Version string `json:"version"` +} + +type ResolveResponse struct { + ResolvedFlags []resolvedFlag `json:"resolvedFlags"` + ResolveToken string `json:"resolveToken"` +} + +type resolveErrorMessage struct { + Code int64 `json:"code"` + Message string `json:"message"` +} + +type resolvedFlag struct { + Flag string `json:"flag"` + Variant string `json:"variant"` + Reason string `json:"reason"` + Value map[string]interface{} `json:"value"` + FlagSchema flagSchema `json:"flagSchema"` +} + +type flagSchema struct { + Schema map[string]interface{} `json:"schema"` +} 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-open-feature/GoDemoApp.go b/demo-open-feature/GoDemoApp.go new file mode 100644 index 0000000..91d2533 --- /dev/null +++ b/demo-open-feature/GoDemoApp.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/open-feature/go-sdk/openfeature" + c "github.com/spotify/confidence-openfeature-provider-go/confidence" + p "github.com/spotify/confidence-openfeature-provider-go/provider" +) + +func main() { + clientSecret := "API_KEY" + fmt.Println("Fetching the flags...") + + confidence := c.NewConfidenceBuilder().SetAPIConfig(c.APIConfig{APIKey: clientSecret}).Build() + + confidence.PutContext("hello", "world") + + provider := p.NewFlagProvider(confidence) + + openfeature.SetProvider(provider) + client := openfeature.NewClient("testApp") + + attributes := make(map[string]interface{}) + targetingKey := uuid.New().String() + + fmt.Println(" Random UUID -> " + targetingKey) + + of := openfeature.NewEvaluationContext(targetingKey, attributes) + + colorValue, _ := client.StringValue(context.Background(), "hawkflag.color", "defaultValue", of) + messageValue, _ := client.StringValue(context.Background(), "hawkflag.message", "defaultValue", of) + + colorYellow := "\033[33m" + colorGreen := "\033[32m" + colorRed := "\033[31m" + + fmt.Println(" Color --> " + colorValue) + + switch { + case colorValue == "Yellow": + fmt.Println(colorYellow, "Message --> "+messageValue) + case colorValue == "Green": + fmt.Println(colorGreen, "Message --> "+messageValue) + default: + fmt.Println(colorRed, "Message --> "+messageValue) + } + + wg := confidence.Track(context.Background(), "navigate", map[string]interface{}{"test": "value"}) + wg.Wait() +} diff --git a/demo-open-feature/go.mod b/demo-open-feature/go.mod new file mode 100644 index 0000000..1c53a5d --- /dev/null +++ b/demo-open-feature/go.mod @@ -0,0 +1,19 @@ +module demo + +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/confidence v1.0.0 + github.com/spotify/confidence-openfeature-provider-go/provider 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 + golang.org/x/exp v0.0.0-20240213143201-ec583247a57a // indirect +) diff --git a/demo-open-feature/go.sum b/demo-open-feature/go.sum new file mode 100644 index 0000000..39c4ad6 --- /dev/null +++ b/demo-open-feature/go.sum @@ -0,0 +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/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/demo/GoDemoApp.go b/demo/GoDemoApp.go index b0e9c2f..cee3450 100644 --- a/demo/GoDemoApp.go +++ b/demo/GoDemoApp.go @@ -3,34 +3,18 @@ package main import ( "context" "fmt" - - "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" ) func main() { - clientSecret := "CLIENT_SECRET" fmt.Println("Fetching the flags...") - provider, err := confidence.NewFlagProvider(*confidence.NewAPIConfig(clientSecret)) - - if err != nil { - // handle error - } - - openfeature.SetProvider(provider) - client := openfeature.NewClient("testApp") - - attributes := make(map[string]interface{}) - targetingKey := uuid.New().String() + confidence := c.NewConfidenceBuilder().SetAPIConfig(c.APIConfig{APIKey: "API_KEY"}).Build() + targetingKey := "Random_targeting_key" + confidence.PutContext("targeting_key", targetingKey) - fmt.Println(" Random UUID -> " + targetingKey) - - of := openfeature.NewEvaluationContext(targetingKey, attributes) - - colorValue, _ := client.StringValue(context.Background(), "hawkflag.color", "defaultValue", of) - messageValue, _ := client.StringValue(context.Background(), "hawkflag.message", "defaultValue", of) + colorValue := confidence.GetStringFlag(context.Background(), "hawkflag.color", "defaultValue").Value + messageValue := confidence.GetStringFlag(context.Background(), "hawkflag.message", "defaultValue").Value colorYellow := "\033[33m" colorGreen := "\033[32m" @@ -46,4 +30,9 @@ func main() { default: fmt.Println(colorRed, "Message --> "+messageValue) } + + wg := confidence.Track(context.Background(), "page-viewed", map[string]interface{}{}) + wg.Wait() + fmt.Println("Event sent") + } diff --git a/demo/go.mod b/demo/go.mod index 6d9a544..2eed506 100644 --- a/demo/go.mod +++ b/demo/go.mod @@ -1,16 +1,7 @@ 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 -) +require github.com/spotify/confidence-openfeature-provider-go/confidence v1.0.0 -replace github.com/spotify/confidence-openfeature-provider-go v0.1.7 => ../ - -require ( - github.com/go-logr/logr v1.4.1 // indirect - golang.org/x/exp v0.0.0-20240213143201-ec583247a57a // indirect -) +replace github.com/spotify/confidence-openfeature-provider-go/confidence v1.0.0 => ../confidence diff --git a/demo/go.sum b/demo/go.sum index dfd5b5f..5496456 100644 --- a/demo/go.sum +++ b/demo/go.sum @@ -1,14 +1,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -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/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/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -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= +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= 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/models.go b/pkg/provider/models.go deleted file mode 100644 index 912704e..0000000 --- a/pkg/provider/models.go +++ /dev/null @@ -1,94 +0,0 @@ -package provider - -import ( - "context" - "errors" -) - -type APIRegion int64 - -type APIConfig struct { - APIKey string - Region APIRegion -} - -func NewAPIConfig(apiKey string) *APIConfig { - return &APIConfig{ - APIKey: apiKey, - Region: APIRegionGlobal, - } -} - -const ( - APIRegionEU = iota - APIRegionUS = iota - APIRegionGlobal = iota -) - -// Private types below - -const euAPIURL = "https://resolver.eu.confidence.dev/v1" -const usAPIURL = "https://resolver.us.confidence.dev/v1" -const globalAPIURL = "https://resolver.confidence.dev/v1" - -func (r APIRegion) apiURL() string { - if r == APIRegionEU { - return euAPIURL - } else if r == APIRegionUS { - return usAPIURL - } else if r == APIRegionGlobal { - return globalAPIURL - } - return "" -} - -func (c APIConfig) validate() error { - if c.APIKey == "" { - return errors.New("api key needs to be set") - } - if c.Region.apiURL() == "" { - return errors.New("api region needs to be set") - } - return nil -} - -type resolveClient interface { - sendResolveRequest(ctx context.Context, request resolveRequest) (resolveResponse, error) -} - -var errFlagNotFound = errors.New("flag not found") - -type resolveRequest struct { - ClientSecret string `json:"client_secret"` - Apply bool `json:"apply"` - EvaluationContext map[string]interface{} `json:"evaluation_context"` - Flags []string `json:"flags"` - Sdk sdk `json:"sdk"` -} - -type sdk struct { - Id string `json:"id"` - Version string `json:"version"` -} - -type resolveResponse struct { - ResolvedFlags []resolvedFlag `json:"resolvedFlags"` - ResolveToken string `json:"resolveToken"` -} - -type resolveErrorMessage struct { - Code int64 `json:"code"` - Message string `json:"message"` -} - -type resolvedFlag struct { - Flag string `json:"flag"` - Variant string `json:"variant"` - Reason string `json:"reason"` - Value map[string]interface{} `json:"value"` - FlagSchema flagSchema `json:"flagSchema"` -} - -type flagSchema struct { - Schema map[string]interface{} `json:"schema"` -} diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go deleted file mode 100755 index 243612d..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.8" // 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/go.mod b/provider/go.mod similarity index 53% rename from go.mod rename to provider/go.mod index cea4a4e..5937c20 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 +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/go.sum b/provider/go.sum similarity index 80% rename from go.sum rename to provider/go.sum index a2c86c1..f83ce24 100644 --- a/go.sum +++ b/provider/go.sum @@ -3,14 +3,12 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs 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= +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= 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/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)