Skip to content

Commit

Permalink
Split module in client and requests. Add unit tests
Browse files Browse the repository at this point in the history
  • Loading branch information
amishas157 committed Nov 18, 2024
1 parent 525079f commit a28db13
Show file tree
Hide file tree
Showing 7 changed files with 380 additions and 100 deletions.
32 changes: 32 additions & 0 deletions support/http/httptest/client_expectation.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package httptest

import (
"fmt"
"net/http"
"net/url"
"strconv"
Expand Down Expand Up @@ -85,6 +86,37 @@ func (ce *ClientExpectation) ReturnStringWithHeader(
return ce.Return(httpmock.ResponderFromResponse(&cResp))
}

// ReturnMultipleResults registers multiple sequential responses for a given client expectation.
// Useful for testing retries
func (ce *ClientExpectation) ReturnMultipleResults(responseSets []ResponseData) *ClientExpectation {
var allResponses []httpmock.Responder
for _, response := range responseSets {
resp := http.Response{
Status: strconv.Itoa(response.Status),
StatusCode: response.Status,
Body: httpmock.NewRespBodyFromString(response.Body),
Header: response.Header,
}
allResponses = append(allResponses, httpmock.ResponderFromResponse(&resp))
}
responseIndex := 0
ce.Client.MockTransport.RegisterResponder(
ce.Method,
ce.URL,
func(req *http.Request) (*http.Response, error) {
if responseIndex >= len(allResponses) {
panic(fmt.Sprintf("no responses available"))
}

resp := allResponses[responseIndex]
responseIndex++
return resp(req)
},
)

return ce
}

// ReturnJSONWithHeader causes this expectation to resolve to a json-based body with the provided
// status code and response header. Panics when the provided body cannot be encoded to JSON.
func (ce *ClientExpectation) ReturnJSONWithHeader(
Expand Down
6 changes: 6 additions & 0 deletions support/http/httptest/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,9 @@ func NewServer(t *testing.T, handler http.Handler) *Server {
Expect: httpexpect.New(t, server.URL),
}
}

type ResponseData struct {
Status int
Body string
Header http.Header
}
123 changes: 55 additions & 68 deletions utils/apiclient/client.go
Original file line number Diff line number Diff line change
@@ -1,98 +1,85 @@
package apiclient

import (
"encoding/base64"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"time"

"github.com/pkg/errors"
)

func (c *APIClient) createRequestBody(endpoint string, queryParams url.Values) (*http.Request, error) {
fullURL := c.url(endpoint, queryParams)
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return nil, errors.Wrap(err, "http GET request creation failed")
}
return req, nil
const (
maxRetries = 5
initialBackoff = 1 * time.Second
)

func isRetryableStatusCode(statusCode int) bool {
return statusCode == http.StatusTooManyRequests || statusCode == http.StatusServiceUnavailable
}

func (c *APIClient) callAPI(req *http.Request) (interface{}, error) {
client := c.HTTP
if client == nil {
client = &http.Client{}
}
func (c *APIClient) GetURL(endpoint string, qstr url.Values) string {
return fmt.Sprintf("%s/%s?%s", c.BaseURL, endpoint, qstr.Encode())
}

resp, err := client.Do(req)
if err != nil {
return nil, errors.Wrap(err, "http GET request failed")
func (c *APIClient) CallAPI(reqParams RequestParams) (interface{}, error) {
if reqParams.QueryParams == nil {
reqParams.QueryParams = url.Values{}
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API request failed with status %d", resp.StatusCode)
if reqParams.Headers == nil {
reqParams.Headers = map[string]interface{}{}
}

body, err := ioutil.ReadAll(resp.Body)
url := c.GetURL(reqParams.Endpoint, reqParams.QueryParams)
reqBody, err := CreateRequestBody(reqParams.RequestType, url)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
return nil, errors.Wrap(err, "http request creation failed")
}

var result interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
SetAuthHeaders(reqBody, c.authType, c.authHeaders)
SetHeaders(reqBody, reqParams.Headers)
client := c.HTTP
if client == nil {
client = &http.Client{}
}

return result, nil
}

func setHeaders(req *http.Request, args map[string]interface{}) {
for key, value := range args {
strValue, ok := value.(string)
if !ok {
fmt.Printf("Skipping non-string value for header %s\n", key)
continue
}

req.Header.Set(key, strValue)
}
}
var result interface{}
retries := 0

func setAuthHeaders(req *http.Request, authType string, args map[string]interface{}) error {
switch authType {
case "basic":
username, ok := args["username"].(string)
if !ok {
return fmt.Errorf("missing or invalid username")
}
password, ok := args["password"].(string)
if !ok {
return fmt.Errorf("missing or invalid password")
for retries <= maxRetries {
resp, err := client.Do(reqBody)
if err != nil {
return nil, errors.Wrap(err, "http request failed")
}

authHeader := "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
setHeaders(req, map[string]interface{}{
"Authorization": authHeader,
})

case "api_key":
apiKey, ok := args["api_key"].(string)
if !ok {
return fmt.Errorf("missing or invalid API key")
defer resp.Body.Close()

if resp.StatusCode == http.StatusOK {
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}

if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
}

return result, nil
} else if isRetryableStatusCode(resp.StatusCode) {
retries++
backoffDuration := initialBackoff * time.Duration(1<<retries)
if retries <= maxRetries {
fmt.Printf("Received retryable status %d. Retrying in %v...\n", resp.StatusCode, backoffDuration)
time.Sleep(backoffDuration)
} else {
return nil, fmt.Errorf("Maximum retries reached after receiving status %d", resp.StatusCode)
}
} else {
return nil, fmt.Errorf("API request failed with status %d", resp.StatusCode)
}
setHeaders(req, map[string]interface{}{
"Authorization": apiKey,
})

default:
return fmt.Errorf("unsupported auth type: %s", authType)
}
return nil
}

func (c *APIClient) url(endpoint string, qstr url.Values) string {
return fmt.Sprintf("%s/%s?%s", c.BaseURL, endpoint, qstr.Encode())
return nil, fmt.Errorf("API request failed after %d retries", retries)
}
115 changes: 85 additions & 30 deletions utils/apiclient/client_test.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
package apiclient

import (
"net/http"
"net/url"
"testing"

"github.com/stellar/go/support/http/httptest"
"github.com/stretchr/testify/assert"
)

func Test_url(t *testing.T) {
func Test_GetURL(t *testing.T) {
c := &APIClient{
BaseURL: "https://stellar.org",
}
Expand All @@ -18,41 +19,95 @@ func Test_url(t *testing.T) {
qstr.Add("federation_type", "bank_account")
qstr.Add("swift", "BOPBPHMM")
qstr.Add("acct", "2382376")
furl := c.url("federation", qstr)
furl := c.GetURL("federation", qstr)
assert.Equal(t, "https://stellar.org/federation?acct=2382376&federation_type=bank_account&swift=BOPBPHMM&type=forward", furl)
}

func Test_callAPI(t *testing.T) {
friendbotFundResponse := `{"key": "value"}`

hmock := httptest.NewClient()
c := &APIClient{
BaseURL: "https://stellar.org",
HTTP: hmock,
func Test_CallAPI(t *testing.T) {
testCases := []struct {
name string
mockResponses []httptest.ResponseData
expected interface{}
expectedError string
retries bool
}{
{
name: "status 200 - Success",
mockResponses: []httptest.ResponseData{
{Status: http.StatusOK, Body: `{"data": "Okay Response"}`, Header: nil},
},
expected: map[string]interface{}{"data": "Okay Response"},
expectedError: "",
retries: false,
},
{
name: "success with retries - status 429 and 503 then 200",
mockResponses: []httptest.ResponseData{
{Status: http.StatusTooManyRequests, Body: `{"data": "First Response"}`, Header: nil},
{Status: http.StatusServiceUnavailable, Body: `{"data": "Second Response"}`, Header: nil},
{Status: http.StatusOK, Body: `{"data": "Third Response"}`, Header: nil},
{Status: http.StatusOK, Body: `{"data": "Fourth Response"}`, Header: nil},
},
expected: map[string]interface{}{"data": "Third Response"},
expectedError: "",
retries: true,
},
{
name: "failure - status 500",
mockResponses: []httptest.ResponseData{
{Status: http.StatusInternalServerError, Body: `{"error": "Internal Server Error"}`, Header: nil},
},
expected: nil,
expectedError: "API request failed with status 500",
retries: false,
},
{
name: "failure - status 401",
mockResponses: []httptest.ResponseData{
{Status: http.StatusUnauthorized, Body: `{"error": "Bad authorization"}`, Header: nil},
},
expected: nil,
expectedError: "API request failed with status 401",
retries: false,
},
}
hmock.On(
"GET",
"https://stellar.org/federation?acct=2382376&federation_type=bank_account&swift=BOPBPHMM&type=forward",
).ReturnString(200, friendbotFundResponse)
qstr := url.Values{}

qstr.Add("type", "forward")
qstr.Add("federation_type", "bank_account")
qstr.Add("swift", "BOPBPHMM")
qstr.Add("acct", "2382376")
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
hmock := httptest.NewClient()
hmock.On("GET", "https://stellar.org/federation?acct=2382376").
ReturnMultipleResults(tc.mockResponses)

req, err := c.createRequestBody("federation", qstr)
if err != nil {
t.Fatal(err)
}
setAuthHeaders(req, "api_key", map[string]interface{}{"api_key": "test_api_key"})
assert.Equal(t, "test_api_key", req.Header.Get("Authorization"))
c := &APIClient{
BaseURL: "https://stellar.org",
HTTP: hmock,
}

result, err := c.callAPI(req)
if err != nil {
t.Fatal(err)
}
qstr := url.Values{}
qstr.Add("acct", "2382376")

reqParams := RequestParams{
RequestType: "GET",
Endpoint: "federation",
QueryParams: qstr,
}

expected := map[string]interface{}{"key": "value"}
assert.Equal(t, expected, result)
result, err := c.CallAPI(reqParams)

if tc.expectedError != "" {
if err == nil {
t.Fatalf("expected error %q, got nil", tc.expectedError)
}
if err.Error() != tc.expectedError {
t.Fatalf("expected error %q, got %q", tc.expectedError, err.Error())
}
} else if err != nil {
t.Fatal(err)
}

if tc.expected != nil {
assert.Equal(t, tc.expected, result)
}
})
}
}
13 changes: 11 additions & 2 deletions utils/apiclient/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ type HTTP interface {
}

type APIClient struct {
BaseURL string
HTTP HTTP
BaseURL string
HTTP HTTP
authType string
authHeaders map[string]interface{}
}

type RequestParams struct {
RequestType string
Endpoint string
QueryParams url.Values
Headers map[string]interface{}
}
Loading

0 comments on commit a28db13

Please sign in to comment.