-
-
Notifications
You must be signed in to change notification settings - Fork 152
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add Bitbucket retriever (#2611)
* 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
1 parent
3525e70
commit 012ea4c
Showing
14 changed files
with
430 additions
and
33 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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")) | ||
} | ||
} | ||
}) | ||
} | ||
} |
Oops, something went wrong.