├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENCE ├── Makefile ├── README.md ├── client.go ├── client_test.go ├── coverage.out ├── examples ├── default-client │ └── main.go ├── rate-limited-client │ └── main.go └── retry-client │ └── main.go ├── go.mod └── go.sum /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | strategy: 8 | matrix: 9 | go-version: ['>=1.18.0'] 10 | os: [ubuntu-latest,] 11 | runs-on: ${{ matrix.os }} 12 | steps: 13 | - name: Install Go 14 | uses: actions/setup-go@v2 15 | with: 16 | go-version: ${{ matrix.go-version }} 17 | - name: Checkout code 18 | uses: actions/checkout@v2 19 | - name: Unshallow git checkout 20 | run: git fetch --prune --unshallow 21 | - name: Run tests 22 | run: go test -v -covermode=count -coverprofile=coverage.out ./... 23 | - name: Convert coverage to lcov 24 | uses: jandelgado/gcov2lcov-action@v1.0.5 25 | - name: Report coverage 26 | uses: coverallsapp/github-action@master 27 | with: 28 | github-token: ${{ secrets.GITHUB_TOKEN }} 29 | path-to-lcov: coverage.lcov 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Get the latest git tag 2 | CURRENT_VERSION=$(shell git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") 3 | 4 | # Extract major, minor, and patch numbers 5 | MAJOR=$(shell echo $(CURRENT_VERSION) | cut -d. -f1 | tr -d v) 6 | MINOR=$(shell echo $(CURRENT_VERSION) | cut -d. -f2) 7 | PATCH=$(shell echo $(CURRENT_VERSION) | cut -d. -f3) 8 | 9 | # Test 10 | test: 11 | go test -v -failfast -count=1 -cover -covermode=count -coverprofile=coverage.out ./... 12 | go tool cover -func coverage.out 13 | 14 | .phony: test 15 | 16 | current-version: 17 | @echo $(CURRENT_VERSION) 18 | 19 | .PHONY: current-version 20 | 21 | release: 22 | @if [ "$(type)" = "major" ]; then \ 23 | NEW_MAJOR=$$(( $(MAJOR) + 1 )); \ 24 | NEW_VERSION="v$$NEW_MAJOR.0.0"; \ 25 | elif [ "$(type)" = "minor" ]; then \ 26 | NEW_MINOR=$$(( $(MINOR) + 1 )); \ 27 | NEW_VERSION="v$(MAJOR).$$NEW_MINOR.0"; \ 28 | elif [ "$(type)" = "patch" ]; then \ 29 | NEW_PATCH=$$(( $(PATCH) + 1 )); \ 30 | NEW_VERSION="v$(MAJOR).$(MINOR).$$NEW_PATCH"; \ 31 | else \ 32 | echo "Invalid release type. Use major, minor, or patch."; \ 33 | exit 1; \ 34 | fi; \ 35 | trap 'echo "Error encountered, cleaning up tags..."; git tag -d $$NEW_VERSION; git push origin :refs/tags/$$NEW_VERSION;' ERR; \ 36 | git tag -a $$NEW_VERSION -m "$(message)"; \ 37 | git push origin $$NEW_VERSION; \ 38 | trap - ERR; 39 | 40 | .PHONY: release 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![test](https://github.com/davesavic/clink/workflows/test/badge.svg)](https://github.com/davesavic/clink/actions?query=workflow%3Atest) 2 | [![coverage](https://coveralls.io/repos/github/davesavic/clink/badge.svg?branch=master)](https://coveralls.io/github/davesavic/clink?branch=master) 3 | [![goreportcard](https://goreportcard.com/badge/github.com/davesavic/clink)](https://goreportcard.com/report/github.com/davesavic/clink) 4 | [![gopkg](https://pkg.go.dev/badge/github.com/davesavic/clink.svg)](https://pkg.go.dev/github.com/davesavic/clink) 5 | [![license](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/davesavic/clink/blob/master/LICENSE) 6 | 7 | 8 | 9 | ## Clink: A Configurable HTTP Client for Go 10 | 11 | Clink is a highly configurable HTTP client for Go, designed for ease of use, extendability, and robustness. It supports various features like automatic retries and request rate limiting, making it ideal for both simple and advanced HTTP requests. 12 | 13 | ### Features 14 | - **Flexible Request Options**: Easily configure headers, URLs, and authentication. 15 | - **Retry Mechanism**: Automatic retries with configurable policies. 16 | - **Rate Limiting**: Client-side rate limiting to avoid server-side limits. 17 | 18 | ### Installation 19 | To use Clink in your Go project, install it using `go get`: 20 | 21 | ```bash 22 | go get -u github.com/davesavic/clink 23 | ``` 24 | 25 | ### Usage 26 | Here is a basic example of how to use Clink: 27 | 28 | ```go 29 | package main 30 | 31 | import ( 32 | "fmt" 33 | "github.com/davesavic/clink" 34 | "net/http" 35 | ) 36 | 37 | func main() { 38 | // Create a new client with default options. 39 | client := clink.NewClient() 40 | 41 | // Create a new request with default options. 42 | req, err := http.NewRequest(http.MethodGet, "https://httpbin.org/anything", nil) 43 | 44 | // Send the request and get the response. 45 | resp, err := client.Do(req) 46 | if err != nil { 47 | panic(err) 48 | } 49 | 50 | // Hydrate the response body into a map. 51 | var target map[string]any 52 | err = clink.ResponseToJson(resp, &target) 53 | 54 | // Print the target map. 55 | fmt.Println(target) 56 | } 57 | ``` 58 | 59 | *HTTP Methods (HEAD, OPTIONS, GET, HEAD, POST, PATCH, DELETE)* are also supported 60 | ```go 61 | package main 62 | 63 | import ( 64 | "github.com/davesavic/clink" 65 | "encoding/json" 66 | ) 67 | 68 | func main() { 69 | client := clink.NewClient() 70 | resp, err := client.Get("https://httpbin.org/get") 71 | // .... 72 | payload, err := json.Marshal(map[string]string{"username": "yumi"}) 73 | resp, err := client.Post("https://httpbin.org/post", payload) 74 | } 75 | ``` 76 | 77 | ### Examples 78 | For more examples, see the [examples](https://github.com/davesavic/clink/tree/master/examples) directory. 79 | 80 | ### Contributing 81 | 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. 82 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package clink 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "time" 11 | 12 | "golang.org/x/time/rate" 13 | ) 14 | 15 | // Client is a wrapper around http.Client with additional functionality. 16 | type Client struct { 17 | HttpClient *http.Client 18 | Headers map[string]string 19 | RateLimiter *rate.Limiter 20 | MaxRetries int 21 | ShouldRetryFunc func(*http.Request, *http.Response, error) bool 22 | } 23 | 24 | // NewClient creates a new client with the given options. 25 | func NewClient(opts ...Option) *Client { 26 | c := defaultClient() 27 | 28 | for _, opt := range opts { 29 | opt(c) 30 | } 31 | 32 | return c 33 | } 34 | 35 | func defaultClient() *Client { 36 | return &Client{ 37 | HttpClient: http.DefaultClient, 38 | Headers: make(map[string]string), 39 | } 40 | } 41 | 42 | // Do sends the given request and returns the response. 43 | // If the request is rate limited, the client will wait for the rate limiter to allow the request. 44 | // If the request fails, the client will retry the request the number of times specified by MaxRetries. 45 | func (c *Client) Do(req *http.Request) (*http.Response, error) { 46 | for key, value := range c.Headers { 47 | req.Header.Set(key, value) 48 | } 49 | 50 | if c.RateLimiter != nil { 51 | if err := c.RateLimiter.Wait(req.Context()); err != nil { 52 | return nil, fmt.Errorf("failed to wait for rate limiter: %w", err) 53 | } 54 | } 55 | 56 | var resp *http.Response 57 | var body []byte 58 | var err error 59 | 60 | if req.Body != nil && req.Body != http.NoBody { 61 | body, err = io.ReadAll(req.Body) 62 | if err != nil { 63 | return nil, fmt.Errorf("failed to read request body: %w", err) 64 | } 65 | 66 | err = req.Body.Close() 67 | if err != nil { 68 | return nil, fmt.Errorf("failed to close request body: %w", err) 69 | } 70 | } 71 | 72 | for attempt := 0; attempt <= c.MaxRetries; attempt++ { 73 | if len(body) > 0 { 74 | req.Body = io.NopCloser(bytes.NewReader(body)) 75 | } 76 | 77 | resp, err = c.HttpClient.Do(req) 78 | 79 | if req.Context().Err() != nil { 80 | return nil, fmt.Errorf("request context error: %w", req.Context().Err()) 81 | } 82 | 83 | if c.ShouldRetryFunc != nil && !c.ShouldRetryFunc(req, resp, err) { 84 | break 85 | } 86 | 87 | if attempt < c.MaxRetries { 88 | select { 89 | case <-time.After(time.Duration(attempt) * time.Second): 90 | case <-req.Context().Done(): 91 | return nil, req.Context().Err() 92 | } 93 | } 94 | } 95 | 96 | if err != nil { 97 | return nil, fmt.Errorf("failed to do request: %w", err) 98 | } 99 | 100 | return resp, nil 101 | } 102 | 103 | // Head sends a HEAD request to the given URL. 104 | func (c *Client) Head(url string) (*http.Response, error) { 105 | req, err := http.NewRequest(http.MethodHead, url, nil) 106 | if err != nil { 107 | return nil, err 108 | } 109 | return c.Do(req) 110 | } 111 | 112 | // Get sends a GET request to the given URL. 113 | func (c *Client) Options(url string) (*http.Response, error) { 114 | req, err := http.NewRequest(http.MethodOptions, url, nil) 115 | if err != nil { 116 | return nil, err 117 | } 118 | return c.Do(req) 119 | } 120 | 121 | // Get sends a GET request to the given URL. 122 | func (c *Client) Get(url string) (*http.Response, error) { 123 | req, err := http.NewRequest(http.MethodGet, url, nil) 124 | if err != nil { 125 | return nil, err 126 | } 127 | return c.Do(req) 128 | } 129 | 130 | // Post sends a POST request to the given URL with the given body. 131 | func (c *Client) Post(url string, body io.Reader) (*http.Response, error) { 132 | req, err := http.NewRequest(http.MethodPost, url, body) 133 | if err != nil { 134 | return nil, err 135 | } 136 | return c.Do(req) 137 | } 138 | 139 | // Put sends a PUT request to the given URL. 140 | func (c *Client) Put(url string, body io.Reader) (*http.Response, error) { 141 | req, err := http.NewRequest(http.MethodPut, url, body) 142 | if err != nil { 143 | return nil, err 144 | } 145 | return c.Do(req) 146 | } 147 | 148 | // Patch sends a PATCH request to the given URL. 149 | func (c *Client) Patch(url string, body io.Reader) (*http.Response, error) { 150 | req, err := http.NewRequest(http.MethodPatch, url, body) 151 | if err != nil { 152 | return nil, err 153 | } 154 | return c.Do(req) 155 | } 156 | 157 | // Delete sends a DELETE request to the given URL. 158 | func (c *Client) Delete(url string) (*http.Response, error) { 159 | req, err := http.NewRequest(http.MethodDelete, url, nil) 160 | if err != nil { 161 | return nil, err 162 | } 163 | return c.Do(req) 164 | } 165 | 166 | type Option func(*Client) 167 | 168 | // WithClient sets the http client for the client. 169 | func WithClient(client *http.Client) Option { 170 | return func(c *Client) { 171 | c.HttpClient = client 172 | } 173 | } 174 | 175 | // WithHeader sets a header for the client. 176 | func WithHeader(key, value string) Option { 177 | return func(c *Client) { 178 | c.Headers[key] = value 179 | } 180 | } 181 | 182 | // WithHeaders sets the headers for the client. 183 | func WithHeaders(headers map[string]string) Option { 184 | return func(c *Client) { 185 | for key, value := range headers { 186 | c.Headers[key] = value 187 | } 188 | } 189 | } 190 | 191 | // WithRateLimit sets the rate limit for the client in requests per minute. 192 | func WithRateLimit(rpm int) Option { 193 | return func(c *Client) { 194 | interval := time.Minute / time.Duration(rpm) 195 | c.RateLimiter = rate.NewLimiter(rate.Every(interval), 1) 196 | } 197 | } 198 | 199 | // WithBasicAuth sets the basic auth header for the client. 200 | func WithBasicAuth(username, password string) Option { 201 | return func(c *Client) { 202 | auth := username + ":" + password 203 | encodedAuth := base64.StdEncoding.EncodeToString([]byte(auth)) 204 | c.Headers["Authorization"] = "Basic " + encodedAuth 205 | } 206 | } 207 | 208 | // WithBearerAuth sets the bearer auth header for the client. 209 | func WithBearerAuth(token string) Option { 210 | return func(c *Client) { 211 | c.Headers["Authorization"] = "Bearer " + token 212 | } 213 | } 214 | 215 | // WithUserAgent sets the user agent header for the client. 216 | func WithUserAgent(ua string) Option { 217 | return func(c *Client) { 218 | c.Headers["User-Agent"] = ua 219 | } 220 | } 221 | 222 | // WithRetries sets the retry count and retry function for the client. 223 | func WithRetries(count int, retryFunc func(*http.Request, *http.Response, error) bool) Option { 224 | return func(c *Client) { 225 | c.MaxRetries = count 226 | c.ShouldRetryFunc = retryFunc 227 | } 228 | } 229 | 230 | // ResponseToJson decodes the response body into the target. 231 | func ResponseToJson[T any](response *http.Response, target *T) error { 232 | if response == nil { 233 | return fmt.Errorf("response is nil") 234 | } 235 | 236 | if response.Body == nil { 237 | return fmt.Errorf("response body is nil") 238 | } 239 | 240 | defer func(Body io.ReadCloser) { 241 | _ = Body.Close() 242 | }(response.Body) 243 | 244 | if err := json.NewDecoder(response.Body).Decode(target); err != nil { 245 | return fmt.Errorf("failed to decode response: %w", err) 246 | } 247 | 248 | return nil 249 | } 250 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package clink_test 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "net/http/httptest" 12 | "strings" 13 | "testing" 14 | "time" 15 | 16 | "github.com/davesavic/clink" 17 | ) 18 | 19 | func TestNewClient(t *testing.T) { 20 | testCases := []struct { 21 | name string 22 | opts []clink.Option 23 | result func(*clink.Client) bool 24 | }{ 25 | { 26 | name: "default client with no options", 27 | opts: []clink.Option{}, 28 | result: func(client *clink.Client) bool { 29 | return client.HttpClient != nil && client.Headers != nil && len(client.Headers) == 0 30 | }, 31 | }, 32 | { 33 | name: "client with custom http client", 34 | opts: []clink.Option{ 35 | clink.WithClient(nil), 36 | }, 37 | result: func(client *clink.Client) bool { 38 | return client.HttpClient == nil 39 | }, 40 | }, 41 | { 42 | name: "client with custom headers", 43 | opts: []clink.Option{ 44 | clink.WithHeaders(map[string]string{"key": "value"}), 45 | }, 46 | result: func(client *clink.Client) bool { 47 | return client.Headers != nil && len(client.Headers) == 1 48 | }, 49 | }, 50 | { 51 | name: "client with custom header", 52 | opts: []clink.Option{ 53 | clink.WithHeader("key", "value"), 54 | }, 55 | result: func(client *clink.Client) bool { 56 | return client.Headers != nil && len(client.Headers) == 1 57 | }, 58 | }, 59 | { 60 | name: "client with custom rate limit", 61 | opts: []clink.Option{ 62 | clink.WithRateLimit(60), 63 | }, 64 | result: func(client *clink.Client) bool { 65 | return client.RateLimiter != nil && client.RateLimiter.Limit() == 1 66 | }, 67 | }, 68 | { 69 | name: "client with basic auth", 70 | opts: []clink.Option{ 71 | clink.WithBasicAuth("username", "password"), 72 | }, 73 | result: func(client *clink.Client) bool { 74 | b64, err := base64.StdEncoding.DecodeString( 75 | strings.Replace(client.Headers["Authorization"], "Basic ", "", 1), 76 | ) 77 | if err != nil { 78 | return false 79 | } 80 | 81 | return string(b64) == "username:password" 82 | }, 83 | }, 84 | { 85 | name: "client with bearer token", 86 | opts: []clink.Option{ 87 | clink.WithBearerAuth("token"), 88 | }, 89 | result: func(client *clink.Client) bool { 90 | return client.Headers["Authorization"] == "Bearer token" 91 | }, 92 | }, 93 | { 94 | name: "client with user agent", 95 | opts: []clink.Option{ 96 | clink.WithUserAgent("user-agent"), 97 | }, 98 | result: func(client *clink.Client) bool { 99 | return client.Headers["User-Agent"] == "user-agent" 100 | }, 101 | }, 102 | { 103 | name: "client with retries", 104 | opts: []clink.Option{ 105 | clink.WithRetries(3, func(request *http.Request, response *http.Response, err error) bool { 106 | return true 107 | }), 108 | }, 109 | result: func(client *clink.Client) bool { 110 | return client.MaxRetries == 3 && client.ShouldRetryFunc != nil 111 | }, 112 | }, 113 | } 114 | 115 | for _, tc := range testCases { 116 | t.Run(tc.name, func(t *testing.T) { 117 | c := clink.NewClient(tc.opts...) 118 | 119 | if c == nil { 120 | t.Error("expected client to be created") 121 | } 122 | 123 | if !tc.result(c) { 124 | t.Errorf("expected client to be created with options: %+v", tc.opts) 125 | } 126 | }) 127 | } 128 | } 129 | 130 | func TestClient_Do(t *testing.T) { 131 | testCases := []struct { 132 | name string 133 | opts []clink.Option 134 | setupServer func() *httptest.Server 135 | resultFunc func(*http.Response, error) bool 136 | }{ 137 | { 138 | name: "successful response no body", 139 | opts: []clink.Option{}, 140 | setupServer: func() *httptest.Server { 141 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 142 | w.WriteHeader(http.StatusOK) 143 | })) 144 | }, 145 | resultFunc: func(response *http.Response, err error) bool { 146 | return response != nil && err == nil && response.StatusCode == http.StatusOK 147 | }, 148 | }, 149 | { 150 | name: "successful response with text body", 151 | opts: []clink.Option{}, 152 | setupServer: func() *httptest.Server { 153 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 154 | _, _ = w.Write([]byte("response")) 155 | })) 156 | }, 157 | resultFunc: func(response *http.Response, err error) bool { 158 | bodyContents, err := io.ReadAll(response.Body) 159 | if err != nil { 160 | return false 161 | } 162 | 163 | return string(bodyContents) == "response" 164 | }, 165 | }, 166 | { 167 | name: "successful response with json body", 168 | opts: []clink.Option{}, 169 | setupServer: func() *httptest.Server { 170 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 171 | _ = json.NewEncoder(w).Encode(map[string]string{"key": "value"}) 172 | })) 173 | }, 174 | resultFunc: func(response *http.Response, err error) bool { 175 | var target map[string]string 176 | er := clink.ResponseToJson(response, &target) 177 | if er != nil { 178 | return false 179 | } 180 | 181 | return target["key"] == "value" 182 | }, 183 | }, 184 | { 185 | name: "successful response with json body and custom headers", 186 | opts: []clink.Option{ 187 | clink.WithHeaders(map[string]string{"key": "value"}), 188 | }, 189 | setupServer: func() *httptest.Server { 190 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 191 | if r.Header.Get("key") != "value" { 192 | w.WriteHeader(http.StatusBadRequest) 193 | } 194 | 195 | _ = json.NewEncoder(w).Encode(map[string]string{"key": "value"}) 196 | })) 197 | }, 198 | resultFunc: func(response *http.Response, err error) bool { 199 | var target map[string]string 200 | er := clink.ResponseToJson(response, &target) 201 | if er != nil { 202 | return false 203 | } 204 | 205 | return target["key"] == "value" 206 | }, 207 | }, 208 | { 209 | name: "successful response with json body and custom header", 210 | opts: []clink.Option{ 211 | clink.WithHeader("key", "value"), 212 | }, 213 | setupServer: func() *httptest.Server { 214 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 215 | if r.Header.Get("key") != "value" { 216 | w.WriteHeader(http.StatusBadRequest) 217 | } 218 | 219 | _ = json.NewEncoder(w).Encode(map[string]string{"key": "value"}) 220 | })) 221 | }, 222 | resultFunc: func(response *http.Response, err error) bool { 223 | var target map[string]string 224 | er := clink.ResponseToJson(response, &target) 225 | if er != nil { 226 | return false 227 | } 228 | 229 | return target["key"] == "value" 230 | }, 231 | }, 232 | } 233 | 234 | for _, tc := range testCases { 235 | t.Run(tc.name, func(t *testing.T) { 236 | server := tc.setupServer() 237 | defer server.Close() 238 | 239 | opts := append(tc.opts, clink.WithClient(server.Client())) 240 | c := clink.NewClient(opts...) 241 | 242 | if c == nil { 243 | t.Error("expected client to be created") 244 | } 245 | 246 | req, err := http.NewRequest(http.MethodGet, server.URL, nil) 247 | if err != nil { 248 | t.Errorf("failed to create request: %v", err) 249 | } 250 | 251 | resp, err := c.Do(req) 252 | if !tc.resultFunc(resp, err) { 253 | t.Errorf("expected result to be successful") 254 | } 255 | }) 256 | } 257 | } 258 | 259 | func TestClient_Methods(t *testing.T) { 260 | serverFunc := func() *httptest.Server { 261 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 262 | w.Header().Add("X-Method", r.Method) 263 | })) 264 | } 265 | resultFunc := func(r *http.Response, m string) bool { 266 | return r.Header.Get("X-Method") == m 267 | } 268 | testCases := []struct { 269 | name string 270 | method string 271 | body io.Reader 272 | setupServer func() *httptest.Server 273 | resultFunc func(*http.Response, string) bool 274 | }{ 275 | { 276 | name: "successful head response", 277 | method: http.MethodHead, 278 | setupServer: serverFunc, 279 | resultFunc: resultFunc, 280 | }, 281 | { 282 | name: "successful options response", 283 | method: http.MethodOptions, 284 | setupServer: serverFunc, 285 | resultFunc: resultFunc, 286 | }, 287 | { 288 | name: "successful get response", 289 | method: http.MethodGet, 290 | setupServer: serverFunc, 291 | resultFunc: resultFunc, 292 | }, 293 | { 294 | name: "successful post response", 295 | method: http.MethodPost, 296 | setupServer: serverFunc, 297 | resultFunc: resultFunc, 298 | }, 299 | { 300 | name: "successful put response", 301 | method: http.MethodPut, 302 | setupServer: serverFunc, 303 | resultFunc: resultFunc, 304 | }, 305 | { 306 | name: "successful patch response", 307 | method: http.MethodPatch, 308 | setupServer: serverFunc, 309 | resultFunc: resultFunc, 310 | }, 311 | { 312 | name: "successful delete response", 313 | method: http.MethodDelete, 314 | setupServer: serverFunc, 315 | resultFunc: resultFunc, 316 | }, 317 | } 318 | 319 | call := func(c *clink.Client, method, url string, body io.Reader) (*http.Response, error) { 320 | switch method { 321 | case http.MethodHead: 322 | return c.Head(url) 323 | case http.MethodOptions: 324 | return c.Options(url) 325 | case http.MethodGet: 326 | return c.Get(url) 327 | case http.MethodPost: 328 | return c.Post(url, body) 329 | case http.MethodPut: 330 | return c.Put(url, body) 331 | case http.MethodPatch: 332 | return c.Patch(url, body) 333 | case http.MethodDelete: 334 | return c.Delete(url) 335 | } 336 | return nil, nil 337 | } 338 | 339 | for _, tc := range testCases { 340 | t.Run(tc.name, func(t *testing.T) { 341 | server := tc.setupServer() 342 | defer server.Close() 343 | c := clink.NewClient(clink.WithClient(server.Client())) 344 | if c == nil { 345 | t.Error("expected client to be created") 346 | } 347 | resp, _ := call(c, tc.method, server.URL, tc.body) 348 | if !tc.resultFunc(resp, tc.method) { 349 | t.Errorf("expected result to be successful") 350 | } 351 | }) 352 | } 353 | } 354 | 355 | func TestClient_ResponseToJson(t *testing.T) { 356 | testCases := []struct { 357 | name string 358 | response *http.Response 359 | target any 360 | resultFunc func(*http.Response, any) bool 361 | }{ 362 | { 363 | name: "successful response with json body", 364 | response: &http.Response{ 365 | Body: io.NopCloser(strings.NewReader(`{"key": "value"}`)), 366 | }, 367 | resultFunc: func(response *http.Response, target any) bool { 368 | var t map[string]string 369 | er := clink.ResponseToJson(response, &t) 370 | if er != nil { 371 | return false 372 | } 373 | 374 | return t["key"] == "value" 375 | }, 376 | }, 377 | { 378 | name: "response is nil", 379 | response: nil, 380 | resultFunc: func(response *http.Response, target any) bool { 381 | var t map[string]string 382 | er := clink.ResponseToJson(response, &t) 383 | if er == nil { 384 | return false 385 | } 386 | 387 | return er.Error() == "response is nil" 388 | }, 389 | }, 390 | { 391 | name: "response body is nil", 392 | response: &http.Response{ 393 | Body: nil, 394 | }, 395 | resultFunc: func(response *http.Response, target any) bool { 396 | var t map[string]string 397 | er := clink.ResponseToJson(response, &t) 398 | if er == nil { 399 | return false 400 | } 401 | 402 | return er.Error() == "response body is nil" 403 | }, 404 | }, 405 | { 406 | name: "json decode error", 407 | response: &http.Response{ 408 | Body: io.NopCloser(strings.NewReader(`{"key": "value`)), 409 | }, 410 | target: nil, 411 | resultFunc: func(response *http.Response, target any) bool { 412 | var t map[string]string 413 | er := clink.ResponseToJson(response, &t) 414 | if er == nil { 415 | return false 416 | } 417 | 418 | return strings.Contains(er.Error(), "failed to decode response") 419 | }, 420 | }, 421 | } 422 | 423 | for _, tc := range testCases { 424 | t.Run(tc.name, func(t *testing.T) { 425 | if !tc.resultFunc(tc.response, tc.target) { 426 | t.Errorf("expected result to be successful") 427 | } 428 | }) 429 | } 430 | } 431 | 432 | func TestRateLimiter(t *testing.T) { 433 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 434 | w.WriteHeader(http.StatusOK) 435 | })) 436 | defer server.Close() 437 | 438 | client := clink.NewClient( 439 | clink.WithRateLimit(60), 440 | clink.WithClient(server.Client()), 441 | ) 442 | 443 | startTime := time.Now() 444 | 445 | for i := 0; i < 2; i++ { 446 | req, err := http.NewRequest(http.MethodGet, server.URL, nil) 447 | if err != nil { 448 | t.Errorf("failed to create request: %v", err) 449 | } 450 | 451 | resp, err := client.Do(req) 452 | if err != nil { 453 | t.Errorf("failed to make request: %v", err) 454 | } 455 | 456 | if resp.StatusCode != http.StatusOK { 457 | t.Errorf("expected status code to be 200") 458 | } 459 | } 460 | 461 | elapsedTime := time.Since(startTime) 462 | if elapsedTime.Seconds() < 0.5 || elapsedTime.Seconds() > 1.5 { 463 | t.Errorf("expected elapsed time to be between 0.5 and 1.5 seconds, got: %f", elapsedTime.Seconds()) 464 | } 465 | } 466 | 467 | func TestSuccessfulRetries(t *testing.T) { 468 | var requestCount int 469 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 470 | requestCount++ // Increment the request count 471 | w.WriteHeader(http.StatusInternalServerError) 472 | })) 473 | defer server.Close() 474 | 475 | retryCount := 3 476 | client := clink.NewClient( 477 | clink.WithRetries(retryCount, func(request *http.Request, response *http.Response, err error) bool { 478 | // Check if the response is a 500 Internal Server Error 479 | return response != nil && response.StatusCode == http.StatusInternalServerError 480 | }), 481 | clink.WithClient(server.Client()), 482 | ) 483 | 484 | req, err := http.NewRequest(http.MethodGet, server.URL, nil) 485 | if err != nil { 486 | t.Fatalf("failed to create request: %v", err) 487 | } 488 | 489 | _, err = client.Do(req) 490 | if err != nil { 491 | t.Fatalf("failed to make request: %v", err) 492 | } 493 | 494 | if requestCount != retryCount+1 { // +1 for the initial request 495 | t.Errorf("expected %d retries (total requests: %d), but got %d", retryCount, retryCount+1, requestCount) 496 | } 497 | } 498 | 499 | func TestUnsuccessfulRetries(t *testing.T) { 500 | var requestCount int 501 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 502 | requestCount++ // Increment the request count 503 | w.WriteHeader(http.StatusInternalServerError) 504 | })) 505 | defer server.Close() 506 | 507 | retryCount := 3 508 | client := clink.NewClient( 509 | clink.WithRetries(retryCount, func(request *http.Request, response *http.Response, err error) bool { 510 | return false 511 | }), 512 | clink.WithClient(server.Client()), 513 | ) 514 | 515 | req, err := http.NewRequest(http.MethodGet, server.URL, nil) 516 | if err != nil { 517 | t.Fatalf("failed to create request: %v", err) 518 | } 519 | 520 | _, err = client.Do(req) 521 | 522 | if requestCount != 1 { // +1 for the initial request 523 | t.Errorf("expected %d retries (total requests: %d), but got %d", retryCount, retryCount+1, requestCount) 524 | } 525 | } 526 | 527 | // TestRequestBodyEmptyOnRetries tests that the request body on a custom io.Reader wrapper is NOT empty on retries. 528 | type oneTimeReaderWrapper struct { 529 | data []byte 530 | consumed bool 531 | } 532 | 533 | func (r *oneTimeReaderWrapper) Read(p []byte) (n int, err error) { 534 | if r.consumed { 535 | return 0, fmt.Errorf("body already read") 536 | } 537 | n = copy(p, r.data) 538 | r.consumed = true 539 | return n, io.EOF 540 | } 541 | 542 | func TestRequestBodyNotEmptyOnRetries(t *testing.T) { 543 | var requestCount int 544 | var lastRequestBody string 545 | 546 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 547 | requestCount++ 548 | 549 | bodyBytes, err := io.ReadAll(r.Body) 550 | if err != nil { 551 | t.Fatalf("failed to read request body: %v", err) 552 | } 553 | lastRequestBody = string(bodyBytes) 554 | 555 | w.WriteHeader(http.StatusInternalServerError) 556 | })) 557 | defer server.Close() 558 | 559 | client := clink.NewClient( 560 | clink.WithRetries(1, func(request *http.Request, response *http.Response, err error) bool { 561 | return true 562 | }), 563 | clink.WithClient(server.Client()), 564 | ) 565 | 566 | requestBody := []byte("test body") 567 | req, err := http.NewRequest(http.MethodPost, server.URL, &oneTimeReaderWrapper{data: requestBody}) 568 | if err != nil { 569 | t.Fatalf("failed to create request: %v", err) 570 | } 571 | 572 | _, err = client.Do(req) 573 | if err != nil { 574 | t.Fatalf("failed to make request: %v", err) 575 | } 576 | 577 | if requestCount != 2 { 578 | t.Fatalf("expected 2 requests due to retry, but got %d", requestCount) 579 | } 580 | 581 | expectedBody := string(requestBody) 582 | if lastRequestBody != expectedBody { 583 | t.Errorf("expected request body to be '%s' on retry, got '%s'", expectedBody, lastRequestBody) 584 | } 585 | } 586 | 587 | func TestContextCancellationDuringRetries(t *testing.T) { 588 | var requestCount int 589 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 590 | requestCount++ 591 | w.WriteHeader(http.StatusInternalServerError) // Always return an error to trigger retries 592 | })) 593 | defer server.Close() 594 | 595 | client := clink.NewClient( 596 | clink.WithRetries(3, func(request *http.Request, response *http.Response, err error) bool { 597 | // Always return true to retry 598 | return true 599 | }), 600 | clink.WithClient(server.Client()), 601 | ) 602 | 603 | ctx, cancel := context.WithCancel(context.Background()) 604 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil) 605 | if err != nil { 606 | t.Fatalf("failed to create request: %v", err) 607 | } 608 | 609 | go func() { 610 | time.Sleep(100 * time.Millisecond) 611 | cancel() 612 | }() 613 | 614 | _, err = client.Do(req) 615 | 616 | if requestCount > 2 { 617 | t.Errorf("expected at most 2 requests due to context cancellation, but got %d", requestCount) 618 | } 619 | 620 | if err == nil || !errors.Is(err, context.Canceled) { 621 | t.Errorf("expected context cancellation error, but got: %v", err) 622 | } 623 | } 624 | 625 | func TestRequestWithCanceledContext(t *testing.T) { 626 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 627 | time.Sleep(2 * time.Second) // Simulate a delay in the response 628 | w.WriteHeader(http.StatusOK) 629 | })) 630 | defer server.Close() 631 | 632 | client := clink.NewClient(clink.WithClient(server.Client())) 633 | 634 | ctx, cancel := context.WithCancel(context.Background()) 635 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil) 636 | if err != nil { 637 | t.Fatalf("failed to create request: %v", err) 638 | } 639 | cancel() // Cancel the context immediately 640 | 641 | _, err = client.Do(req) 642 | 643 | if err == nil || !errors.Is(err, context.Canceled) { 644 | t.Errorf("expected context cancellation error, but got: %v", err) 645 | } 646 | } 647 | -------------------------------------------------------------------------------- /coverage.out: -------------------------------------------------------------------------------- 1 | mode: count 2 | github.com/davesavic/clink/client.go:25.40,28.27 2 27 3 | github.com/davesavic/clink/client.go:28.27,30.3 1 33 4 | github.com/davesavic/clink/client.go:32.2,32.10 1 27 5 | github.com/davesavic/clink/client.go:35.30,40.2 1 27 6 | github.com/davesavic/clink/client.go:45.64,46.36 1 19 7 | github.com/davesavic/clink/client.go:46.36,48.3 1 2 8 | github.com/davesavic/clink/client.go:50.2,50.26 1 19 9 | github.com/davesavic/clink/client.go:50.26,51.59 1 2 10 | github.com/davesavic/clink/client.go:51.59,53.4 1 0 11 | github.com/davesavic/clink/client.go:56.2,60.48 4 19 12 | github.com/davesavic/clink/client.go:60.48,62.17 2 1 13 | github.com/davesavic/clink/client.go:62.17,64.4 1 0 14 | github.com/davesavic/clink/client.go:66.3,67.17 2 1 15 | github.com/davesavic/clink/client.go:67.17,69.4 1 0 16 | github.com/davesavic/clink/client.go:72.2,72.55 1 19 17 | github.com/davesavic/clink/client.go:72.55,73.20 1 24 18 | github.com/davesavic/clink/client.go:73.20,75.4 1 2 19 | github.com/davesavic/clink/client.go:77.3,79.33 2 24 20 | github.com/davesavic/clink/client.go:79.33,81.4 1 1 21 | github.com/davesavic/clink/client.go:83.3,83.69 1 23 22 | github.com/davesavic/clink/client.go:83.69,84.9 1 1 23 | github.com/davesavic/clink/client.go:87.3,87.29 1 22 24 | github.com/davesavic/clink/client.go:87.29,88.11 1 6 25 | github.com/davesavic/clink/client.go:89.60,89.60 0 5 26 | github.com/davesavic/clink/client.go:90.32,91.36 1 1 27 | github.com/davesavic/clink/client.go:96.2,96.16 1 17 28 | github.com/davesavic/clink/client.go:96.16,98.3 1 0 29 | github.com/davesavic/clink/client.go:100.2,100.18 1 17 30 | github.com/davesavic/clink/client.go:104.59,106.16 2 1 31 | github.com/davesavic/clink/client.go:106.16,108.3 1 0 32 | github.com/davesavic/clink/client.go:109.2,109.18 1 1 33 | github.com/davesavic/clink/client.go:113.62,115.16 2 1 34 | github.com/davesavic/clink/client.go:115.16,117.3 1 0 35 | github.com/davesavic/clink/client.go:118.2,118.18 1 1 36 | github.com/davesavic/clink/client.go:122.58,124.16 2 1 37 | github.com/davesavic/clink/client.go:124.16,126.3 1 0 38 | github.com/davesavic/clink/client.go:127.2,127.18 1 1 39 | github.com/davesavic/clink/client.go:131.75,133.16 2 1 40 | github.com/davesavic/clink/client.go:133.16,135.3 1 0 41 | github.com/davesavic/clink/client.go:136.2,136.18 1 1 42 | github.com/davesavic/clink/client.go:140.74,142.16 2 1 43 | github.com/davesavic/clink/client.go:142.16,144.3 1 0 44 | github.com/davesavic/clink/client.go:145.2,145.18 1 1 45 | github.com/davesavic/clink/client.go:149.76,151.16 2 1 46 | github.com/davesavic/clink/client.go:151.16,153.3 1 0 47 | github.com/davesavic/clink/client.go:154.2,154.18 1 1 48 | github.com/davesavic/clink/client.go:158.61,160.16 2 1 49 | github.com/davesavic/clink/client.go:160.16,162.3 1 0 50 | github.com/davesavic/clink/client.go:163.2,163.18 1 1 51 | github.com/davesavic/clink/client.go:169.45,170.25 1 19 52 | github.com/davesavic/clink/client.go:170.25,172.3 1 19 53 | github.com/davesavic/clink/client.go:176.43,177.25 1 2 54 | github.com/davesavic/clink/client.go:177.25,179.3 1 2 55 | github.com/davesavic/clink/client.go:183.52,184.25 1 2 56 | github.com/davesavic/clink/client.go:184.25,185.35 1 2 57 | github.com/davesavic/clink/client.go:185.35,187.4 1 2 58 | github.com/davesavic/clink/client.go:192.36,193.25 1 2 59 | github.com/davesavic/clink/client.go:193.25,196.3 2 2 60 | github.com/davesavic/clink/client.go:200.54,201.25 1 1 61 | github.com/davesavic/clink/client.go:201.25,205.3 3 1 62 | github.com/davesavic/clink/client.go:209.42,210.25 1 1 63 | github.com/davesavic/clink/client.go:210.25,212.3 1 1 64 | github.com/davesavic/clink/client.go:216.38,217.25 1 1 65 | github.com/davesavic/clink/client.go:217.25,219.3 1 1 66 | github.com/davesavic/clink/client.go:223.95,224.25 1 5 67 | github.com/davesavic/clink/client.go:224.25,227.3 2 5 68 | github.com/davesavic/clink/client.go:231.70,232.21 1 7 69 | github.com/davesavic/clink/client.go:232.21,234.3 1 1 70 | github.com/davesavic/clink/client.go:236.2,236.26 1 6 71 | github.com/davesavic/clink/client.go:236.26,238.3 1 1 72 | github.com/davesavic/clink/client.go:240.2,240.33 1 5 73 | github.com/davesavic/clink/client.go:240.33,242.3 1 5 74 | github.com/davesavic/clink/client.go:244.2,244.70 1 5 75 | github.com/davesavic/clink/client.go:244.70,246.3 1 1 76 | github.com/davesavic/clink/client.go:248.2,248.12 1 4 77 | -------------------------------------------------------------------------------- /examples/default-client/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/davesavic/clink" 6 | "net/http" 7 | ) 8 | 9 | func main() { 10 | // Create a new client with default options. 11 | client := clink.NewClient() 12 | 13 | // Create a new request with default options. 14 | req, err := http.NewRequest(http.MethodGet, "https://httpbin.org/anything", nil) 15 | 16 | // Send the request and get the response. 17 | resp, err := client.Do(req) 18 | if err != nil { 19 | panic(err) 20 | } 21 | 22 | // Hydrate the response body into a map. 23 | var target map[string]any 24 | err = clink.ResponseToJson(resp, &target) 25 | 26 | // Print the target map. 27 | fmt.Println(target) 28 | } 29 | -------------------------------------------------------------------------------- /examples/rate-limited-client/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/davesavic/clink" 6 | "net/http" 7 | ) 8 | 9 | func main() { 10 | // Create a new client with a limit of 60 requests per minute (1 per second). 11 | client := clink.NewClient( 12 | clink.WithRateLimit(60), 13 | ) 14 | 15 | // Create a new request with default options. 16 | req, _ := http.NewRequest(http.MethodGet, "https://httpbin.org/anything", nil) 17 | 18 | reqCount := 0 19 | for i := 0; i < 100; i++ { 20 | fmt.Println("Request no.", i) 21 | reqCount++ 22 | 23 | // Send the rate limited request and get the response. 24 | // The client will wait for the rate limiter to allow the request. 25 | _, err := client.Do(req) 26 | if err != nil { 27 | panic(err) 28 | } 29 | } 30 | 31 | fmt.Println(reqCount) 32 | } 33 | -------------------------------------------------------------------------------- /examples/retry-client/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/davesavic/clink" 6 | "net/http" 7 | ) 8 | 9 | func main() { 10 | // Create a new client with retries enabled. 11 | client := clink.NewClient( 12 | // Retry the request if the status code is 429 (Too Many Requests). 13 | clink.WithRetries(3, func(req *http.Request, resp *http.Response, err error) bool { 14 | fmt.Println("Retrying request") 15 | 16 | return resp.StatusCode == http.StatusTooManyRequests 17 | }), 18 | ) 19 | 20 | // Make a request (randomly selects between status codes 200 and 429). 21 | for i := 0; i < 10; i++ { 22 | fmt.Println("Request no.", i) 23 | req, err := http.NewRequest(http.MethodGet, "https://httpbin.org/status/200%2C429", nil) 24 | 25 | _, err = client.Do(req) 26 | if err != nil { 27 | panic(err) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/davesavic/clink 2 | 3 | go 1.21.4 4 | 5 | require golang.org/x/time v0.5.0 // indirect 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 2 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 3 | --------------------------------------------------------------------------------