Skip to content
Merged
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
59 changes: 47 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!")
}
}
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -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()
}
```
Expand All @@ -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()
}
Expand All @@ -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
Expand Down Expand Up @@ -388,18 +418,22 @@ 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
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
Expand All @@ -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"])
```
Expand Down
4 changes: 4 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
@@ -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...)}}
}
215 changes: 215 additions & 0 deletions remote.go
Original file line number Diff line number Diff line change
@@ -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"
}
Loading
Loading