diff --git a/client.go b/client.go index af83cb918..d668eb6d3 100644 --- a/client.go +++ b/client.go @@ -1,8 +1,11 @@ package linodego import ( + "bytes" "context" + "encoding/json" "fmt" + "io" "log" "net/http" "net/url" @@ -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(). diff --git a/client_http.go b/client_http.go new file mode 100644 index 000000000..e7f4b8f16 --- /dev/null +++ b/client_http.go @@ -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 +} diff --git a/client_test.go b/client_test.go index f0c5665fc..9b6aa0d59 100644 --- a/client_test.go +++ b/client_test.go @@ -3,7 +3,10 @@ package linodego import ( "bytes" "context" + "errors" "fmt" + "net/http" + "net/http/httptest" "reflect" "strings" "testing" @@ -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) + } +} diff --git a/errors.go b/errors.go index ab9613be9..be15c0146 100644 --- a/errors.go +++ b/errors.go @@ -1,8 +1,10 @@ package linodego import ( + "encoding/json" "errors" "fmt" + "io" "net/http" "reflect" "strings" @@ -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 @@ -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)} } @@ -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 { diff --git a/errors_test.go b/errors_test.go index c10cfdb9e..68428fcd6 100644 --- a/errors_test.go +++ b/errors_test.go @@ -3,6 +3,7 @@ package linodego import ( "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -202,6 +203,152 @@ func TestCoupleAPIErrors(t *testing.T) { }) } +func TestCoupleAPIErrorsHTTP(t *testing.T) { + t.Run("not nil error generates error", func(t *testing.T) { + err := errors.New("test") + if _, err := coupleAPIErrorsHTTP(nil, err); !cmp.Equal(err, NewError(err)) { + t.Errorf("expect a not nil error to be returned as an Error") + } + }) + + t.Run("http 500 response error with reasons", func(t *testing.T) { + // Create the simulated HTTP response with a 500 status and a JSON body containing the error details + apiError := APIError{ + Errors: []APIErrorReason{ + {Reason: "testreason", Field: "testfield"}, + }, + } + apiErrorBody, _ := json.Marshal(apiError) + bodyReader := io.NopCloser(bytes.NewBuffer(apiErrorBody)) + + resp := &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: bodyReader, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Request: &http.Request{Header: http.Header{"Accept": []string{"application/json"}}}, + } + + _, err := coupleAPIErrorsHTTP(resp, nil) + expectedMessage := "[500] [testfield] testreason" + if err == nil || err.Error() != expectedMessage { + t.Errorf("expected error message %q, got: %v", expectedMessage, err) + } + }) + + t.Run("http 500 response error without reasons", func(t *testing.T) { + // Create the simulated HTTP response with a 500 status and an empty errors array + apiError := APIError{ + Errors: []APIErrorReason{}, + } + apiErrorBody, _ := json.Marshal(apiError) + bodyReader := io.NopCloser(bytes.NewBuffer(apiErrorBody)) + + resp := &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: bodyReader, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Request: &http.Request{Header: http.Header{"Accept": []string{"application/json"}}}, + } + + _, err := coupleAPIErrorsHTTP(resp, nil) + if err != nil { + t.Error("http error with no reasons should return no error") + } + }) + + t.Run("http response with nil error", func(t *testing.T) { + // Create the simulated HTTP response with a 500 status and a nil error + resp := &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(bytes.NewBuffer([]byte(`{"errors":[]}`))), // empty errors array in body + Header: http.Header{"Content-Type": []string{"application/json"}}, + Request: &http.Request{Header: http.Header{"Accept": []string{"application/json"}}}, + } + + _, err := coupleAPIErrorsHTTP(resp, nil) + if err != nil { + t.Error("http error with no reasons should return no error") + } + }) + + t.Run("generic html error", func(t *testing.T) { + rawResponse := ` +500 Internal Server Error + +

500 Internal Server Error

+
nginx
+ +` + + route := "/v4/linode/instances/123" + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(rawResponse)) + })) + defer ts.Close() + + client := &httpClient{ + httpClient: ts.Client(), + } + + expectedError := Error{ + Code: http.StatusInternalServerError, + Message: "Unexpected Content-Type: Expected: application/json, Received: text/html\nResponse body: " + rawResponse, + } + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+route, nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + req.Header.Set("Accept", "application/json") + + resp, err := client.httpClient.Do(req) + if err != nil { + t.Fatalf("failed to send request: %v", err) + } + defer resp.Body.Close() + + _, err = coupleAPIErrorsHTTP(resp, nil) + if diff := cmp.Diff(expectedError, err); diff != "" { + t.Errorf("expected error to match but got diff:\n%s", diff) + } + }) + + t.Run("bad gateway error", func(t *testing.T) { + rawResponse := ` +502 Bad Gateway + +

502 Bad Gateway

+
nginx
+ +` + buf := io.NopCloser(bytes.NewBuffer([]byte(rawResponse))) + + resp := &http.Response{ + StatusCode: http.StatusBadGateway, + Body: buf, + Header: http.Header{ + "Content-Type": []string{"text/html"}, + }, + Request: &http.Request{ + Header: http.Header{"Accept": []string{"application/json"}}, + }, + } + + expectedError := Error{ + Code: http.StatusBadGateway, + Message: http.StatusText(http.StatusBadGateway), + } + + _, err := coupleAPIErrorsHTTP(resp, nil) + if !cmp.Equal(err, expectedError) { + t.Errorf("expected error %#v to match error %#v", err, expectedError) + } + }) +} + func TestErrorIs(t *testing.T) { t.Parallel()