Skip to content

Commit cb87239

Browse files
authored
Merge pull request #161 from Flagsmith/feat/allow-custom-http-client
feat: option-to-pass-optional-resty-or-http-client
2 parents 4c50e98 + 99fcd81 commit cb87239

File tree

6 files changed

+295
-5
lines changed

6 files changed

+295
-5
lines changed

.github/workflows/go.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,6 @@ jobs:
4444
run: go build -v ./...
4545

4646
- name: Test
47-
run: go test -v -race ./...
47+
run: |
48+
go test -v -race ./...
49+
go test -tags=test ./...

client.go

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import (
44
"context"
55
"fmt"
66
"log/slog"
7+
"net/http"
8+
"reflect"
9+
"runtime"
710
"strings"
811
"sync/atomic"
912
"time"
@@ -34,6 +37,7 @@ type Client struct {
3437
defaultFlagHandler func(string) (Flag, error)
3538

3639
client *resty.Client
40+
httpClient *http.Client
3741
ctxLocalEval context.Context
3842
ctxAnalytics context.Context
3943
log *slog.Logger
@@ -52,26 +56,64 @@ func GetEvaluationContextFromCtx(ctx context.Context) (ec EvaluationContext, ok
5256
return ec, ok
5357
}
5458

59+
func getOptionQualifiedName(opt Option) string {
60+
return runtime.FuncForPC(reflect.ValueOf(opt).Pointer()).Name()
61+
}
62+
63+
func isClientOption(name string) bool {
64+
return strings.Contains(name, OptionWithHTTPClient) || strings.Contains(name, OptionWithRestyClient)
65+
}
66+
5567
// NewClient creates instance of Client with given configuration.
5668
func NewClient(apiKey string, options ...Option) *Client {
5769
c := &Client{
5870
apiKey: apiKey,
5971
config: defaultConfig(),
60-
client: resty.New(),
72+
}
73+
74+
customClientCount := 0
75+
for _, opt := range options {
76+
name := getOptionQualifiedName(opt)
77+
if isClientOption(name) {
78+
customClientCount = customClientCount + 1
79+
if customClientCount > 1 {
80+
panic("Only one client option can be provided")
81+
}
82+
opt(c)
83+
}
84+
}
85+
86+
// If a resty custom client has been provided, client is already set - otherwise we use a custom http client or default to a resty
87+
if c.client == nil {
88+
if c.httpClient != nil {
89+
c.client = resty.NewWithClient(c.httpClient)
90+
c.config.userProvidedClient = true
91+
} else {
92+
c.client = resty.New()
93+
}
94+
} else {
95+
c.config.userProvidedClient = true
6196
}
6297

6398
c.client.SetHeaders(map[string]string{
6499
"Accept": "application/json",
65100
EnvironmentKeyHeader: c.apiKey,
66101
})
67-
c.client.SetTimeout(c.config.timeout)
102+
103+
if c.client.GetClient().Timeout == 0 {
104+
c.client.SetTimeout(c.config.timeout)
105+
}
106+
68107
c.log = createLogger()
69108

70109
for _, opt := range options {
71-
if opt != nil {
72-
opt(c)
110+
name := getOptionQualifiedName(opt)
111+
if isClientOption(name) {
112+
continue
73113
}
114+
opt(c)
74115
}
116+
75117
c.client = c.client.
76118
SetLogger(newSlogToRestyAdapter(c.log)).
77119
OnBeforeRequest(newRestyLogRequestMiddleware(c.log)).

client_http_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//go:build test
2+
3+
package flagsmith
4+
5+
import (
6+
"testing"
7+
"time"
8+
9+
"github.com/go-resty/resty/v2"
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func (c *Client) ExposeRestyClient() *resty.Client {
14+
return c.client
15+
}
16+
17+
func TestCustomClientRetriesAreSet(t *testing.T) {
18+
retryCount := 5
19+
20+
customResty := resty.New().
21+
SetRetryCount(retryCount).
22+
SetRetryWaitTime(10 * time.Millisecond)
23+
24+
client := NewClient("env-key", WithRestyClient(customResty))
25+
26+
internal := client.ExposeRestyClient()
27+
assert.Equal(t, retryCount, internal.RetryCount)
28+
assert.Equal(t, 10*time.Millisecond, internal.RetryWaitTime)
29+
}
30+
31+
func TestCustomRestyClientTimeoutIsNotOverriddenWithDefaultTimeout(t *testing.T) {
32+
customResty := resty.New().SetTimeout(13 * time.Millisecond)
33+
34+
client := NewClient("env-key", WithRestyClient(customResty))
35+
36+
internal := client.ExposeRestyClient()
37+
38+
assert.Equal(t, 13*time.Millisecond, internal.GetClient().Timeout)
39+
}
40+
41+
func TestCustomRestyClientHasDefaultTimeoutIfNotProvided(t *testing.T) {
42+
customResty := resty.New()
43+
44+
client := NewClient("env-key", WithRestyClient(customResty))
45+
46+
internal := client.ExposeRestyClient()
47+
assert.Equal(t, 10*time.Second, internal.GetClient().Timeout)
48+
}

client_test.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515

1616
flagsmith "github.com/Flagsmith/flagsmith-go-client/v4"
1717
"github.com/Flagsmith/flagsmith-go-client/v4/fixtures"
18+
"github.com/go-resty/resty/v2"
1819
"github.com/stretchr/testify/assert"
1920
)
2021

@@ -1019,3 +1020,160 @@ type writerFunc func(p []byte) (n int, err error)
10191020
func (f writerFunc) Write(p []byte) (n int, err error) {
10201021
return f(p)
10211022
}
1023+
1024+
// Helper function to implement a header interceptor.
1025+
func roundTripperWithHeader(key, value string) http.RoundTripper {
1026+
return &injectHeaderTransport{key: key, value: value}
1027+
}
1028+
1029+
type injectHeaderTransport struct {
1030+
key string
1031+
value string
1032+
}
1033+
1034+
func (t *injectHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
1035+
req.Header.Set(t.key, t.value)
1036+
return http.DefaultTransport.RoundTrip(req)
1037+
}
1038+
1039+
func TestCustomHTTPClientIsUsed(t *testing.T) {
1040+
ctx := context.Background()
1041+
1042+
hasCustomHeader := false
1043+
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
1044+
assert.Equal(t, "/api/v1/flags/", req.URL.Path)
1045+
assert.Equal(t, fixtures.EnvironmentAPIKey, req.Header.Get("x-Environment-Key"))
1046+
if req.Header.Get("X-Test-Client") == "http" {
1047+
hasCustomHeader = true
1048+
}
1049+
rw.Header().Set("Content-Type", "application/json")
1050+
rw.WriteHeader(http.StatusOK)
1051+
_, err := io.WriteString(rw, fixtures.FlagsJson)
1052+
assert.NoError(t, err)
1053+
}))
1054+
defer server.Close()
1055+
1056+
customClient := &http.Client{
1057+
Transport: roundTripperWithHeader("X-Test-Client", "http"),
1058+
}
1059+
1060+
client := flagsmith.NewClient(fixtures.EnvironmentAPIKey,
1061+
flagsmith.WithHTTPClient(customClient),
1062+
flagsmith.WithBaseURL(server.URL+"/api/v1/"))
1063+
1064+
flags, err := client.GetFlags(ctx, nil)
1065+
assert.Equal(t, 1, len(flags.AllFlags()))
1066+
assert.NoError(t, err)
1067+
assert.True(t, hasCustomHeader, "Expected http header")
1068+
flag, err := flags.GetFlag(fixtures.Feature1Name)
1069+
assert.NoError(t, err)
1070+
assert.Equal(t, fixtures.Feature1Value, flag.Value)
1071+
}
1072+
1073+
func TestCustomRestyClientIsUsed(t *testing.T) {
1074+
ctx := context.Background()
1075+
1076+
hasCustomHeader := false
1077+
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
1078+
if req.Header.Get("X-Custom-Test-Header") == "resty" {
1079+
hasCustomHeader = true
1080+
}
1081+
rw.Header().Set("Content-Type", "application/json")
1082+
rw.WriteHeader(http.StatusOK)
1083+
_, err := io.WriteString(rw, fixtures.FlagsJson)
1084+
assert.NoError(t, err)
1085+
}))
1086+
defer server.Close()
1087+
1088+
restyClient := resty.New().
1089+
SetHeader("X-Custom-Test-Header", "resty")
1090+
1091+
client := flagsmith.NewClient(fixtures.EnvironmentAPIKey,
1092+
flagsmith.WithRestyClient(restyClient),
1093+
flagsmith.WithBaseURL(server.URL+"/api/v1/"))
1094+
1095+
flags, err := client.GetFlags(ctx, nil)
1096+
assert.NoError(t, err)
1097+
assert.Equal(t, 1, len(flags.AllFlags()))
1098+
assert.True(t, hasCustomHeader, "Expected custom resty header")
1099+
}
1100+
1101+
func TestRestyClientOverridesHTTPClientShouldPanic(t *testing.T) {
1102+
httpClient := &http.Client{
1103+
Transport: roundTripperWithHeader("X-Test-Client", "http"),
1104+
}
1105+
1106+
restyClient := resty.New().
1107+
SetHeader("X-Test-Client", "resty")
1108+
1109+
assert.Panics(t, func() {
1110+
_ = flagsmith.NewClient(fixtures.EnvironmentAPIKey,
1111+
flagsmith.WithHTTPClient(httpClient),
1112+
flagsmith.WithRestyClient(restyClient),
1113+
flagsmith.WithBaseURL("http://example.com/api/v1/"))
1114+
}, "Expected panic when both HTTP and Resty clients are provided")
1115+
}
1116+
1117+
func TestDefaultRestyClientIsUsed(t *testing.T) {
1118+
ctx := context.Background()
1119+
1120+
serverCalled := false
1121+
1122+
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
1123+
serverCalled = true
1124+
1125+
assert.Equal(t, "/api/v1/flags/", req.URL.Path)
1126+
assert.Equal(t, fixtures.EnvironmentAPIKey, req.Header.Get("x-Environment-Key"))
1127+
1128+
rw.Header().Set("Content-Type", "application/json")
1129+
rw.WriteHeader(http.StatusOK)
1130+
_, err := io.WriteString(rw, fixtures.FlagsJson)
1131+
assert.NoError(t, err)
1132+
}))
1133+
defer server.Close()
1134+
1135+
client := flagsmith.NewClient(fixtures.EnvironmentAPIKey,
1136+
flagsmith.WithBaseURL(server.URL+"/api/v1/"))
1137+
1138+
flags, err := client.GetFlags(ctx, nil)
1139+
1140+
assert.NoError(t, err)
1141+
assert.True(t, serverCalled, "Expected server to be")
1142+
assert.Equal(t, 1, len(flags.AllFlags()))
1143+
}
1144+
1145+
func TestCustomClientOptionsShoudPanic(t *testing.T) {
1146+
restyClient := resty.New()
1147+
1148+
testCases := []struct {
1149+
name string
1150+
option flagsmith.Option
1151+
}{
1152+
{
1153+
name: "WithRequestTimeout",
1154+
option: flagsmith.WithRequestTimeout(5 * time.Second),
1155+
},
1156+
{
1157+
name: "WithRetries",
1158+
option: flagsmith.WithRetries(3, time.Second),
1159+
},
1160+
{
1161+
name: "WithCustomHeaders",
1162+
option: flagsmith.WithCustomHeaders(map[string]string{"X-Custom": "value"}),
1163+
},
1164+
{
1165+
name: "WithProxy",
1166+
option: flagsmith.WithProxy("http://proxy.example.com"),
1167+
},
1168+
}
1169+
1170+
for _, test := range testCases {
1171+
t.Run(test.name, func(t *testing.T) {
1172+
assert.Panics(t, func() {
1173+
_ = flagsmith.NewClient(fixtures.EnvironmentAPIKey,
1174+
flagsmith.WithRestyClient(restyClient),
1175+
test.option)
1176+
}, "Expected panic when using %s with custom resty client", test.name)
1177+
})
1178+
}
1179+
}

config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type config struct {
2727
realtimeBaseUrl string
2828
useRealtime bool
2929
polling bool
30+
userProvidedClient bool
3031
}
3132

3233
// defaultConfig returns default configuration.
@@ -36,5 +37,6 @@ func defaultConfig() config {
3637
timeout: DefaultTimeout,
3738
envRefreshInterval: time.Second * 60,
3839
realtimeBaseUrl: DefaultRealtimeBaseUrl,
40+
userProvidedClient: false,
3941
}
4042
}

0 commit comments

Comments
 (0)