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

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
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
}
85 changes: 85 additions & 0 deletions utils/apiclient/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package apiclient

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

"github.com/pkg/errors"
)

const (
maxRetries = 5
initialBackoff = 1 * time.Second
)

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

func (c *APIClient) GetURL(endpoint string, qstr url.Values) string {
return fmt.Sprintf("%s/%s?%s", c.BaseURL, endpoint, qstr.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{}{}
}

url := c.GetURL(reqParams.Endpoint, reqParams.QueryParams)
reqBody, err := CreateRequestBody(reqParams.RequestType, url)
if err != nil {
return nil, errors.Wrap(err, "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 <= maxRetries {
resp, err := client.Do(reqBody)
if err != nil {
return nil, errors.Wrap(err, "http request failed")
}
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)
}
}

return nil, fmt.Errorf("API request failed after %d retries", retries)
}
108 changes: 108 additions & 0 deletions utils/apiclient/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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",
}

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

func TestCallAPI(t *testing.T) {
testCases := []struct {
name string
mockResponses []httptest.ResponseData
expected interface{}
expectedError string
}{
{
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,
}

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

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

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)
}
})
}
}
26 changes: 26 additions & 0 deletions utils/apiclient/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package apiclient

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

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{}
}

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/pkg/errors"
)

func CreateRequestBody(requestType string, url string) (*http.Request, error) {
req, err := http.NewRequest(requestType, url, nil)
if err != nil {
return nil, errors.Wrap(err, "http GET request creation failed")
}
return req, 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)
}
}

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
Loading