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

Add General purpose API wrapper util #5534

Merged
merged 10 commits into from
Dec 3, 2024
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)
amishas157 marked this conversation as resolved.
Show resolved Hide resolved
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)
amishas157 marked this conversation as resolved.
Show resolved Hide resolved
}

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)
}
chowbao marked this conversation as resolved.
Show resolved Hide resolved
return nil
}
Loading
Loading