diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index f090de95..96b84164 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -23,6 +23,7 @@ jobs: - "config" - "generate" - "healthcheck" + - "httpclient" - "log" - "trace" diff --git a/.github/workflows/httpclient-ci.yml b/.github/workflows/httpclient-ci.yml new file mode 100644 index 00000000..e4c2681e --- /dev/null +++ b/.github/workflows/httpclient-ci.yml @@ -0,0 +1,31 @@ +name: "httpclient-ci" + +on: + push: + branches: + - "feat**" + - "fix**" + - "hotfix**" + - "chore**" + paths: + - "httpclient/**.go" + - "httpclient/go.mod" + - "httpclient/go.sum" + pull_request: + types: + - opened + - synchronize + - reopened + branches: + - main + paths: + - "httpclient/**.go" + - "httpclient/go.mod" + - "httpclient/go.sum" + +jobs: + ci: + uses: ./.github/workflows/common-ci.yml + secrets: inherit + with: + module: "httpclient" diff --git a/httpclient/.golangci.yml b/httpclient/.golangci.yml new file mode 100644 index 00000000..edf0e9ec --- /dev/null +++ b/httpclient/.golangci.yml @@ -0,0 +1,66 @@ +run: + timeout: 5m + concurrency: 8 + +linters: + enable: + - asasalint + - asciicheck + - bidichk + - bodyclose + - containedctx + - contextcheck + - cyclop + - decorder + - dogsled + - dupl + - durationcheck + - errcheck + - errchkjson + - errname + - errorlint + - exhaustive + - forbidigo + - forcetypeassert + - gocognit + - goconst + - gocritic + - gocyclo + - godot + - godox + - gofmt + - goheader + - gomoddirectives + - gomodguard + - goprintffuncname + - gosec + - gosimple + - govet + - grouper + - importas + - ineffassign + - interfacebloat + - logrlint + - maintidx + - makezero + - misspell + - nestif + - nilerr + - nilnil + - nlreturn + - nolintlint + - nosprintfhostport + - prealloc + - predeclared + - promlinter + - reassign + - staticcheck + - tenv + - thelper + - tparallel + - typecheck + - unconvert + - unparam + - unused + - usestdlibvars + - whitespace diff --git a/httpclient/README.md b/httpclient/README.md new file mode 100644 index 00000000..f87ceec3 --- /dev/null +++ b/httpclient/README.md @@ -0,0 +1,167 @@ +# Http Client Module + +[![ci](https://github.com/ankorstore/yokai/actions/workflows/httpclient-ci.yml/badge.svg)](https://github.com/ankorstore/yokai/actions/workflows/httpclient-ci.yml) +[![go report](https://goreportcard.com/badge/github.com/ankorstore/yokai/httpclient)](https://goreportcard.com/report/github.com/ankorstore/yokai/httpclient) +[![codecov](https://codecov.io/gh/ankorstore/yokai/graph/badge.svg?token=5s0g5WyseS&flag=httpclient)](https://app.codecov.io/gh/ankorstore/yokai/tree/main/httpclient) +[![PkgGoDev](https://pkg.go.dev/badge/github.com/ankorstore/yokai/httpclient)](https://pkg.go.dev/github.com/ankorstore/yokai/httpclient) + +> Http client module based on [net/http](https://pkg.go.dev/net/http). + + + +* [Installation](#installation) +* [Documentation](#documentation) + * [Requests](#requests) + * [Transports](#transports) + * [BaseTransport](#basetransport) + * [LoggerTransport](#loggertransport) + + + +## Installation + +```shell +go get github.com/ankorstore/yokai/httpclient +``` + +## Documentation + +To create a `http.Client`: + +```go +package main + +import ( + "time" + + "github.com/ankorstore/yokai/httpclient" + "github.com/ankorstore/yokai/httpclient/transport" +) + +var client, _ = httpclient.NewDefaultHttpClientFactory().Create() + +// equivalent to: +var client, _ = httpclient.NewDefaultHttpClientFactory().Create( + httpclient.WithTransport(transport.NewBaseTransport()), // base http transport (optimized) + httpclient.WithTimeout(30*time.Second), // 30 seconds timeout + httpclient.WithCheckRedirect(nil), // default redirection checks + httpclient.WithCookieJar(nil), // default cookie jar +) +``` + +### Requests + +This module provide some [request helpers](request.go) to ease client requests headers propagation from an incoming +request: + +- `CopyObservabilityRequestHeaders` to copy `x-request-id` and `traceparent` headers +- `CopyRequestHeaders` to choose a list of headers to copy + +For example: + +```go +package main + +import ( + "net/http" + + "github.com/ankorstore/yokai/httpclient" +) + +func exampleHandler(w http.ResponseWriter, r *http.Request) { + // create http client + client, _ := httpclient.NewDefaultHttpClientFactory().Create() + + // build a request to send with the client + rc, _ := http.NewRequest(http.MethodGet, "https://example.com", nil) + + // propagate observability headers: x-request-id and traceparent + httpclient.CopyObservabilityRequestHeaders(r, rc) + + // client call + resp, _ := client.Do(rc) + + // propagate response code + w.WriteHeader(resp.StatusCode) +} + +func main() { + http.HandleFunc("/", exampleHandler) + http.ListenAndServe(":8080", nil) +} +``` + +### Transports + +#### BaseTransport + +This module provide a [BaseTransport](transport/base.go), optimized regarding max connections handling. + +To use it: + +```go +package main + +import ( + "github.com/ankorstore/yokai/httpclient" + "github.com/ankorstore/yokai/httpclient/transport" +) + +var client, _ = httpclient.NewDefaultHttpClientFactory().Create( + httpclient.WithTransport(transport.NewBaseTransport()), +) + +// equivalent to: +var client, _ = httpclient.NewDefaultHttpClientFactory().Create( + httpclient.WithTransport( + transport.NewBaseTransportWithConfig(&transport.BaseTransportConfig{ + MaxIdleConnections: 100, + MaxConnectionsPerHost: 100, + MaxIdleConnectionsPerHost: 100, + }), + ), +) +``` + +#### LoggerTransport + +This module provide a [LoggerTransport](transport/logger.go), able to decorate any `http.RoundTripper` to add logging: + +- with requests and response details (and optionally body) +- with configurable log level for each + +To use it: + +```go +package main + +import ( + "github.com/ankorstore/yokai/httpclient" + "github.com/ankorstore/yokai/httpclient/transport" + "github.com/rs/zerolog" +) + +var client, _ = httpclient.NewDefaultHttpClientFactory().Create( + httpclient.WithTransport(transport.NewLoggerTransport(nil)), +) + +// equivalent to: +var client, _ = httpclient.NewDefaultHttpClientFactory().Create( + httpclient.WithTransport( + transport.NewLoggerTransportWithConfig( + transport.NewBaseTransport(), + &transport.LoggerTransportConfig{ + LogRequest: false, // to log request details + LogResponse: false, // to log response details + LogRequestBody: false, // to log request body (if request details logging enabled) + LogResponseBody: false, // to log response body (if response details logging enabled) + LogRequestLevel: zerolog.InfoLevel, // log level for request log + LogResponseLevel: zerolog.InfoLevel, // log level for response log + LogResponseLevelFromResponseCode: false, // to use response code for response log level + }, + ), + ), +) +``` + +Note: if no transport is provided for decoration in `transport.NewLoggerTransport(nil)`, the [BaseTransport](transport/base.go) will be used as base transport. diff --git a/httpclient/coverage.txt b/httpclient/coverage.txt new file mode 100644 index 00000000..924e8ee9 --- /dev/null +++ b/httpclient/coverage.txt @@ -0,0 +1,62 @@ +mode: atomic +github.com/ankorstore/yokai/httpclient/factory.go:16.54,18.2 1 2 +github.com/ankorstore/yokai/httpclient/factory.go:32.94,34.35 2 1 +github.com/ankorstore/yokai/httpclient/factory.go:34.35,36.3 1 3 +github.com/ankorstore/yokai/httpclient/factory.go:38.2,43.8 1 1 +github.com/ankorstore/yokai/httpclient/option.go:19.41,26.2 1 5 +github.com/ankorstore/yokai/httpclient/option.go:32.58,33.26 1 1 +github.com/ankorstore/yokai/httpclient/option.go:33.26,35.3 1 1 +github.com/ankorstore/yokai/httpclient/option.go:39.95,40.26 1 2 +github.com/ankorstore/yokai/httpclient/option.go:40.26,42.3 1 2 +github.com/ankorstore/yokai/httpclient/option.go:46.55,47.26 1 2 +github.com/ankorstore/yokai/httpclient/option.go:47.26,49.3 1 2 +github.com/ankorstore/yokai/httpclient/option.go:53.52,54.26 1 2 +github.com/ankorstore/yokai/httpclient/option.go:54.26,56.3 1 2 +github.com/ankorstore/yokai/httpclient/request.go:11.86,12.33 1 2 +github.com/ankorstore/yokai/httpclient/request.go:12.33,15.63 2 4 +github.com/ankorstore/yokai/httpclient/request.go:15.63,17.4 1 5 +github.com/ankorstore/yokai/httpclient/request.go:22.80,24.2 1 1 +github.com/ankorstore/yokai/httpclient/transport/base.go:21.40,29.2 1 5 +github.com/ankorstore/yokai/httpclient/transport/base.go:32.77,44.2 5 5 +github.com/ankorstore/yokai/httpclient/transport/base.go:47.48,49.2 1 0 +github.com/ankorstore/yokai/httpclient/transport/base.go:52.78,54.2 1 0 +github.com/ankorstore/yokai/httpclient/transport/logger.go:30.66,43.2 1 0 +github.com/ankorstore/yokai/httpclient/transport/logger.go:46.107,47.17 1 0 +github.com/ankorstore/yokai/httpclient/transport/logger.go:47.17,49.3 1 0 +github.com/ankorstore/yokai/httpclient/transport/logger.go:51.2,54.3 1 0 +github.com/ankorstore/yokai/httpclient/transport/logger.go:58.52,60.2 1 0 +github.com/ankorstore/yokai/httpclient/transport/logger.go:63.80,68.25 3 0 +github.com/ankorstore/yokai/httpclient/transport/logger.go:68.25,70.17 2 0 +github.com/ankorstore/yokai/httpclient/transport/logger.go:70.17,72.4 1 0 +github.com/ankorstore/yokai/httpclient/transport/logger.go:75.2,85.47 6 0 +github.com/ankorstore/yokai/httpclient/transport/logger.go:85.47,86.10 1 0 +github.com/ankorstore/yokai/httpclient/transport/logger.go:87.101,88.27 1 0 +github.com/ankorstore/yokai/httpclient/transport/logger.go:89.58,90.28 1 0 +github.com/ankorstore/yokai/httpclient/transport/logger.go:91.11,92.57 1 0 +github.com/ankorstore/yokai/httpclient/transport/logger.go:94.8,96.3 1 0 +github.com/ankorstore/yokai/httpclient/transport/logger.go:98.2,98.26 1 0 +github.com/ankorstore/yokai/httpclient/transport/logger.go:98.26,100.17 2 0 +github.com/ankorstore/yokai/httpclient/transport/logger.go:100.17,102.4 1 0 +github.com/ankorstore/yokai/httpclient/transport/logger.go:105.2,112.18 2 0 +github.com/ankorstore/yokai/httpclient/transport/base.go:21.40,29.2 1 5 +github.com/ankorstore/yokai/httpclient/transport/base.go:32.77,44.2 5 6 +github.com/ankorstore/yokai/httpclient/transport/base.go:47.48,49.2 1 3 +github.com/ankorstore/yokai/httpclient/transport/base.go:52.78,54.2 1 5 +github.com/ankorstore/yokai/httpclient/transport/logger.go:30.66,43.2 1 3 +github.com/ankorstore/yokai/httpclient/transport/logger.go:46.107,47.17 1 4 +github.com/ankorstore/yokai/httpclient/transport/logger.go:47.17,49.3 1 3 +github.com/ankorstore/yokai/httpclient/transport/logger.go:51.2,54.3 1 4 +github.com/ankorstore/yokai/httpclient/transport/logger.go:58.52,60.2 1 1 +github.com/ankorstore/yokai/httpclient/transport/logger.go:63.80,68.25 3 4 +github.com/ankorstore/yokai/httpclient/transport/logger.go:68.25,70.17 2 3 +github.com/ankorstore/yokai/httpclient/transport/logger.go:70.17,72.4 1 3 +github.com/ankorstore/yokai/httpclient/transport/logger.go:75.2,85.47 6 4 +github.com/ankorstore/yokai/httpclient/transport/logger.go:85.47,86.10 1 3 +github.com/ankorstore/yokai/httpclient/transport/logger.go:87.101,88.27 1 1 +github.com/ankorstore/yokai/httpclient/transport/logger.go:89.58,90.28 1 1 +github.com/ankorstore/yokai/httpclient/transport/logger.go:91.11,92.57 1 1 +github.com/ankorstore/yokai/httpclient/transport/logger.go:94.8,96.3 1 1 +github.com/ankorstore/yokai/httpclient/transport/logger.go:98.2,98.26 1 4 +github.com/ankorstore/yokai/httpclient/transport/logger.go:98.26,100.17 2 3 +github.com/ankorstore/yokai/httpclient/transport/logger.go:100.17,102.4 1 3 +github.com/ankorstore/yokai/httpclient/transport/logger.go:105.2,112.18 2 4 diff --git a/httpclient/factory.go b/httpclient/factory.go new file mode 100644 index 00000000..39088e64 --- /dev/null +++ b/httpclient/factory.go @@ -0,0 +1,44 @@ +package httpclient + +import ( + "net/http" +) + +// HttpClientFactory is the interface for [http.Client] factories. +type HttpClientFactory interface { + Create(opts ...HttpClientOption) (*http.Client, error) +} + +// DefaultHttpClientFactory is the default [HttpClientFactory] implementation. +type DefaultHttpClientFactory struct{} + +// NewDefaultHttpClientFactory returns a [DefaultHttpClientFactory], implementing [HttpClientFactory]. +func NewDefaultHttpClientFactory() HttpClientFactory { + return &DefaultHttpClientFactory{} +} + +// Create returns a new [http.Client], and accepts a list of [HttpClientOption]. +// For example: +// +// var client, _ = httpclient.NewDefaultHttpClientFactory().Create() +// +// // equivalent to: +// var client, _ = httpclient.NewDefaultHttpClientFactory().Create( +// httpclient.WithTransport(transport.NewBaseTransport()), // base http transport (optimized) +// httpclient.WithTimeout(30*time.Second), // 30 seconds timeout +// httpclient.WithCheckRedirect(nil), // default redirection checks +// httpclient.WithCookieJar(nil), // default cookie jar +// ) +func (f *DefaultHttpClientFactory) Create(options ...HttpClientOption) (*http.Client, error) { + appliedOpts := DefaultHttpClientOptions() + for _, applyOpt := range options { + applyOpt(&appliedOpts) + } + + return &http.Client{ + Transport: appliedOpts.Transport, + CheckRedirect: appliedOpts.CheckRedirect, + Jar: appliedOpts.Jar, + Timeout: appliedOpts.Timeout, + }, nil +} diff --git a/httpclient/factory_test.go b/httpclient/factory_test.go new file mode 100644 index 00000000..f6faf2c0 --- /dev/null +++ b/httpclient/factory_test.go @@ -0,0 +1,55 @@ +package httpclient_test + +import ( + "fmt" + "net/http" + "net/http/cookiejar" + "testing" + "time" + + "github.com/ankorstore/yokai/httpclient" + "github.com/ankorstore/yokai/httpclient/transport" + "github.com/stretchr/testify/assert" +) + +func TestDefaultHttpClientFactory(t *testing.T) { + t.Parallel() + + factory := httpclient.NewDefaultHttpClientFactory() + + assert.IsType(t, &httpclient.DefaultHttpClientFactory{}, factory) + assert.Implements(t, (*httpclient.HttpClientFactory)(nil), factory) +} + +func TestCreate(t *testing.T) { + t.Parallel() + + factory := httpclient.NewDefaultHttpClientFactory() + + checkRedirectFunc := func(req *http.Request, via []*http.Request) error { + return fmt.Errorf("custom error") + } + jar, _ := cookiejar.New(nil) + timeout := time.Second * 20 + + options := []httpclient.HttpClientOption{ + httpclient.WithCheckRedirect(checkRedirectFunc), + httpclient.WithCookieJar(jar), + httpclient.WithTimeout(timeout), + } + + client, err := factory.Create(options...) + + assert.NoError(t, err) + assert.IsType(t, &transport.BaseTransport{}, client.Transport) + assert.Equal(t, jar, client.Jar) + assert.Equal(t, timeout, client.Timeout) + + req, err := http.NewRequest(http.MethodGet, "https://test.com", nil) + assert.NoError(t, err) + + err = client.CheckRedirect(req, []*http.Request{}) + + assert.Error(t, err) + assert.Equal(t, "custom error", err.Error()) +} diff --git a/httpclient/go.mod b/httpclient/go.mod new file mode 100644 index 00000000..032c066f --- /dev/null +++ b/httpclient/go.mod @@ -0,0 +1,20 @@ +module github.com/ankorstore/yokai/httpclient + +go 1.20 + +require ( + github.com/ankorstore/yokai/log v1.0.0 + github.com/rs/zerolog v1.29.1 + github.com/stretchr/testify v1.8.4 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.opentelemetry.io/otel v1.16.0 // indirect + go.opentelemetry.io/otel/trace v1.16.0 // indirect + golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/httpclient/go.sum b/httpclient/go.sum new file mode 100644 index 00000000..68d2d88e --- /dev/null +++ b/httpclient/go.sum @@ -0,0 +1,31 @@ +github.com/ankorstore/yokai/log v1.0.0 h1:9NsM0J+1O028WuNDW7vr0yeUdWDX1JKYTkuz7hiYCSs= +github.com/ankorstore/yokai/log v1.0.0/go.mod h1:lyBRVA8VkrmlNjaR2jVTH9XjV06ioolWTuDVN6wF0vk= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc= +github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.opentelemetry.io/otel v1.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s= +go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4= +go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs= +go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 h1:foEbQz/B0Oz6YIqu/69kfXPYeFQAuuMYFkjaqXzl5Wo= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/httpclient/option.go b/httpclient/option.go new file mode 100644 index 00000000..75a2cde0 --- /dev/null +++ b/httpclient/option.go @@ -0,0 +1,57 @@ +package httpclient + +import ( + "net/http" + "time" + + "github.com/ankorstore/yokai/httpclient/transport" +) + +// Options are options for the [HttpClientFactory] implementations. +type Options struct { + Transport http.RoundTripper + CheckRedirect func(req *http.Request, via []*http.Request) error + Jar http.CookieJar + Timeout time.Duration +} + +// DefaultHttpClientOptions are the default options used in the [DefaultHttpClientFactory]. +func DefaultHttpClientOptions() Options { + return Options{ + Transport: transport.NewBaseTransport(), + CheckRedirect: nil, + Jar: nil, + Timeout: time.Second * 30, + } +} + +// HttpClientOption are functional options for the [HttpClientFactory] implementations. +type HttpClientOption func(o *Options) + +// WithTransport is used to specify the [http.RoundTripper] to use by the [http.Client]. +func WithTransport(t http.RoundTripper) HttpClientOption { + return func(o *Options) { + o.Transport = t + } +} + +// WithCheckRedirect is used to specify the check redirect func to use by the [http.Client]. +func WithCheckRedirect(f func(req *http.Request, via []*http.Request) error) HttpClientOption { + return func(o *Options) { + o.CheckRedirect = f + } +} + +// WithCookieJar is used to specify the [http.CookieJar] to use by the [http.Client]. +func WithCookieJar(j http.CookieJar) HttpClientOption { + return func(o *Options) { + o.Jar = j + } +} + +// WithTimeout is used to specify the timeout to use by the [http.Client]. +func WithTimeout(t time.Duration) HttpClientOption { + return func(o *Options) { + o.Timeout = t + } +} diff --git a/httpclient/option_test.go b/httpclient/option_test.go new file mode 100644 index 00000000..6958ee77 --- /dev/null +++ b/httpclient/option_test.go @@ -0,0 +1,61 @@ +package httpclient_test + +import ( + "fmt" + "net/http" + "net/http/cookiejar" + "testing" + "time" + + "github.com/ankorstore/yokai/httpclient" + "github.com/stretchr/testify/assert" +) + +func TestWithTransport(t *testing.T) { + t.Parallel() + + opts := httpclient.DefaultHttpClientOptions() + + transport := &http.Transport{} + httpclient.WithTransport(transport)(&opts) + + assert.Equal(t, transport, opts.Transport) +} + +func TestWithCheckRedirect(t *testing.T) { + t.Parallel() + + opts := httpclient.DefaultHttpClientOptions() + + req, _ := http.NewRequest(http.MethodGet, "https://test.com", nil) + checkRedirectFunc := func(req *http.Request, via []*http.Request) error { + return fmt.Errorf("custom error") + } + httpclient.WithCheckRedirect(checkRedirectFunc)(&opts) + + err := opts.CheckRedirect(req, []*http.Request{}) + assert.Error(t, err) + assert.Equal(t, "custom error", err.Error()) +} + +func TestWithCookieJar(t *testing.T) { + t.Parallel() + + opts := httpclient.DefaultHttpClientOptions() + + jar, _ := cookiejar.New(nil) + httpclient.WithCookieJar(jar)(&opts) + + assert.Equal(t, jar, opts.Jar) +} + +func TestWithTimeout(t *testing.T) { + t.Parallel() + + opts := httpclient.DefaultHttpClientOptions() + + timeout := time.Second * 20 + httpclient.WithTimeout(timeout)(&opts) + + assert.Equal(t, timeout, opts.Timeout) +} diff --git a/httpclient/request.go b/httpclient/request.go new file mode 100644 index 00000000..212482f2 --- /dev/null +++ b/httpclient/request.go @@ -0,0 +1,24 @@ +package httpclient + +import "net/http" + +const ( + HeaderXRequestId = "x-request-id" + HeaderTraceParent = "traceparent" +) + +// CopyRequestHeaders performs a copy of a specified list of headers between two [http.Request]. +func CopyRequestHeaders(source *http.Request, dest *http.Request, headers ...string) { + for _, header := range headers { + canonicalHeader := http.CanonicalHeaderKey(header) + + for _, value := range source.Header.Values(canonicalHeader) { + dest.Header.Add(canonicalHeader, value) + } + } +} + +// CopyObservabilityRequestHeaders performs a copy of x-request-id and traceparent headers between two [http.Request]. +func CopyObservabilityRequestHeaders(source *http.Request, dest *http.Request) { + CopyRequestHeaders(source, dest, HeaderXRequestId, HeaderTraceParent) +} diff --git a/httpclient/request_test.go b/httpclient/request_test.go new file mode 100644 index 00000000..3e2deb07 --- /dev/null +++ b/httpclient/request_test.go @@ -0,0 +1,55 @@ +package httpclient_test + +import ( + "net/http" + "testing" + + "github.com/ankorstore/yokai/httpclient" + "github.com/stretchr/testify/assert" +) + +func TestCopyRequestHeaders(t *testing.T) { + t.Parallel() + + source, err := http.NewRequest(http.MethodGet, "https://test.com", nil) + assert.NoError(t, err) + + source.Header.Add("foo", "foo1") + source.Header.Add("Bar", "bar") + source.Header.Add("ignore", "ignore") + + dest, err := http.NewRequest(http.MethodGet, "https://other-test.com", nil) + assert.NoError(t, err) + + source.Header.Add("foo", "foo2") + dest.Header.Add("Baz", "baz") + + httpclient.CopyRequestHeaders(source, dest, "foo", "bar") + + assert.Equal(t, "foo1", dest.Header.Get("foo")) + assert.Equal(t, []string{"foo1", "foo2"}, dest.Header.Values("foo")) + + assert.Equal(t, "bar", dest.Header.Get("bar")) + assert.Equal(t, []string{"bar"}, dest.Header.Values("bar")) + + assert.Equal(t, "baz", dest.Header.Get("baz")) + assert.Equal(t, []string{"baz"}, dest.Header.Values("baz")) +} + +func TestCopyObservabilityRequestHeaders(t *testing.T) { + t.Parallel() + + source, err := http.NewRequest(http.MethodGet, "https://test.com", nil) + assert.NoError(t, err) + + source.Header.Add("x-request-id", "test-request-id") + source.Header.Add("traceparent", "test-traceparent") + + dest, err := http.NewRequest(http.MethodGet, "https://other-test.com", nil) + assert.NoError(t, err) + + httpclient.CopyObservabilityRequestHeaders(source, dest) + + assert.Equal(t, "test-request-id", dest.Header.Get("x-request-id")) + assert.Equal(t, "test-traceparent", dest.Header.Get("traceparent")) +} diff --git a/httpclient/transport/base.go b/httpclient/transport/base.go new file mode 100644 index 00000000..f859d7f0 --- /dev/null +++ b/httpclient/transport/base.go @@ -0,0 +1,54 @@ +package transport + +import ( + "net/http" +) + +// BaseTransport is a wrapper around [http.Transport] with some [BaseTransportConfig] configuration. +type BaseTransport struct { + transport *http.Transport + config *BaseTransportConfig +} + +// BaseTransportConfig is the configuration of the [BaseTransport]. +type BaseTransportConfig struct { + MaxIdleConnections int + MaxConnectionsPerHost int + MaxIdleConnectionsPerHost int +} + +// NewBaseTransport returns a [BaseTransport] instance with optimized default [BaseTransportConfig] configuration. +func NewBaseTransport() *BaseTransport { + return NewBaseTransportWithConfig( + &BaseTransportConfig{ + MaxIdleConnections: 100, + MaxConnectionsPerHost: 100, + MaxIdleConnectionsPerHost: 100, + }, + ) +} + +// NewBaseTransportWithConfig returns a [BaseTransport] instance for a provided [BaseTransportConfig] configuration. +func NewBaseTransportWithConfig(config *BaseTransportConfig) *BaseTransport { + //nolint:forcetypeassert + transport := http.DefaultTransport.(*http.Transport).Clone() + + transport.MaxIdleConns = config.MaxIdleConnections + transport.MaxConnsPerHost = config.MaxConnectionsPerHost + transport.MaxIdleConnsPerHost = config.MaxIdleConnectionsPerHost + + return &BaseTransport{ + transport: transport, + config: config, + } +} + +// Base returns the wrapped [http.Transport]. +func (t *BaseTransport) Base() *http.Transport { + return t.transport +} + +// RoundTrip performs a request / response round trip, based on the wrapped [http.Transport]. +func (t *BaseTransport) RoundTrip(req *http.Request) (*http.Response, error) { + return t.transport.RoundTrip(req) +} diff --git a/httpclient/transport/base_test.go b/httpclient/transport/base_test.go new file mode 100644 index 00000000..a177aba2 --- /dev/null +++ b/httpclient/transport/base_test.go @@ -0,0 +1,54 @@ +package transport_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/ankorstore/yokai/httpclient/transport" + "github.com/stretchr/testify/assert" +) + +func TestNewBaseTransport(t *testing.T) { + t.Parallel() + + trans := transport.NewBaseTransport() + + assert.IsType(t, &transport.BaseTransport{}, trans) + assert.Implements(t, (*http.RoundTripper)(nil), trans) +} + +func TestBaseTransportBaseWithConfig(t *testing.T) { + t.Parallel() + + trans := transport.NewBaseTransportWithConfig( + &transport.BaseTransportConfig{ + MaxIdleConnections: 50, + MaxConnectionsPerHost: 50, + MaxIdleConnectionsPerHost: 50, + }, + ) + + assert.Equal(t, 50, trans.Base().MaxIdleConns) + assert.Equal(t, 50, trans.Base().MaxConnsPerHost) + assert.Equal(t, 50, trans.Base().MaxIdleConnsPerHost) +} + +func TestBaseTransportRoundTrip(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + req := httptest.NewRequest(http.MethodGet, server.URL, nil) + + resp, err := transport.NewBaseTransport().RoundTrip(req) + assert.NoError(t, err) + + err = resp.Body.Close() + assert.NoError(t, err) + + assert.Equal(t, http.StatusNoContent, resp.StatusCode) +} diff --git a/httpclient/transport/logger.go b/httpclient/transport/logger.go new file mode 100644 index 00000000..579733e5 --- /dev/null +++ b/httpclient/transport/logger.go @@ -0,0 +1,113 @@ +package transport + +import ( + "net/http" + "net/http/httputil" + "time" + + "github.com/ankorstore/yokai/log" + "github.com/rs/zerolog" +) + +// LoggerTransport is a wrapper around [http.RoundTripper] with some [LoggerTransportConfig] configuration. +type LoggerTransport struct { + transport http.RoundTripper + config *LoggerTransportConfig +} + +// LoggerTransportConfig is the configuration of the [LoggerTransport]. +type LoggerTransportConfig struct { + LogRequest bool + LogResponse bool + LogRequestBody bool + LogResponseBody bool + LogRequestLevel zerolog.Level + LogResponseLevel zerolog.Level + LogResponseLevelFromResponseCode bool +} + +// NewLoggerTransport returns a [LoggerTransport] instance with default [LoggerTransportConfig] configuration. +func NewLoggerTransport(base http.RoundTripper) *LoggerTransport { + return NewLoggerTransportWithConfig( + base, + &LoggerTransportConfig{ + LogRequest: false, + LogResponse: false, + LogRequestBody: false, + LogResponseBody: false, + LogRequestLevel: zerolog.InfoLevel, + LogResponseLevel: zerolog.InfoLevel, + LogResponseLevelFromResponseCode: false, + }, + ) +} + +// NewLoggerTransportWithConfig returns a [LoggerTransport] instance for a provided [LoggerTransportConfig] configuration. +func NewLoggerTransportWithConfig(base http.RoundTripper, config *LoggerTransportConfig) *LoggerTransport { + if base == nil { + base = NewBaseTransport() + } + + return &LoggerTransport{ + transport: base, + config: config, + } +} + +// Base returns the wrapped [http.RoundTripper]. +func (t *LoggerTransport) Base() http.RoundTripper { + return t.transport +} + +// RoundTrip performs a request / response round trip, based on the wrapped [http.RoundTripper]. +func (t *LoggerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + logger := log.CtxLogger(req.Context()) + + reqEvt := logger.WithLevel(t.config.LogRequestLevel) + + if t.config.LogRequest { + reqDump, err := httputil.DumpRequestOut(req, t.config.LogRequestBody) + if err == nil { + reqEvt.Bytes("request", reqDump) + } + } + + reqEvt. + Str("method", req.Method). + Str("url", req.URL.String()). + Msg("http client request") + + start := time.Now() + resp, err := t.transport.RoundTrip(req) + latency := time.Since(start).String() + + var respEvt *zerolog.Event + if t.config.LogResponseLevelFromResponseCode { + switch { + case resp.StatusCode >= http.StatusBadRequest && resp.StatusCode < http.StatusInternalServerError: + respEvt = logger.Warn() + case resp.StatusCode >= http.StatusInternalServerError: + respEvt = logger.Error() + default: + respEvt = logger.WithLevel(t.config.LogResponseLevel) + } + } else { + respEvt = logger.WithLevel(t.config.LogResponseLevel) + } + + if t.config.LogResponse { + respDump, err := httputil.DumpResponse(resp, t.config.LogResponseBody) + if err == nil { + respEvt.Bytes("response", respDump) + } + } + + respEvt. + Str("method", resp.Request.Method). + Str("url", resp.Request.URL.String()). + Int("code", resp.StatusCode). + Str("latency", latency). + Msg("http client response") + + return resp, err +} diff --git a/httpclient/transport/logger_test.go b/httpclient/transport/logger_test.go new file mode 100644 index 00000000..57e75084 --- /dev/null +++ b/httpclient/transport/logger_test.go @@ -0,0 +1,203 @@ +package transport_test + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/ankorstore/yokai/httpclient/transport" + "github.com/ankorstore/yokai/log" + "github.com/ankorstore/yokai/log/logtest" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" +) + +func TestNewLoggerTransport(t *testing.T) { + t.Parallel() + + trans := transport.NewLoggerTransport(nil) + + assert.IsType(t, &transport.LoggerTransport{}, trans) + assert.Implements(t, (*http.RoundTripper)(nil), trans) +} + +func TestLoggerTransportBase(t *testing.T) { + t.Parallel() + + base := &http.Transport{} + + trans := transport.NewLoggerTransport(base) + + assert.Equal(t, base, trans.Base()) +} + +func TestLoggerTransportRoundTrip(t *testing.T) { + t.Parallel() + + logBuffer := logtest.NewDefaultTestLogBuffer() + logger, err := log.NewDefaultLoggerFactory().Create( + log.WithLevel(zerolog.DebugLevel), + log.WithOutputWriter(logBuffer), + ) + assert.NoError(t, err) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + req := httptest.NewRequest(http.MethodGet, server.URL, nil) + req = req.WithContext(logger.WithContext(context.Background())) + + resp, err := transport.NewLoggerTransport(nil).RoundTrip(req) + assert.NoError(t, err) + + err = resp.Body.Close() + assert.NoError(t, err) + + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + logtest.AssertHasLogRecord(t, logBuffer, map[string]interface{}{ + "level": "info", + "method": "GET", + "url": server.URL, + "message": "http client request", + }) + + logtest.AssertHasLogRecord(t, logBuffer, map[string]interface{}{ + "level": "info", + "url": server.URL, + "code": http.StatusNoContent, + "message": "http client response", + }) +} + +func TestLoggerTransportRoundTripWithConfig(t *testing.T) { + t.Parallel() + + logBuffer := logtest.NewDefaultTestLogBuffer() + logger, err := log.NewDefaultLoggerFactory().Create( + log.WithLevel(zerolog.DebugLevel), + log.WithOutputWriter(logBuffer), + ) + assert.NoError(t, err) + + // server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expectedStatus, err := strconv.Atoi(r.Header.Get("expected-response-code")) + assert.NoError(t, err) + + w.WriteHeader(expectedStatus) + + _, err = w.Write([]byte(r.Header.Get("expected-response-body"))) + assert.NoError(t, err) + })) + defer server.Close() + + // transport + trans := transport.NewLoggerTransportWithConfig(nil, &transport.LoggerTransportConfig{ + LogRequest: true, + LogResponse: true, + LogRequestBody: true, + LogResponseBody: true, + LogRequestLevel: zerolog.DebugLevel, + LogResponseLevel: zerolog.DebugLevel, + LogResponseLevelFromResponseCode: true, + }) + + // 200 response + data := []byte(`{"input":"data"}`) + req := httptest.NewRequest(http.MethodPost, server.URL, bytes.NewBuffer(data)) + req.Header.Add("expected-response-code", "200") + req.Header.Add("expected-response-body", `{"output":"ok"}`) + req = req.WithContext(logger.WithContext(context.Background())) + + resp, err := trans.RoundTrip(req) + assert.NoError(t, err) + + err = resp.Body.Close() + assert.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + logtest.AssertContainLogRecord(t, logBuffer, map[string]interface{}{ + "level": "debug", + "method": "POST", + "url": server.URL, + "request": `{"input":"data"}`, + "message": "http client request", + }) + + logtest.AssertContainLogRecord(t, logBuffer, map[string]interface{}{ + "level": "debug", + "url": server.URL, + "code": http.StatusOK, + "response": `{"output":"ok"}`, + "message": "http client response", + }) + + // 400 response + data = []byte(`{"input":"data"}`) + req = httptest.NewRequest(http.MethodPost, server.URL, bytes.NewBuffer(data)) + req.Header.Add("expected-response-code", "400") + req.Header.Add("expected-response-body", `{"output":"bad request"}`) + req = req.WithContext(logger.WithContext(context.Background())) + + resp, err = trans.RoundTrip(req) + assert.NoError(t, err) + + err = resp.Body.Close() + assert.NoError(t, err) + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + logtest.AssertContainLogRecord(t, logBuffer, map[string]interface{}{ + "level": "debug", + "method": "POST", + "url": server.URL, + "request": `{"input":"data"}`, + "message": "http client request", + }) + + logtest.AssertContainLogRecord(t, logBuffer, map[string]interface{}{ + "level": "warn", + "url": server.URL, + "code": http.StatusBadRequest, + "response": `{"output":"bad request"}`, + "message": "http client response", + }) + + // 500 response + data = []byte(`{"input":"data"}`) + req = httptest.NewRequest(http.MethodPost, server.URL, bytes.NewBuffer(data)) + req.Header.Add("expected-response-code", "500") + req.Header.Add("expected-response-body", `{"output":"error"}`) + req = req.WithContext(logger.WithContext(context.Background())) + + resp, err = trans.RoundTrip(req) + assert.NoError(t, err) + + err = resp.Body.Close() + assert.NoError(t, err) + + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) + + logtest.AssertContainLogRecord(t, logBuffer, map[string]interface{}{ + "level": "debug", + "method": "POST", + "url": server.URL, + "request": `{"input":"data"}`, + "message": "http client request", + }) + + logtest.AssertContainLogRecord(t, logBuffer, map[string]interface{}{ + "level": "error", + "url": server.URL, + "code": http.StatusInternalServerError, + "response": `{"output":"error"}`, + "message": "http client response", + }) +} diff --git a/release-please-config.json b/release-please-config.json index f42c5468..6517e2a5 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -16,6 +16,11 @@ "component": "healthcheck", "tag-separator": "/" }, + "httpclient": { + "release-type": "go", + "component": "httpclient", + "tag-separator": "/" + }, "log": { "release-type": "go", "component": "log",