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

Implemented doRequest helper method #557

Merged
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