Skip to content

Commit

Permalink
Implemented doRequest helper method (#557)
Browse files Browse the repository at this point in the history
* Implemented doRequest helper method and added unit tests

* Added more detailed response for HTTP errors

* Addressed pr comments

* Addressed more pr comments
  • Loading branch information
ezilber-akamai committed Jul 29, 2024
1 parent a83ab1a commit a9e3be2
Show file tree
Hide file tree
Showing 5 changed files with 451 additions and 1 deletion.
64 changes: 64 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package linodego

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
Expand Down Expand Up @@ -110,6 +113,67 @@ func (c *Client) SetUserAgent(ua string) *Client {
return c
}

type RequestParams struct {
Body any
Response any
}

// Generic helper to execute HTTP requests using the
//
//nolint:unused
func (c *httpClient) doRequest(ctx context.Context, method, url string, params RequestParams, mutators ...func(req *http.Request) error) error {
// Create a new HTTP request
var bodyReader io.Reader
if params.Body != nil {
buf := new(bytes.Buffer)
if err := json.NewEncoder(buf).Encode(params.Body); err != nil {
return fmt.Errorf("failed to encode body: %w", err)
}
bodyReader = buf
}

req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}

// Set default headers
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.userAgent != "" {
req.Header.Set("User-Agent", c.userAgent)
}

// Apply mutators
for _, mutate := range mutators {
if err := mutate(req); err != nil {
return fmt.Errorf("failed to mutate request: %w", err)
}
}

// Send the request
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()

// Check for HTTP errors
resp, err = coupleAPIErrorsHTTP(resp, err)
if err != nil {
return err
}

// Decode the response body
if params.Response != nil {
if err := json.NewDecoder(resp.Body).Decode(params.Response); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
}

return nil
}

// R wraps resty's R method
func (c *Client) R(ctx context.Context) *resty.Request {
return c.resty.R().
Expand Down
48 changes: 48 additions & 0 deletions client_http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package linodego

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

// Client is a wrapper around the Resty client
//
//nolint:unused
type httpClient struct {
//nolint:unused
httpClient *http.Client
//nolint:unused
userAgent string
//nolint:unused
debug bool
//nolint:unused
retryConditionals []RetryConditional

//nolint:unused
pollInterval time.Duration

//nolint:unused
baseURL string
//nolint:unused
apiVersion string
//nolint:unused
apiProto string
//nolint:unused
selectedProfile string
//nolint:unused
loadedProfile string

//nolint:unused
configProfiles map[string]ConfigProfile

// Fields for caching endpoint responses
//nolint:unused
shouldCache bool
//nolint:unused
cacheExpiration time.Duration
//nolint:unused
cachedEntries map[string]clientCacheEntry
//nolint:unused
cachedEntryLock *sync.RWMutex
}
138 changes: 138 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package linodego
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
Expand Down Expand Up @@ -198,3 +201,138 @@ func TestDebugLogSanitization(t *testing.T) {
t.Fatalf("actual response does not equal desired response: %s", cmp.Diff(result, testResponse))
}
}

func TestDoRequest_Success(t *testing.T) {
handler := func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"message":"success"}`))
}
server := httptest.NewServer(http.HandlerFunc(handler))
defer server.Close()

client := &httpClient{
httpClient: server.Client(),
}

params := RequestParams{
Response: &map[string]string{},
}

err := client.doRequest(context.Background(), http.MethodGet, server.URL, params)
if err != nil {
t.Fatal(cmp.Diff(nil, err))
}

expected := "success"
actual := (*params.Response.(*map[string]string))["message"]
if diff := cmp.Diff(expected, actual); diff != "" {
t.Fatalf("response mismatch (-expected +actual):\n%s", diff)
}
}

func TestDoRequest_FailedEncodeBody(t *testing.T) {
client := &httpClient{
httpClient: http.DefaultClient,
}

params := RequestParams{
Body: map[string]interface{}{
"invalid": func() {},
},
}

err := client.doRequest(context.Background(), http.MethodPost, "http://example.com", params)
expectedErr := "failed to encode body"
if err == nil || !strings.Contains(err.Error(), expectedErr) {
t.Fatalf("expected error %q, got: %v", expectedErr, err)
}
}

func TestDoRequest_FailedCreateRequest(t *testing.T) {
client := &httpClient{
httpClient: http.DefaultClient,
}

// Create a request with an invalid URL to simulate a request creation failure
err := client.doRequest(context.Background(), http.MethodGet, "http://invalid url", RequestParams{})
expectedErr := "failed to create request"
if err == nil || !strings.Contains(err.Error(), expectedErr) {
t.Fatalf("expected error %q, got: %v", expectedErr, err)
}
}

func TestDoRequest_Non2xxStatusCode(t *testing.T) {
handler := func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "error", http.StatusInternalServerError)
}
server := httptest.NewServer(http.HandlerFunc(handler))
defer server.Close()

client := &httpClient{
httpClient: server.Client(),
}

err := client.doRequest(context.Background(), http.MethodGet, server.URL, RequestParams{})
if err == nil {
t.Fatal("expected error, got nil")
}
httpError, ok := err.(Error)
if !ok {
t.Fatalf("expected error to be of type Error, got %T", err)
}
if httpError.Code != http.StatusInternalServerError {
t.Fatalf("expected status code %d, got %d", http.StatusInternalServerError, httpError.Code)
}
if !strings.Contains(httpError.Message, "error") {
t.Fatalf("expected error message to contain %q, got %v", "error", httpError.Message)
}
}

func TestDoRequest_FailedDecodeResponse(t *testing.T) {
handler := func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`invalid json`))
}
server := httptest.NewServer(http.HandlerFunc(handler))
defer server.Close()

client := &httpClient{
httpClient: server.Client(),
}

params := RequestParams{
Response: &map[string]string{},
}

err := client.doRequest(context.Background(), http.MethodGet, server.URL, params)
expectedErr := "failed to decode response"
if err == nil || !strings.Contains(err.Error(), expectedErr) {
t.Fatalf("expected error %q, got: %v", expectedErr, err)
}
}

func TestDoRequest_MutatorError(t *testing.T) {
handler := func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"message":"success"}`))
}
server := httptest.NewServer(http.HandlerFunc(handler))
defer server.Close()

