From 438e689d3a566a94b537236c03468308a22a56aa Mon Sep 17 00:00:00 2001 From: davesavic Date: Fri, 5 Jan 2024 12:38:03 +1000 Subject: [PATCH] More tests and updated readme --- .gitignore | 1 + README.md | 34 ++++++- client.go | 17 ++-- client_test.go | 144 +++++++++++++++++++++++++++ coverage.out | 72 +++++++------- examples/default-client/main.go | 28 ++++++ examples/rate-limited-client/main.go | 32 ++++++ examples/retry-client/main.go | 30 ++++++ 8 files changed, 308 insertions(+), 50 deletions(-) create mode 100644 .gitignore create mode 100644 examples/default-client/main.go create mode 100644 examples/rate-limited-client/main.go create mode 100644 examples/retry-client/main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..723ef36 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea \ No newline at end of file diff --git a/README.md b/README.md index 5cb79ed..497536a 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,7 @@ Clink is a highly configurable HTTP client for Go, designed for ease of use, ext ### Features - **Flexible Request Options**: Easily configure headers, URLs, and authentication. - **Retry Mechanism**: Automatic retries with configurable policies. -- **Middleware Support**: Extend functionality with custom middleware modules. - **Rate Limiting**: Client-side rate limiting to avoid server-side limits. -- **Logging & Tracing**: Built-in support for logging and distributed tracing. ### Installation To use Clink in your Go project, install it using `go get`: @@ -20,8 +18,38 @@ go get -u github.com/davesavic/clink Here is a basic example of how to use Clink: ```go -TODO +package main + +import ( + "fmt" + "github.com/davesavic/clink" + "net/http" +) + +func main() { + // Create a new client with default options. + client := clink.NewClient() + + // Create a new request with default options. + req, err := http.NewRequest(http.MethodGet, "https://httpbin.org/anything", nil) + + // Send the request and get the response. + resp, err := client.Do(req) + if err != nil { + panic(err) + } + + // Hydrate the response body into a map. + var target map[string]any + err = clink.ResponseToJson(resp, &target) + + // Print the target map. + fmt.Println(target) +} ``` +### Examples +For more examples, see the [examples](https://github.com/davesavic/clink/tree/master/examples) directory. + ### Contributing Contributions to Clink are welcome! If you find a bug, have a feature request, or want to contribute code, please open an issue or submit a pull request. \ No newline at end of file diff --git a/client.go b/client.go index c5d8198..e1a535c 100644 --- a/client.go +++ b/client.go @@ -51,9 +51,6 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) { for attempt := 0; attempt <= c.MaxRetries; attempt++ { resp, err = c.HttpClient.Do(req) - if err == nil { - break - } if c.ShouldRetryFunc != nil && !c.ShouldRetryFunc(req, resp, err) { break @@ -91,7 +88,9 @@ func WithHeader(key, value string) Option { // WithHeaders sets the headers for the client. func WithHeaders(headers map[string]string) Option { return func(c *Client) { - c.Headers = headers + for key, value := range headers { + c.Headers[key] = value + } } } @@ -106,15 +105,12 @@ func WithRateLimit(rpm int) Option { // WithBasicAuth sets the basic auth header for the client. func WithBasicAuth(username, password string) Option { return func(c *Client) { - c.Headers["Authorization"] = "Basic " + basicAuth(username, password) + auth := username + ":" + password + encodedAuth := base64.StdEncoding.EncodeToString([]byte(auth)) + c.Headers["Authorization"] = "Basic " + encodedAuth } } -func basicAuth(username, password string) string { - auth := username + ":" + password - return base64.StdEncoding.EncodeToString([]byte(auth)) -} - // WithBearerAuth sets the bearer auth header for the client. func WithBearerAuth(token string) Option { return func(c *Client) { @@ -137,6 +133,7 @@ func WithRetries(count int, retryFunc func(*http.Request, *http.Response, error) } } +// ResponseToJson decodes the response body into the target. func ResponseToJson[T any](response *http.Response, target *T) error { if response == nil { return fmt.Errorf("response is nil") diff --git a/client_test.go b/client_test.go index bbf3ef6..bd06ef0 100644 --- a/client_test.go +++ b/client_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" ) func TestNewClient(t *testing.T) { @@ -173,6 +174,54 @@ func TestClient_Do(t *testing.T) { return false } + return target["key"] == "value" + }, + }, + { + name: "successful response with json body and custom headers", + opts: []clink.Option{ + clink.WithHeaders(map[string]string{"key": "value"}), + }, + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("key") != "value" { + w.WriteHeader(http.StatusBadRequest) + } + + _ = json.NewEncoder(w).Encode(map[string]string{"key": "value"}) + })) + }, + resultFunc: func(response *http.Response, err error) bool { + var target map[string]string + er := clink.ResponseToJson(response, &target) + if er != nil { + return false + } + + return target["key"] == "value" + }, + }, + { + name: "successful response with json body and custom header", + opts: []clink.Option{ + clink.WithHeader("key", "value"), + }, + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("key") != "value" { + w.WriteHeader(http.StatusBadRequest) + } + + _ = json.NewEncoder(w).Encode(map[string]string{"key": "value"}) + })) + }, + resultFunc: func(response *http.Response, err error) bool { + var target map[string]string + er := clink.ResponseToJson(response, &target) + if er != nil { + return false + } + return target["key"] == "value" }, }, @@ -279,3 +328,98 @@ func TestClient_ResponseToJson(t *testing.T) { }) } } + +func TestRateLimiter(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := clink.NewClient( + clink.WithRateLimit(60), + clink.WithClient(server.Client()), + ) + + startTime := time.Now() + + for i := 0; i < 2; i++ { + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + if err != nil { + t.Errorf("failed to create request: %v", err) + } + + resp, err := client.Do(req) + if err != nil { + t.Errorf("failed to make request: %v", err) + } + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status code to be 200") + } + } + + elapsedTime := time.Since(startTime) + if elapsedTime.Seconds() < 0.5 || elapsedTime.Seconds() > 1.5 { + t.Errorf("expected elapsed time to be between 0.5 and 1.5 seconds, got: %f", elapsedTime.Seconds()) + } +} + +func TestSuccessfulRetries(t *testing.T) { + var requestCount int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ // Increment the request count + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + retryCount := 3 + client := clink.NewClient( + clink.WithRetries(retryCount, func(request *http.Request, response *http.Response, err error) bool { + // Check if the response is a 500 Internal Server Error + return response != nil && response.StatusCode == http.StatusInternalServerError + }), + clink.WithClient(server.Client()), + ) + + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + _, err = client.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + + if requestCount != retryCount+1 { // +1 for the initial request + t.Errorf("expected %d retries (total requests: %d), but got %d", retryCount, retryCount+1, requestCount) + } +} + +func TestUnsuccessfulRetries(t *testing.T) { + var requestCount int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ // Increment the request count + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + retryCount := 3 + client := clink.NewClient( + clink.WithRetries(retryCount, func(request *http.Request, response *http.Response, err error) bool { + return false + }), + clink.WithClient(server.Client()), + ) + + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + _, err = client.Do(req) + + if requestCount != 1 { // +1 for the initial request + t.Errorf("expected %d retries (total requests: %d), but got %d", retryCount, retryCount+1, requestCount) + } +} diff --git a/coverage.out b/coverage.out index d467256..34a7f4b 100644 --- a/coverage.out +++ b/coverage.out @@ -4,43 +4,41 @@ github.com/davesavic/clink/client.go:24.27,26.3 1 1 github.com/davesavic/clink/client.go:28.2,28.10 1 1 github.com/davesavic/clink/client.go:31.30,36.2 1 1 github.com/davesavic/clink/client.go:38.64,39.36 1 1 -github.com/davesavic/clink/client.go:39.36,41.3 1 0 +github.com/davesavic/clink/client.go:39.36,41.3 1 1 github.com/davesavic/clink/client.go:43.2,43.26 1 1 -github.com/davesavic/clink/client.go:43.26,44.59 1 0 +github.com/davesavic/clink/client.go:43.26,44.59 1 1 github.com/davesavic/clink/client.go:44.59,46.4 1 0 github.com/davesavic/clink/client.go:49.2,52.55 3 1 -github.com/davesavic/clink/client.go:52.55,54.17 2 1 -github.com/davesavic/clink/client.go:54.17,55.9 1 1 -github.com/davesavic/clink/client.go:58.3,58.69 1 0 -github.com/davesavic/clink/client.go:58.69,59.9 1 0 -github.com/davesavic/clink/client.go:62.3,62.29 1 0 -github.com/davesavic/clink/client.go:62.29,65.4 1 0 -github.com/davesavic/clink/client.go:68.2,68.16 1 1 -github.com/davesavic/clink/client.go:68.16,70.3 1 0 -github.com/davesavic/clink/client.go:72.2,72.18 1 1 -github.com/davesavic/clink/client.go:78.45,79.25 1 1 -github.com/davesavic/clink/client.go:79.25,81.3 1 1 -github.com/davesavic/clink/client.go:85.43,86.25 1 1 -github.com/davesavic/clink/client.go:86.25,88.3 1 1 -github.com/davesavic/clink/client.go:92.52,93.25 1 1 -github.com/davesavic/clink/client.go:93.25,95.3 1 1 -github.com/davesavic/clink/client.go:99.36,100.25 1 1 -github.com/davesavic/clink/client.go:100.25,103.3 2 1 -github.com/davesavic/clink/client.go:107.54,108.25 1 1 -github.com/davesavic/clink/client.go:108.25,110.3 1 1 -github.com/davesavic/clink/client.go:113.50,116.2 2 1 -github.com/davesavic/clink/client.go:119.42,120.25 1 1 -github.com/davesavic/clink/client.go:120.25,122.3 1 1 -github.com/davesavic/clink/client.go:126.38,127.25 1 1 -github.com/davesavic/clink/client.go:127.25,129.3 1 1 -github.com/davesavic/clink/client.go:133.95,134.25 1 1 -github.com/davesavic/clink/client.go:134.25,137.3 2 1 -github.com/davesavic/clink/client.go:140.70,141.21 1 1 -github.com/davesavic/clink/client.go:141.21,143.3 1 1 -github.com/davesavic/clink/client.go:145.2,145.26 1 1 -github.com/davesavic/clink/client.go:145.26,147.3 1 1 -github.com/davesavic/clink/client.go:149.2,149.33 1 1 -github.com/davesavic/clink/client.go:149.33,151.3 1 1 -github.com/davesavic/clink/client.go:153.2,153.70 1 1 -github.com/davesavic/clink/client.go:153.70,155.3 1 1 -github.com/davesavic/clink/client.go:157.2,157.12 1 1 +github.com/davesavic/clink/client.go:52.55,55.69 2 1 +github.com/davesavic/clink/client.go:55.69,56.9 1 1 +github.com/davesavic/clink/client.go:59.3,59.29 1 1 +github.com/davesavic/clink/client.go:59.29,62.4 1 1 +github.com/davesavic/clink/client.go:65.2,65.16 1 1 +github.com/davesavic/clink/client.go:65.16,67.3 1 0 +github.com/davesavic/clink/client.go:69.2,69.18 1 1 +github.com/davesavic/clink/client.go:75.45,76.25 1 1 +github.com/davesavic/clink/client.go:76.25,78.3 1 1 +github.com/davesavic/clink/client.go:82.43,83.25 1 1 +github.com/davesavic/clink/client.go:83.25,85.3 1 1 +github.com/davesavic/clink/client.go:89.52,90.25 1 1 +github.com/davesavic/clink/client.go:90.25,91.35 1 1 +github.com/davesavic/clink/client.go:91.35,93.4 1 1 +github.com/davesavic/clink/client.go:98.36,99.25 1 1 +github.com/davesavic/clink/client.go:99.25,102.3 2 1 +github.com/davesavic/clink/client.go:106.54,107.25 1 1 +github.com/davesavic/clink/client.go:107.25,111.3 3 1 +github.com/davesavic/clink/client.go:115.42,116.25 1 1 +github.com/davesavic/clink/client.go:116.25,118.3 1 1 +github.com/davesavic/clink/client.go:122.38,123.25 1 1 +github.com/davesavic/clink/client.go:123.25,125.3 1 1 +github.com/davesavic/clink/client.go:129.95,130.25 1 1 +github.com/davesavic/clink/client.go:130.25,133.3 2 1 +github.com/davesavic/clink/client.go:137.70,138.21 1 1 +github.com/davesavic/clink/client.go:138.21,140.3 1 1 +github.com/davesavic/clink/client.go:142.2,142.26 1 1 +github.com/davesavic/clink/client.go:142.26,144.3 1 1 +github.com/davesavic/clink/client.go:146.2,146.33 1 1 +github.com/davesavic/clink/client.go:146.33,148.3 1 1 +github.com/davesavic/clink/client.go:150.2,150.70 1 1 +github.com/davesavic/clink/client.go:150.70,152.3 1 1 +github.com/davesavic/clink/client.go:154.2,154.12 1 1 diff --git a/examples/default-client/main.go b/examples/default-client/main.go new file mode 100644 index 0000000..798e464 --- /dev/null +++ b/examples/default-client/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "github.com/davesavic/clink" + "net/http" +) + +func main() { + // Create a new client with default options. + client := clink.NewClient() + + // Create a new request with default options. + req, err := http.NewRequest(http.MethodGet, "https://httpbin.org/anything", nil) + + // Send the request and get the response. + resp, err := client.Do(req) + if err != nil { + panic(err) + } + + // Hydrate the response body into a map. + var target map[string]any + err = clink.ResponseToJson(resp, &target) + + // Print the target map. + fmt.Println(target) +} diff --git a/examples/rate-limited-client/main.go b/examples/rate-limited-client/main.go new file mode 100644 index 0000000..17d4471 --- /dev/null +++ b/examples/rate-limited-client/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "fmt" + "github.com/davesavic/clink" + "net/http" +) + +func main() { + // Create a new client with a limit of 60 requests per minute (1 per second). + client := clink.NewClient( + clink.WithRateLimit(60), + ) + + // Create a new request with default options. + req, _ := http.NewRequest(http.MethodGet, "https://httpbin.org/anything", nil) + + reqCount := 0 + for i := 0; i < 100; i++ { + fmt.Println("Request no.", i) + reqCount++ + + // Send the rate limited request and get the response. + // The client will wait for the rate limiter to allow the request. + _, err := client.Do(req) + if err != nil { + panic(err) + } + } + + fmt.Println(reqCount) +} diff --git a/examples/retry-client/main.go b/examples/retry-client/main.go new file mode 100644 index 0000000..4fa50cb --- /dev/null +++ b/examples/retry-client/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "fmt" + "github.com/davesavic/clink" + "net/http" +) + +func main() { + // Create a new client with retries enabled. + client := clink.NewClient( + // Retry the request if the status code is 429 (Too Many Requests). + clink.WithRetries(3, func(req *http.Request, resp *http.Response, err error) bool { + fmt.Println("Retrying request") + + return resp.StatusCode == http.StatusTooManyRequests + }), + ) + + // Make a request (randomly selects between status codes 200 and 429). + for i := 0; i < 10; i++ { + fmt.Println("Request no.", i) + req, err := http.NewRequest(http.MethodGet, "https://httpbin.org/status/200%2C429", nil) + + _, err = client.Do(req) + if err != nil { + panic(err) + } + } +}