diff --git a/.covignore b/.covignore new file mode 100644 index 0000000..069acce --- /dev/null +++ b/.covignore @@ -0,0 +1,2 @@ +.gen.go +dad_jokes.go \ No newline at end of file diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 36125f9..9df3b0f 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -5,7 +5,16 @@ updates: directory: / schedule: interval: monthly - - directory: github - package-ecosystem: github-actions + groups: + prod: + patterns: + - "*" + + - package-ecosystem: "github-actions" + directory: "/" schedule: - interval: monthly \ No newline at end of file + interval: "monthly" + groups: + prod: + patterns: + - "*" diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 31b8bab..c508404 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -9,8 +9,6 @@ on: types: [opened, synchronize, reopened] -env: - GOLANG_VERSION: 1.22 jobs: @@ -36,9 +34,10 @@ jobs: fetch-depth: 0 - name: Setup golang ${{ env.GOLANG_VERSION }} - uses: actions/setup-go@v4 + uses: actions/setup-go@v6 with: - go-version: ${{ env.GOLANG_VERSION }} + go-version-file: go.mod + cache-dependency-path: go.sum - name: install tools run: make install-ci diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cbd6a33 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea +.DS_Store +coverage* \ No newline at end of file diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..9c6e948 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,56 @@ +version: "2" + +linters: + enable: + - cyclop + - nestif + - bodyclose + - iface + - gosec + - errcheck + - errchkjson + - errname + - errorlint + - exptostd + - fatcontext + - forbidigo + - forcetypeassert + - govet + - importas + - ireturn + - perfsprint + - recvcheck + - sloglint + - staticcheck + - unparam + - unused + - wastedassign + exclusions: + generated: lax + + rules: + # Exclude some linters from running on tests files. + - path: _test\.go + + linters: + - gocyclo + - cyclop + - dupl + - gosec + - forbidigo + - bodyclose + - exhaustruct + - fatcontext + + - path: dad_jokes.go + linters: + - forbidigo + - errchkjson + + settings: + depguard: + rules: + main: + list-mode: lax + + diff --git a/README.md b/README.md index e4fd985..e0d5253 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,28 @@ if err != nil { ``` + +### Cancelable HTTP calls +Use Ctx if you want more granular control and the ability to cancel. + +```go + var apiErr *fetch.APIError + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + resp, err := client.PutCtx(ctx,url, bytes.NewReader([]byte(`{"hello": "world"}`)), nil) + if err != nil { + if errors.is(err, context.Canceled) { + // Handle context cancelled + } + if errors.As(err, &apiErr) { + fmt.Println("API Response error", apiErr) + } + // Handle non-API Error + } +``` + +

