Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Bitbucket retriever #2611

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ The available retrievers are:
- **Kubernetes ConfigMaps**
- **MongoDB**
- **Redis**
- **BitBucket**
thomaspoignant marked this conversation as resolved.
Show resolved Hide resolved
- ...

_[See the full list and more information.](https://gofeatureflag.org/docs/configure_flag/store_your_flags)_
Expand Down
5 changes: 3 additions & 2 deletions cmd/relayproxy/config/retriever.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions cmd/relayproxy/config/retriever_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 11 additions & 8 deletions cmd/relayproxy/service/gofeatureflag.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@
"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"
Expand Down Expand Up @@ -142,6 +142,14 @@
RepositorySlug: c.RepositorySlug,
Timeout: retrieverTimeout,
}, nil
case config.BitbucketRetriever:
return &bitbucketretriever.Retriever{
thomaspoignant marked this conversation as resolved.
Show resolved Hide resolved
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:
Expand Down Expand Up @@ -317,11 +325,7 @@
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})

Check failure on line 328 in cmd/relayproxy/service/gofeatureflag.go

View workflow job for this annotation

GitHub Actions / Lint

SA1019: cNotif.SlackWebhookURL is deprecated: Use WebhookURL instead (staticcheck)

Check failure on line 328 in cmd/relayproxy/service/gofeatureflag.go

View workflow job for this annotation

GitHub Actions / Lint

SA1019: cNotif.SlackWebhookURL is deprecated: Use WebhookURL instead (staticcheck)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔨 issue: ‏Why did you removed the change on the slack notifier here?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

soo sorry that was a mistake


case config.WebhookNotifier:
notifiers = append(notifiers,
Expand All @@ -332,8 +336,7 @@
Headers: cNotif.Headers,
},
)
case config.DiscordNotifier:
notifiers = append(notifiers, &discordnotifier.Notifier{DiscordWebhookURL: cNotif.WebhookURL})

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔨 issue: ‏Why did you removed the discord integration? Is this on purpose?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it wasn't on purpose. I must have made this mistakes in a bit of resolving conflict

default:
return nil, fmt.Errorf("invalid notifier: kind \"%s\" is not supported", cNotif.Kind)
}
Expand Down
106 changes: 106 additions & 0 deletions retriever/bitbucketretriever/retriever.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
thomaspoignant marked this conversation as resolved.
Show resolved Hide resolved
142 changes: 142 additions & 0 deletions retriever/bitbucketretriever/retriever_test.go
Original file line number Diff line number Diff line change
@@ -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"))
}
}
})
}
}
Loading