From 35371eb55e3e60ecd08a072e4fed0989c079cff2 Mon Sep 17 00:00:00 2001 From: davesavic Date: Fri, 5 Jan 2024 09:04:55 +1000 Subject: [PATCH] Initial commit --- .idea/.gitignore | 8 ++ .idea/clink.iml | 9 ++ .idea/modules.xml | 8 ++ .idea/vcs.xml | 6 + LICENCE | 1 + Makefile | 7 ++ README.md | 27 +++++ client.go | 158 ++++++++++++++++++++++++++ client_test.go | 281 ++++++++++++++++++++++++++++++++++++++++++++++ coverage.out | 46 ++++++++ go.mod | 5 + go.sum | 2 + 12 files changed, 558 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/clink.iml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 LICENCE create mode 100644 Makefile create mode 100644 README.md create mode 100644 client.go create mode 100644 client_test.go create mode 100644 coverage.out create mode 100644 go.mod create mode 100644 go.sum diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/clink.iml b/.idea/clink.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/clink.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..6f31ff9 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..8ab70c0 --- /dev/null +++ b/LICENCE @@ -0,0 +1 @@ +MIT \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7e4c539 --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +#Makefile + +# Test +test: + go test -v -cover -coverprofile=coverage.out ./... + +.phony: test \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5cb79ed --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +## Clink: A Configurable HTTP Client for Go + +Clink is a highly configurable HTTP client for Go, designed for ease of use, extendability, and robustness. It supports various features like automatic retries, request rate limiting, and customisable middlewares, making it ideal for both simple and advanced HTTP requests. + +### 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`: + +```bash +go get -u github.com/davesavic/clink +``` + +### Usage +Here is a basic example of how to use Clink: + +```go +TODO +``` + +### 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 new file mode 100644 index 0000000..c5d8198 --- /dev/null +++ b/client.go @@ -0,0 +1,158 @@ +package clink + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "golang.org/x/time/rate" + "io" + "net/http" + "time" +) + +type Client struct { + HttpClient *http.Client + Headers map[string]string + RateLimiter *rate.Limiter + MaxRetries int + ShouldRetryFunc func(*http.Request, *http.Response, error) bool +} + +func NewClient(opts ...Option) *Client { + c := defaultClient() + + for _, opt := range opts { + opt(c) + } + + return c +} + +func defaultClient() *Client { + return &Client{ + HttpClient: http.DefaultClient, + Headers: make(map[string]string), + } +} + +func (c *Client) Do(req *http.Request) (*http.Response, error) { + for key, value := range c.Headers { + req.Header.Set(key, value) + } + + if c.RateLimiter != nil { + if err := c.RateLimiter.Wait(req.Context()); err != nil { + return nil, fmt.Errorf("failed to wait for rate limiter: %w", err) + } + } + + var resp *http.Response + var err 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 + } + + if attempt < c.MaxRetries { + // Exponential backoff only if we're going to retry. + time.Sleep(time.Duration(attempt) * time.Second) + } + } + + if err != nil { + return nil, fmt.Errorf("failed to do request: %w", err) + } + + return resp, nil +} + +type Option func(*Client) + +// WithClient sets the http client for the client. +func WithClient(client *http.Client) Option { + return func(c *Client) { + c.HttpClient = client + } +} + +// WithHeader sets a header for the client. +func WithHeader(key, value string) Option { + return func(c *Client) { + c.Headers[key] = value + } +} + +// WithHeaders sets the headers for the client. +func WithHeaders(headers map[string]string) Option { + return func(c *Client) { + c.Headers = headers + } +} + +// WithRateLimit sets the rate limit for the client in requests per minute. +func WithRateLimit(rpm int) Option { + return func(c *Client) { + interval := time.Minute / time.Duration(rpm) + c.RateLimiter = rate.NewLimiter(rate.Every(interval), 1) + } +} + +// 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) + } +} + +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) { + c.Headers["Authorization"] = "Bearer " + token + } +} + +// WithUserAgent sets the user agent header for the client. +func WithUserAgent(ua string) Option { + return func(c *Client) { + c.Headers["User-Agent"] = ua + } +} + +// WithRetries sets the retry count and retry function for the client. +func WithRetries(count int, retryFunc func(*http.Request, *http.Response, error) bool) Option { + return func(c *Client) { + c.MaxRetries = count + c.ShouldRetryFunc = retryFunc + } +} + +func ResponseToJson[T any](response *http.Response, target *T) error { + if response == nil { + return fmt.Errorf("response is nil") + } + + if response.Body == nil { + return fmt.Errorf("response body is nil") + } + + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(response.Body) + + if err := json.NewDecoder(response.Body).Decode(target); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + + return nil +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..bbf3ef6 --- /dev/null +++ b/client_test.go @@ -0,0 +1,281 @@ +package clink_test + +import ( + "encoding/base64" + "encoding/json" + "github.com/davesavic/clink" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestNewClient(t *testing.T) { + testCases := []struct { + name string + opts []clink.Option + result func(*clink.Client) bool + }{ + { + name: "default client with no options", + opts: []clink.Option{}, + result: func(client *clink.Client) bool { + return client.HttpClient != nil && client.Headers != nil && len(client.Headers) == 0 + }, + }, + { + name: "client with custom http client", + opts: []clink.Option{ + clink.WithClient(nil), + }, + result: func(client *clink.Client) bool { + return client.HttpClient == nil + }, + }, + { + name: "client with custom headers", + opts: []clink.Option{ + clink.WithHeaders(map[string]string{"key": "value"}), + }, + result: func(client *clink.Client) bool { + return client.Headers != nil && len(client.Headers) == 1 + }, + }, + { + name: "client with custom header", + opts: []clink.Option{ + clink.WithHeader("key", "value"), + }, + result: func(client *clink.Client) bool { + return client.Headers != nil && len(client.Headers) == 1 + }, + }, + { + name: "client with custom rate limit", + opts: []clink.Option{ + clink.WithRateLimit(60), + }, + result: func(client *clink.Client) bool { + return client.RateLimiter != nil && client.RateLimiter.Limit() == 1 + }, + }, + { + name: "client with basic auth", + opts: []clink.Option{ + clink.WithBasicAuth("username", "password"), + }, + result: func(client *clink.Client) bool { + b64, err := base64.StdEncoding.DecodeString( + strings.Replace(client.Headers["Authorization"], "Basic ", "", 1), + ) + if err != nil { + return false + } + + return string(b64) == "username:password" + }, + }, + { + name: "client with bearer token", + opts: []clink.Option{ + clink.WithBearerAuth("token"), + }, + result: func(client *clink.Client) bool { + return client.Headers["Authorization"] == "Bearer token" + }, + }, + { + name: "client with user agent", + opts: []clink.Option{ + clink.WithUserAgent("user-agent"), + }, + result: func(client *clink.Client) bool { + return client.Headers["User-Agent"] == "user-agent" + }, + }, + { + name: "client with retries", + opts: []clink.Option{ + clink.WithRetries(3, func(request *http.Request, response *http.Response, err error) bool { + return true + }), + }, + result: func(client *clink.Client) bool { + return client.MaxRetries == 3 && client.ShouldRetryFunc != nil + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + c := clink.NewClient(tc.opts...) + + if c == nil { + t.Error("expected client to be created") + } + + if !tc.result(c) { + t.Errorf("expected client to be created with options: %+v", tc.opts) + } + }) + } +} + +func TestClient_Do(t *testing.T) { + testCases := []struct { + name string + opts []clink.Option + setupServer func() *httptest.Server + resultFunc func(*http.Response, error) bool + }{ + { + name: "successful response no body", + opts: []clink.Option{}, + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + }, + resultFunc: func(response *http.Response, err error) bool { + return response != nil && err == nil && response.StatusCode == http.StatusOK + }, + }, + { + name: "successful response with text body", + opts: []clink.Option{}, + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("response")) + })) + }, + resultFunc: func(response *http.Response, err error) bool { + bodyContents, err := io.ReadAll(response.Body) + if err != nil { + return false + } + + return string(bodyContents) == "response" + }, + }, + { + name: "successful response with json body", + opts: []clink.Option{}, + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = 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" + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + server := tc.setupServer() + defer server.Close() + + opts := append(tc.opts, clink.WithClient(server.Client())) + c := clink.NewClient(opts...) + + if c == nil { + t.Error("expected client to be created") + } + + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + if err != nil { + t.Errorf("failed to create request: %v", err) + } + + resp, err := c.Do(req) + if !tc.resultFunc(resp, err) { + t.Errorf("expected result to be successful") + } + }) + } +} + +func TestClient_ResponseToJson(t *testing.T) { + testCases := []struct { + name string + response *http.Response + target any + resultFunc func(*http.Response, any) bool + }{ + { + name: "successful response with json body", + response: &http.Response{ + Body: io.NopCloser(strings.NewReader(`{"key": "value"}`)), + }, + resultFunc: func(response *http.Response, target any) bool { + var t map[string]string + er := clink.ResponseToJson(response, &t) + if er != nil { + return false + } + + return t["key"] == "value" + }, + }, + { + name: "response is nil", + response: nil, + resultFunc: func(response *http.Response, target any) bool { + var t map[string]string + er := clink.ResponseToJson(response, &t) + if er == nil { + return false + } + + return er.Error() == "response is nil" + }, + }, + { + name: "response body is nil", + response: &http.Response{ + Body: nil, + }, + resultFunc: func(response *http.Response, target any) bool { + var t map[string]string + er := clink.ResponseToJson(response, &t) + if er == nil { + return false + } + + return er.Error() == "response body is nil" + }, + }, + { + name: "json decode error", + response: &http.Response{ + Body: io.NopCloser(strings.NewReader(`{"key": "value`)), + }, + target: nil, + resultFunc: func(response *http.Response, target any) bool { + var t map[string]string + er := clink.ResponseToJson(response, &t) + if er == nil { + return false + } + + return strings.Contains(er.Error(), "failed to decode response") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if !tc.resultFunc(tc.response, tc.target) { + t.Errorf("expected result to be successful") + } + }) + } +} diff --git a/coverage.out b/coverage.out new file mode 100644 index 0000000..d467256 --- /dev/null +++ b/coverage.out @@ -0,0 +1,46 @@ +mode: set +github.com/davesavic/clink/client.go:21.40,24.27 2 1 +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:43.2,43.26 1 1 +github.com/davesavic/clink/client.go:43.26,44.59 1 0 +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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..11cd031 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/davesavic/clink + +go 1.21.4 + +require golang.org/x/time v0.5.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a2652c5 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=