client := &httpClient{
httpClient: server.Client(),
}

mutator := func(req *http.Request) error {
return errors.New("mutator error")
}

err := client.doRequest(context.Background(), http.MethodGet, server.URL, RequestParams{}, mutator)
expectedErr := "failed to mutate request"
if err == nil || !strings.Contains(err.Error(), expectedErr) {
t.Fatalf("expected error %q, got: %v", expectedErr, err)
}
}
55 changes: 54 additions & 1 deletion errors.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package linodego

import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"reflect"
"strings"
Expand Down Expand Up @@ -46,6 +48,11 @@ type APIError struct {
Errors []APIErrorReason `json:"errors"`
}

// String returns the error reason in a formatted string
func (r APIErrorReason) String() string {
return fmt.Sprintf("[%s] %s", r.Field, r.Reason)
}

func coupleAPIErrors(r *resty.Response, err error) (*resty.Response, error) {
if err != nil {
// an error was raised in go code, no need to check the resty Response
Expand All @@ -66,7 +73,7 @@ func coupleAPIErrors(r *resty.Response, err error) (*resty.Response, error) {
// If the upstream Linode API server being fronted fails to respond to the request,
// the http server will respond with a default "Bad Gateway" page with Content-Type
// "text/html".
if r.StatusCode() == http.StatusBadGateway && responseContentType == "text/html" {
if r.StatusCode() == http.StatusBadGateway && responseContentType == "text/html" { //nolint:goconst
return nil, Error{Code: http.StatusBadGateway, Message: http.StatusText(http.StatusBadGateway)}
}

Expand All @@ -89,6 +96,52 @@ func coupleAPIErrors(r *resty.Response, err error) (*resty.Response, error) {
return nil, NewError(r)
}

//nolint:unused
func coupleAPIErrorsHTTP(resp *http.Response, err error) (*http.Response, error) {
if err != nil {
// an error was raised in go code, no need to check the http.Response
return nil, NewError(err)
}

if resp == nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
// Check that response is of the correct content-type before unmarshalling
expectedContentType := resp.Request.Header.Get("Accept")
responseContentType := resp.Header.Get("Content-Type")

// If the upstream server fails to respond to the request,
// the http server will respond with a default error page with Content-Type "text/html".
if resp.StatusCode == http.StatusBadGateway && responseContentType == "text/html" { //nolint:goconst
return nil, Error{Code: http.StatusBadGateway, Message: http.StatusText(http.StatusBadGateway)}
}

if responseContentType != expectedContentType {
bodyBytes, _ := io.ReadAll(resp.Body)
msg := fmt.Sprintf(
"Unexpected Content-Type: Expected: %v, Received: %v\nResponse body: %s",
expectedContentType,
responseContentType,
string(bodyBytes),
)

return nil, Error{Code: resp.StatusCode, Message: msg}
}

var apiError APIError
if err := json.NewDecoder(resp.Body).Decode(&apiError); err != nil {
return nil, NewError(fmt.Errorf("failed to decode response body: %w", err))
}

if len(apiError.Errors) == 0 {
return resp, nil
}

return nil, Error{Code: resp.StatusCode, Message: apiError.Errors[0].String()}
}

// no error in the http.Response
return resp, nil
}

func (e APIError) Error() string {
x := []string{}
for _, msg := range e.Errors {
Expand Down
Loading

0 comments on commit a9e3be2

Please sign in to comment.