From 2bbef9d8683e75d3f3582cddc49c02f2b6718436 Mon Sep 17 00:00:00 2001 From: Roman Dmytrenko Date: Sun, 10 Nov 2024 00:18:30 +0200 Subject: [PATCH] feat(ofrep): implement OFREP bulk provider Signed-off-by: Roman Dmytrenko --- providers/ofrep/bulk_provider.go | 153 ++++++++++++++++++ providers/ofrep/bulk_provider_test.go | 124 ++++++++++++++ providers/ofrep/go.mod | 6 +- providers/ofrep/go.sum | 18 +-- .../ofrep/internal/evaluate/bulk_evaluator.go | 109 +++++++++++++ .../internal/evaluate/bulk_evaluator_test.go | 107 ++++++++++++ providers/ofrep/internal/evaluate/resolver.go | 23 +-- .../ofrep/internal/evaluate/resolver_test.go | 4 + providers/ofrep/internal/outbound/http.go | 28 +++- .../ofrep/internal/outbound/http_test.go | 39 ++++- providers/ofrep/provider_options.go | 14 ++ 11 files changed, 592 insertions(+), 33 deletions(-) create mode 100644 providers/ofrep/bulk_provider.go create mode 100644 providers/ofrep/bulk_provider_test.go create mode 100644 providers/ofrep/internal/evaluate/bulk_evaluator.go create mode 100644 providers/ofrep/internal/evaluate/bulk_evaluator_test.go create mode 100644 providers/ofrep/provider_options.go diff --git a/providers/ofrep/bulk_provider.go b/providers/ofrep/bulk_provider.go new file mode 100644 index 000000000..803a783de --- /dev/null +++ b/providers/ofrep/bulk_provider.go @@ -0,0 +1,153 @@ +package ofrep + +import ( + "context" + "sync" + "time" + + "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" + of "github.com/open-feature/go-sdk/openfeature" +) + +const providerName = "OFREP Bulk Provider" + +func NewBulkProvider(baseUri string, options ...Option) *BulkProvider { + cfg := outbound.Configuration{ + BaseURI: baseUri, + ClientPollingInterval: 30 * time.Second, + } + + for _, option := range options { + option(&cfg) + } + + return &BulkProvider{ + events: make(chan of.Event, 3), + cfg: cfg, + state: of.NotReadyState, + } +} + +var ( + _ of.FeatureProvider = (*BulkProvider)(nil) // ensure BulkProvider implements FeatureProvider + _ of.StateHandler = (*BulkProvider)(nil) // ensure BulkProvider implements StateHandler + _ of.EventHandler = (*BulkProvider)(nil) // ensure BulkProvider implements EventHandler +) + +type BulkProvider struct { + Provider + cfg outbound.Configuration + state of.State + mu sync.RWMutex + events chan of.Event + cancelFunc context.CancelFunc +} + +func (p *BulkProvider) Metadata() openfeature.Metadata { + return of.Metadata{ + Name: providerName, + } +} + +func (p *BulkProvider) Status() of.State { + p.mu.RLock() + defer p.mu.RUnlock() + return p.state +} + +func (p *BulkProvider) Init(evalCtx of.EvaluationContext) error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.state != of.NotReadyState { + // avoid reinitialization if initialized + return nil + } + + ctx, cancel := context.WithCancel(context.Background()) + p.cancelFunc = cancel + + client := outbound.NewHttp(p.cfg) + + flatCtx := FlattenContext(evalCtx) + + evaluator := evaluate.NewBulkEvaluator(client, flatCtx) + err := evaluator.Fetch(ctx) + if err != nil { + return err + } + + if p.cfg.PollingEnabled() { + p.startPolling(ctx, evaluator, p.cfg.PollingInterval()) + } + + p.evaluator = evaluator + p.state = of.ReadyState + return nil +} + +func (p *BulkProvider) Shutdown() { + p.mu.Lock() + defer p.mu.Unlock() + + if p.cancelFunc != nil { + p.cancelFunc() + p.cancelFunc = nil + } + + p.state = of.NotReadyState + p.evaluator = nil +} + +func (p *BulkProvider) EventChannel() <-chan of.Event { + return p.events +} + +func (p *BulkProvider) startPolling(ctx context.Context, evaluator *evaluate.BulkEvaluator, pollingInterval time.Duration) { + go func() { + ticker := time.NewTicker(pollingInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + err := evaluator.Fetch(ctx) + if err != nil { + if err != context.Canceled { + p.mu.Lock() + p.state = of.StaleState + p.mu.Unlock() + p.events <- of.Event{ + ProviderName: providerName, EventType: of.ProviderStale, + ProviderEventDetails: of.ProviderEventDetails{Message: err.Error()}, + } + } + continue + } + p.mu.RLock() + state := p.state + p.mu.RUnlock() + if state != of.ReadyState { + p.mu.Lock() + p.state = of.ReadyState + p.mu.Unlock() + p.events <- of.Event{ + ProviderName: providerName, EventType: of.ProviderReady, + ProviderEventDetails: of.ProviderEventDetails{Message: "Provider is ready"}, + } + } + } + } + }() +} + +func FlattenContext(evalCtx of.EvaluationContext) of.FlattenedContext { + flatCtx := evalCtx.Attributes() + if evalCtx.TargetingKey() != "" { + flatCtx[of.TargetingKey] = evalCtx.TargetingKey() + } + return flatCtx +} diff --git a/providers/ofrep/bulk_provider_test.go b/providers/ofrep/bulk_provider_test.go new file mode 100644 index 000000000..c03970e30 --- /dev/null +++ b/providers/ofrep/bulk_provider_test.go @@ -0,0 +1,124 @@ +package ofrep_test + +import ( + "context" + "encoding/json" + "io" + "maps" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/open-feature/go-sdk-contrib/providers/ofrep" + of "github.com/open-feature/go-sdk/openfeature" +) + +var evalCtx = of.NewEvaluationContext("keyboard", map[string]any{ + "color": "red", +}) + +func TestBulkProviderEvaluationE2EBasic(t *testing.T) { + of.SetEvaluationContext(evalCtx) + baseUrl := setupTestServer(t) + p := ofrep.NewBulkProvider(baseUrl, ofrep.WithBearerToken("api-key")) + + err := of.SetProviderAndWait(p) + if err != nil { + t.Errorf("expected ready provider, but got %v", err) + } + + client := of.NewClient("app") + ctx := context.Background() + + result := client.Boolean(ctx, "flag-bool", false, evalCtx) + if !result { + t.Errorf("expected %v, but got %v", true, result) + } + + _, err = client.BooleanValueDetails(ctx, "flag-error", false, evalCtx) + + if err == nil { + t.Errorf("expected error, but got nil") + } + + if err.Error() != "error code: GENERAL: something wrong" { + t.Errorf("expected error message '%v', but got '%v'", "error code: GENERAL: something wrong", err.Error()) + } + + of.Shutdown() + + if p.Status() != of.NotReadyState { + t.Errorf("expected %v, but got %v", of.NotReadyState, p.Status()) + } +} + +func TestBulkProviderEvaluationE2EPolling(t *testing.T) { + of.SetEvaluationContext(evalCtx) + baseUrl := setupTestServer(t) + p := ofrep.NewBulkProvider(baseUrl, ofrep.WithBearerToken("api-key"), ofrep.WithPollingInterval(30*time.Millisecond)) + + err := of.SetProviderAndWait(p) + if err != nil { + t.Errorf("expected ready provider, but got %v", err) + } + if p.Status() != of.ReadyState { + t.Errorf("expected %v, but got %v", of.ReadyState, p.Status()) + } + + // let the provider poll for flags in background at least once + time.Sleep(60 * time.Millisecond) + + of.Shutdown() + if p.Status() != of.NotReadyState { + t.Errorf("expected %v, but got %v", of.NotReadyState, p.Status()) + } +} + +func setupTestServer(t testing.TB) string { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/ofrep/v1/evaluate/flags", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected post request, got: %v", r.Method) + } + + if r.Header.Get("Authorization") != "Bearer api-key" { + t.Errorf("expected Authorization header, got: %v", r.Header.Get("Authorization")) + } + + requestData, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("error reading request data: %v", err) + } + + evalData := struct { + Context map[string]any `json:"context"` + }{} + + err = json.Unmarshal(requestData, &evalData) + if err != nil { + t.Errorf("error parsing request data: %v", err) + } + + flatCtx := ofrep.FlattenContext(evalCtx) + if !maps.Equal(flatCtx, evalData.Context) { + t.Errorf("expected request data with %v, but got %v", flatCtx, evalData.Context) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + data := `{"flags":[ + {"key":"flag-bool","reason":"DEFAULT","variant":"true","metadata":{},"value":true}, + {"key":"flag-error", "errorCode": "INVALID", "errorDetails": "something wrong" } + ]}` + _, err = w.Write([]byte(data)) + if err != nil { + t.Errorf("error writing response: %v", err) + } + }) + + s := httptest.NewServer(mux) + t.Cleanup(s.Close) + return s.URL +} diff --git a/providers/ofrep/go.mod b/providers/ofrep/go.mod index 7a66e08b9..0f68b4396 100644 --- a/providers/ofrep/go.mod +++ b/providers/ofrep/go.mod @@ -2,9 +2,9 @@ module github.com/open-feature/go-sdk-contrib/providers/ofrep go 1.21.0 -require github.com/open-feature/go-sdk v1.11.0 +require github.com/open-feature/go-sdk v1.13.1 require ( - github.com/go-logr/logr v1.4.1 // indirect - golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3 // indirect + github.com/go-logr/logr v1.4.2 // indirect + golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect ) diff --git a/providers/ofrep/go.sum b/providers/ofrep/go.sum index f93306a37..fa2110b0c 100644 --- a/providers/ofrep/go.sum +++ b/providers/ofrep/go.sum @@ -1,12 +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/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/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= -github.com/open-feature/go-sdk v1.11.0 h1:4cp9rXl16ZvlMCef7O+I3vQSXae8DzAF0SfV9mvYInw= -github.com/open-feature/go-sdk v1.11.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= +github.com/open-feature/go-sdk v1.13.1 h1:RJbS70eyi7Jd3Zm5bFnaahNKNDXn+RAVnctpGu+uPis= +github.com/open-feature/go-sdk v1.13.1/go.mod h1:O8r4mhgeRIsjJ0ZBXlnE0BtbT/79W44gQceR7K8KYgo= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= diff --git a/providers/ofrep/internal/evaluate/bulk_evaluator.go b/providers/ofrep/internal/evaluate/bulk_evaluator.go new file mode 100644 index 000000000..e002c6a4a --- /dev/null +++ b/providers/ofrep/internal/evaluate/bulk_evaluator.go @@ -0,0 +1,109 @@ +package evaluate + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sync" + + "github.com/open-feature/go-sdk-contrib/providers/ofrep/internal/outbound" + of "github.com/open-feature/go-sdk/openfeature" +) + +// Outbound defines the contract for resolver's outbound communication, matching OFREP API. +type BulkOutbound interface { + // Bulk flags resolving + Bulk(ctx context.Context, paylod []byte) (*outbound.Resolution, error) +} + +func NewBulkEvaluator(client BulkOutbound, evalCtx of.FlattenedContext) *BulkEvaluator { + b := &BulkEvaluator{ + client: client, + evalCtx: evalCtx, + values: map[string]bulkEvaluationValue{}, + } + b.resolver = b + return b +} + +type BulkEvaluator struct { + Flags + client BulkOutbound + evalCtx of.FlattenedContext + values map[string]bulkEvaluationValue + mu sync.RWMutex +} + +func (b *BulkEvaluator) Fetch(ctx context.Context) error { + payload, err := json.Marshal(requestFrom(b.evalCtx)) + if err != nil { + return err + } + res, err := b.client.Bulk(ctx, payload) + if err != nil { + return err + } + + switch res.Status { + case http.StatusOK: // 200 + var data bulkEvaluationSuccess + err := json.Unmarshal(res.Data, &data) + if err != nil { + return err + } + values := make(map[string]bulkEvaluationValue) + for _, value := range data.Flags { + values[value.Key] = value + } + b.setValues(values) + case http.StatusNotModified: // 304 + // No changes + case http.StatusBadRequest: // 400 + return parseError400(res.Data) + case http.StatusUnauthorized, http.StatusForbidden: // 401, 403 + return of.NewGeneralResolutionError("authentication/authorization error") + case http.StatusTooManyRequests: // 429 + after := parse429(res) + if after == 0 { + return of.NewGeneralResolutionError("rate limit exceeded") + } + return of.NewGeneralResolutionError( + fmt.Sprintf("rate limit exceeded, try again after %f seconds", after.Seconds())) + case http.StatusInternalServerError: // 500 + return parseError500(res.Data) + default: + return parseError500(res.Data) + } + + return nil +} + +func (b *BulkEvaluator) setValues(values map[string]bulkEvaluationValue) { + b.mu.Lock() + defer b.mu.Unlock() + b.values = values +} + +func (b *BulkEvaluator) resolveSingle(ctx context.Context, key string, evalCtx map[string]any) (*successDto, *of.ResolutionError) { + b.mu.RLock() + defer b.mu.RUnlock() + if s, ok := b.values[key]; ok { + if s.ErrorCode != "" { + resErr := resolutionFromEvaluationError(s.evaluationError) + return nil, &resErr + } + return &s.successDto, nil + } + resErr := of.NewFlagNotFoundResolutionError(fmt.Sprintf("flag for key '%s' does not exist", key)) + return nil, &resErr +} + +type bulkEvaluationSuccess struct { + Flags []bulkEvaluationValue +} + +type bulkEvaluationValue struct { + successDto + evaluationError +} diff --git a/providers/ofrep/internal/evaluate/bulk_evaluator_test.go b/providers/ofrep/internal/evaluate/bulk_evaluator_test.go new file mode 100644 index 000000000..812d92f96 --- /dev/null +++ b/providers/ofrep/internal/evaluate/bulk_evaluator_test.go @@ -0,0 +1,107 @@ +package evaluate + +import ( + "context" + "encoding/json" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/open-feature/go-sdk-contrib/providers/ofrep/internal/outbound" + of "github.com/open-feature/go-sdk/openfeature" +) + +func TestBulkSuccess200(t *testing.T) { + t.Run("success evaluation response", func(t *testing.T) { + seed := bulkEvaluationSuccess{ + Flags: []bulkEvaluationValue{ + {successDto: successDto{Value: true}, evaluationError: evaluationError{Key: "flag-bool"}}, + {evaluationError: evaluationError{Key: "flag-error", ErrorCode: "GENERAL", ErrorDetails: "something wrong"}}, + }, + } + + successBytes, err := json.Marshal(seed) + if err != nil { + t.Fatal(err) + } + + evalCtx := of.FlattenedContext{} + resolver := NewBulkEvaluator(mockOutbound{ + rsp: outbound.Resolution{ + Status: http.StatusOK, + Data: successBytes, + }, + }, evalCtx) + + ctx := context.Background() + err = resolver.Fetch(ctx) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + value, errRes := resolver.resolveSingle(ctx, "flag-bool", evalCtx) + if errRes != nil { + t.Fatalf("expected no error, got %v", errRes) + } + if !reflect.DeepEqual(value, &seed.Flags[0].successDto) { + t.Fatalf("expected %v, got %v", seed.Flags[0].successDto, value) + } + + _, errRes = resolver.resolveSingle(context.Background(), "flag-error", evalCtx) + if errRes == nil { + t.Fatal("expected error, but got nil") + } + if errRes.Error() != "GENERAL: something wrong" { + t.Errorf("expected %v, but got %v", "GENERAL: something wrong", errRes.Error()) + } + + _, errRes = resolver.resolveSingle(context.Background(), "flag-unknown", evalCtx) + if errRes == nil { + t.Fatalf("expected error, but got nil") + } + if errRes.Error() != "FLAG_NOT_FOUND: flag for key 'flag-unknown' does not exist" { + t.Errorf("expected %v, but got %v", "FLAG_NOT_FOUND: flag fork key 'flag-unknown' does not exist", errRes.Error()) + } + }) +} + +func TestBulkErrors(t *testing.T) { + emptyHeaders := map[string][]string{} + tests := []struct { + name string + statusCode int + headers http.Header + expectedErroCode of.ErrorCode + }{ + {"internal server error", http.StatusInternalServerError, emptyHeaders, of.GeneralCode}, + {"server error", http.StatusServiceUnavailable, emptyHeaders, of.GeneralCode}, + {"bad request", http.StatusBadRequest, emptyHeaders, of.GeneralCode}, + {"unauthorized", http.StatusUnauthorized, emptyHeaders, of.GeneralCode}, + {"forbidden", http.StatusForbidden, emptyHeaders, of.GeneralCode}, + {"too many requests", http.StatusTooManyRequests, emptyHeaders, of.GeneralCode}, + {"too many requests with header", http.StatusTooManyRequests, map[string][]string{"Retry-After": {"100"}}, of.GeneralCode}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resolver := NewBulkEvaluator(mockOutbound{ + rsp: outbound.Resolution{ + Status: tt.statusCode, + Data: []byte{}, + Headers: map[string][]string{"Retry-After": {"1"}}, + }, + }, of.FlattenedContext{}) + + err := resolver.Fetch(context.Background()) + + if err == nil { + t.Fatal("expected non nil error, but got empty") + } + + if !strings.Contains(err.Error(), string(tt.expectedErroCode)) { + t.Errorf("expected error to contain error code %v, got %v", tt.expectedErroCode, err.Error()) + } + }) + } +} diff --git a/providers/ofrep/internal/evaluate/resolver.go b/providers/ofrep/internal/evaluate/resolver.go index 20baba2f2..6c5dcf47d 100644 --- a/providers/ofrep/internal/evaluate/resolver.go +++ b/providers/ofrep/internal/evaluate/resolver.go @@ -29,8 +29,8 @@ func NewOutboundResolver(cfg outbound.Configuration) *OutboundResolver { } func (g *OutboundResolver) resolveSingle(ctx context.Context, key string, evalCtx map[string]interface{}) ( - *successDto, *of.ResolutionError) { - + *successDto, *of.ResolutionError, +) { b, err := json.Marshal(requestFrom(evalCtx)) if err != nil { resErr := of.NewGeneralResolutionError(fmt.Sprintf("context marshelling error: %v", err)) @@ -88,21 +88,24 @@ func parseError400(data []byte) *of.ResolutionError { return &resErr } - var resErr of.ResolutionError + resErr := resolutionFromEvaluationError(evalError) + + return &resErr +} + +func resolutionFromEvaluationError(evalError evaluationError) of.ResolutionError { switch evalError.ErrorCode { case string(of.ParseErrorCode): - resErr = of.NewParseErrorResolutionError(evalError.ErrorDetails) + return of.NewParseErrorResolutionError(evalError.ErrorDetails) case string(of.TargetingKeyMissingCode): - resErr = of.NewTargetingKeyMissingResolutionError(evalError.ErrorDetails) + return of.NewTargetingKeyMissingResolutionError(evalError.ErrorDetails) case string(of.InvalidContextCode): - resErr = of.NewInvalidContextResolutionError(evalError.ErrorDetails) + return of.NewInvalidContextResolutionError(evalError.ErrorDetails) case string(of.GeneralCode): - resErr = of.NewGeneralResolutionError(evalError.ErrorDetails) + return of.NewGeneralResolutionError(evalError.ErrorDetails) default: - resErr = of.NewGeneralResolutionError(evalError.ErrorDetails) + return of.NewGeneralResolutionError(evalError.ErrorDetails) } - - return &resErr } func parse429(rsp *outbound.Resolution) time.Duration { diff --git a/providers/ofrep/internal/evaluate/resolver_test.go b/providers/ofrep/internal/evaluate/resolver_test.go index 60bbde0f6..45ec352a1 100644 --- a/providers/ofrep/internal/evaluate/resolver_test.go +++ b/providers/ofrep/internal/evaluate/resolver_test.go @@ -347,3 +347,7 @@ type mockOutbound struct { func (m mockOutbound) Single(_ context.Context, _ string, _ []byte) (*outbound.Resolution, error) { return &m.rsp, m.err } + +func (m mockOutbound) Bulk(_ context.Context, _ []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 index 238889f8f..9ec09430a 100644 --- a/providers/ofrep/internal/outbound/http.go +++ b/providers/ofrep/internal/outbound/http.go @@ -12,15 +12,24 @@ import ( of "github.com/open-feature/go-sdk/openfeature" ) -const ofrepV1 = "/ofrep/v1/evaluate/flags/" +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 + BaseURI string + Callbacks []HeaderCallback + Client *http.Client + ClientPollingInterval time.Duration +} + +func (c *Configuration) PollingEnabled() bool { + return c.ClientPollingInterval > 0 +} + +func (c *Configuration) PollingInterval() time.Duration { + return c.ClientPollingInterval } type Resolution struct { @@ -55,7 +64,18 @@ func (h *Outbound) Single(ctx context.Context, key string, payload []byte) (*Res if err != nil { return nil, fmt.Errorf("error building request path: %w", err) } + return h.sendRequest(ctx, path, payload) +} + +func (h *Outbound) Bulk(ctx context.Context, payload []byte) (*Resolution, error) { + path, err := url.JoinPath(h.baseURI, ofrepV1) + if err != nil { + return nil, fmt.Errorf("error building request path: %w", err) + } + return h.sendRequest(ctx, path, payload) +} +func (h *Outbound) sendRequest(ctx context.Context, path string, payload []byte) (*Resolution, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, path, bytes.NewReader(payload)) if err != nil { resErr := of.NewGeneralResolutionError(fmt.Sprintf("request building error: %v", err)) diff --git a/providers/ofrep/internal/outbound/http_test.go b/providers/ofrep/internal/outbound/http_test.go index 80a5aa9e0..5cbf5c947 100644 --- a/providers/ofrep/internal/outbound/http_test.go +++ b/providers/ofrep/internal/outbound/http_test.go @@ -11,7 +11,8 @@ import ( func TestHttpOutbound(t *testing.T) { // given key := "flag" - server := httptest.NewServer(mockHandler{t: t, key: key}) + path := fmt.Sprintf("%s/%s", ofrepV1, key) + server := httptest.NewServer(mockHandler{t: t, path: path}) t.Cleanup(server.Close) outbound := NewHttp(Configuration{ @@ -36,9 +37,36 @@ func TestHttpOutbound(t *testing.T) { } } +func TestHttpOutboundBulk(t *testing.T) { + // given + server := httptest.NewServer(mockHandler{t: t, path: ofrepV1}) + t.Cleanup(server.Close) + + outbound := NewHttp(Configuration{ + Callbacks: []HeaderCallback{ + func() (string, string) { + return "Authorization", "Token" + }, + }, + BaseURI: server.URL, + }) + + // when + response, err := outbound.Bulk(context.Background(), []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) + } +} + type mockHandler struct { - key string - t *testing.T + path string + t *testing.T } func (r mockHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) { @@ -48,9 +76,8 @@ func (r mockHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) { 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) + if req.RequestURI != r.path { + r.t.Logf("invalid request path, expected %s, got %s. test will fail", r.path, req.RequestURI) resp.WriteHeader(http.StatusBadRequest) return } diff --git a/providers/ofrep/provider_options.go b/providers/ofrep/provider_options.go new file mode 100644 index 000000000..bbf4796ad --- /dev/null +++ b/providers/ofrep/provider_options.go @@ -0,0 +1,14 @@ +package ofrep + +import ( + "time" + + "github.com/open-feature/go-sdk-contrib/providers/ofrep/internal/outbound" +) + +// WithPollingInterval allows to set the polling interval for the OFREP bulk provider +func WithPollingInterval(interval time.Duration) func(*outbound.Configuration) { + return func(c *outbound.Configuration) { + c.ClientPollingInterval = interval + } +}