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

Merged
merged 7 commits into from
Nov 8, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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
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
15 changes: 15 additions & 0 deletions cmd/relayproxy/service/gofeatureflag.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,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 @@ -142,6 +143,20 @@ func initRetriever(c *config.RetrieverConf) (retriever.Retriever, error) {
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: func() string {
if c.Branch == "" {
return config.DefaultRetriever.GitBranch
}
return c.Branch
}(),
thomaspoignant marked this conversation as resolved.
Show resolved Hide resolved
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
22 changes: 22 additions & 0 deletions cmd/relayproxy/service/gofeatureflag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,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 @@ -181,6 +182,27 @@ func Test_initRetriever(t *testing.T) {
Kind: "unknown",
},
},
{
name: "Convert Bitbucket Retriever",
wantErr: assert.NoError,
conf: &config.RetrieverConf{
Kind: "bitbucket",
RepositorySlug: "gofeatureflag/config-repo",
Branch: "main",
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{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
113 changes: 113 additions & 0 deletions retriever/bitbucketretriever/retriever.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
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)
}
}
}
thomaspoignant marked this conversation as resolved.
Show resolved Hide resolved
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