Skip to content

Commit

Permalink
Merge pull request #5534 from stellar/patch/add-general-purpose-api-c…
Browse files Browse the repository at this point in the history
…lient

Add General purpose API wrapper util
  • Loading branch information
amishas157 authored Dec 3, 2024
2 parents 9b89f4b + 0c4d354 commit e7f96aa
Show file tree
Hide file tree
Showing 7 changed files with 460 additions and 0 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.Errorf("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
}
97 changes: 97 additions & 0 deletions utils/apiclient/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package apiclient

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

"github.com/stellar/go/support/log"
)

const (
defaultMaxRetries = 5
defaultInitialBackoffTime = 1 * time.Second
)

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

func (c *APIClient) GetURL(endpoint string, queryParams url.Values) string {
return fmt.Sprintf("%s/%s?%s", c.BaseURL, endpoint, queryParams.Encode())
}

func (c *APIClient) CallAPI(reqParams RequestParams) (interface{}, error) {
if reqParams.QueryParams == nil {
reqParams.QueryParams = url.Values{}
}

if reqParams.Headers == nil {
reqParams.Headers = map[string]interface{}{}
}

if c.MaxRetries == 0 {
c.MaxRetries = defaultMaxRetries
}

if c.InitialBackoffTime == 0 {
c.InitialBackoffTime = defaultInitialBackoffTime
}

if reqParams.Endpoint == "" {
return nil, fmt.Errorf("Please set endpoint to query")
}

url := c.GetURL(reqParams.Endpoint, reqParams.QueryParams)
reqBody, err := CreateRequestBody(reqParams.RequestType, url)
if err != nil {
return nil, fmt.Errorf("http request creation failed")
}

SetAuthHeaders(reqBody, c.AuthType, c.AuthHeaders)
SetHeaders(reqBody, reqParams.Headers)
client := c.HTTP
if client == nil {
client = &http.Client{}
}

var result interface{}
retries := 0

for retries <= c.MaxRetries {
resp, err := client.Do(reqBody)
if err != nil {
return nil, fmt.Errorf("http request failed: %w", err)
}
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 := c.InitialBackoffTime * time.Duration(1<<retries)
if retries <= c.MaxRetries {
log.Debugf("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)
}
}

return nil, fmt.Errorf("API request failed after %d retries", retries)
}
105 changes: 105 additions & 0 deletions utils/apiclient/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package apiclient

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

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

func TestGetURL(t *testing.T) {
c := &APIClient{
BaseURL: "https://stellar.org",
}

queryParams := url.Values{}
queryParams.Add("type", "forward")
queryParams.Add("federation_type", "bank_account")
queryParams.Add("swift", "BOPBPHMM")
queryParams.Add("acct", "2382376")
furl := c.GetURL("federation", queryParams)
assert.Equal(t, "https://stellar.org/federation?acct=2382376&federation_type=bank_account&swift=BOPBPHMM&type=forward", furl)
}

type testCase struct {
name string
mockResponses []httptest.ResponseData
expected interface{}
expectedError string
}

func TestCallAPI(t *testing.T) {
testCases := []testCase{
{
name: "status 200 - Success",
mockResponses: []httptest.ResponseData{
{Status: http.StatusOK, Body: `{"data": "Okay Response"}`, Header: nil},
},
expected: map[string]interface{}{"data": "Okay Response"},
expectedError: "",
},
{
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: "",
},
{
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",
},
{
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",
},
}

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)

c := &APIClient{
BaseURL: "https://stellar.org",
HTTP: hmock,
}

queryParams := url.Values{}
queryParams.Add("acct", "2382376")

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

result, err := c.CallAPI(reqParams)

if tc.expectedError != "" {
assert.EqualError(t, err, tc.expectedError)
} else {
assert.NoError(t, err)
}

if tc.expected != nil {
assert.Equal(t, tc.expected, result)
}
})
}
}
29 changes: 29 additions & 0 deletions utils/apiclient/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package apiclient

import (
"net/http"
"net/url"
"time"
)

type HTTP interface {
Do(req *http.Request) (resp *http.Response, err error)
Get(url string) (resp *http.Response, err error)
PostForm(url string, data url.Values) (resp *http.Response, err error)
}

type APIClient struct {
BaseURL string
HTTP HTTP
AuthType string
AuthHeaders map[string]interface{}
MaxRetries int
InitialBackoffTime time.Duration
}

type RequestParams struct {
RequestType string
Endpoint string
QueryParams url.Values
Headers map[string]interface{}
}
61 changes: 61 additions & 0 deletions utils/apiclient/request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package apiclient

import (
"encoding/base64"
"fmt"
"net/http"

"github.com/stellar/go/support/log"
)

func CreateRequestBody(requestType string, url string) (*http.Request, error) {
req, err := http.NewRequest(requestType, url, nil)
if err != nil {
return nil, fmt.Errorf("http GET request creation failed: %w", err)
}
return req, nil
}

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

req.Header.Set(key, strValue)
}
}

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")
}

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")
}
SetHeaders(req, map[string]interface{}{
"Authorization": apiKey,
})

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

0 comments on commit e7f96aa

Please sign in to comment.