diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index dc1be9d..d589e0c 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.9' - services: switcher-client-go: diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 01cb552..d7a1541 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -33,7 +33,7 @@ jobs: run: go test -p 1 -v ./... -coverprofile="coverage.out" - name: SonarCloud Scan - uses: sonarsource/sonarqube-scan-action@v8.0.0 + uses: sonarsource/sonarqube-scan-action@v8.1.0 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} if: env.SONAR_TOKEN != '' diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 07fd458..1d30c54 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -50,7 +50,7 @@ jobs: run: go test -p 1 -v ./... -coverprofile="coverage.out" - name: SonarCloud Scan - uses: sonarsource/sonarqube-scan-action@v8.0.0 + uses: sonarsource/sonarqube-scan-action@v8.1.0 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} if: env.SONAR_TOKEN != '' diff --git a/client.go b/client.go index 3880ca5..ccaf6eb 100644 --- a/client.go +++ b/client.go @@ -211,6 +211,30 @@ func (c *Client) CheckSnapshot() (bool, error) { return true, nil } +// CheckSwitchers validates that the provided switcher keys exist in the current snapshot +// or on the remote API, depending on the client's mode. +func CheckSwitchers(switcherKeys []string) error { + return defaultClient().CheckSwitchers(switcherKeys) +} + +// CheckSwitchers validates switcher keys against local snapshot data or the remote API. +func (c *Client) CheckSwitchers(switcherKeys []string) error { + if c.Context().Options.Local { + return checkLocalSwitchers(c.snapshotState(), switcherKeys) + } + + token, err := c.ensureToken() + if err != nil { + return err + } + + if err := missingTokenError(token); err != nil { + return err + } + + return c.checkSwitchers(token, switcherKeys) +} + // GetExecution retrieves the last execution log entry for the provided Switcher using the default client. func GetExecution(switcher *Switcher) ExecutionEntry { return defaultClient().GetExecution(switcher) diff --git a/client_check_switchers_test.go b/client_check_switchers_test.go new file mode 100644 index 0000000..75ec335 --- /dev/null +++ b/client_check_switchers_test.go @@ -0,0 +1,231 @@ +package client + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestCheckSwitchers(t *testing.T) { + t.Run("should validate switchers through the package-level helper", func(t *testing.T) { + useLocalSnapshotFixture(t, "default") + + err := CheckSwitchers([]string{"FF2FOR2022", "FF2FOR2040"}) + + assert.NoError(t, err) + }) + + t.Run("should validate switchers through the remote API", func(t *testing.T) { + var captured map[string]any + server := newCheckSwitchersTestServer(t, checkSwitchersTestHandlers{ + authStatus: http.StatusOK, + authBody: map[string]any{"token": "[token]", "exp": time.Now().Add(time.Hour).Unix()}, + switchersStatus: http.StatusOK, + switchersBody: map[string]any{"not_found": []string{}}, + onSwitchersRequest: func(body map[string]any, _ *http.Request) { + captured = body + }, + }) + defer server.Close() + + client := newRemoteTestClient(server.URL) + + err := client.CheckSwitchers([]string{"MY_SWITCHER", "ANOTHER_SWITCHER"}) + + assert.NoError(t, err) + assert.Equal(t, map[string]any{ + "switchers": []any{"MY_SWITCHER", "ANOTHER_SWITCHER"}, + }, captured) + }) + + t.Run("should return a remote switcher error when the remote API reports missing keys", func(t *testing.T) { + server := newCheckSwitchersTestServer(t, checkSwitchersTestHandlers{ + authStatus: http.StatusOK, + authBody: map[string]any{"token": "[token]", "exp": time.Now().Add(time.Hour).Unix()}, + switchersStatus: http.StatusOK, + switchersBody: map[string]any{"not_found": []string{"MY_SWITCHER"}}, + }) + defer server.Close() + + client := newRemoteTestClient(server.URL) + + err := client.CheckSwitchers([]string{"MY_SWITCHER", "ANOTHER_SWITCHER"}) + + assert.Error(t, err) + var remoteSwitcherErr *RemoteSwitcherError + assert.ErrorAs(t, err, &remoteSwitcherErr) + assert.EqualError(t, err, "MY_SWITCHER not found") + }) + + t.Run("should return a remote auth error when authentication fails", func(t *testing.T) { + server := newCheckSwitchersTestServer(t, checkSwitchersTestHandlers{ + authStatus: http.StatusUnauthorized, + authBody: map[string]any{}, + }) + defer server.Close() + + client := newRemoteTestClient(server.URL) + + err := client.CheckSwitchers([]string{"MY_SWITCHER"}) + + assert.Error(t, err) + var remoteAuthErr *RemoteAuthError + assert.ErrorAs(t, err, &remoteAuthErr) + assert.EqualError(t, err, "invalid API key") + }) + + t.Run("should return a remote error when the switchers endpoint fails", func(t *testing.T) { + server := newCheckSwitchersTestServer(t, checkSwitchersTestHandlers{ + authStatus: http.StatusOK, + authBody: map[string]any{"token": "[token]", "exp": time.Now().Add(time.Hour).Unix()}, + switchersStatus: http.StatusInternalServerError, + switchersBody: map[string]any{"not_found": []string{}}, + }) + defer server.Close() + + client := newRemoteTestClient(server.URL) + + err := client.CheckSwitchers([]string{"MY_SWITCHER"}) + + assert.Error(t, err) + var remoteErr *RemoteError + assert.ErrorAs(t, err, &remoteErr) + assert.EqualError(t, err, "[check_switchers] failed with status: 500") + }) + + t.Run("should return an error when the auth response token is missing", func(t *testing.T) { + server := newCheckSwitchersTestServer(t, checkSwitchersTestHandlers{ + authStatus: http.StatusOK, + authBody: map[string]any{"exp": time.Now().Add(time.Hour).Unix()}, + }) + defer server.Close() + + client := newRemoteTestClient(server.URL) + + err := client.CheckSwitchers([]string{"MY_SWITCHER"}) + + assert.EqualError(t, err, "something went wrong: missing token field") + }) + + t.Run("should return an error when check switchers endpoint is unavailable", func(t *testing.T) { + server := newCheckSwitchersTestServer(t, checkSwitchersTestHandlers{ + authStatus: http.StatusOK, + authBody: map[string]any{"token": "[token]", "exp": time.Now().Add(time.Hour).Unix()}, + }) + defer server.Close() + + client := newRemoteTestClient(server.URL) + + err := client.CheckSwitchers([]string{"MY_SWITCHER"}) + + assert.Error(t, err) + var remoteErr *RemoteError + assert.ErrorAs(t, err, &remoteErr) + assert.EqualError(t, err, "[check_switchers] remote unavailable") + }) + + t.Run("should return a local switcher error when keys are missing from the snapshot", func(t *testing.T) { + useLocalSnapshotFixture(t, "default") + + err := CheckSwitchers([]string{"FF2FOR2022", "NON_EXISTENT_SWITCHER"}) + + assert.Error(t, err) + var localSwitcherErr *LocalSwitcherError + assert.ErrorAs(t, err, &localSwitcherErr) + assert.EqualError(t, err, "NON_EXISTENT_SWITCHER not found") + }) + + t.Run("should return an error when check switchers response cannot be decoded", func(t *testing.T) { + rawBody := "{invalid-json" + server := newCheckSwitchersTestServer(t, checkSwitchersTestHandlers{ + authStatus: http.StatusOK, + authBody: map[string]any{"token": "[token]", "exp": time.Now().Add(time.Hour).Unix()}, + switchersStatus: http.StatusOK, + switchersRawBody: &rawBody, + }) + defer server.Close() + + client := newRemoteTestClient(server.URL) + + err := client.CheckSwitchers([]string{"MY_SWITCHER"}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid character") + }) + + t.Run("should return all requested keys when no local snapshot is loaded", func(t *testing.T) { + client := NewClient(Context{ + Domain: "My Domain", + Options: ContextOptions{ + Local: true, + SnapshotLocation: t.TempDir(), + }, + }) + + err := client.CheckSwitchers([]string{"FF2FOR2022", "NON_EXISTENT_SWITCHER"}) + + assert.Error(t, err) + var localSwitcherErr *LocalSwitcherError + assert.ErrorAs(t, err, &localSwitcherErr) + assert.EqualError(t, err, "FF2FOR2022, NON_EXISTENT_SWITCHER not found") + }) +} + +type checkSwitchersTestHandlers struct { + authStatus int + authBody map[string]any + authRawBody *string + switchersStatus int + switchersBody map[string]any + switchersRawBody *string + onAuthRequest func(*http.Request) + onSwitchersRequest func(body map[string]any, request *http.Request) +} + +func newCheckSwitchersTestServer(t *testing.T, handlers checkSwitchersTestHandlers) *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.onAuthRequest != nil { + handlers.onAuthRequest(request) + } + 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/switchers_check", 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.onSwitchersRequest != nil { + handlers.onSwitchersRequest(body, request) + } + + if handlers.switchersRawBody != nil { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(handlers.switchersStatus) + _, err := writer.Write([]byte(*handlers.switchersRawBody)) + assert.NoError(t, err) + return + } + + writeJSONResponse(t, writer, handlers.switchersStatus, handlers.switchersBody) + }) + + return httptest.NewServer(mux) +} diff --git a/errors.go b/errors.go index c1c17fe..825cb35 100644 --- a/errors.go +++ b/errors.go @@ -1,6 +1,9 @@ package client -import "fmt" +import ( + "fmt" + "strings" +) // RemoteError represents a generic error returned by remote Switcher API calls. // Concrete remote error types embed RemoteError to allow type assertions by callers. @@ -30,16 +33,31 @@ type RemoteSnapshotError struct { RemoteError } +// RemoteSwitcherError indicates a switcher configuration error returned by the remote API. +// It embeds RemoteError. +type RemoteSwitcherError struct { + RemoteError +} + // LocalCriteriaError represents an error raised when local snapshot evaluation fails due to // invalid criteria or inputs. It implements the error interface. type LocalCriteriaError struct { message string } +// LocalSwitcherError indicates a missing switcher in local snapshot validation. +type LocalSwitcherError struct { + message string +} + func (e *LocalCriteriaError) Error() string { return e.message } +func (e *LocalSwitcherError) Error() string { + return e.message +} + func newRemoteAuthError(format string, args ...any) error { return &RemoteAuthError{RemoteError: RemoteError{message: fmt.Sprintf(format, args...)}} } @@ -52,6 +70,26 @@ func newRemoteSnapshotError(format string, args ...any) error { return &RemoteSnapshotError{RemoteError: RemoteError{message: fmt.Sprintf(format, args...)}} } +func newRemoteError(format string, args ...any) error { + return &RemoteError{message: fmt.Sprintf(format, args...)} +} + +func newRemoteSwitcherError(notFound []string) error { + if len(notFound) == 0 { + return nil + } + + return &RemoteSwitcherError{RemoteError: RemoteError{message: fmt.Sprintf("%s not found", strings.Join(notFound, ", "))}} +} + func newLocalCriteriaError(format string, args ...any) error { return &LocalCriteriaError{message: fmt.Sprintf(format, args...)} } + +func newLocalSwitcherError(notFound []string) error { + if len(notFound) == 0 { + return nil + } + + return &LocalSwitcherError{message: fmt.Sprintf("%s not found", strings.Join(notFound, ", "))} +} diff --git a/remote.go b/remote.go index 98cedd8..c3fca40 100644 --- a/remote.go +++ b/remote.go @@ -32,6 +32,10 @@ type criteriaResponse struct { Metadata map[string]any `json:"metadata"` } +type switchersCheckResponse struct { + NotFound []string `json:"not_found"` +} + type snapshotCheckResponse struct { Status bool `json:"status"` } @@ -143,6 +147,41 @@ func (c *Client) checkCriteria(token string, switcher *Switcher, showDetails boo return ResultDetail(payload), nil } +func (c *Client) checkSwitchers(token string, switcherKeys []string) error { + ctx := c.Context() + endpoint := strings.TrimRight(ctx.URL, "/") + "/criteria/switchers_check" + + response, err := c.doJSONRequest( + http.MethodPost, + endpoint, + map[string]any{ + "switchers": switcherKeys, + }, + c.authHeaders(token), + ) + if err != nil { + return newRemoteError("[check_switchers] remote unavailable") + } + defer func() { + _ = response.Body.Close() + }() + + if response.StatusCode != http.StatusOK { + return newRemoteError("[check_switchers] failed with status: %d", response.StatusCode) + } + + var payload switchersCheckResponse + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + return err + } + + if err := newRemoteSwitcherError(payload.NotFound); err != nil { + return err + } + + return nil +} + func (c *Client) checkSnapshotVersion(token string, snapshotVersion int) (bool, error) { ctx := c.Context() endpoint := fmt.Sprintf("%s/criteria/snapshot_check/%d", strings.TrimRight(ctx.URL, "/"), snapshotVersion) diff --git a/resolver.go b/resolver.go index 45fadd4..53382b0 100644 --- a/resolver.go +++ b/resolver.go @@ -79,3 +79,25 @@ func findCriteriaEntry(entries []criteriaEntry, strategy string) (criteriaEntry, return criteriaEntry{}, false } + +func checkLocalSwitchers(snapshot *Snapshot, switcherKeys []string) error { + if snapshot == nil { + return newLocalSwitcherError(switcherKeys) + } + + found := make(map[string]struct{}) + for _, group := range snapshot.Domain.Groups { + for _, config := range group.Configs { + found[config.Key] = struct{}{} + } + } + + missing := make([]string, 0) + for _, key := range switcherKeys { + if _, ok := found[key]; !ok { + missing = append(missing, key) + } + } + + return newLocalSwitcherError(missing) +}