diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 18343ca78..290baee35 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -11,5 +11,6 @@ "providers/unleash": "0.0.3-alpha", "providers/harness": "0.0.4-alpha", "providers/statsig": "0.0.2", + "providers/ofrep": "0.0.0", "tests/flagd": "1.4.1" } diff --git a/providers/ofrep/README.md b/providers/ofrep/README.md new file mode 100644 index 000000000..3722b0d8b --- /dev/null +++ b/providers/ofrep/README.md @@ -0,0 +1,50 @@ +# OpenFeature Remote Evaluation Protocol Provider + +This is the Go implementation of the OFREP provider. +The provider works by evaluating flags against OFREP single flag evaluation endpoint. + +## Installation + +Use OFREP provider with the latest OpenFeature Go SDK + +```sh +go get github.com/open-feature/go-sdk-contrib/providers/ofrep +go get github.com/open-feature/go-sdk +``` + +## Usage + +Initialize the provider with the URL of the OFREP implementing service, + +```go +ofrepProvider := ofrep.NewProvider("http://localhost:8016") +``` + +Then, register the provider with the OpenFeature Go SDK and use derived clients for flag evaluations, + +```go +openfeature.SetProvider(ofrepProvider) +``` + +## Configuration + +You can configure the provider using following configuration options, + +| Configuration option | Details | +|----------------------|-------------------------------------------------------------------------------------------------------------------------| +| WithApiKeyAuth | Set the token to be used with "X-API-Key" header | +| WithBearerToken | Set the token to be used with "Bearer" HTTP Authorization schema | +| WithClient | Provider a custom, pre-configured http.Client for OFREP service communication | +| WithHeaderProvider | Register a custom header provider for OFREP calls. You may utilize this for custom authentication/authorization headers | + + +For example, consider below example which set bearer token and provider a customized http client, + +```go +provider := ofrep.NewProvider( + "http://localhost:8016", + ofrep.WithBearerToken("TOKEN"), + ofrep.WithClient(&http.Client{ + Timeout: 1 * time.Second, + })) +``` \ No newline at end of file diff --git a/providers/ofrep/evaluate.go b/providers/ofrep/evaluate.go new file mode 100644 index 000000000..0eb7e6e70 --- /dev/null +++ b/providers/ofrep/evaluate.go @@ -0,0 +1,21 @@ +package ofrep + +import ( + "context" + + of "github.com/open-feature/go-sdk/openfeature" +) + +// Evaluator contract for flag evaluation +type Evaluator interface { + ResolveBoolean(ctx context.Context, key string, defaultValue bool, + evalCtx map[string]interface{}) of.BoolResolutionDetail + ResolveString(ctx context.Context, key string, defaultValue string, + evalCtx map[string]interface{}) of.StringResolutionDetail + ResolveFloat(ctx context.Context, key string, defaultValue float64, + evalCtx map[string]interface{}) of.FloatResolutionDetail + ResolveInt(ctx context.Context, key string, defaultValue int64, + evalCtx map[string]interface{}) of.IntResolutionDetail + ResolveObject(ctx context.Context, key string, defaultValue interface{}, + evalCtx map[string]interface{}) of.InterfaceResolutionDetail +} diff --git a/providers/ofrep/go.mod b/providers/ofrep/go.mod new file mode 100644 index 000000000..11f9cfacb --- /dev/null +++ b/providers/ofrep/go.mod @@ -0,0 +1,10 @@ +module github.com/open-feature/go-sdk-contrib/providers/ofrep + +go 1.21.0 + +require github.com/open-feature/go-sdk v1.10.0 + +require ( + github.com/go-logr/logr v1.4.1 // indirect + golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3 // indirect +) diff --git a/providers/ofrep/go.sum b/providers/ofrep/go.sum new file mode 100644 index 000000000..1cc0723df --- /dev/null +++ b/providers/ofrep/go.sum @@ -0,0 +1,10 @@ +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/open-feature/go-sdk v1.10.0 h1:druQtYOrN+gyz3rMsXp0F2jW1oBXJb0V26PVQnUGLbM= +github.com/open-feature/go-sdk v1.10.0/go.mod h1:+rkJhLBtYsJ5PZNddAgFILhRAAxwrJ32aU7UEUm4zQI= +golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3 h1:/RIbNt/Zr7rVhIkQhooTxCxFcdWLGIKnZA4IXNFSrvo= +golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= diff --git a/providers/ofrep/internal/evaluate/flags.go b/providers/ofrep/internal/evaluate/flags.go new file mode 100644 index 000000000..89e10f487 --- /dev/null +++ b/providers/ofrep/internal/evaluate/flags.go @@ -0,0 +1,194 @@ +package evaluate + +import ( + "context" + "fmt" + + "github.com/open-feature/go-sdk-contrib/providers/ofrep/internal/outbound" + of "github.com/open-feature/go-sdk/openfeature" +) + +// Flags is the flag evaluator implementation. It contains domain logic of the OpenFeature flag evaluation. +type Flags struct { + resolver resolver +} + +type resolver interface { + resolveSingle(ctx context.Context, key string, evalCtx map[string]interface{}) (*successDto, *of.ResolutionError) +} + +func NewFlagsEvaluator(cfg outbound.Configuration) *Flags { + return &Flags{ + resolver: NewOutboundResolver(cfg), + } +} + +func (h Flags) ResolveBoolean(ctx context.Context, key string, defaultValue bool, evalCtx map[string]interface{}) of.BoolResolutionDetail { + evalSuccess, resolutionError := h.resolver.resolveSingle(ctx, key, evalCtx) + if resolutionError != nil { + return of.BoolResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: *resolutionError, + Reason: of.ErrorReason, + }, + } + } + + b, ok := evalSuccess.Value.(bool) + if !ok { + return of.BoolResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.NewTypeMismatchResolutionError(fmt.Sprintf( + "resolved value %v is not of boolean type", evalSuccess.Value)), + Reason: of.ErrorReason, + }, + } + } + + return of.BoolResolutionDetail{ + Value: b, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.Reason(evalSuccess.Reason), + Variant: evalSuccess.Variant, + FlagMetadata: evalSuccess.Metadata, + }, + } +} + +func (h Flags) ResolveString(ctx context.Context, key string, defaultValue string, evalCtx map[string]interface{}) of.StringResolutionDetail { + evalSuccess, resolutionError := h.resolver.resolveSingle(ctx, key, evalCtx) + if resolutionError != nil { + return of.StringResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: *resolutionError, + Reason: of.ErrorReason, + }, + } + } + + b, ok := evalSuccess.Value.(string) + if !ok { + return of.StringResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.NewTypeMismatchResolutionError(fmt.Sprintf( + "resolved value %v is not of string type", evalSuccess.Value)), + Reason: of.ErrorReason, + }, + } + } + + return of.StringResolutionDetail{ + Value: b, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.Reason(evalSuccess.Reason), + Variant: evalSuccess.Variant, + FlagMetadata: evalSuccess.Metadata, + }, + } +} + +func (h Flags) ResolveFloat(ctx context.Context, key string, defaultValue float64, evalCtx map[string]interface{}) of.FloatResolutionDetail { + evalSuccess, resolutionError := h.resolver.resolveSingle(ctx, key, evalCtx) + if resolutionError != nil { + return of.FloatResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: *resolutionError, + Reason: of.ErrorReason, + }, + } + } + + var value float64 + + switch evalSuccess.Value.(type) { + case float32: + value = float64(evalSuccess.Value.(float32)) + case float64: + value = evalSuccess.Value.(float64) + default: + return of.FloatResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.NewTypeMismatchResolutionError(fmt.Sprintf( + "resolved value %v is not of float type", evalSuccess.Value)), + Reason: of.ErrorReason, + }, + } + } + + return of.FloatResolutionDetail{ + Value: value, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.Reason(evalSuccess.Reason), + Variant: evalSuccess.Variant, + FlagMetadata: evalSuccess.Metadata, + }, + } +} + +func (h Flags) ResolveInt(ctx context.Context, key string, defaultValue int64, evalCtx map[string]interface{}) of.IntResolutionDetail { + evalSuccess, resolutionError := h.resolver.resolveSingle(ctx, key, evalCtx) + if resolutionError != nil { + return of.IntResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: *resolutionError, + Reason: of.ErrorReason, + }, + } + } + + var value int64 + + switch evalSuccess.Value.(type) { + case int: + value = int64(evalSuccess.Value.(int)) + case int64: + value = evalSuccess.Value.(int64) + default: + return of.IntResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: of.NewTypeMismatchResolutionError(fmt.Sprintf( + "resolved value %v is not of integer type", evalSuccess.Value)), + Reason: of.ErrorReason, + }, + } + } + + return of.IntResolutionDetail{ + Value: value, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.Reason(evalSuccess.Reason), + Variant: evalSuccess.Variant, + FlagMetadata: evalSuccess.Metadata, + }, + } +} + +func (h Flags) ResolveObject(ctx context.Context, key string, defaultValue interface{}, evalCtx map[string]interface{}) of.InterfaceResolutionDetail { + evalSuccess, resolutionError := h.resolver.resolveSingle(ctx, key, evalCtx) + if resolutionError != nil { + return of.InterfaceResolutionDetail{ + Value: defaultValue, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + ResolutionError: *resolutionError, + Reason: of.ErrorReason, + }, + } + } + + return of.InterfaceResolutionDetail{ + Value: evalSuccess.Value, + ProviderResolutionDetail: of.ProviderResolutionDetail{ + Reason: of.Reason(evalSuccess.Reason), + Variant: evalSuccess.Variant, + FlagMetadata: evalSuccess.Metadata, + }, + } +} diff --git a/providers/ofrep/internal/evaluate/flags_test.go b/providers/ofrep/internal/evaluate/flags_test.go new file mode 100644 index 000000000..4496d5b53 --- /dev/null +++ b/providers/ofrep/internal/evaluate/flags_test.go @@ -0,0 +1,319 @@ +package evaluate + +import ( + "context" + "reflect" + "testing" + + of "github.com/open-feature/go-sdk/openfeature" +) + +type mockResolver struct { + success *successDto + err *of.ResolutionError +} + +func (m mockResolver) resolveSingle(ctx context.Context, key string, evalCtx map[string]interface{}) (*successDto, *of.ResolutionError) { + return m.success, m.err +} + +type knownTypes interface { + int64 | bool | float64 | string | interface{} +} + +type testDefinition[T knownTypes] struct { + name string + resolver mockResolver + isError bool + defaultValue T + expect T +} + +var successBoolean = successDto{ + Value: true, + Reason: string(of.StaticReason), + Variant: "true", + Metadata: nil, +} + +var successInt = successDto{ + Value: 10, + Reason: string(of.StaticReason), + Variant: "10", + Metadata: nil, +} + +var successInt64 = successDto{ + Value: int64(10), + Reason: string(of.StaticReason), + Variant: "10", + Metadata: nil, +} + +var successFloat = successDto{ + Value: float32(1.10), + Reason: string(of.StaticReason), + Variant: "1.10", + Metadata: nil, +} + +var successFloat64 = successDto{ + Value: float64(1.10), + Reason: string(of.StaticReason), + Variant: "1.10", + Metadata: nil, +} + +var successString = successDto{ + Value: "pass", + Reason: string(of.StaticReason), + Variant: "pass", + Metadata: nil, +} + +var successObject = successDto{ + Value: map[string]string{ + "key": "value", + }, + Reason: string(of.StaticReason), + Variant: "pass", + Metadata: nil, +} + +var parseError = of.NewParseErrorResolutionError("flag parsing error") + +func TestBooleanEvaluation(t *testing.T) { + ctx := context.Background() + + tests := []testDefinition[bool]{ + { + name: "Success evaluation", + resolver: mockResolver{ + success: &successBoolean, + }, + defaultValue: false, + expect: successBoolean.Value.(bool), + }, + { + name: "Error evaluation", + resolver: mockResolver{ + err: &parseError, + }, + isError: true, + defaultValue: false, + expect: false, + }, + { + name: "Type conversion error", + isError: true, + resolver: mockResolver{ + success: &successInt, + }, + defaultValue: false, + expect: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + flags := Flags{resolver: test.resolver} + resolutionDetail := flags.ResolveBoolean(ctx, "booleanFlag", test.defaultValue, nil) + genericValidator[bool](test, resolutionDetail.Value, resolutionDetail.Reason, resolutionDetail.Error(), t) + }) + } +} + +func TestIntegerEvaluation(t *testing.T) { + ctx := context.Background() + + tests := []testDefinition[int64]{ + { + name: "Success evaluation", + resolver: mockResolver{ + success: &successInt, + }, + defaultValue: 1, + expect: int64(successInt.Value.(int)), + }, + { + name: "Success evaluation - int64", + resolver: mockResolver{ + success: &successInt64, + }, + defaultValue: 1, + expect: successInt64.Value.(int64), + }, + { + name: "Error evaluation", + resolver: mockResolver{ + err: &parseError, + }, + isError: true, + defaultValue: 1, + expect: 1, + }, + { + name: "Type conversion error", + resolver: mockResolver{ + success: &successBoolean, + }, + isError: true, + defaultValue: 1, + expect: 1, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + flags := Flags{resolver: test.resolver} + resolutionDetail := flags.ResolveInt(ctx, "booleanFlag", test.defaultValue, nil) + genericValidator[int64](test, resolutionDetail.Value, resolutionDetail.Reason, resolutionDetail.Error(), t) + }) + } +} + +func TestFloatEvaluation(t *testing.T) { + ctx := context.Background() + + tests := []testDefinition[float64]{ + { + name: "Success evaluation", + resolver: mockResolver{ + success: &successFloat, + }, + defaultValue: 1.05, + expect: float64(successFloat.Value.(float32)), + }, + { + name: "Success evaluation - float64", + resolver: mockResolver{ + success: &successFloat64, + }, + defaultValue: 1.05, + expect: successFloat64.Value.(float64), + }, + { + name: "Error evaluation", + resolver: mockResolver{ + err: &parseError, + }, + isError: true, + defaultValue: 1.05, + expect: 1.05, + }, + { + name: "Type conversion error", + resolver: mockResolver{ + success: &successBoolean, + }, + isError: true, + defaultValue: 1.05, + expect: 1.05, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + flags := Flags{resolver: test.resolver} + resolutionDetail := flags.ResolveFloat(ctx, "booleanFlag", test.defaultValue, nil) + genericValidator[float64](test, resolutionDetail.Value, resolutionDetail.Reason, resolutionDetail.Error(), t) + }) + } +} + +func TestStringEvaluation(t *testing.T) { + ctx := context.Background() + + tests := []testDefinition[string]{ + { + name: "Success evaluation", + resolver: mockResolver{ + success: &successString, + }, + defaultValue: "fail", + expect: successString.Value.(string), + }, + { + name: "Error evaluation", + resolver: mockResolver{ + err: &parseError, + }, + isError: true, + defaultValue: "fail", + expect: "fail", + }, + { + name: "Type conversion error", + resolver: mockResolver{ + success: &successBoolean, + }, + isError: true, + defaultValue: "fail", + expect: "fail", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + flags := Flags{resolver: test.resolver} + resolutionDetail := flags.ResolveString(ctx, "booleanFlag", test.defaultValue, nil) + genericValidator[string](test, resolutionDetail.Value, resolutionDetail.Reason, resolutionDetail.Error(), t) + }) + } +} + +func TestObjectEvaluation(t *testing.T) { + ctx := context.Background() + + tests := []testDefinition[interface{}]{ + { + name: "Success evaluation", + resolver: mockResolver{ + success: &successObject, + }, + defaultValue: map[string]interface{}{}, + expect: successObject.Value, + }, + { + name: "Error evaluation", + resolver: mockResolver{ + err: &parseError, + }, + isError: true, + defaultValue: map[string]interface{}{}, + expect: map[string]interface{}{}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + flags := Flags{resolver: test.resolver} + resolutionDetail := flags.ResolveObject(ctx, "booleanFlag", test.defaultValue, nil) + genericValidator[interface{}](test, resolutionDetail.Value, resolutionDetail.Reason, resolutionDetail.Error(), t) + }) + } +} + +func genericValidator[T knownTypes](test testDefinition[T], resolvedValue T, reason of.Reason, err error, t *testing.T) { + if test.isError { + if err == nil { + t.Error("expected error but got nil") + } + + if !reflect.DeepEqual(test.defaultValue, resolvedValue) { + t.Errorf("expected deafault value %v, but got %v", test.defaultValue, resolvedValue) + } + + if reason != of.ErrorReason { + t.Errorf("expected reason %v, but got %v", of.ErrorReason, reason) + } + } else { + if err != nil { + t.Fatalf("expected no error, but got none nil error: %v", err) + } + + if !reflect.DeepEqual(test.expect, resolvedValue) { + t.Errorf("expected value %v, but got %v", test.expect, resolvedValue) + } + } +} diff --git a/providers/ofrep/internal/evaluate/resolver.go b/providers/ofrep/internal/evaluate/resolver.go new file mode 100644 index 000000000..fedb46c0b --- /dev/null +++ b/providers/ofrep/internal/evaluate/resolver.go @@ -0,0 +1,190 @@ +package evaluate + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/open-feature/go-sdk-contrib/providers/ofrep/internal/outbound" + of "github.com/open-feature/go-sdk/openfeature" +) + +// OutboundResolver is responsible for resolving flags with outbound communications. +// It contains domain logic of the OFREP specification. +type OutboundResolver struct { + client Outbound +} + +// Outbound defines the contract for resolver's outbound communication, matching OFREP API. +type Outbound interface { + // Single flag resolving + Single(ctx context.Context, key string, payload []byte) (*outbound.Resolution, error) +} + +func NewOutboundResolver(cfg outbound.Configuration) *OutboundResolver { + return &OutboundResolver{client: outbound.NewHttp(cfg)} +} + +func (g *OutboundResolver) resolveSingle(ctx context.Context, key string, evalCtx map[string]interface{}) ( + *successDto, *of.ResolutionError) { + + b, err := json.Marshal(requestFrom(evalCtx)) + if err != nil { + resErr := of.NewGeneralResolutionError(fmt.Sprintf("context marshelling error: %v", err)) + return nil, &resErr + } + + rsp, err := g.client.Single(ctx, key, b) + if err != nil { + resErr := of.NewGeneralResolutionError(fmt.Sprintf("ofrep request error: %v", err)) + return nil, &resErr + } + + // detect handler based on known ofrep status codes + switch rsp.Status { + case 200: + var success evaluationSuccess + err := json.Unmarshal(rsp.Data, &success) + if err != nil { + resErr := of.NewGeneralResolutionError(fmt.Sprintf("error parsing the response: %v", err)) + return nil, &resErr + } + return toSuccessDto(success) + case 400: + return nil, parseError400(rsp.Data) + case 401, 403: + resErr := of.NewGeneralResolutionError("authentication/authorization error") + return nil, &resErr + case 404: + resErr := of.NewFlagNotFoundResolutionError(fmt.Sprintf("flag for key '%s' does not exist", key)) + return nil, &resErr + case 429: + after := parse429(rsp) + var resErr of.ResolutionError + if after == 0 { + resErr = of.NewGeneralResolutionError("rate limit exceeded") + } else { + // todo - we may introduce a request blocker with derived time + resErr = of.NewGeneralResolutionError( + fmt.Sprintf("rate limit exceeded, try again after %f seconds", after.Seconds())) + } + return nil, &resErr + case 500: + return nil, parseError500(rsp.Data) + default: + resErr := of.NewGeneralResolutionError("invalid response") + return nil, &resErr + } +} + +func parseError400(data []byte) *of.ResolutionError { + var evalError evaluationError + err := json.Unmarshal(data, &evalError) + if err != nil { + resErr := of.NewGeneralResolutionError(fmt.Sprintf("error parsing error payload: %v", err)) + return &resErr + } + + var resErr of.ResolutionError + switch evalError.ErrorCode { + case string(of.ParseErrorCode): + resErr = of.NewParseErrorResolutionError(evalError.ErrorDetails) + case string(of.TargetingKeyMissingCode): + resErr = of.NewTargetingKeyMissingResolutionError(evalError.ErrorDetails) + case string(of.InvalidContextCode): + resErr = of.NewInvalidContextResolutionError(evalError.ErrorDetails) + case string(of.GeneralCode): + resErr = of.NewGeneralResolutionError(evalError.ErrorDetails) + default: + resErr = of.NewGeneralResolutionError(evalError.ErrorDetails) + } + + return &resErr +} + +func parse429(rsp *outbound.Resolution) time.Duration { + retryHeader := rsp.Headers.Get("Retry-After") + if retryHeader == "" { + return 0 + } + + if i, err := strconv.Atoi(retryHeader); err == nil { + return time.Duration(i) * time.Second + } + + parsed, err := http.ParseTime(retryHeader) + if err != nil { + return 0 + } + + return time.Until(parsed) +} + +func parseError500(data []byte) *of.ResolutionError { + var evalError errorResponse + var resErr of.ResolutionError + + err := json.Unmarshal(data, &evalError) + if err != nil { + resErr = of.NewGeneralResolutionError(fmt.Sprintf("error parsing error payload: %v", err)) + } else { + resErr = of.NewGeneralResolutionError(evalError.ErrorDetails) + } + + return &resErr +} + +// DTOs and OFREP models + +type successDto struct { + Value interface{} + Reason string + Variant string + Metadata map[string]interface{} +} + +func toSuccessDto(e evaluationSuccess) (*successDto, *of.ResolutionError) { + m, ok := e.Metadata.(map[string]interface{}) + if !ok { + resErr := of.NewParseErrorResolutionError("metadata must be a map of string keys and arbitrary values") + return nil, &resErr + } + + return &successDto{ + Value: e.Value, + Reason: e.Reason, + Variant: e.Variant, + Metadata: m, + }, nil +} + +type request struct { + Context interface{} `json:"context"` +} + +func requestFrom(ctx map[string]interface{}) request { + return request{ + Context: ctx, + } +} + +type evaluationSuccess struct { + Value interface{} `json:"value"` + Key string `json:"key"` + Reason string `json:"reason"` + Variant string `json:"variant"` + Metadata interface{} `json:"metadata"` +} + +type evaluationError struct { + Key string `json:"key"` + ErrorCode string `json:"errorCode"` + ErrorDetails string `json:"errorDetails"` +} + +type errorResponse struct { + ErrorDetails string `json:"errorDetails"` +} diff --git a/providers/ofrep/internal/evaluate/resolver_test.go b/providers/ofrep/internal/evaluate/resolver_test.go new file mode 100644 index 000000000..c48ae2b1d --- /dev/null +++ b/providers/ofrep/internal/evaluate/resolver_test.go @@ -0,0 +1,324 @@ +package evaluate + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "github.com/open-feature/go-sdk-contrib/providers/ofrep/internal/outbound" + of "github.com/open-feature/go-sdk/openfeature" +) + +type resolverTest struct { + name string + client mockOutbound +} + +var success = evaluationSuccess{ + Value: true, + Key: "flagA", + Reason: string(of.StaticReason), + Variant: "true", + Metadata: map[string]interface{}{ + "key": "value", + }, +} + +var successWithInvalidMetadata = evaluationSuccess{ + Value: true, + Key: "flagA", + Reason: string(of.StaticReason), + Variant: "true", + Metadata: "metadata", +} + +func TestSuccess200(t *testing.T) { + t.Run("success evaluation response", func(t *testing.T) { + successBytes, err := json.Marshal(success) + if err != nil { + t.Fatal(err) + } + + resolver := OutboundResolver{client: mockOutbound{ + rsp: outbound.Resolution{ + Status: http.StatusOK, + Data: successBytes, + }, + }} + + successDto, resolutionError := resolver.resolveSingle(context.Background(), "", make(map[string]interface{})) + + if resolutionError != nil { + t.Errorf("expected no errors, but got error: %v", err) + } + + if successDto == nil { + t.Fatal("expected non empty success response") + } + + if successDto.Value != success.Value { + t.Errorf("expected value %v, but got %v", success.Value, successDto.Value) + } + + if successDto.Variant != success.Variant { + t.Errorf("expected variant %v, but got %v", success.Variant, successDto.Variant) + } + + if successDto.Reason != success.Reason { + t.Errorf("expected reason %s, but got %s", success.Reason, successDto.Reason) + } + + if successDto.Metadata["key"] != "value" { + t.Errorf("expected key to contain value %s, but got %s", "value", successDto.Metadata["key"]) + } + }) + + t.Run("invalid payload type results in general error", func(t *testing.T) { + resolver := OutboundResolver{client: mockOutbound{ + rsp: outbound.Resolution{ + Status: http.StatusOK, + Data: []byte("some payload"), + }, + }} + success, resolutionError := resolver.resolveSingle(context.Background(), "", make(map[string]interface{})) + + validateErrorCode(success, resolutionError, of.GeneralCode, t) + }) + + t.Run("invalid metadata results in a parsing error", func(t *testing.T) { + b, err := json.Marshal(successWithInvalidMetadata) + if err != nil { + t.Fatal(err) + } + + resolver := OutboundResolver{client: mockOutbound{ + rsp: outbound.Resolution{ + Status: http.StatusOK, + Data: b, + }, + }} + success, resolutionError := resolver.resolveSingle(context.Background(), "", make(map[string]interface{})) + + validateErrorCode(success, resolutionError, of.ParseErrorCode, t) + }) +} + +func TestResolveGeneralErrors(t *testing.T) { + tests := []resolverTest{ + { + name: "http error results in a general error", + client: mockOutbound{ + err: errors.New("some http error"), + rsp: outbound.Resolution{}, + }, + }, + { + name: "non ofrep http status codes results in general error", + client: mockOutbound{ + rsp: outbound.Resolution{ + Status: http.StatusServiceUnavailable, + }, + }, + }, + { + name: "http 401", + client: mockOutbound{ + rsp: outbound.Resolution{ + Status: http.StatusUnauthorized, + }, + }, + }, + { + name: "http 403", + client: mockOutbound{ + rsp: outbound.Resolution{ + Status: http.StatusForbidden, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + resolver := OutboundResolver{client: test.client} + success, resolutionError := resolver.resolveSingle(context.Background(), "key", map[string]interface{}{}) + + validateErrorCode(success, resolutionError, of.GeneralCode, t) + }) + } +} + +func TestEvaluationError4xx(t *testing.T) { + tests := []struct { + name string + errorCode of.ErrorCode + expectCode of.ErrorCode + }{ + { + name: "validate parse error", + errorCode: of.ParseErrorCode, + expectCode: of.ParseErrorCode, + }, + { + name: "validate targeting key missing error", + errorCode: of.TargetingKeyMissingCode, + expectCode: of.TargetingKeyMissingCode, + }, + { + name: "validate invalid context error", + errorCode: of.InvalidContextCode, + expectCode: of.InvalidContextCode, + }, + { + name: "validate general error", + errorCode: of.GeneralCode, + expectCode: of.GeneralCode, + }, + { + name: "validate ofrep unhandled code", + errorCode: of.ProviderNotReadyCode, + expectCode: of.GeneralCode, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // derive test specific error response + errBytes, err := json.Marshal(evaluationError{ + ErrorCode: string(test.errorCode), + }) + if err != nil { + t.Fatal(err) + } + + resolver := OutboundResolver{client: mockOutbound{ + rsp: outbound.Resolution{ + Status: http.StatusBadRequest, + Data: errBytes, + }, + }} + success, resolutionError := resolver.resolveSingle(context.Background(), "", make(map[string]interface{})) + + validateErrorCode(success, resolutionError, test.expectCode, t) + }) + } +} + +func TestFlagNotFound404(t *testing.T) { + resolver := OutboundResolver{client: mockOutbound{ + rsp: outbound.Resolution{ + Status: http.StatusNotFound, + }, + }} + success, resolutionError := resolver.resolveSingle(context.Background(), "", make(map[string]interface{})) + + validateErrorCode(success, resolutionError, of.FlagNotFoundCode, t) +} + +func Test429(t *testing.T) { + tests := []struct { + name string + retryAfter string + }{ + { + name: "handle 429 with retry after header with seconds", + retryAfter: "10", + }, + { + name: "handle 429 with retry after header with date", + retryAfter: "Wed, 21 Oct 2015 07:28:00 GMT", + }, + { + name: "handle 429 without retry header", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // derive test specific handler + response := outbound.Resolution{ + Status: http.StatusTooManyRequests, + } + + if test.retryAfter != "" { + response.Headers = map[string][]string{ + "Retry-After": {test.retryAfter}, + } + } + + resolver := OutboundResolver{client: mockOutbound{rsp: response}} + success, resolutionError := resolver.resolveSingle(context.Background(), "", make(map[string]interface{})) + + validateErrorCode(success, resolutionError, of.GeneralCode, t) + }) + } +} + +func TestEvaluationError5xx(t *testing.T) { + t.Run("without body", func(t *testing.T) { + resolver := OutboundResolver{client: mockOutbound{ + rsp: outbound.Resolution{ + Status: http.StatusInternalServerError, + Data: []byte{}, + }, + }} + success, resolutionError := resolver.resolveSingle(context.Background(), "", make(map[string]interface{})) + + validateErrorCode(success, resolutionError, of.GeneralCode, t) + }) + + t.Run("with valid body", func(t *testing.T) { + errorBytes, err := json.Marshal(errorResponse{ErrorDetails: "some error detail"}) + if err != nil { + t.Fatal(err) + } + + resolver := OutboundResolver{client: mockOutbound{ + rsp: outbound.Resolution{ + Status: http.StatusInternalServerError, + Data: errorBytes, + }, + }} + success, resolutionError := resolver.resolveSingle(context.Background(), "", make(map[string]interface{})) + + validateErrorCode(success, resolutionError, of.GeneralCode, t) + }) + + t.Run("with invalid body", func(t *testing.T) { + resolver := OutboundResolver{client: mockOutbound{ + rsp: outbound.Resolution{ + Status: http.StatusInternalServerError, + Data: []byte("some error"), + }, + }} + success, resolutionError := resolver.resolveSingle(context.Background(), "", make(map[string]interface{})) + + validateErrorCode(success, resolutionError, of.GeneralCode, t) + }) +} + +func validateErrorCode(success *successDto, resolutionError *of.ResolutionError, errorCode of.ErrorCode, t *testing.T) { + if success != nil { + t.Fatal("expected no success result, but got non nil value") + } + + if resolutionError == nil { + t.Fatal("expected non nil error, but got empty") + } + + if !strings.Contains(resolutionError.Error(), string(errorCode)) { + t.Errorf("expected error to contain error code %s", errorCode) + } +} + +type mockOutbound struct { + err error + rsp outbound.Resolution +} + +func (m mockOutbound) Single(_ context.Context, _ string, _ []byte) (*outbound.Resolution, error) { + return &m.rsp, m.err +} diff --git a/providers/ofrep/internal/outbound/http.go b/providers/ofrep/internal/outbound/http.go new file mode 100644 index 000000000..630986652 --- /dev/null +++ b/providers/ofrep/internal/outbound/http.go @@ -0,0 +1,84 @@ +package outbound + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/url" + "time" + + of "github.com/open-feature/go-sdk/openfeature" +) + +const ofrepV1 = "/ofrep/v1/evaluate/flags/" + +// HeaderCallback is a callback returning header name and header value +type HeaderCallback func() (name string, value string) + +type Configuration struct { + BaseURI string + Callbacks []HeaderCallback + Client *http.Client +} + +type Resolution struct { + Data []byte + Status int + Headers http.Header +} + +// Outbound client for http communication +type Outbound struct { + baseURI string + client *http.Client + headerProvider []HeaderCallback +} + +func NewHttp(cfg Configuration) *Outbound { + if cfg.Client == nil { + cfg.Client = &http.Client{ + Timeout: 10 * time.Second, + } + } + + return &Outbound{ + headerProvider: cfg.Callbacks, + baseURI: cfg.BaseURI, + client: cfg.Client, + } +} + +func (h *Outbound) Single(ctx context.Context, key string, payload []byte) (*Resolution, error) { + path, err := url.JoinPath(h.baseURI, ofrepV1, key) + if err != nil { + return nil, fmt.Errorf("error building request path: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, path, bytes.NewReader(payload)) + if err != nil { + resErr := of.NewGeneralResolutionError(fmt.Sprintf("request building error: %v", err)) + return nil, &resErr + } + + for _, callback := range h.headerProvider { + req.Header.Set(callback()) + } + + rsp, err := h.client.Do(req) + if err != nil { + return nil, err + } + + b, err := io.ReadAll(rsp.Body) + if err != nil { + return nil, err + } + + return &Resolution{ + Data: b, + Status: rsp.StatusCode, + Headers: rsp.Header, + }, nil +} diff --git a/providers/ofrep/internal/outbound/http_test.go b/providers/ofrep/internal/outbound/http_test.go new file mode 100644 index 000000000..0e2947151 --- /dev/null +++ b/providers/ofrep/internal/outbound/http_test.go @@ -0,0 +1,89 @@ +package outbound + +import ( + "context" + "errors" + "fmt" + "net/http" + "testing" + "time" +) + +func TestHttpOutbound(t *testing.T) { + // given + host := "localhost:18181" + key := "flag" + + server := http.Server{ + Addr: host, + Handler: mockHandler{ + t: t, + key: key, + }, + } + + go func() { + err := server.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + t.Logf("error starting mock server: %v", err) + return + } + }() + + <-time.After(3 * time.Second) + + outbound := NewHttp(Configuration{ + Callbacks: []HeaderCallback{ + func() (string, string) { + return "Authorization", "Token" + }, + }, + BaseURI: fmt.Sprintf("http://%s", host), + }) + + // when + response, err := outbound.Single(context.Background(), key, []byte{}) + if err != nil { + t.Fatalf("error from request: %v", err) + return + } + + // then - expect an ok response + if response.Status != http.StatusOK { + t.Errorf("expected 200, but got %d", response.Status) + } + + // cleanup + err = server.Shutdown(context.Background()) + if err != nil { + t.Errorf("error shuttting down mock server: %v", err) + } +} + +type mockHandler struct { + key string + t *testing.T +} + +func (r mockHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + r.t.Logf("invalid request method, expected %s, got %s. test will fail", http.MethodPost, req.Method) + resp.WriteHeader(http.StatusBadRequest) + return + } + + path := fmt.Sprintf("%s%s", ofrepV1, r.key) + if req.RequestURI != fmt.Sprintf("%s%s", ofrepV1, r.key) { + r.t.Logf("invalid request path, expected %s, got %s. test will fail", path, req.RequestURI) + resp.WriteHeader(http.StatusBadRequest) + return + } + + if req.Header.Get("Authorization") == "" { + r.t.Log("expected non-empty Authorization header, but got empty. test will fail") + resp.WriteHeader(http.StatusBadRequest) + return + } + + resp.WriteHeader(http.StatusOK) +} diff --git a/providers/ofrep/provider.go b/providers/ofrep/provider.go new file mode 100644 index 000000000..1c8236f96 --- /dev/null +++ b/providers/ofrep/provider.go @@ -0,0 +1,100 @@ +package ofrep + +import ( + "context" + "fmt" + "net/http" + + "github.com/open-feature/go-sdk-contrib/providers/ofrep/internal/evaluate" + "github.com/open-feature/go-sdk-contrib/providers/ofrep/internal/outbound" + "github.com/open-feature/go-sdk/openfeature" +) + +// Provider implementation for OFREP +type Provider struct { + evaluator Evaluator +} + +type option func(*outbound.Configuration) + +// NewProvider returns an OFREP provider configured with provided configuration. +// The only mandatory configuration is the baseUri, which is the base path of the OFREP service implementation. +func NewProvider(baseUri string, options ...option) *Provider { + cfg := outbound.Configuration{ + BaseURI: baseUri, + } + + for _, option := range options { + option(&cfg) + } + + provider := &Provider{ + evaluator: evaluate.NewFlagsEvaluator(cfg), + } + + return provider +} + +func (p Provider) Metadata() openfeature.Metadata { + return openfeature.Metadata{ + Name: "OpenFeature Remote Evaluation Protocol Provider", + } +} + +func (p Provider) BooleanEvaluation(ctx context.Context, flag string, defaultValue bool, evalCtx openfeature.FlattenedContext) openfeature.BoolResolutionDetail { + return p.evaluator.ResolveBoolean(ctx, flag, defaultValue, evalCtx) +} + +func (p Provider) StringEvaluation(ctx context.Context, flag string, defaultValue string, evalCtx openfeature.FlattenedContext) openfeature.StringResolutionDetail { + return p.evaluator.ResolveString(ctx, flag, defaultValue, evalCtx) +} + +func (p Provider) FloatEvaluation(ctx context.Context, flag string, defaultValue float64, evalCtx openfeature.FlattenedContext) openfeature.FloatResolutionDetail { + return p.evaluator.ResolveFloat(ctx, flag, defaultValue, evalCtx) +} + +func (p Provider) IntEvaluation(ctx context.Context, flag string, defaultValue int64, evalCtx openfeature.FlattenedContext) openfeature.IntResolutionDetail { + return p.evaluator.ResolveInt(ctx, flag, defaultValue, evalCtx) +} + +func (p Provider) ObjectEvaluation(ctx context.Context, flag string, defaultValue interface{}, evalCtx openfeature.FlattenedContext) openfeature.InterfaceResolutionDetail { + return p.evaluator.ResolveObject(ctx, flag, defaultValue, evalCtx) +} + +func (p Provider) Hooks() []openfeature.Hook { + return []openfeature.Hook{} +} + +// options of the OFREP provider + +// WithHeaderProvider allows to configure a custom header callback to set a custom authorization header +func WithHeaderProvider(callback outbound.HeaderCallback) func(*outbound.Configuration) { + return func(c *outbound.Configuration) { + c.Callbacks = append(c.Callbacks, callback) + } +} + +// WithBearerToken allows to set token to be used for bearer token authorization +func WithBearerToken(token string) func(*outbound.Configuration) { + return func(c *outbound.Configuration) { + c.Callbacks = append(c.Callbacks, func() (string, string) { + return "Authorization", fmt.Sprintf("Bearer %s", token) + }) + } +} + +// WithApiKeyAuth allows to set token to be used for api key authorization +func WithApiKeyAuth(token string) func(*outbound.Configuration) { + return func(c *outbound.Configuration) { + c.Callbacks = append(c.Callbacks, func() (string, string) { + return "X-API-Key", token + }) + } +} + +// WithClient allows to provide a pre-configured http.Client for the communication with the OFREP service +func WithClient(client *http.Client) func(configuration *outbound.Configuration) { + return func(configuration *outbound.Configuration) { + configuration.Client = client + } +} diff --git a/providers/ofrep/provider_test.go b/providers/ofrep/provider_test.go new file mode 100644 index 000000000..262871e90 --- /dev/null +++ b/providers/ofrep/provider_test.go @@ -0,0 +1,130 @@ +package ofrep + +import ( + "context" + "fmt" + "github.com/open-feature/go-sdk/openfeature" + "net/http" + "testing" + "time" + + "github.com/open-feature/go-sdk-contrib/providers/ofrep/internal/outbound" +) + +func TestConfigurations(t *testing.T) { + t.Run("validate header provider", func(t *testing.T) { + c := outbound.Configuration{} + + WithHeaderProvider(func() (key string, value string) { + return "HEADER", "VALUE" + })(&c) + + h, v := c.Callbacks[0]() + + if h != "HEADER" { + t.Errorf(fmt.Sprintf("expected header %s, but got %s", "HEADER", h)) + } + + if v != "VALUE" { + t.Errorf(fmt.Sprintf("expected value %s, but got %s", "VALUE", v)) + } + }) + + t.Run("validate bearer token", func(t *testing.T) { + c := outbound.Configuration{} + + WithBearerToken("TOKEN")(&c) + + h, v := c.Callbacks[0]() + + if h != "Authorization" { + t.Errorf(fmt.Sprintf("expected header %s, but got %s", "Authorization", h)) + } + + if v != "Bearer TOKEN" { + t.Errorf(fmt.Sprintf("expected value %s, but got %s", "Bearer TOKEN", v)) + } + }) + + t.Run("validate api auth key", func(t *testing.T) { + c := outbound.Configuration{} + + WithApiKeyAuth("TOKEN")(&c) + + h, v := c.Callbacks[0]() + + if h != "X-API-Key" { + t.Errorf(fmt.Sprintf("expected header %s, but got %s", "X-API-Key", h)) + } + + if v != "TOKEN" { + t.Errorf(fmt.Sprintf("expected value %s, but got %s", "TOKEN", v)) + } + }) +} + +func TestWiringE2E(t *testing.T) { + // mock server with mocked response + host := "localhost:18182" + + server := http.Server{ + Addr: host, + Handler: mockHandler{ + response: "{\"value\":true,\"key\":\"my-flag\",\"reason\":\"STATIC\",\"variant\":\"true\",\"metadata\":{}}", + t: t, + }, + } + + go func() { + err := server.ListenAndServe() + if err != nil { + t.Logf("error starting mock server: %v", err) + return + } + }() + + // time for server to be ready + <-time.After(3 * time.Second) + + // custom client with reduced timeout + customClient := &http.Client{ + Timeout: 1 * time.Second, + } + + provider := NewProvider(fmt.Sprintf("http://%s", host), WithClient(customClient)) + booleanEvaluation := provider.BooleanEvaluation(context.Background(), "flag", false, nil) + + if booleanEvaluation.Value != true { + t.Errorf("expected %v, but got %v", true, booleanEvaluation.Value) + } + + if booleanEvaluation.Variant != "true" { + t.Errorf("expected %v, but got %v", "true", booleanEvaluation.Variant) + } + + if booleanEvaluation.Reason != openfeature.StaticReason { + t.Errorf("expected %v, but got %v", openfeature.StaticReason, booleanEvaluation.Reason) + } + + if booleanEvaluation.Error() != nil { + t.Errorf("expected no errors, but got %v", booleanEvaluation.Error()) + } + + err := server.Shutdown(context.Background()) + if err != nil { + t.Errorf("error shuttting down mock server: %v", err) + } +} + +type mockHandler struct { + response string + t *testing.T +} + +func (r mockHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(http.StatusOK) + _, err := resp.Write([]byte(r.response)) + if err != nil { + r.t.Logf("error wriging bytes: %v", err) + } +} diff --git a/release-please-config.json b/release-please-config.json index f592db447..35c43645d 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -107,6 +107,14 @@ "bump-patch-for-minor-pre-major": true, "versioning": "default", "extra-files": [] + }, + "providers/ofrep": { + "release-type": "go", + "package-name": "providers/ofrep", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [] } }, "changelog-sections": [