diff --git a/providers/ofrep/README.md b/providers/ofrep/README.md index 3722b0d8b..0f25af3f3 100644 --- a/providers/ofrep/README.md +++ b/providers/ofrep/README.md @@ -34,11 +34,15 @@ You can configure the provider using following configuration options, |----------------------|-------------------------------------------------------------------------------------------------------------------------| | 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 | +| WithClient | Provide a custom, pre-configured http.Client for OFREP service communication | +| WithHeader | Set a custom header to be used for authorization | | WithHeaderProvider | Register a custom header provider for OFREP calls. You may utilize this for custom authentication/authorization headers | +| WithBaseURI | Set the base URI of the OFREP service | +| WithTimeout | Set the timeout for the http client used for communication with the OFREP service (ignored if custom client is used) | +| WithFromEnv | Configure the provider using environment variables (experimental) | -For example, consider below example which set bearer token and provider a customized http client, +For example, consider below example which sets bearer token and provides a customized http client, ```go provider := ofrep.NewProvider( @@ -47,4 +51,24 @@ provider := ofrep.NewProvider( ofrep.WithClient(&http.Client{ Timeout: 1 * time.Second, })) -``` \ No newline at end of file +``` + +### Environment Variable Configuration (Experimental) + +You can use the `WithFromEnv()` option to configure the provider using environment variables: + +```go +provider := ofrep.NewProvider( + "http://localhost:8016", + ofrep.WithFromEnv()) +``` + +Supported environment variables: + +| Environment Variable | Description | Example | +|---------------------|------------------------------------------------------------------|----------------------------------| +| OFREP_ENDPOINT | Base URI for the OFREP service (overrides the baseUri parameter) | `http://localhost:8016` | +| OFREP_TIMEOUT | Timeout duration for HTTP requests (ignored if custom client is used) | `30s`, `500ms` | +| OFREP_API_KEY | API key for X-API-Key authentication | `your-api-key` | +| OFREP_BEARER_TOKEN | Token for Bearer authentication | `your-bearer-token` | +| OFREP_HEADERS | Comma-separated custom headers | `Key1=Value1,Key2=Value2` | \ No newline at end of file diff --git a/providers/ofrep/go.mod b/providers/ofrep/go.mod index d3e25b2bc..4f2a34ff9 100644 --- a/providers/ofrep/go.mod +++ b/providers/ofrep/go.mod @@ -2,7 +2,7 @@ module github.com/open-feature/go-sdk-contrib/providers/ofrep go 1.24.0 -require github.com/open-feature/go-sdk v1.16.0 +require github.com/open-feature/go-sdk v1.16.1-0.20251030122235-1a0d39ea7e4f require ( github.com/go-logr/logr v1.4.3 // indirect diff --git a/providers/ofrep/go.sum b/providers/ofrep/go.sum index 364abd8dc..9f2138c51 100644 --- a/providers/ofrep/go.sum +++ b/providers/ofrep/go.sum @@ -1,12 +1,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/open-feature/go-sdk v1.15.1 h1:TC3FtHtOKlGlIbSf3SEpxXVhgTd/bCbuc39XHIyltkw= -github.com/open-feature/go-sdk v1.15.1/go.mod h1:2WAFYzt8rLYavcubpCoiym3iSCXiHdPB6DxtMkv2wyo= -github.com/open-feature/go-sdk v1.16.0 h1:5NCHYv5slvNBIZhYXAzAufo0OI59OACZ5tczVqSE+Tg= -github.com/open-feature/go-sdk v1.16.0/go.mod h1:EIF40QcoYT1VbQkMPy2ZJH4kvZeY+qGUXAorzSWgKSo= -go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= -go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +github.com/open-feature/go-sdk v1.16.1-0.20251030122235-1a0d39ea7e4f h1:LECR8thRHyrC16SqGXa96+aEFQchcZheYkfrXyewQL4= +github.com/open-feature/go-sdk v1.16.1-0.20251030122235-1a0d39ea7e4f/go.mod h1:lPxPSu1UnZ4E3dCxZi5gV3et2ACi8O8P+zsTGVsDZUw= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= diff --git a/providers/ofrep/internal/evaluate/flags_test.go b/providers/ofrep/internal/evaluate/flags_test.go index 4be2896fd..118d7395a 100644 --- a/providers/ofrep/internal/evaluate/flags_test.go +++ b/providers/ofrep/internal/evaluate/flags_test.go @@ -359,7 +359,7 @@ func genericValidator[T knownTypes](test testDefinition[T], resolvedValue T, rea } if !reflect.DeepEqual(test.defaultValue, resolvedValue) { - t.Errorf("expected deafault value %v, but got %v", test.defaultValue, resolvedValue) + t.Errorf("expected default value %v, but got %v", test.defaultValue, resolvedValue) } if reason != of.ErrorReason { diff --git a/providers/ofrep/internal/evaluate/resolver.go b/providers/ofrep/internal/evaluate/resolver.go index b1b3cb2ec..0ed73c5d7 100644 --- a/providers/ofrep/internal/evaluate/resolver.go +++ b/providers/ofrep/internal/evaluate/resolver.go @@ -31,13 +31,13 @@ func NewOutboundResolver(cfg outbound.Configuration) *OutboundResolver { func (g *OutboundResolver) resolveSingle(ctx context.Context, key string, evalCtx map[string]any) (*successDto, *of.ResolutionError) { b, err := json.Marshal(requestFrom(evalCtx)) if err != nil { - resErr := of.NewGeneralResolutionError(fmt.Sprintf("context marshelling error: %v", err)) + resErr := of.NewGeneralResolutionError(fmt.Sprintf("context marshelling error: %v", err), 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)) + resErr := of.NewGeneralResolutionError(fmt.Sprintf("ofrep request error: %v", err), err) return nil, &resErr } @@ -47,7 +47,7 @@ func (g *OutboundResolver) resolveSingle(ctx context.Context, key string, evalCt var success evaluationSuccess err := json.Unmarshal(rsp.Data, &success) if err != nil { - resErr := of.NewParseErrorResolutionError(fmt.Sprintf("error parsing the response: %v", err)) + resErr := of.NewParseErrorResolutionError(fmt.Sprintf("error parsing the response: %v", err), err) return nil, &resErr } return toSuccessDto(success) @@ -82,7 +82,7 @@ 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)) + resErr := of.NewGeneralResolutionError(fmt.Sprintf("error parsing error payload: %v", err), err) return &resErr } @@ -127,7 +127,7 @@ func parseError500(data []byte) *of.ResolutionError { err := json.Unmarshal(data, &evalError) if err != nil { - resErr = of.NewGeneralResolutionError(fmt.Sprintf("error parsing error payload: %v", err)) + resErr = of.NewGeneralResolutionError(fmt.Sprintf("error parsing error payload: %v", err), err) } else { resErr = of.NewGeneralResolutionError(evalError.ErrorDetails) } diff --git a/providers/ofrep/internal/outbound/http.go b/providers/ofrep/internal/outbound/http.go index 55a3d8fec..9ef2577ba 100644 --- a/providers/ofrep/internal/outbound/http.go +++ b/providers/ofrep/internal/outbound/http.go @@ -19,6 +19,7 @@ type HeaderCallback func() (name string, value string) type Configuration struct { BaseURI string + Timeout time.Duration Callbacks []HeaderCallback Client *http.Client } @@ -39,7 +40,7 @@ type Outbound struct { func NewHttp(cfg Configuration) *Outbound { if cfg.Client == nil { cfg.Client = &http.Client{ - Timeout: 10 * time.Second, + Timeout: cfg.Timeout, } } @@ -62,6 +63,8 @@ func (h *Outbound) Single(ctx context.Context, key string, payload []byte) (*Res return nil, &resErr } + req.Header.Set("Content-Type", "application/json") + for _, callback := range h.headerProvider { req.Header.Set(callback()) } diff --git a/providers/ofrep/provider.go b/providers/ofrep/provider.go index 74913d094..e450464fe 100644 --- a/providers/ofrep/provider.go +++ b/providers/ofrep/provider.go @@ -4,12 +4,17 @@ import ( "context" "fmt" "net/http" + "os" + "strings" + "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" ) +var _ openfeature.FeatureProvider = (*Provider)(nil) + // Provider implementation for OFREP type Provider struct { evaluator Evaluator @@ -22,6 +27,7 @@ type Option func(*outbound.Configuration) func NewProvider(baseUri string, options ...Option) *Provider { cfg := outbound.Configuration{ BaseURI: baseUri, + Timeout: 10 * time.Second, } for _, option := range options { @@ -67,14 +73,21 @@ func (p Provider) Hooks() []openfeature.Hook { // options of the OFREP provider -// WithHeaderProvider allows to configure a custom header callback to set a custom authorization header +// WithHeader allows to set a custom header to be used for authorization. +func WithHeader(name, value string) func(*outbound.Configuration) { + return WithHeaderProvider(func() (string, string) { + return name, value + }) +} + +// 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 +// 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) { @@ -83,7 +96,7 @@ func WithBearerToken(token string) func(*outbound.Configuration) { } } -// WithApiKeyAuth allows to set token to be used for api key authorization +// 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) { @@ -92,9 +105,67 @@ func WithApiKeyAuth(token string) func(*outbound.Configuration) { } } -// WithClient allows to provide a pre-configured http.Client for the communication with the OFREP service +// 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 } } + +// WithBaseURI allows to set the base URI of the OFREP service. +func WithBaseURI(baseURI string) func(configuration *outbound.Configuration) { + return func(configuration *outbound.Configuration) { + configuration.BaseURI = baseURI + } +} + +// WithTimeout allows to set the timeout for the http client used for communication with the OFREP service. +func WithTimeout(timeout time.Duration) func(configuration *outbound.Configuration) { + return func(configuration *outbound.Configuration) { + configuration.Timeout = timeout + } +} + +// WithFromEnv uses environment variables to configure the provider. +// +// Experimental: This feature is experimental and may change in future versions. +// +// Supported environment variables: +// - OFREP_ENDPOINT: base URI for the OFREP service +// - OFREP_TIMEOUT: timeout duration (e.g., "30s", "500ms") +// - OFREP_API_KEY: API key for X-API-Key authentication +// - OFREP_BEARER_TOKEN: token for Bearer authentication +// - OFREP_HEADERS: comma-separated custom headers (e.g., "Key1=Value1,Key2=Value2") +func WithFromEnv() func(*outbound.Configuration) { + envHandlers := map[string]func(*outbound.Configuration, string){ + "OFREP_ENDPOINT": func(c *outbound.Configuration, v string) { + WithBaseURI(v)(c) + }, + "OFREP_TIMEOUT": func(c *outbound.Configuration, v string) { + if t, err := time.ParseDuration(v); err == nil { + WithTimeout(t)(c) + } + }, + "OFREP_API_KEY": func(c *outbound.Configuration, v string) { + WithApiKeyAuth(v)(c) + }, + "OFREP_BEARER_TOKEN": func(c *outbound.Configuration, v string) { + WithBearerToken(v)(c) + }, + "OFREP_HEADERS": func(c *outbound.Configuration, v string) { + for pair := range strings.SplitSeq(v, ",") { + kv := strings.SplitN(strings.TrimSpace(pair), "=", 2) + if len(kv) == 2 { + WithHeader(kv[0], kv[1])(c) + } + } + }, + } + return func(c *outbound.Configuration) { + for key, handler := range envHandlers { + if v := os.Getenv(key); v != "" { + handler(c, v) + } + } + } +} diff --git a/providers/ofrep/provider_test.go b/providers/ofrep/provider_test.go index d3ffc4baf..ccdded4df 100644 --- a/providers/ofrep/provider_test.go +++ b/providers/ofrep/provider_test.go @@ -108,6 +108,141 @@ 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) + r.t.Logf("error writing bytes: %v", err) + } +} + +func TestWithFromEnv(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + initialConfig outbound.Configuration + wantBaseURI string + wantTimeout time.Duration + wantHeaders map[string]string + }{ + { + name: "configure endpoint from env", + envVars: map[string]string{ + "OFREP_ENDPOINT": "http://test.example.com", + }, + initialConfig: outbound.Configuration{}, + wantBaseURI: "http://test.example.com", + }, + { + name: "configure timeout from env", + envVars: map[string]string{ + "OFREP_TIMEOUT": "5s", + }, + initialConfig: outbound.Configuration{}, + wantTimeout: 5 * time.Second, + }, + { + name: "ignore invalid timeout", + envVars: map[string]string{ + "OFREP_TIMEOUT": "invalid", + }, + initialConfig: outbound.Configuration{Timeout: 10 * time.Second}, + wantTimeout: 10 * time.Second, + }, + { + name: "configure api key from env", + envVars: map[string]string{ + "OFREP_API_KEY": "test-api-key", + }, + initialConfig: outbound.Configuration{}, + wantHeaders: map[string]string{ + "X-API-Key": "test-api-key", + }, + }, + { + name: "configure bearer token from env", + envVars: map[string]string{ + "OFREP_BEARER_TOKEN": "test-token", + }, + initialConfig: outbound.Configuration{}, + wantHeaders: map[string]string{ + "Authorization": "Bearer test-token", + }, + }, + { + name: "configure custom headers from env", + envVars: map[string]string{ + "OFREP_HEADERS": "X-Custom-1=Value1,X-Custom-2=Value2", + }, + initialConfig: outbound.Configuration{}, + wantHeaders: map[string]string{ + "X-Custom-1": "Value1", + "X-Custom-2": "Value2", + }, + }, + { + name: "configure all options from env", + envVars: map[string]string{ + "OFREP_ENDPOINT": "http://all.example.com", + "OFREP_TIMEOUT": "3s", + "OFREP_API_KEY": "all-key", + "OFREP_HEADERS": "X-Test=TestValue", + }, + initialConfig: outbound.Configuration{}, + wantBaseURI: "http://all.example.com", + wantTimeout: 3 * time.Second, + wantHeaders: map[string]string{ + "X-API-Key": "all-key", + "X-Test": "TestValue", + }, + }, + { + name: "empty env variables do not override defaults", + envVars: map[string]string{ + "OFREP_ENDPOINT": "", + "OFREP_TIMEOUT": "", + }, + initialConfig: outbound.Configuration{ + BaseURI: "http://default.example.com", + Timeout: 15 * time.Second, + }, + wantBaseURI: "http://default.example.com", + wantTimeout: 15 * time.Second, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for key, value := range tt.envVars { + t.Setenv(key, value) + } + + c := tt.initialConfig + WithFromEnv()(&c) + + if tt.wantBaseURI != "" && c.BaseURI != tt.wantBaseURI { + t.Errorf("expected BaseURI %s, but got %s", tt.wantBaseURI, c.BaseURI) + } + + if tt.wantTimeout != 0 && c.Timeout != tt.wantTimeout { + t.Errorf("expected Timeout %v, but got %v", tt.wantTimeout, c.Timeout) + } + + actualHeaders := make(map[string]string) + for _, cb := range c.Callbacks { + k, v := cb() + actualHeaders[k] = v + } + + if tt.wantHeaders != nil { + for expectedKey, expectedValue := range tt.wantHeaders { + if actualValue, ok := actualHeaders[expectedKey]; !ok { + t.Errorf("expected header %s not found", expectedKey) + } else if actualValue != expectedValue { + t.Errorf("expected %s=%s, but got %s=%s", expectedKey, expectedValue, expectedKey, actualValue) + } + } + } + + if len(tt.wantHeaders) == 0 && len(actualHeaders) != 0 { + t.Errorf("expected no headers, but got %v", actualHeaders) + } + }) } }