Skip to content

Commit

Permalink
feat: Add Bitbucket retriever (#2611)
Browse files Browse the repository at this point in the history
* feat: BitBucket Retriever

* Fix: Diiscord Hub

* clean up of the retriever

* fix linter

* add test for default branch

---------

Co-authored-by: Collins C Augustine <colins4chinonso@gmail.com>
Co-authored-by: Thomas Poignant <thomas.poignant@gofeatureflag.org>
  • Loading branch information
3 people authored Nov 8, 2024
1 parent 3525e70 commit 012ea4c
Show file tree
Hide file tree
Showing 14 changed files with 430 additions and 33 deletions.
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**
- ...

_[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
15 changes: 15 additions & 0 deletions cmd/relayproxy/service/gofeatureflag.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"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 @@ -143,6 +144,20 @@ func initRetriever(c *config.RetrieverConf) (retriever.Retriever, error) {
RepositorySlug: c.RepositorySlug,
Timeout: retrieverTimeout,
}, nil
case config.BitbucketRetriever:
return &bitbucketretriever.Retriever{
RepositorySlug: c.RepositorySlug,
Branch: func() string {
if c.Branch == "" {
return config.DefaultRetriever.GitBranch
}
return c.Branch
}(),
FilePath: c.Path,
BitBucketToken: c.AuthToken,
BaseURL: c.BaseURL,
Timeout: retrieverTimeout,
}, nil
case config.FileRetriever:
return &fileretriever.Retriever{Path: c.Path}, nil
case config.S3Retriever:
Expand Down
42 changes: 42 additions & 0 deletions cmd/relayproxy/service/gofeatureflag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"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 @@ -183,6 +184,47 @@ func Test_initRetriever(t *testing.T) {
Kind: "unknown",
},
},
{
name: "Convert Bitbucket Retriever default branch",
wantErr: assert.NoError,
conf: &config.RetrieverConf{
Kind: "bitbucket",
RepositorySlug: "gofeatureflag/config-repo",
Path: "flags/config.goff.yaml",
AuthToken: "XXX_BITBUCKET_TOKEN",
BaseURL: "https://api.bitbucket.goff.org",
},
want: &bitbucketretriever.Retriever{
RepositorySlug: "gofeatureflag/config-repo",
Branch: "main",
FilePath: "flags/config.goff.yaml",
BitBucketToken: "XXX_BITBUCKET_TOKEN",
BaseURL: "https://api.bitbucket.goff.org",
Timeout: 10000000000,
},
wantType: &bitbucketretriever.Retriever{},
},
{
name: "Convert Bitbucket Retriever branch specified",
wantErr: assert.NoError,
conf: &config.RetrieverConf{
Kind: "bitbucket",
Branch: "develop",
RepositorySlug: "gofeatureflag/config-repo",
Path: "flags/config.goff.yaml",
AuthToken: "XXX_BITBUCKET_TOKEN",
BaseURL: "https://api.bitbucket.goff.org",
},
want: &bitbucketretriever.Retriever{
RepositorySlug: "gofeatureflag/config-repo",
Branch: "develop",
FilePath: "flags/config.goff.yaml",
BitBucketToken: "XXX_BITBUCKET_TOKEN",
BaseURL: "https://api.bitbucket.goff.org",
Timeout: 10000000000,
},
wantType: &bitbucketretriever.Retriever{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
114 changes: 114 additions & 0 deletions retriever/bitbucketretriever/retriever.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
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
BaseURL string
Timeout time.Duration
httpClient internal.HTTPClient
rateLimitRemaining int
rateLimitNearLimit bool
rateLimitReset time.Time
}

// Retrieve get the content of the file from the Bitbucket API
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)
}

if r.BaseURL == "" {
r.BaseURL = "https://api.bitbucket.org"
}

URL := fmt.Sprintf(
"%s/2.0/repositories/%s/src/%s/%s",
r.BaseURL,
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
}

// SetHTTPClient set the HTTP client to use for the API call if you don't want to use the default one
func (r *Retriever) SetHTTPClient(client internal.HTTPClient) {
r.httpClient = client
}

// updateRateLimit update the rate limit information from the headers to avoid calling the API if
// the rate limit is reached
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)
}
}
}
141 changes: 141 additions & 0 deletions retriever/bitbucketretriever/retriever_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
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: "gofeatureflag/config-repo",
filePath: "flags/config.goff.yaml",
},
want: []byte(sampleText()),
wantErr: false,
},
{
name: "Success with context",
fields: fields{
httpClient: mock.HTTP{},
repositorySlug: "gofeatureflag/config-repo",
filePath: "flags/config.goff.yaml",
context: context.Background(),
},
want: []byte(sampleText()),
wantErr: false,
},
{
name: "HTTP Error",
fields: fields{
httpClient: mock.HTTP{},
repositorySlug: "gofeatureflag/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: "gofeatureflag/config-repo",
filePath: "",
},
wantErr: true,
},
{
name: "Rate limiting",
fields: fields{
httpClient: mock.HTTP{RateLimit: true, EndRatelimit: endRatelimit},
repositorySlug: "gofeatureflag/config-repo",
filePath: "flags/config.goff.yaml",
},
wantErr: true,
errMsg: "request to https://api.bitbucket.org/2.0/repositories/gofeatureflag/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: "gofeatureflag/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

0 comments on commit 012ea4c

Please sign in to comment.