diff --git a/errors.go b/errors.go index 4e94b24..eb7e2f9 100644 --- a/errors.go +++ b/errors.go @@ -16,7 +16,7 @@ type APIError struct { } func (e *APIError) Error() string { - return fmt.Sprintf("%s [%d]: %s", e.StatusText, e.StatusCode, e.Message) + return fmt.Sprintf("%s: [%d]: %s", e.StatusText, e.StatusCode, e.Message) } func (e *APIError) Unwrap() error { diff --git a/errors_test.go b/errors_test.go index 656c4b5..61f5a47 100644 --- a/errors_test.go +++ b/errors_test.go @@ -26,7 +26,7 @@ func TestAPIError_Error(t *testing.T) { StatusText: http.StatusText(http.StatusBadRequest), Message: "there was an issue with the request", }, - want: "Bad Request [400]: there was an issue with the request", + want: "Bad Request: [400]: there was an issue with the request", }, { name: "should return 5xx error", @@ -35,7 +35,7 @@ func TestAPIError_Error(t *testing.T) { StatusText: http.StatusText(http.StatusInternalServerError), Message: "there was an issue with the request", }, - want: "Internal Server Error [500]: there was an issue with the request", + want: "Internal Server Error: [500]: there was an issue with the request", }, } for _, tt := range tests { @@ -79,3 +79,18 @@ func TestAPIError_errors_is(t *testing.T) { err := fn() odize.AssertEqual(t, err.Error(), expected.Error()) } + +func TestAPIError_Unwrap(t *testing.T) { + var err *APIError + expectedErr := APIError{ + StatusCode: http.StatusBadRequest, + StatusText: http.StatusText(http.StatusBadRequest), + Message: "some issue", + } + var testErr error = &expectedErr + if errors.As(testErr, &err) { + odize.AssertEqual(t, err.Unwrap().Error(), expectedErr.Error()) + } else { + t.Error("non matching error") + } +} diff --git a/fetch.go b/fetch.go index 02db9e6..919fff3 100644 --- a/fetch.go +++ b/fetch.go @@ -1,14 +1,16 @@ +// Package fetch provides a simple fetch client with built in backup / retry strategy. package fetch import ( + "context" "errors" - "fmt" "io" + "log" "net/http" - "sync" "time" ) +// New initialises and returns a new Client instance with the provided options or default configurations if nil. func New(options *Options) *Client { if options == nil { @@ -35,67 +37,240 @@ func New(options *Options) *Client { return &fetch } +// Get sends an HTTP GET request to the specified URL with optional headers and returns the HTTP response or an error. +// +// Example: +// +// var apiErr *fetch.APIError +// +// resp, err := client.Get(url, nil) +// if err != nil { +// if errors.As(err, &apiErr) { +// fmt.Println("API Response error", apiErr) +// } +// // Handle non-API Error +// } func (a *Client) Get(url string, headers map[string]string) (*http.Response, error) { - return a.do(url, http.MethodGet, nil, headers) + ctx := context.Background() + return a.do(ctx, url, http.MethodGet, nil, headers) } +// Post sends an HTTP POST request to the specified URL with a body and optional headers, returning the HTTP response or an error. +// +// Example: +// +// var apiErr *fetch.APIError +// +// resp, err := client.Post(url, bytes.NewReader([]byte(`{"hello": "world"}`)), nil) +// if err != nil { +// if errors.As(err, &apiErr) { +// fmt.Println("API Response error", apiErr) +// } +// // Handle non-API Error +// } func (a *Client) Post(url string, body io.Reader, headers map[string]string) (*http.Response, error) { - return a.do(url, http.MethodPost, body, headers) + ctx := context.Background() + return a.do(ctx, url, http.MethodPost, body, headers) } +// Put sends an HTTP PUT request to the specified URL with a body and optional headers, returning the HTTP response or an error. +// +// Example: +// +// var apiErr *fetch.APIError +// +// resp, err := client.Put(url, bytes.NewReader([]byte(`{"hello": "world"}`)), nil) +// if err != nil { +// if errors.As(err, &apiErr) { +// fmt.Println("API Response error", apiErr) +// } +// // Handle non-API Error +// } func (a *Client) Put(url string, body io.Reader, headers map[string]string) (*http.Response, error) { - return a.do(url, http.MethodPut, body, headers) + ctx := context.Background() + return a.do(ctx, url, http.MethodPut, body, headers) } +// Delete sends an HTTP DELETE request to the specified URL with a body and optional headers, returning the response or an error. +// +// Example: +// +// var apiErr *fetch.APIError +// +// resp, err := client.Delete(url, bytes.NewReader([]byte(`{"hello": "world"}`)), nil) +// if err != nil { +// if errors.As(err, &apiErr) { +// fmt.Println("API Response error", apiErr) +// } +// // Handle non-API Error +// } func (a *Client) Delete(url string, body io.Reader, headers map[string]string) (*http.Response, error) { - return a.do(url, http.MethodDelete, body, headers) + ctx := context.Background() + return a.do(ctx, url, http.MethodDelete, body, headers) } +// Patch sends an HTTP PATCH request to the specified URL with a body and optional headers, returning the response or an error. +// Example: +// +// var apiErr *fetch.APIError +// +// resp, err := client.Patch(url, bytes.NewReader([]byte(`{"hello": "world"}`)), nil) +// if err != nil { +// if errors.As(err, &apiErr) { +// fmt.Println("API Response error", apiErr) +// } +// // Handle non-API Error +// } func (a *Client) Patch(url string, body io.Reader, headers map[string]string) (*http.Response, error) { - return a.do(url, http.MethodPatch, body, headers) + ctx := context.Background() + return a.do(ctx, url, http.MethodPatch, body, headers) } -// do - make http call with provided configuration -func (a *Client) do(url string, method string, body io.Reader, headers map[string]string) (*http.Response, error) { +// GetCtx sends a cancelable HTTP GET request to the specified URL with context and optional headers, returning the response or an error. +// +// Example: +// +// var apiErr *fetch.APIError +// ctx, cancel := context.WithCancel(context.Background()) +// +// resp, err := client.GetCtx(ctx,url, nil) +// if err != nil { +// if errors.is(err, context.Canceled) { +// // Handle context cancelled +// } +// if errors.As(err, &apiErr) { +// fmt.Println("API Response error", apiErr) +// } +// // Handle non-API Error +// } +func (a *Client) GetCtx(ctx context.Context, url string, headers map[string]string) (*http.Response, error) { + return a.do(ctx, url, http.MethodGet, nil, headers) +} + +// PostCtx sends a cancelable HTTP POST request to the specified URL with context, body, and headers, returning a response or error. +// +// Example: +// +// var apiErr *fetch.APIError +// ctx, cancel := context.WithCancel(context.Background()) +// +// resp, err := client.PostCtx(ctx,url, bytes.NewReader([]byte(`{"hello": "world"}`)), nil) +// if err != nil { +// if errors.is(err, context.Canceled) { +// // Handle context cancelled +// } +// if errors.As(err, &apiErr) { +// fmt.Println("API Response error", apiErr) +// } +// // Handle non-API Error +// } +func (a *Client) PostCtx(ctx context.Context, url string, body io.Reader, headers map[string]string) (*http.Response, error) { + return a.do(ctx, url, http.MethodPost, body, headers) +} + +// PutCtx sends a cancelable HTTP PUT request to the specified URL with context, body, and headers, returning a response or error. +// +// Example: +// +// var apiErr *fetch.APIError +// ctx, cancel := context.WithCancel(context.Background()) +// +// resp, err := client.PutCtx(ctx,url, bytes.NewReader([]byte(`{"hello": "world"}`)), nil) +// if err != nil { +// if errors.is(err, context.Canceled) { +// // Handle context cancelled +// } +// if errors.As(err, &apiErr) { +// fmt.Println("API Response error", apiErr) +// } +// // Handle non-API Error +// } +func (a *Client) PutCtx(ctx context.Context, url string, body io.Reader, headers map[string]string) (*http.Response, error) { + return a.do(ctx, url, http.MethodPut, body, headers) +} + +// DeleteCtx sends a cancelable HTTP DELETE request to the specified URL with context, body, and headers, returning a response or error. +// +// Example: +// +// var apiErr *fetch.APIError +// ctx, cancel := context.WithCancel(context.Background()) +// +// resp, err := client.DeleteCtx(ctx,url, bytes.NewReader([]byte(`{"hello": "world"}`)), nil) +// if err != nil { +// if errors.is(err, context.Canceled) { +// // Handle context cancelled +// } +// if errors.As(err, &apiErr) { +// fmt.Println("API Response error", apiErr) +// } +// // Handle non-API Error +// } +func (a *Client) DeleteCtx(ctx context.Context, url string, body io.Reader, headers map[string]string) (*http.Response, error) { + return a.do(ctx, url, http.MethodDelete, body, headers) +} + +// PatchCtx sends an HTTP PATCH request to the specified URL with the provided context, body, and headers. +// +// Example: +// +// var apiErr *fetch.APIError +// ctx, cancel := context.WithCancel(context.Background()) +// +// resp, err := client.PatchCtx(ctx,url, bytes.NewReader([]byte(`{"hello": "world"}`)), nil) +// if err != nil { +// if errors.is(err, context.Canceled) { +// // Handle context cancelled +// } +// if errors.As(err, &apiErr) { +// fmt.Println("API Response error", apiErr) +// } +// // Handle non-API Error +// } +func (a *Client) PatchCtx(ctx context.Context, url string, body io.Reader, headers map[string]string) (*http.Response, error) { + return a.do(ctx, url, http.MethodPatch, body, headers) +} + +// do - make http call with the provided configuration +func (a *Client) do(ctx context.Context, url string, method string, body io.Reader, headers map[string]string) (*http.Response, error) { if a.RetryStrategy == nil { - return call(url, method, body, a.Client, headers, a.DefaultHeaders) + return a.call(ctx, url, method, body, headers, a.DefaultHeaders) } - return callWithRetry(url, method, body, a.Client, a.RetryStrategy, headers, a.DefaultHeaders) + + return a.callWithRetry(ctx, url, method, body, headers, a.DefaultHeaders) } // callWithRetry - wrap the call method with the retry strategy -func callWithRetry(url string, method string, body io.Reader, client httpClient, retryStrategy []time.Duration, headers ...map[string]string) (*http.Response, error) { +func (a *Client) callWithRetry(ctx context.Context, url string, method string, body io.Reader, headers ...map[string]string) (*http.Response, error) { logPrefix := "fetch: callWithRetry" var resp *http.Response var err error - if len(retryStrategy) == 0 { + if len(a.RetryStrategy) == 0 { return resp, ErrNoValidRetryStrategy } - waitGroup := sync.WaitGroup{} - waitGroup.Add(1) + for _, retryWait := range a.RetryStrategy { + resp, err = a.call(ctx, url, method, body, headers...) - go func() { - for _, retryWait := range retryStrategy { - resp, err = call(url, method, body, client, headers...) - if err == nil || !isRecoverable(err) { - break + if err == nil || !isRecoverable(err) { + if errors.Is(err, context.Canceled) { + log.Printf("%s: http %s request canceled", logPrefix, method) } - fmt.Printf("%s: http %s request error [%s], will retry in [%s]", logPrefix, method, err, retryWait) - time.Sleep(retryWait) + break } - waitGroup.Done() - }() - waitGroup.Wait() + log.Printf("%s: http %s request error [%s], will retry in [%s]", logPrefix, method, err, retryWait) + time.Sleep(retryWait) + } + return resp, err } // call - creates a new HTTP request and returns an HTTP response -func call(url string, method string, body io.Reader, client httpClient, headers ...map[string]string) (*http.Response, error) { - req, err := http.NewRequest(method, url, body) +func (a *Client) call(ctx context.Context, url string, method string, body io.Reader, headers ...map[string]string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, url, body) if err != nil { return &http.Response{}, err } @@ -105,7 +280,9 @@ func call(url string, method string, body io.Reader, client httpClient, headers req.Header.Add(key, value) } - resp, err := client.Do(req) + log.Println("request", req == nil) + + resp, err := a.Client.Do(req) if err != nil { return resp, err } diff --git a/fetch_test.go b/fetch_test.go index ba4a586..09e23f8 100644 --- a/fetch_test.go +++ b/fetch_test.go @@ -2,7 +2,7 @@ package fetch import ( "bytes" - "encoding/json" + "context" "errors" "net/http" "testing" @@ -11,274 +11,111 @@ import ( "github.com/code-gorilla-au/odize" ) -func Test_call_POST_should_not_return_error_and_match_req(t *testing.T) { +func TestClient_Patch_no_retry(t *testing.T) { m := MockHTTPClient{ Resp: &http.Response{ + Status: http.StatusText(http.StatusOK), StatusCode: http.StatusOK, }, } - expectedHeaders := map[string]string{ - "Auth": "/app/json", - } - - url := "foo" - - _, err := call(url, http.MethodPost, nil, &m, expectedHeaders) - odize.AssertNoError(t, err) - for key, value := range expectedHeaders { - odize.AssertEqual(t, m.Req.Header.Get(key), value) - } - odize.AssertEqual(t, m.Req.URL.String(), url) - odize.AssertEqual(t, m.Req.Method, http.MethodPost) - -} - -func Test_call_POST_4xx_should_return_error(t *testing.T) { - m := MockHTTPClient{ - Resp: &http.Response{ - StatusCode: http.StatusBadRequest, - }, - } - - expectedHeaders := map[string]string{ - "Auth": "/app/json", - } - - url := "foo" - - var expectedErr *APIError - - _, err := call(url, http.MethodPost, nil, &m, expectedHeaders) - if err == nil { - t.Error("expected error, got none") - return + headers := map[string]string{ + "Content-Type": "application/json", } - odize.AssertTrue(t, errors.As(err, &expectedErr)) -} -func Test_call_POST_5xx_should_return_error(t *testing.T) { - m := MockHTTPClient{ - Resp: &http.Response{ - StatusCode: http.StatusInternalServerError, - }, + defaultHeaders := map[string]string{ + "default-header": "bar", } - expectedHeaders := map[string]string{ - "Auth": "/app/json", + c := &Client{ + RetryStrategy: nil, + Client: &m, + DefaultHeaders: defaultHeaders, } - url := "foo" - - var expectedErr *APIError - - _, err := call(url, http.MethodPost, nil, &m, expectedHeaders) - if err == nil { - t.Error("expected error, got none") - return - } - odize.AssertTrue(t, errors.As(err, &expectedErr)) + resp, err := c.Patch("", bytes.NewReader(nil), headers) + odize.AssertNoError(t, err) + odize.AssertEqual(t, resp, m.Resp) } -func Test_call_GET_should_not_return_error_and_match_req(t *testing.T) { +func TestClient_Patch_no_retry_with_default_and_normal_headers(t *testing.T) { m := MockHTTPClient{ Resp: &http.Response{ + Status: http.StatusText(http.StatusOK), StatusCode: http.StatusOK, }, } - expectedHeaders := map[string]string{ - "Auth": "/app/json", - } - - url := "foo" - - _, err := call(url, http.MethodGet, nil, &m, expectedHeaders) - odize.AssertNoError(t, err) - for key, value := range expectedHeaders { - odize.AssertEqual(t, m.Req.Header.Get(key), value) + headers := map[string]string{ + "Content-Type": "application/json", } - odize.AssertEqual(t, m.Req.URL.String(), url) - odize.AssertEqual(t, m.Req.Method, http.MethodGet) -} -func Test_call_PUT_should_not_return_error_and_match_req(t *testing.T) { - m := MockHTTPClient{ - Resp: &http.Response{ - StatusCode: http.StatusOK, - }, + defaultHeaders := map[string]string{ + "default-header": "bar", } - expectedHeaders := map[string]string{ - "Auth": "/app/json", + c := &Client{ + RetryStrategy: nil, + Client: &m, + DefaultHeaders: defaultHeaders, } - url := "foo" - - _, err := call(url, http.MethodPut, nil, &m, expectedHeaders) + resp, err := c.Patch("", bytes.NewReader(nil), headers) odize.AssertNoError(t, err) - for key, value := range expectedHeaders { + odize.AssertEqual(t, resp, m.Resp) + for key, value := range defaultHeaders { odize.AssertEqual(t, m.Req.Header.Get(key), value) } - odize.AssertEqual(t, m.Req.URL.String(), url) - odize.AssertEqual(t, m.Req.Method, http.MethodPut) - -} - -func Test_call_PATCH_should_not_return_error_and_match_req(t *testing.T) { - m := MockHTTPClient{ - Resp: &http.Response{ - StatusCode: http.StatusOK, - }, - } - - expectedHeaders := map[string]string{ - "Auth": "/app/json", - } - - url := "foo" - - _, err := call(url, http.MethodPatch, nil, &m, expectedHeaders) - odize.AssertNoError(t, err) - for key, value := range expectedHeaders { + for key, value := range headers { odize.AssertEqual(t, m.Req.Header.Get(key), value) } - odize.AssertEqual(t, m.Req.URL.String(), url) - odize.AssertEqual(t, m.Req.Method, http.MethodPatch) - } -func Test_call_DELETE_should_not_return_error_and_match_req(t *testing.T) { +func TestClient_Patch_with_retry(t *testing.T) { m := MockHTTPClient{ Resp: &http.Response{ + Status: http.StatusText(http.StatusOK), StatusCode: http.StatusOK, }, } - expectedHeaders := map[string]string{ - "Auth": "/app/json", - } - - url := "foo" - - _, err := call(url, http.MethodDelete, nil, &m, expectedHeaders) - odize.AssertNoError(t, err) - for key, value := range expectedHeaders { - odize.AssertEqual(t, m.Req.Header.Get(key), value) + headers := map[string]string{ + "Content-Type": "application/json", } - odize.AssertEqual(t, m.Req.URL.String(), url) - odize.AssertEqual(t, m.Req.Method, http.MethodDelete) -} - -func Test_call_body_should_match(t *testing.T) { - m := MockHTTPClient{} - - body := map[string]string{ - "slap": "foo", + c := &Client{ + RetryStrategy: []time.Duration{1 * time.Nanosecond}, + Client: &m, } - data, err := json.Marshal(&body) - odize.AssertNoError(t, err) - - url := "foo" - - _, err = call(url, http.MethodPost, bytes.NewReader(data), &m, body) + resp, err := c.Patch("", bytes.NewReader(nil), headers) odize.AssertNoError(t, err) - - test := map[string]string{} - err = json.NewDecoder(m.Req.Body).Decode(&test) - odize.AssertNoError(t, err) - odize.AssertEqual(t, test, body) - -} - -func Test_call_should_should_return_error(t *testing.T) { - m := MockHTTPClient{ - ErrDo: true, - Err: errors.New("expected error"), - } - - expectedHeaders := map[string]string{ - "Auth": "/app/json", - } - - url := "foo" - - _, err := call(url, http.MethodPost, nil, &m, expectedHeaders) - odize.AssertTrue(t, errors.Is(err, m.Err)) -} - -func Test_callWithRetry_client_error_should_return_error(t *testing.T) { - m := MockHTTPClient{ - ErrDo: true, - Err: errors.New("expected error"), - } - - _, err := callWithRetry("", http.MethodPost, nil, &m, []time.Duration{1 * time.Nanosecond}) - odize.AssertTrue(t, errors.Is(err, m.Err)) -} - -func Test_callWithRetry_4xx_client_error_should_return_error(t *testing.T) { - m := MockHTTPClient{ - Resp: &http.Response{ - StatusCode: http.StatusBadRequest, - }, - } - var apiErr *APIError - _, err := callWithRetry("", http.MethodPost, nil, &m, []time.Duration{1 * time.Nanosecond}) - odize.AssertTrue(t, errors.As(err, &apiErr)) - odize.AssertEqual(t, 1, m.Retries) + odize.AssertEqual(t, resp, m.Resp) } -func Test_callWithRetry_5xx_client_error_retry_and_should_return_error(t *testing.T) { +func TestClient_Delete_no_retry(t *testing.T) { m := MockHTTPClient{ Resp: &http.Response{ - StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, }, } - var apiErr *APIError - _, err := callWithRetry("", http.MethodPost, nil, &m, []time.Duration{1 * time.Nanosecond, 1 * time.Nanosecond}) - - odize.AssertTrue(t, errors.As(err, &apiErr)) - odize.AssertEqual(t, 2, m.Retries) -} - -func Test_callWithRetry_no_retries_should_return_error(t *testing.T) { - m := MockHTTPClient{ - ErrDo: true, - Err: errors.New("not expected error"), - } - _, err := callWithRetry("", http.MethodPost, nil, &m, []time.Duration{}) - odize.AssertTrue(t, errors.Is(err, ErrNoValidRetryStrategy)) -} - -func Test_callWithRetry_nill_retries_should_return_error(t *testing.T) { - m := MockHTTPClient{ - ErrDo: true, - Err: errors.New("not expected error"), + headers := map[string]string{ + "Content-Type": "application/json", } - _, err := callWithRetry("", http.MethodPost, nil, &m, nil) - odize.AssertTrue(t, errors.Is(err, ErrNoValidRetryStrategy)) -} - -func Test_callWithRetry_should_return_response(t *testing.T) { - m := MockHTTPClient{ - Resp: &http.Response{ - Status: http.StatusText(http.StatusOK), - StatusCode: http.StatusOK, - }, + c := &Client{ + RetryStrategy: nil, + Client: &m, } - resp, err := callWithRetry("", http.MethodGet, nil, &m, []time.Duration{1 * time.Nanosecond}) + resp, err := c.Delete("", bytes.NewReader(nil), headers) odize.AssertNoError(t, err) odize.AssertEqual(t, resp, m.Resp) - odize.AssertEqual(t, m.Req.Method, http.MethodGet) } -func TestAxios_Patch_no_retry(t *testing.T) { +func TestClient_Delete_with_retry(t *testing.T) { m := MockHTTPClient{ Resp: &http.Response{ Status: http.StatusText(http.StatusOK), @@ -290,22 +127,17 @@ func TestAxios_Patch_no_retry(t *testing.T) { "Content-Type": "application/json", } - defaultHeaders := map[string]string{ - "default-header": "bar", - } - - axios := &Client{ - RetryStrategy: nil, - Client: &m, - DefaultHeaders: defaultHeaders, + c := &Client{ + RetryStrategy: []time.Duration{1 * time.Nanosecond}, + Client: &m, } - resp, err := axios.Patch("", bytes.NewReader(nil), headers) + resp, err := c.Delete("", bytes.NewReader(nil), headers) odize.AssertNoError(t, err) odize.AssertEqual(t, resp, m.Resp) } -func TestAxios_Patch_no_retry_with_default_and_normal_headers(t *testing.T) { +func TestClient_Delete_with_retry_with_default_and_normal_headers(t *testing.T) { m := MockHTTPClient{ Resp: &http.Response{ Status: http.StatusText(http.StatusOK), @@ -321,13 +153,13 @@ func TestAxios_Patch_no_retry_with_default_and_normal_headers(t *testing.T) { "default-header": "bar", } - axios := &Client{ + c := &Client{ RetryStrategy: nil, Client: &m, DefaultHeaders: defaultHeaders, } - resp, err := axios.Patch("", bytes.NewReader(nil), headers) + resp, err := c.Delete("", bytes.NewReader(nil), headers) odize.AssertNoError(t, err) odize.AssertEqual(t, resp, m.Resp) for key, value := range defaultHeaders { @@ -338,7 +170,7 @@ func TestAxios_Patch_no_retry_with_default_and_normal_headers(t *testing.T) { } } -func TestAxios_Patch_with_retry(t *testing.T) { +func TestClient_Put_no_retry(t *testing.T) { m := MockHTTPClient{ Resp: &http.Response{ Status: http.StatusText(http.StatusOK), @@ -350,39 +182,17 @@ func TestAxios_Patch_with_retry(t *testing.T) { "Content-Type": "application/json", } - axios := &Client{ - RetryStrategy: []time.Duration{1 * time.Nanosecond}, - Client: &m, - } - - resp, err := axios.Patch("", bytes.NewReader(nil), headers) - odize.AssertNoError(t, err) - odize.AssertEqual(t, resp, m.Resp) -} - -func TestAxios_Delete_no_retry(t *testing.T) { - m := MockHTTPClient{ - Resp: &http.Response{ - Status: http.StatusText(http.StatusOK), - StatusCode: http.StatusOK, - }, - } - - headers := map[string]string{ - "Content-Type": "application/json", - } - - axios := &Client{ + c := &Client{ RetryStrategy: nil, Client: &m, } - resp, err := axios.Delete("", bytes.NewReader(nil), headers) + resp, err := c.Put("", bytes.NewReader(nil), headers) odize.AssertNoError(t, err) odize.AssertEqual(t, resp, m.Resp) } -func TestAxios_Delete_with_retry(t *testing.T) { +func TestClient_Put_with_retry(t *testing.T) { m := MockHTTPClient{ Resp: &http.Response{ Status: http.StatusText(http.StatusOK), @@ -394,17 +204,17 @@ func TestAxios_Delete_with_retry(t *testing.T) { "Content-Type": "application/json", } - axios := &Client{ + c := &Client{ RetryStrategy: []time.Duration{1 * time.Nanosecond}, Client: &m, } - resp, err := axios.Delete("", bytes.NewReader(nil), headers) + resp, err := c.Put("", bytes.NewReader(nil), headers) odize.AssertNoError(t, err) odize.AssertEqual(t, resp, m.Resp) } -func TestAxios_Delete_with_retry_with_default_and_normal_headers(t *testing.T) { +func TestClient_Put_with_retry_with_default_and_normal_headers(t *testing.T) { m := MockHTTPClient{ Resp: &http.Response{ Status: http.StatusText(http.StatusOK), @@ -420,13 +230,13 @@ func TestAxios_Delete_with_retry_with_default_and_normal_headers(t *testing.T) { "default-header": "bar", } - axios := &Client{ + c := &Client{ RetryStrategy: nil, Client: &m, DefaultHeaders: defaultHeaders, } - resp, err := axios.Delete("", bytes.NewReader(nil), headers) + resp, err := c.Put("", bytes.NewReader(nil), headers) odize.AssertNoError(t, err) odize.AssertEqual(t, resp, m.Resp) for key, value := range defaultHeaders { @@ -437,7 +247,7 @@ func TestAxios_Delete_with_retry_with_default_and_normal_headers(t *testing.T) { } } -func TestAxios_Put_no_retry(t *testing.T) { +func TestClient_Get_with_retry(t *testing.T) { m := MockHTTPClient{ Resp: &http.Response{ Status: http.StatusText(http.StatusOK), @@ -449,17 +259,16 @@ func TestAxios_Put_no_retry(t *testing.T) { "Content-Type": "application/json", } - axios := &Client{ - RetryStrategy: nil, + c := &Client{ + RetryStrategy: []time.Duration{1 * time.Nanosecond}, Client: &m, } - resp, err := axios.Put("", bytes.NewReader(nil), headers) + resp, err := c.Get("", headers) odize.AssertNoError(t, err) odize.AssertEqual(t, resp, m.Resp) } - -func TestAxios_Put_with_retry(t *testing.T) { +func TestClient_Get_no_retry(t *testing.T) { m := MockHTTPClient{ Resp: &http.Response{ Status: http.StatusText(http.StatusOK), @@ -471,17 +280,17 @@ func TestAxios_Put_with_retry(t *testing.T) { "Content-Type": "application/json", } - axios := &Client{ - RetryStrategy: []time.Duration{1 * time.Nanosecond}, + c := &Client{ + RetryStrategy: nil, Client: &m, } - resp, err := axios.Put("", bytes.NewReader(nil), headers) + resp, err := c.Get("", headers) odize.AssertNoError(t, err) odize.AssertEqual(t, resp, m.Resp) } -func TestAxios_Put_with_retry_with_default_and_normal_headers(t *testing.T) { +func TestClient_Get_no_retry_with_default_and_normal_headers(t *testing.T) { m := MockHTTPClient{ Resp: &http.Response{ Status: http.StatusText(http.StatusOK), @@ -497,13 +306,13 @@ func TestAxios_Put_with_retry_with_default_and_normal_headers(t *testing.T) { "default-header": "bar", } - axios := &Client{ + c := &Client{ RetryStrategy: nil, Client: &m, DefaultHeaders: defaultHeaders, } - resp, err := axios.Put("", bytes.NewReader(nil), headers) + resp, err := c.Get("", headers) odize.AssertNoError(t, err) odize.AssertEqual(t, resp, m.Resp) for key, value := range defaultHeaders { @@ -514,7 +323,7 @@ func TestAxios_Put_with_retry_with_default_and_normal_headers(t *testing.T) { } } -func TestAxios_Get_with_retry(t *testing.T) { +func TestClient_Post_with_retry_response_status_ok(t *testing.T) { m := MockHTTPClient{ Resp: &http.Response{ Status: http.StatusText(http.StatusOK), @@ -526,16 +335,17 @@ func TestAxios_Get_with_retry(t *testing.T) { "Content-Type": "application/json", } - axios := &Client{ + c := &Client{ RetryStrategy: []time.Duration{1 * time.Nanosecond}, Client: &m, } - resp, err := axios.Get("", headers) + resp, err := c.Post("", bytes.NewReader(nil), headers) odize.AssertNoError(t, err) odize.AssertEqual(t, resp, m.Resp) } -func TestAxios_Get_no_retry(t *testing.T) { + +func TestClient_Post_with_retry_response_should_try_once(t *testing.T) { m := MockHTTPClient{ Resp: &http.Response{ Status: http.StatusText(http.StatusOK), @@ -547,21 +357,20 @@ func TestAxios_Get_no_retry(t *testing.T) { "Content-Type": "application/json", } - axios := &Client{ - RetryStrategy: nil, + c := &Client{ + RetryStrategy: []time.Duration{1 * time.Nanosecond}, Client: &m, } - resp, err := axios.Get("", headers) - odize.AssertNoError(t, err) - odize.AssertEqual(t, resp, m.Resp) + _, _ = c.Post("", bytes.NewReader(nil), headers) + odize.AssertEqual(t, m.Retries, 1) } -func TestAxios_Get_no_retry_with_default_and_normal_headers(t *testing.T) { +func TestClient_Post_with_retry_response_should_try_twice(t *testing.T) { m := MockHTTPClient{ Resp: &http.Response{ - Status: http.StatusText(http.StatusOK), - StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusGatewayTimeout), + StatusCode: http.StatusGatewayTimeout, }, } @@ -569,28 +378,16 @@ func TestAxios_Get_no_retry_with_default_and_normal_headers(t *testing.T) { "Content-Type": "application/json", } - defaultHeaders := map[string]string{ - "default-header": "bar", - } - - axios := &Client{ - RetryStrategy: nil, - Client: &m, - DefaultHeaders: defaultHeaders, + c := &Client{ + RetryStrategy: []time.Duration{1 * time.Nanosecond, 1 * time.Nanosecond}, + Client: &m, } - resp, err := axios.Get("", headers) - odize.AssertNoError(t, err) - odize.AssertEqual(t, resp, m.Resp) - for key, value := range defaultHeaders { - odize.AssertEqual(t, m.Req.Header.Get(key), value) - } - for key, value := range headers { - odize.AssertEqual(t, m.Req.Header.Get(key), value) - } + _, _ = c.Post("", bytes.NewReader(nil), headers) + odize.AssertEqual(t, m.Retries, 2) } -func TestAxios_Post_with_retry(t *testing.T) { +func TestClient_Post_no_retry(t *testing.T) { m := MockHTTPClient{ Resp: &http.Response{ Status: http.StatusText(http.StatusOK), @@ -602,17 +399,17 @@ func TestAxios_Post_with_retry(t *testing.T) { "Content-Type": "application/json", } - axios := &Client{ - RetryStrategy: []time.Duration{1 * time.Nanosecond}, + c := &Client{ + RetryStrategy: nil, Client: &m, } - resp, err := axios.Post("", bytes.NewReader(nil), headers) + resp, err := c.Post("", bytes.NewReader(nil), headers) odize.AssertNoError(t, err) odize.AssertEqual(t, resp, m.Resp) } -func TestAxios_Post_no_retry(t *testing.T) { +func TestClient_Post_empty_retry_list(t *testing.T) { m := MockHTTPClient{ Resp: &http.Response{ Status: http.StatusText(http.StatusOK), @@ -624,17 +421,16 @@ func TestAxios_Post_no_retry(t *testing.T) { "Content-Type": "application/json", } - axios := &Client{ - RetryStrategy: nil, + c := &Client{ + RetryStrategy: []time.Duration{}, Client: &m, } - resp, err := axios.Post("", bytes.NewReader(nil), headers) - odize.AssertNoError(t, err) - odize.AssertEqual(t, resp, m.Resp) + _, err := c.Post("", bytes.NewReader(nil), headers) + odize.AssertError(t, err) } -func TestAxios_Post_no_retry_with_default_and_normal_headers(t *testing.T) { +func TestClient_Post_no_retry_with_default_and_normal_headers(t *testing.T) { m := MockHTTPClient{ Resp: &http.Response{ Status: http.StatusText(http.StatusOK), @@ -650,13 +446,13 @@ func TestAxios_Post_no_retry_with_default_and_normal_headers(t *testing.T) { "default-header": "bar", } - axios := &Client{ + c := &Client{ RetryStrategy: nil, Client: &m, DefaultHeaders: defaultHeaders, } - resp, err := axios.Post("", bytes.NewReader(nil), headers) + resp, err := c.Post("", bytes.NewReader(nil), headers) odize.AssertNoError(t, err) odize.AssertEqual(t, resp, m.Resp) for key, value := range defaultHeaders { @@ -668,21 +464,21 @@ func TestAxios_Post_no_retry_with_default_and_normal_headers(t *testing.T) { } func TestNew_with_default_retry(t *testing.T) { - axios := New(nil) - odize.AssertEqual(t, axios.RetryStrategy, setDefaultFetch().RetryStrategy) + c := New(nil) + odize.AssertEqual(t, c.RetryStrategy, setDefaultFetch().RetryStrategy) } func TestNew_with_default_header(t *testing.T) { - axios := New(nil) - odize.AssertEqual(t, axios.DefaultHeaders, setDefaultFetch().DefaultHeaders) + c := New(nil) + odize.AssertEqual(t, c.DefaultHeaders, setDefaultFetch().DefaultHeaders) } func TestNew_with_functional_options(t *testing.T) { expected := []time.Duration{1, 2} - axios := New(WithOpts( + c := New(WithOpts( WithRetryStrategy(&expected), )) - odize.AssertEqual(t, axios.RetryStrategy, expected) + odize.AssertEqual(t, c.RetryStrategy, expected) } func TestNew_with_options_headers(t *testing.T) { @@ -692,8 +488,8 @@ func TestNew_with_options_headers(t *testing.T) { "foo": "bar", }, } - axios := New(&options) - odize.AssertEqual(t, axios.DefaultHeaders, options.DefaultHeaders) + c := New(&options) + odize.AssertEqual(t, c.DefaultHeaders, options.DefaultHeaders) } func TestNew_with_options_no_retry(t *testing.T) { @@ -703,15 +499,15 @@ func TestNew_with_options_no_retry(t *testing.T) { "foo": "bar", }, } - axios := New(&options) - odize.AssertEqual(t, axios.RetryStrategy, []time.Duration(nil)) + c := New(&options) + odize.AssertEqual(t, c.RetryStrategy, []time.Duration(nil)) } func TestNew_with_options_with_retry(t *testing.T) { options := Options{ WithRetry: true, } - axios := New(&options) - odize.AssertEqual(t, axios.RetryStrategy, setDefaultRetryStrategy()) + c := New(&options) + odize.AssertEqual(t, c.RetryStrategy, setDefaultRetryStrategy()) } func Test_mergeHeaders_should_merge_correctly(t *testing.T) { @@ -732,3 +528,91 @@ func Test_mergeHeaders_empty_should_work(t *testing.T) { odize.AssertEqual(t, expected, test) } + +func TestClient_context_methods(t *testing.T) { + group := odize.NewGroup(t, nil) + + var c Client + var mock *MockHTTPClient + var ctx context.Context + var cancelFunc context.CancelFunc + + group.BeforeEach(func() { + ctx, cancelFunc = context.WithTimeout(context.Background(), 1*time.Nanosecond) + + mock = &MockHTTPClient{ + Err: context.Canceled, + ErrDo: true, + Resp: &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + }, + } + + c = Client{Client: mock} + }) + + err := group. + Test("GetCtx cancel() should return error", func(t *testing.T) { + + c = Client{ + Client: mock, + } + + cancelFunc() + + _, err := c.GetCtx(ctx, "/https://google.com", nil) + odize.AssertTrue(t, errors.Is(err, context.Canceled)) + + }). + Test("PostCtx cancel() should return error", func(t *testing.T) { + + c = Client{ + Client: mock, + } + + cancelFunc() + + _, err := c.PostCtx(ctx, "/https://google.com", bytes.NewReader([]byte(`{"hello": "world"}`)), nil) + odize.AssertTrue(t, errors.Is(err, context.Canceled)) + + }). + Test("PutCtx cancel() should return error", func(t *testing.T) { + + c = Client{ + Client: mock, + } + + cancelFunc() + + _, err := c.PutCtx(ctx, "/https://google.com", bytes.NewReader([]byte(`{"hello": "world"}`)), nil) + odize.AssertTrue(t, errors.Is(err, context.Canceled)) + + }). + Test("DeleteCtx cancel() should return error", func(t *testing.T) { + + c = Client{ + Client: mock, + } + + cancelFunc() + + _, err := c.DeleteCtx(ctx, "/https://google.com", bytes.NewReader([]byte(`{"hello": "world"}`)), nil) + odize.AssertTrue(t, errors.Is(err, context.Canceled)) + + }). + Test("PatchCtx cancel() should return error", func(t *testing.T) { + + c = Client{ + Client: mock, + } + + cancelFunc() + + _, err := c.PatchCtx(ctx, "/https://google.com", bytes.NewReader([]byte(`{"hello": "world"}`)), nil) + odize.AssertTrue(t, errors.Is(err, context.Canceled)) + + }). + Run() + odize.AssertNoError(t, err) +} diff --git a/go.mod b/go.mod index e51e99c..5200d2b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/code-gorilla-au/fetch -go 1.22.4 +go 1.25.3 require github.com/code-gorilla-au/odize v1.3.4 diff --git a/mocks.go b/mocks.gen.go similarity index 100% rename from mocks.go rename to mocks.gen.go diff --git a/options.go b/options.go index bcd6a82..40db27d 100644 --- a/options.go +++ b/options.go @@ -36,7 +36,7 @@ func WithOpts(opts ...FnOpts) *Options { return &o } -// WithDefaultRetry - use client default retry strategy +// WithDefaultRetryStrategy - use client default retry strategy func WithDefaultRetryStrategy() FnOpts { return func(o *Options) error { o.WithRetry = true diff --git a/options_test.go b/options_test.go index 2970ce1..e0464d8 100644 --- a/options_test.go +++ b/options_test.go @@ -65,3 +65,41 @@ func TestWithOpts_with_custom_client(t *testing.T) { odize.AssertEqual(t, &cl, options.HTTPClient) odize.AssertNil(t, options.RetryStrategy) } + +func TestWithOpts_with_custom_retry_and_client(t *testing.T) { + st := []time.Duration{1, 2} + cl := http.Client{} + options := WithOpts(WithHTTPClient(&cl), WithRetryStrategy(&[]time.Duration{1, 2})) + odize.AssertEqual(t, &st, options.RetryStrategy) +} + +func TestWithOpts_with_multiple_options(t *testing.T) { + st := []time.Duration{1, 2} + cl := http.Client{} + options := WithOpts(WithHTTPClient(&cl), WithRetryStrategy(&[]time.Duration{1, 2})) + odize.AssertEqual(t, &st, options.RetryStrategy) +} + +func TestWithOpts_with_nil_options(t *testing.T) { + options := WithOpts(nil) + odize.AssertNil(t, options.HTTPClient) + odize.AssertNil(t, options.RetryStrategy) +} + +func TestWithOpts_with_empty_options(t *testing.T) { + options := WithOpts() + odize.AssertNil(t, options.HTTPClient) +} + +func TestWithOpts_with_default_retry_strategy(t *testing.T) { + options := WithOpts(WithDefaultRetryStrategy()) + odize.AssertTrue(t, options.WithRetry) +} + +func TestWithOpts_with_headers(t *testing.T) { + headers := map[string]string{ + "foo": "bar", + } + options := WithOpts(WithHeaders(headers)) + odize.AssertEqual(t, headers, options.DefaultHeaders) +} diff --git a/scripts/lints.mk b/scripts/lints.mk index 8673a95..1e73a33 100644 --- a/scripts/lints.mk +++ b/scripts/lints.mk @@ -7,7 +7,6 @@ lint: ## Lint tools golangci-lint run ./... scan: ## run golang security scan - gosec ./... govulncheck ./... trivy: ## run trivy scan diff --git a/scripts/tests.mk b/scripts/tests.mk index 4f6c1c7..09abbe1 100644 --- a/scripts/tests.mk +++ b/scripts/tests.mk @@ -10,12 +10,15 @@ test: test-unit ## Run all tests test-unit: ## Run unit tests go test -coverprofile $(COVER_OUTPUT_RAW) --short -cover -failfast ./... -test-cover: ## generate html coverage report + open +test-watch: ## Run tests in watch mode + gow test -coverprofile $(COVER_OUTPUT_RAW) --short -cover -failfast ./... + + +test-gen-coverage: + grep -v -E -f ${PWD}/.covignore $(COVER_OUTPUT_RAW) > coverage.filtered.out + mv coverage.filtered.out $(COVER_OUTPUT_RAW) + +test-cover: test-unit test-gen-coverage ## generate html coverage report + open go tool cover -html=$(COVER_OUTPUT_RAW) -o $(COVER_OUTPUT_HTML) open coverage.html -test-purge: build ## Run purge integration tests - ./goety purge -e http://localhost:8000 -t "dev-main-adjacent" -p "inventoryId" -s "relationshipId" - -test-dump: build ## Run dump integration tests - ./goety dump -e http://localhost:8000 -t "dev-main-adjacent" -p "test.json" \ No newline at end of file diff --git a/scripts/tools.mk b/scripts/tools.mk index aa9ffd6..f2554b2 100644 --- a/scripts/tools.mk +++ b/scripts/tools.mk @@ -6,8 +6,7 @@ tools-all: tools-dev tools-scan ## Get all tools for development tools-scan: ## get all the tools required go install golang.org/x/vuln/cmd/govulncheck@latest - go install github.com/securego/gosec/v2/cmd/gosec@latest - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.2.2 tools-dev: ## Dev specific tooling