From 3e02f4369fda1ec8fcdb2e9c236605b46592cdcb Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Sun, 10 May 2026 14:11:29 -0700 Subject: [PATCH] feat: added remote API module --- README.md | 59 +++++-- client.go | 4 + errors.go | 27 ++++ remote.go | 215 +++++++++++++++++++++++++ remote_test.go | 402 +++++++++++++++++++++++++++++++++++++++++++++++ result.go | 15 ++ switcher.go | 73 ++++++++- switcher_test.go | 70 ++++++++- 8 files changed, 840 insertions(+), 25 deletions(-) create mode 100644 errors.go create mode 100644 remote.go create mode 100644 remote_test.go create mode 100644 result.go diff --git a/README.md b/README.md index 1475e86..c41f102 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,12 @@ func main() { }) switcher := client.GetSwitcher("FEATURE_TOGGLE") - if switcher.IsOn() { + enabled, err := switcher.IsOn() + if err != nil { + panic(err) + } + + if enabled { fmt.Println("Feature is enabled!") } } @@ -221,7 +226,12 @@ The simplest way to check if a feature is enabled: ```go switcher := client.GetSwitcher("FEATURE_LOGIN_V2") -if switcher.IsOn() { +enabled, err := switcher.IsOn() +if err != nil { + panic(err) +} + +if enabled { newLogin() } else { legacyLogin() @@ -233,7 +243,10 @@ if switcher.IsOn() { Get comprehensive information about the feature flag evaluation: ```go -response := client.GetSwitcher("FEATURE_LOGIN_V2").IsOnWithDetails() +response, err := client.GetSwitcher("FEATURE_LOGIN_V2").IsOnWithDetails() +if err != nil { + panic(err) +} fmt.Printf("Feature enabled: %v\n", response.Result) fmt.Printf("Reason: %s\n", response.Reason) @@ -250,9 +263,16 @@ Load validation data separately, useful for complex applications: prepared := client.GetSwitcher(""). CheckValue("USER_123") -prepared.Prepare("USER_FEATURE") +if err := prepared.Prepare("USER_FEATURE"); err != nil { + panic(err) +} -if prepared.IsOn() { +enabled, err := prepared.IsOn() +if err != nil { + panic(err) +} + +if enabled { enableUserFeature() } ``` @@ -262,13 +282,17 @@ if prepared.IsOn() { Chain multiple validation strategies for comprehensive feature control: ```go -isEnabled := client.GetSwitcher("PREMIUM_FEATURES"). +isEnabled, err := client.GetSwitcher("PREMIUM_FEATURES"). CheckValue("premium_user"). CheckNetwork("192.168.1.0/24"). DefaultResult(true). Throttle(time.Second). IsOn() +if err != nil { + panic(err) +} + if isEnabled { showPremiumDashboard() } @@ -288,12 +312,18 @@ client.SubscribeNotifyError(func(err error) { #### Throttling ```go -client.GetSwitcher("FEATURE01").Throttle(time.Second).IsOn() +_, err := client.GetSwitcher("FEATURE01").Throttle(time.Second).IsOn() +if err != nil { + panic(err) +} ``` #### Hybrid Mode ```go -client.GetSwitcher("FEATURE01").Remote().IsOn() +_, err := client.GetSwitcher("FEATURE01").Remote().IsOn() +if err != nil { + panic(err) +} ``` ## Snapshot Management @@ -388,7 +418,9 @@ The Go SDK provides test-oriented mocking capabilities adapted to Go idioms and sdk := client.NewClient(ctx) sdk.Assume("FEATURE01").True() -assert.Equal(t, true, sdk.GetSwitcher("FEATURE01").IsOn()) +enabled, err := sdk.GetSwitcher("FEATURE01").IsOn() +assert.NoError(t, err) +assert.True(t, enabled) ``` ```go @@ -396,10 +428,12 @@ sdk.Assume("FEATURE01").True(). When(client.StrategyValue, []string{"guest", "admin"}). When(client.StrategyNetwork, "10.0.0.3") -assert.Equal(t, true, sdk.GetSwitcher("FEATURE01"). +enabled, err := sdk.GetSwitcher("FEATURE01"). CheckValue("guest"). CheckNetwork("10.0.0.3"). - IsOn()) + IsOn() +assert.NoError(t, err) +assert.True(t, enabled) ``` ```go @@ -411,7 +445,8 @@ sdk.Assume("FEATURE01").False().WithMetadata(map[string]any{ "message": "Feature is disabled", }) -response := sdk.GetSwitcher("FEATURE01").IsOnWithDetails() +response, err := sdk.GetSwitcher("FEATURE01").IsOnWithDetails() +assert.NoError(t, err) assert.Equal(t, false, response.Result) assert.Equal(t, "Feature is disabled", response.Metadata["message"]) ``` diff --git a/client.go b/client.go index c88a85e..67c2ef8 100644 --- a/client.go +++ b/client.go @@ -13,6 +13,10 @@ type Client struct { mu sync.RWMutex context Context switchers map[string]*Switcher + + authMu sync.Mutex + authToken string + authTokenExp int64 } func NewClient(ctx Context) *Client { diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..18b0a2f --- /dev/null +++ b/errors.go @@ -0,0 +1,27 @@ +package client + +import "fmt" + +type RemoteError struct { + message string +} + +func (e *RemoteError) Error() string { + return e.message +} + +type RemoteAuthError struct { + RemoteError +} + +type RemoteCriteriaError struct { + RemoteError +} + +func newRemoteAuthError(format string, args ...any) error { + return &RemoteAuthError{RemoteError: RemoteError{message: fmt.Sprintf(format, args...)}} +} + +func newRemoteCriteriaError(format string, args ...any) error { + return &RemoteCriteriaError{RemoteError: RemoteError{message: fmt.Sprintf(format, args...)}} +} diff --git a/remote.go b/remote.go new file mode 100644 index 0000000..620ce50 --- /dev/null +++ b/remote.go @@ -0,0 +1,215 @@ +package client + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + StrategyValue = "VALUE_VALIDATION" +) + +type criteriaEntry struct { + Strategy string `json:"strategy"` + Input string `json:"input"` +} + +type authResponse struct { + Token *string `json:"token"` + Exp json.Number `json:"exp"` +} + +type criteriaResponse struct { + Result bool `json:"result"` + Reason string `json:"reason"` + Metadata map[string]any `json:"metadata"` +} + +func (c *Client) ensureToken() (string, error) { + c.authMu.Lock() + defer c.authMu.Unlock() + + if strings.TrimSpace(c.authToken) != "" && !tokenExpired(c.authTokenExp) { + return c.authToken, nil + } + + ctx := c.Context() + endpoint := strings.TrimRight(ctx.URL, "/") + "/criteria/auth" + + response, err := c.doJSONRequest( + http.MethodPost, + endpoint, + map[string]any{ + "domain": ctx.Domain, + "component": ctx.Component, + "environment": ctx.Environment, + }, + map[string]string{ + "switcher-api-key": ctx.APIKey, + "Content-Type": "application/json", + }, + ) + if err != nil { + return "", newRemoteAuthError("[auth] remote unavailable") + } + defer func() { + _ = response.Body.Close() + }() + + if response.StatusCode != http.StatusOK { + return "", newRemoteAuthError("invalid API key") + } + + var payload authResponse + decoder := json.NewDecoder(response.Body) + decoder.UseNumber() + if err := decoder.Decode(&payload); err != nil { + return "", err + } + + if payload.Token == nil { + c.authToken = "" + c.authTokenExp = parseTokenExpiration(payload.Exp) + return "", nil + } + + c.authToken = *payload.Token + c.authTokenExp = parseTokenExpiration(payload.Exp) + return c.authToken, nil +} + +func (c *Client) checkCriteria(token string, switcher *Switcher, showDetails bool) (ResultDetail, error) { + ctx := c.Context() + endpoint := strings.TrimRight(ctx.URL, "/") + "/criteria" + + query := make(url.Values) + query.Set("showReason", strings.ToLower(strconvFormatBool(showDetails))) + query.Set("key", switcher.key) + + response, err := c.doJSONRequest( + http.MethodPost, + endpoint+"?"+query.Encode(), + map[string]any{ + "entry": switcher.entries, + }, + map[string]string{ + "Authorization": "Bearer " + token, + "Content-Type": "application/json", + }, + ) + if err != nil { + return ResultDetail{}, newRemoteCriteriaError("[check_criteria] remote unavailable") + } + defer func() { + _ = response.Body.Close() + }() + + if response.StatusCode != http.StatusOK { + return ResultDetail{}, newRemoteCriteriaError("[check_criteria] failed with status: %d", response.StatusCode) + } + + var payload criteriaResponse + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + return ResultDetail{}, err + } + + if payload.Metadata == nil { + payload.Metadata = map[string]any{} + } + + return ResultDetail(payload), nil +} + +func (c *Client) doJSONRequest(method, endpoint string, payload any, headers map[string]string) (*http.Response, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + request, err := http.NewRequestWithContext(context.Background(), method, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + for key, value := range headers { + request.Header.Set(key, value) + } + + return c.httpClient().Do(request) +} + +func (c *Client) httpClient() *http.Client { + ctx := c.Context() + dialer := &net.Dialer{ + Timeout: ctx.Options.Remote.ConnectTimeout, + } + + transport := &http.Transport{ + DialContext: dialer.DialContext, + ResponseHeaderTimeout: ctx.Options.Remote.ReadTimeout, + TLSHandshakeTimeout: ctx.Options.Remote.ConnectTimeout, + IdleConnTimeout: ctx.Options.Remote.PoolTimeout, + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + }, + } + + return &http.Client{ + Transport: transport, + Timeout: requestTimeout(ctx.Options.Remote), + } +} + +func requestTimeout(options RemoteOptions) time.Duration { + timeout := options.ConnectTimeout + options.ReadTimeout + options.WriteTimeout + if timeout <= 0 { + return DefaultRemoteConnectTimeout + DefaultRemoteReadTimeout + DefaultRemoteWriteTimeout + } + + return timeout +} + +func missingTokenError(token string) error { + if strings.TrimSpace(token) != "" { + return nil + } + + return errors.New("something went wrong: missing token field") +} + +func parseTokenExpiration(value json.Number) int64 { + parsed, err := value.Int64() + if err != nil { + return 0 + } + + return parsed +} + +func tokenExpired(expiration int64) bool { + if expiration == 0 { + return true + } + + if expiration > 1_000_000_000_000 { + return time.Now().UnixMilli() >= expiration + } + + return time.Now().Unix() >= expiration +} + +func strconvFormatBool(value bool) string { + if value { + return "true" + } + + return "false" +} diff --git a/remote_test.go b/remote_test.go new file mode 100644 index 0000000..e4a6db1 --- /dev/null +++ b/remote_test.go @@ -0,0 +1,402 @@ +package client + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestSwitcherRemoteEvaluation(t *testing.T) { + t.Run("should call the remote API with success", func(t *testing.T) { + server := newRemoteTestServer(t, remoteTestHandlers{ + authStatus: http.StatusOK, + authBody: map[string]any{"token": "[token]", "exp": time.Now().Add(time.Hour).Unix()}, + criteriaStatus: http.StatusOK, + criteriaBody: map[string]any{"result": true}, + }) + defer server.Close() + + client := newRemoteTestClient(server.URL) + + got, err := client.GetSwitcher("MY_SWITCHER").IsOn() + + assert.NoError(t, err) + assert.True(t, got) + }) + + t.Run("should send input parameters to the remote criteria endpoint", func(t *testing.T) { + var captured map[string]any + server := newRemoteTestServer(t, remoteTestHandlers{ + authStatus: http.StatusOK, + authBody: map[string]any{"token": "[token]", "exp": time.Now().Add(time.Hour).Unix()}, + criteriaStatus: http.StatusOK, + criteriaBody: map[string]any{"result": true}, + onCriteriaRequest: func(body map[string]any, request *http.Request) { + captured = body + }, + }) + defer server.Close() + + client := newRemoteTestClient(server.URL) + + got, err := client.GetSwitcher("MY_SWITCHER").CheckValue("user_id").IsOn() + + assert.NoError(t, err) + assert.True(t, got) + assert.Equal(t, map[string]any{ + "entry": []any{ + map[string]any{ + "strategy": StrategyValue, + "input": "user_id", + }, + }, + }, captured) + }) + + t.Run("should return detailed response from the remote API", func(t *testing.T) { + server := newRemoteTestServer(t, remoteTestHandlers{ + authStatus: http.StatusOK, + authBody: map[string]any{"token": "[token]", "exp": time.Now().Add(time.Hour).Unix()}, + criteriaStatus: http.StatusOK, + criteriaBody: map[string]any{ + "result": true, + "reason": "Success", + "metadata": map[string]any{"key": "value"}, + }, + onCriteriaRequest: func(_ map[string]any, request *http.Request) { + assert.Equal(t, "true", request.URL.Query().Get("showReason")) + }, + }) + defer server.Close() + + client := newRemoteTestClient(server.URL) + + got, err := client.GetSwitcher("MY_SWITCHER").IsOnWithDetails() + + assert.NoError(t, err) + assert.True(t, got.Result) + assert.Equal(t, "Success", got.Reason) + assert.Equal(t, map[string]any{"key": "value"}, got.Metadata) + assert.Equal(t, map[string]any{ + "result": true, + "reason": "Success", + "metadata": map[string]any{"key": "value"}, + }, got.ToMap()) + }) + + t.Run("should authenticate during prepare and reuse the prepared key", func(t *testing.T) { + server := newRemoteTestServer(t, remoteTestHandlers{ + authStatus: http.StatusOK, + authBody: map[string]any{"token": "[token]", "exp": time.Now().Add(time.Hour).Unix()}, + criteriaStatus: http.StatusOK, + criteriaBody: map[string]any{"result": true}, + onCriteriaRequest: func(_ map[string]any, request *http.Request) { + assert.Equal(t, "USER_FEATURE", request.URL.Query().Get("key")) + }, + }) + defer server.Close() + + client := newRemoteTestClient(server.URL) + switcher := client.GetSwitcher("").CheckValue("user_id") + + err := switcher.Prepare("USER_FEATURE") + got, evalErr := switcher.IsOn() + + assert.NoError(t, err) + assert.NoError(t, evalErr) + assert.True(t, got) + }) + + t.Run("should return an auth error when the API key is invalid", func(t *testing.T) { + server := newRemoteTestServer(t, remoteTestHandlers{ + authStatus: http.StatusUnauthorized, + authBody: map[string]any{}, + }) + defer server.Close() + + client := newRemoteTestClient(server.URL) + + _, err := client.GetSwitcher("MY_SWITCHER").IsOn() + + assert.Error(t, err) + var remoteAuthErr *RemoteAuthError + assert.ErrorAs(t, err, &remoteAuthErr) + assert.EqualError(t, err, "invalid API key") + }) + + t.Run("should return an auth unavailable error when the auth endpoint cannot be reached", func(t *testing.T) { + server := newRemoteTestServer(t, remoteTestHandlers{ + authStatus: http.StatusOK, + authBody: map[string]any{"token": "[token]", "exp": time.Now().Add(time.Hour).Unix()}, + criteriaStatus: http.StatusOK, + criteriaBody: map[string]any{"result": true}, + }) + + client := newRemoteTestClient(server.URL) + server.Close() + + _, err := client.GetSwitcher("MY_SWITCHER").IsOn() + + assert.Error(t, err) + var remoteAuthErr *RemoteAuthError + assert.ErrorAs(t, err, &remoteAuthErr) + assert.EqualError(t, err, "[auth] remote unavailable") + }) + + t.Run("should return a decode error when the auth response body is malformed", func(t *testing.T) { + rawBody := "{invalid-json" + server := newRemoteTestServer(t, remoteTestHandlers{ + authStatus: http.StatusOK, + authRawBody: &rawBody, + }) + defer server.Close() + + client := newRemoteTestClient(server.URL) + + _, err := client.GetSwitcher("MY_SWITCHER").IsOn() + + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid character") + }) + + t.Run("should return an error when the auth response token is missing", func(t *testing.T) { + server := newRemoteTestServer(t, remoteTestHandlers{ + authStatus: http.StatusOK, + authBody: map[string]any{"token": nil, "exp": time.Now().Add(time.Hour).Unix()}, + }) + defer server.Close() + + client := newRemoteTestClient(server.URL) + + _, err := client.GetSwitcher("MY_SWITCHER").IsOn() + + assert.EqualError(t, err, "something went wrong: missing token field") + }) + + t.Run("should return a remote criteria error when the criteria endpoint fails", func(t *testing.T) { + server := newRemoteTestServer(t, remoteTestHandlers{ + authStatus: http.StatusOK, + authBody: map[string]any{"token": "[token]", "exp": time.Now().Add(time.Hour).Unix()}, + criteriaStatus: http.StatusInternalServerError, + criteriaBody: map[string]any{"error": "boom"}, + }) + defer server.Close() + + client := newRemoteTestClient(server.URL) + + _, err := client.GetSwitcher("MY_SWITCHER").IsOn() + + assert.Error(t, err) + var remoteCriteriaErr *RemoteCriteriaError + assert.ErrorAs(t, err, &remoteCriteriaErr) + assert.EqualError(t, err, "[check_criteria] failed with status: 500") + }) + + t.Run("should return a decode error when the criteria response body is malformed", func(t *testing.T) { + rawBody := "{invalid-json" + server := newRemoteTestServer(t, remoteTestHandlers{ + authStatus: http.StatusOK, + authBody: map[string]any{"token": "[token]", "exp": time.Now().Add(time.Hour).Unix()}, + criteriaStatus: http.StatusOK, + criteriaRawBody: &rawBody, + }) + defer server.Close() + + client := newRemoteTestClient(server.URL) + + _, err := client.GetSwitcher("MY_SWITCHER").IsOn() + + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid character") + }) + + t.Run("should return a remote unavailable error when the criteria endpoint cannot be reached", func(t *testing.T) { + server := newRemoteTestServer(t, remoteTestHandlers{ + authStatus: http.StatusOK, + authBody: map[string]any{"token": "[token]", "exp": time.Now().Add(time.Hour).Unix()}, + criteriaStatus: http.StatusOK, + criteriaBody: map[string]any{"result": true}, + }) + + client := newRemoteTestClient(server.URL) + switcher := client.GetSwitcher("MY_SWITCHER") + + err := switcher.Prepare("") + server.Close() + _, evalErr := switcher.IsOn() + + assert.NoError(t, err) + assert.Error(t, evalErr) + var remoteCriteriaErr *RemoteCriteriaError + assert.ErrorAs(t, evalErr, &remoteCriteriaErr) + assert.EqualError(t, evalErr, "[check_criteria] remote unavailable") + }) +} + +func TestClientDoJSONRequest(t *testing.T) { + t.Run("should return an error when the payload cannot be marshaled", func(t *testing.T) { + client := newRemoteTestClient("https://api.switcherapi.com") + + response, err := client.doJSONRequest( + http.MethodPost, + "https://api.switcherapi.com/criteria/auth", + map[string]any{ + "invalid": func() {}, + }, + map[string]string{ + "Content-Type": "application/json", + }, + ) + + assert.Nil(t, response) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported type") + }) + + t.Run("should return an error when the request cannot be created", func(t *testing.T) { + client := newRemoteTestClient("https://api.switcherapi.com") + + response, err := client.doJSONRequest( + http.MethodPost, + "://bad-url", + map[string]any{ + "domain": "My Domain", + }, + map[string]string{ + "Content-Type": "application/json", + }, + ) + + assert.Nil(t, response) + assert.Error(t, err) + assert.Contains(t, err.Error(), "missing protocol scheme") + }) +} + +func TestRequestTimeout(t *testing.T) { + t.Run("should return the default combined timeout when configured timeout is zero", func(t *testing.T) { + got := requestTimeout(RemoteOptions{}) + + assert.Equal( + t, + DefaultRemoteConnectTimeout+DefaultRemoteReadTimeout+DefaultRemoteWriteTimeout, + got, + ) + }) + + t.Run("should return the default combined timeout when configured timeout is negative", func(t *testing.T) { + got := requestTimeout(RemoteOptions{ + ConnectTimeout: -time.Second, + }) + + assert.Equal( + t, + DefaultRemoteConnectTimeout+DefaultRemoteReadTimeout+DefaultRemoteWriteTimeout, + got, + ) + }) +} + +func TestParseTokenExpiration(t *testing.T) { + t.Run("should parse the expiration from a json number", func(t *testing.T) { + got := parseTokenExpiration(json.Number("1700000000")) + + assert.Equal(t, int64(1700000000), got) + }) + + t.Run("should return zero when the json number is invalid", func(t *testing.T) { + got := parseTokenExpiration(json.Number("invalid")) + + assert.Zero(t, got) + }) +} + +func TestTokenExpired(t *testing.T) { + t.Run("should treat zero expiration as expired", func(t *testing.T) { + assert.True(t, tokenExpired(0)) + }) + + t.Run("should compare millisecond expirations against the current time", func(t *testing.T) { + assert.False(t, tokenExpired(time.Now().Add(time.Minute).UnixMilli())) + assert.True(t, tokenExpired(time.Now().Add(-time.Minute).UnixMilli())) + }) +} + +func TestStrconvFormatBool(t *testing.T) { + t.Run("should return false when the input is false", func(t *testing.T) { + assert.Equal(t, "false", strconvFormatBool(false)) + }) +} + +type remoteTestHandlers struct { + authStatus int + authBody map[string]any + authRawBody *string + criteriaStatus int + criteriaBody map[string]any + criteriaRawBody *string + onCriteriaRequest func(body map[string]any, request *http.Request) +} + +func newRemoteTestClient(serverURL string) *Client { + return NewClient(Context{ + Domain: "My Domain", + URL: serverURL, + APIKey: "[YOUR_API_KEY]", + Component: "MyApp", + }) +} + +func newRemoteTestServer(t *testing.T, handlers remoteTestHandlers) *httptest.Server { + t.Helper() + + mux := http.NewServeMux() + mux.HandleFunc("/criteria/auth", func(writer http.ResponseWriter, request *http.Request) { + assert.Equal(t, http.MethodPost, request.Method) + if handlers.authRawBody != nil { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(handlers.authStatus) + _, err := writer.Write([]byte(*handlers.authRawBody)) + assert.NoError(t, err) + return + } + + writeJSONResponse(t, writer, handlers.authStatus, handlers.authBody) + }) + mux.HandleFunc("/criteria", func(writer http.ResponseWriter, request *http.Request) { + assert.Equal(t, http.MethodPost, request.Method) + + var body map[string]any + err := json.NewDecoder(request.Body).Decode(&body) + assert.NoError(t, err) + + if handlers.onCriteriaRequest != nil { + handlers.onCriteriaRequest(body, request) + } + + if handlers.criteriaRawBody != nil { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(handlers.criteriaStatus) + _, writeErr := writer.Write([]byte(*handlers.criteriaRawBody)) + assert.NoError(t, writeErr) + return + } + + writeJSONResponse(t, writer, handlers.criteriaStatus, handlers.criteriaBody) + }) + + return httptest.NewServer(mux) +} + +func writeJSONResponse(t *testing.T, writer http.ResponseWriter, status int, payload map[string]any) { + t.Helper() + + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(status) + err := json.NewEncoder(writer).Encode(payload) + assert.NoError(t, err) +} diff --git a/result.go b/result.go new file mode 100644 index 0000000..19b8535 --- /dev/null +++ b/result.go @@ -0,0 +1,15 @@ +package client + +type ResultDetail struct { + Result bool + Reason string + Metadata map[string]any +} + +func (r ResultDetail) ToMap() map[string]any { + return map[string]any{ + "result": r.Result, + "reason": r.Reason, + "metadata": r.Metadata, + } +} diff --git a/switcher.go b/switcher.go index 5df918b..efc247e 100644 --- a/switcher.go +++ b/switcher.go @@ -6,15 +6,12 @@ import ( ) type Switcher struct { - client *Client - key string + client *Client + key string + entries []criteriaEntry } func (s *Switcher) Validate() error { - if s == nil || s.client == nil { - return fmt.Errorf("something went wrong: client is not configured") - } - ctx := s.client.Context() missingFields := make([]string, 0, 3) @@ -43,3 +40,67 @@ func (s *Switcher) Validate() error { return nil } + +func (s *Switcher) CheckValue(input string) *Switcher { + s.entries = appendFilteredEntries(s.entries, StrategyValue) + s.entries = append(s.entries, criteriaEntry{ + Strategy: StrategyValue, + Input: input, + }) + + return s +} + +func (s *Switcher) Prepare(key string) error { + if strings.TrimSpace(key) != "" { + s.key = key + } + + if err := s.Validate(); err != nil { + return err + } + + token, err := s.client.ensureToken() + if err != nil { + return err + } + + return missingTokenError(token) +} + +func (s *Switcher) IsOn() (bool, error) { + result, err := s.IsOnWithDetails() + if err != nil { + return false, err + } + + return result.Result, nil +} + +func (s *Switcher) IsOnWithDetails() (ResultDetail, error) { + if err := s.Validate(); err != nil { + return ResultDetail{}, err + } + + token, err := s.client.ensureToken() + if err != nil { + return ResultDetail{}, err + } + + if err := missingTokenError(token); err != nil { + return ResultDetail{}, err + } + + return s.client.checkCriteria(token, s, true) +} + +func appendFilteredEntries(entries []criteriaEntry, strategy string) []criteriaEntry { + filtered := entries[:0] + for _, entry := range entries { + if entry.Strategy != strategy { + filtered = append(filtered, entry) + } + } + + return filtered +} diff --git a/switcher_test.go b/switcher_test.go index acd040f..b6f763c 100644 --- a/switcher_test.go +++ b/switcher_test.go @@ -1,19 +1,13 @@ package client import ( + "net/http" "testing" "github.com/stretchr/testify/assert" ) func TestSwitcherValidate(t *testing.T) { - t.Run("should return an error when the client is not configured", func(t *testing.T) { - var switcher *Switcher - - err := switcher.Validate() - assert.EqualError(t, err, "something went wrong: client is not configured") - }) - t.Run("should return an error when remote fields are missing", func(t *testing.T) { BuildContext(Context{ Domain: "My Domain", @@ -47,3 +41,65 @@ func TestSwitcherValidate(t *testing.T) { assert.NoError(t, err) }) } + +func TestSwitcherPrepare(t *testing.T) { + t.Run("should return a validation error when the switcher is invalid", func(t *testing.T) { + client := NewClient(Context{ + Domain: "My Domain", + }) + + err := client.GetSwitcher("").Prepare("") + + assert.EqualError(t, err, "something went wrong: missing or empty required fields (url, component, api_key)") + }) + + t.Run("should return the auth error when token preparation fails", func(t *testing.T) { + server := newRemoteTestServer(t, remoteTestHandlers{ + authStatus: http.StatusUnauthorized, + authBody: map[string]any{}, + }) + defer server.Close() + + client := NewClient(Context{ + Domain: "My Domain", + URL: server.URL, + APIKey: "[YOUR_API_KEY]", + Component: "MyApp", + }) + + err := client.GetSwitcher("MY_SWITCHER").Prepare("") + + assert.Error(t, err) + var remoteAuthErr *RemoteAuthError + assert.ErrorAs(t, err, &remoteAuthErr) + assert.EqualError(t, err, "invalid API key") + }) +} + +func TestSwitcherIsOnWithDetails(t *testing.T) { + t.Run("should return a validation error when the switcher is invalid", func(t *testing.T) { + client := NewClient(Context{ + Domain: "My Domain", + }) + + got, err := client.GetSwitcher("").IsOnWithDetails() + + assert.Equal(t, ResultDetail{}, got) + assert.EqualError(t, err, "something went wrong: missing or empty required fields (url, component, api_key)") + }) +} + +func TestAppendFilteredEntries(t *testing.T) { + t.Run("should preserve entries whose strategy does not match the filtered strategy", func(t *testing.T) { + entries := []criteriaEntry{ + {Strategy: StrategyValue, Input: "user_id"}, + {Strategy: "NETWORK_VALIDATION", Input: "127.0.0.1"}, + } + + got := appendFilteredEntries(entries, StrategyValue) + + assert.Equal(t, []criteriaEntry{ + {Strategy: "NETWORK_VALIDATION", Input: "127.0.0.1"}, + }, got) + }) +}