Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ofrep): implement OFREP bulk provider #596

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions providers/ofrep/bulk_provider.go
Original file line number Diff line number Diff line change
@@ -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
}
124 changes: 124 additions & 0 deletions providers/ofrep/bulk_provider_test.go
Original file line number Diff line number Diff line change
@@ -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
}
6 changes: 3 additions & 3 deletions providers/ofrep/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
18 changes: 8 additions & 10 deletions providers/ofrep/go.sum
Original file line number Diff line number Diff line change
@@ -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=
Loading
Loading