From 311377682ae2ebbae4d8474f028c0446773ffb47 Mon Sep 17 00:00:00 2001 From: Collins C Augustine Date: Wed, 6 Nov 2024 19:45:58 +0100 Subject: [PATCH 1/2] feat: BitBucket Retriever --- README.md | 1 + cmd/relayproxy/config/retriever.go | 5 +- cmd/relayproxy/config/retriever_test.go | 7 + cmd/relayproxy/service/gofeatureflag.go | 19 ++- retriever/bitbucketretriever/retriever.go | 106 +++++++++++++ .../bitbucketretriever/retriever_test.go | 142 ++++++++++++++++++ 6 files changed, 270 insertions(+), 10 deletions(-) create mode 100644 retriever/bitbucketretriever/retriever.go create mode 100644 retriever/bitbucketretriever/retriever_test.go diff --git a/README.md b/README.md index 1dec8bfe2bd..ddd8925a282 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,7 @@ The available retrievers are: - **Kubernetes ConfigMaps** - **MongoDB** - **Redis** +- **BitBucket** - ... _[See the full list and more information.](https://gofeatureflag.org/docs/configure_flag/store_your_flags)_ diff --git a/cmd/relayproxy/config/retriever.go b/cmd/relayproxy/config/retriever.go index 60b33bc4eed..d0212287ad0 100644 --- a/cmd/relayproxy/config/retriever.go +++ b/cmd/relayproxy/config/retriever.go @@ -40,7 +40,7 @@ func (c *RetrieverConf) IsValid() error { if err := c.Kind.IsValid(); err != nil { return err } - if c.Kind == GitHubRetriever || c.Kind == GitlabRetriever { + if c.Kind == GitHubRetriever || c.Kind == GitlabRetriever || c.Kind == BitbucketRetriever { return c.validateGitRetriever() } if c.Kind == S3Retriever && c.Item == "" { @@ -126,13 +126,14 @@ const ( KubernetesRetriever RetrieverKind = "configmap" MongoDBRetriever RetrieverKind = "mongodb" RedisRetriever RetrieverKind = "redis" + BitbucketRetriever RetrieverKind = "bitbucket" ) // IsValid is checking if the value is part of the enum func (r RetrieverKind) IsValid() error { switch r { case HTTPRetriever, GitHubRetriever, GitlabRetriever, S3Retriever, RedisRetriever, - FileRetriever, GoogleStorageRetriever, KubernetesRetriever, MongoDBRetriever: + FileRetriever, GoogleStorageRetriever, KubernetesRetriever, MongoDBRetriever, BitbucketRetriever: return nil } return fmt.Errorf("invalid retriever: kind \"%s\" is not supported", r) diff --git a/cmd/relayproxy/config/retriever_test.go b/cmd/relayproxy/config/retriever_test.go index 230e797c080..53650272833 100644 --- a/cmd/relayproxy/config/retriever_test.go +++ b/cmd/relayproxy/config/retriever_test.go @@ -55,6 +55,13 @@ func TestRetrieverConf_IsValid(t *testing.T) { }, wantErr: true, errValue: "invalid retriever: no \"path\" property found for kind \"gitlab\"", + },{ + name: "kind BitbucketRetriever without repo slug", + fields: config.RetrieverConf{ + Kind: "bitbucket", + }, + wantErr: true, + errValue: "invalid retriever: no \"repositorySlug\" property found for kind \"bitbucket\"", }, { name: "kind S3Retriever without item", diff --git a/cmd/relayproxy/service/gofeatureflag.go b/cmd/relayproxy/service/gofeatureflag.go index e527351e120..33933e5b288 100644 --- a/cmd/relayproxy/service/gofeatureflag.go +++ b/cmd/relayproxy/service/gofeatureflag.go @@ -20,10 +20,10 @@ import ( "github.com/thomaspoignant/go-feature-flag/exporter/sqsexporter" "github.com/thomaspoignant/go-feature-flag/exporter/webhookexporter" "github.com/thomaspoignant/go-feature-flag/notifier" - "github.com/thomaspoignant/go-feature-flag/notifier/discordnotifier" "github.com/thomaspoignant/go-feature-flag/notifier/slacknotifier" "github.com/thomaspoignant/go-feature-flag/notifier/webhooknotifier" "github.com/thomaspoignant/go-feature-flag/retriever" + "github.com/thomaspoignant/go-feature-flag/retriever/bitbucketretriever" "github.com/thomaspoignant/go-feature-flag/retriever/fileretriever" "github.com/thomaspoignant/go-feature-flag/retriever/gcstorageretriever" "github.com/thomaspoignant/go-feature-flag/retriever/githubretriever" @@ -142,6 +142,14 @@ func initRetriever(c *config.RetrieverConf) (retriever.Retriever, error) { RepositorySlug: c.RepositorySlug, Timeout: retrieverTimeout, }, nil + case config.BitbucketRetriever: + return &bitbucketretriever.Retriever{ + RepositorySlug: c.RepositorySlug, + Branch: c.Branch, + FilePath: c.Path, + BitBucketToken: c.AuthToken, + Timeout: retrieverTimeout, + }, nil case config.FileRetriever: return &fileretriever.Retriever{Path: c.Path}, nil case config.S3Retriever: @@ -317,11 +325,7 @@ func initNotifier(c []config.NotifierConf) ([]notifier.Notifier, error) { for _, cNotif := range c { switch cNotif.Kind { case config.SlackNotifier: - if cNotif.WebhookURL == "" && cNotif.SlackWebhookURL != "" { // nolint - zap.L().Warn("slackWebhookURL field is deprecated, please use webhookURL instead") - cNotif.WebhookURL = cNotif.SlackWebhookURL // nolint - } - notifiers = append(notifiers, &slacknotifier.Notifier{SlackWebhookURL: cNotif.WebhookURL}) + notifiers = append(notifiers, &slacknotifier.Notifier{SlackWebhookURL: cNotif.SlackWebhookURL}) case config.WebhookNotifier: notifiers = append(notifiers, @@ -332,8 +336,7 @@ func initNotifier(c []config.NotifierConf) ([]notifier.Notifier, error) { Headers: cNotif.Headers, }, ) - case config.DiscordNotifier: - notifiers = append(notifiers, &discordnotifier.Notifier{DiscordWebhookURL: cNotif.WebhookURL}) + default: return nil, fmt.Errorf("invalid notifier: kind \"%s\" is not supported", cNotif.Kind) } diff --git a/retriever/bitbucketretriever/retriever.go b/retriever/bitbucketretriever/retriever.go new file mode 100644 index 00000000000..e567743abb5 --- /dev/null +++ b/retriever/bitbucketretriever/retriever.go @@ -0,0 +1,106 @@ +package bitbucketretriever + +import ( + "context" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/thomaspoignant/go-feature-flag/internal" + "github.com/thomaspoignant/go-feature-flag/retriever/shared" +) + +type Retriever struct { + RepositorySlug string + FilePath string + Branch string + BitBucketToken string + Timeout time.Duration + httpClient internal.HTTPClient + rateLimitRemaining int + rateLimitNearLimit bool + rateLimitReset time.Time +} + +func (r *Retriever) Retrieve(ctx context.Context) ([]byte, error) { + if r.FilePath == "" || r.RepositorySlug == "" { + return nil, fmt.Errorf("missing mandatory information filePath=%s, repositorySlug=%s", r.FilePath, r.RepositorySlug) + } + + header := http.Header{} + header.Add("Accept", "application/json") + + branch := r.Branch + if branch == "" { + branch = "main" + } + + if r.BitBucketToken != "" { + header.Add("Authorization", fmt.Sprintf("Bearer %s", r.BitBucketToken)) + } + + if (r.rateLimitRemaining <= 0) && time.Now().Before(r.rateLimitReset) { + return nil, fmt.Errorf("rate limit exceeded. Next call will be after %s", r.rateLimitReset) + } + + URL := fmt.Sprintf( + "https://api.bitbucket.org/2.0/repositories/%s/src/%s/%s", + r.RepositorySlug, + branch, + r.FilePath) + + resp, err := shared.CallHTTPAPI(ctx, URL, http.MethodGet, "", r.Timeout, header, r.httpClient) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + r.updateRateLimit(resp.Header) + + if resp.StatusCode > 399 { + // Collect the headers to add in the error message + bitbucketHeaders := map[string]string{} + for name := range resp.Header { + if strings.HasPrefix(name, "X-") { + bitbucketHeaders[name] = resp.Header.Get(name) + } + } + + return nil, fmt.Errorf("request to %s failed with code %d."+ + " Bitbucket Headers: %v", URL, resp.StatusCode, bitbucketHeaders) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return body, nil +} + +func (r *Retriever) SetHTTPClient(client internal.HTTPClient) { + r.httpClient = client +} + +func (r *Retriever) updateRateLimit(headers http.Header) { + if remaining := headers.Get("X-RateLimit-Limit"); remaining != "" { + if remainingInt, err := strconv.Atoi(remaining); err == nil { + r.rateLimitRemaining = remainingInt + } + } + + if nearLimit := headers.Get("X-RateLimit-NearLimit"); nearLimit != "" { + if nearLimitBool, err := strconv.ParseBool(nearLimit); err == nil { + r.rateLimitNearLimit = nearLimitBool + } + } + + if reset := headers.Get("X-RateLimit-Reset"); reset != "" { + if resetInt, err := strconv.ParseInt(reset, 10, 64); err == nil { + r.rateLimitReset = time.Unix(resetInt, 0) + } + } +} diff --git a/retriever/bitbucketretriever/retriever_test.go b/retriever/bitbucketretriever/retriever_test.go new file mode 100644 index 00000000000..c4aa3188763 --- /dev/null +++ b/retriever/bitbucketretriever/retriever_test.go @@ -0,0 +1,142 @@ +package bitbucketretriever_test + +import ( + "context" + "net/http" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/thomaspoignant/go-feature-flag/retriever/bitbucketretriever" + "github.com/thomaspoignant/go-feature-flag/testutils/mock" +) + +func sampleText() string { + return `test-flag: + variations: + true_var: true + false_var: false + targeting: + - query: key eq "random-key" + percentage: + true_var: 0 + false_var: 100 + defaultRule: + variation: false_var +` +} + +func Test_bitbucket_Retrieve(t *testing.T) { + endRatelimit := time.Now().Add(1 * time.Hour) + type fields struct { + httpClient mock.HTTP + context context.Context + repositorySlug string + filePath string + bitbucketToken string + branch string + } + tests := []struct { + name string + fields fields + want []byte + wantErr bool + errMsg string + }{ + { + name: "Success", + fields: fields{ + httpClient: mock.HTTP{}, + repositorySlug: "tomypunk/config-repo", + filePath: "flags/config.goff.yaml", + }, + want: []byte(sampleText()), + wantErr: false, + }, + { + name: "Success with context", + fields: fields{ + httpClient: mock.HTTP{}, + repositorySlug: "tomypunk/config-repo", + filePath: "flags/config.goff.yaml", + context: context.Background(), + }, + want: []byte(sampleText()), + wantErr: false, + }, + { + name: "HTTP Error", + fields: fields{ + httpClient: mock.HTTP{}, + repositorySlug: "tomypunk/config-repo", + filePath: "flags/error", + }, + wantErr: true, + }, + { + name: "Error missing slug", + fields: fields{ + httpClient: mock.HTTP{}, + filePath: "tests/__init__.py", + branch: "main", + }, + wantErr: true, + }, + { + name: "Error missing file path", + fields: fields{ + httpClient: mock.HTTP{}, + repositorySlug: "tomypunk/config-repo", + filePath: "", + }, + wantErr: true, + }, + { + name: "Rate limiting", + fields: fields{ + httpClient: mock.HTTP{RateLimit: true, EndRatelimit: endRatelimit}, + repositorySlug: "tomypunk/config-repo", + filePath: "flags/config.goff.yaml", + }, + wantErr: true, + errMsg: "request to https://api.bitbucket.org/2.0/repositories/tomypunk/config-repo/src/main/flags/config.goff.yaml failed with code 429. Bitbucket Headers: map[X-Content-Type-Options:nosniff X-Frame-Options:deny X-Github-Media-Type:github.v3; format=json X-Github-Request-Id:F82D:37B98C:232EF263:235C93BD:6650BDC6 X-Ratelimit-Limit:60 X-Ratelimit-Remaining:0 X-Ratelimit-Reset:" + strconv.FormatInt(endRatelimit.Unix(), 10) + " X-Ratelimit-Resource:core X-Ratelimit-Used:60 X-Xss-Protection:1; mode=block]", + }, + { + name: "Use Bitbucket token", + fields: fields{ + httpClient: mock.HTTP{}, + repositorySlug: "tomypunk/config-repo", + filePath: "flags/config.goff.yaml", + bitbucketToken: "XXX_BITBUCKET_TOKEN", + }, + want: []byte(sampleText()), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := bitbucketretriever.Retriever{ + + RepositorySlug: tt.fields.repositorySlug, + Branch: tt.fields.branch, + FilePath: tt.fields.filePath, + BitBucketToken: tt.fields.bitbucketToken, + } + h.SetHTTPClient(&tt.fields.httpClient) + got, err := h.Retrieve(tt.fields.context) + if tt.errMsg != "" { + assert.EqualError(t, err, tt.errMsg) + } + assert.Equal(t, tt.wantErr, err != nil, "Retrieve() error = %v wantErr %v", err, tt.wantErr) + if !tt.wantErr { + assert.Equal(t, http.MethodGet, tt.fields.httpClient.Req.Method) + assert.Equal(t, strings.TrimSpace(string(tt.want)), strings.TrimSpace(string(got))) + if tt.fields.bitbucketToken != "" { + assert.Equal(t, "Bearer "+tt.fields.bitbucketToken, tt.fields.httpClient.Req.Header.Get("Authorization")) + } + } + }) + } +} From af2d590ce8663d48c67493fc738b22c540ceeac5 Mon Sep 17 00:00:00 2001 From: Collins C Augustine Date: Thu, 7 Nov 2024 10:35:48 +0100 Subject: [PATCH 2/2] Fix: Diiscord Hub --- cmd/relayproxy/service/gofeatureflag.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/relayproxy/service/gofeatureflag.go b/cmd/relayproxy/service/gofeatureflag.go index 33933e5b288..b5e3f0d1a53 100644 --- a/cmd/relayproxy/service/gofeatureflag.go +++ b/cmd/relayproxy/service/gofeatureflag.go @@ -20,6 +20,7 @@ import ( "github.com/thomaspoignant/go-feature-flag/exporter/sqsexporter" "github.com/thomaspoignant/go-feature-flag/exporter/webhookexporter" "github.com/thomaspoignant/go-feature-flag/notifier" + "github.com/thomaspoignant/go-feature-flag/notifier/discordnotifier" "github.com/thomaspoignant/go-feature-flag/notifier/slacknotifier" "github.com/thomaspoignant/go-feature-flag/notifier/webhooknotifier" "github.com/thomaspoignant/go-feature-flag/retriever" @@ -336,7 +337,8 @@ func initNotifier(c []config.NotifierConf) ([]notifier.Notifier, error) { Headers: cNotif.Headers, }, ) - + case config.DiscordNotifier: + notifiers = append(notifiers, &discordnotifier.Notifier{DiscordWebhookURL: cNotif.WebhookURL}) default: return nil, fmt.Errorf("invalid notifier: kind \"%s\" is not supported", cNotif.Kind) }