From d1df1ccd0d074845c7a83fa4e67e6ce3b2217561 Mon Sep 17 00:00:00 2001 From: Vee Zhang Date: Wed, 30 Mar 2022 13:34:28 +0800 Subject: [PATCH] add httpclient pkg (#9) --- README.md | 1 + go.mod | 2 + go.sum | 14 ++ httpclient/bytes_client.go | 66 +++++++ httpclient/bytes_client_test.go | 122 +++++++++++++ httpclient/client.go | 177 ++++++++++++++++++ httpclient/client_test.go | 301 +++++++++++++++++++++++++++++++ httpclient/error.go | 93 ++++++++++ httpclient/error_test.go | 87 +++++++++ httpclient/object_client.go | 84 +++++++++ httpclient/object_client_test.go | 134 ++++++++++++++ 11 files changed, 1081 insertions(+) create mode 100644 httpclient/bytes_client.go create mode 100644 httpclient/bytes_client_test.go create mode 100644 httpclient/client.go create mode 100644 httpclient/client_test.go create mode 100644 httpclient/error.go create mode 100644 httpclient/error_test.go create mode 100644 httpclient/object_client.go create mode 100644 httpclient/object_client_test.go diff --git a/README.md b/README.md index 85f552e..288db65 100644 --- a/README.md +++ b/README.md @@ -6,3 +6,4 @@ # Go Common Packages - [errorx](errorx) - Error extension with code and message. +- [httpclient](httpclient) - HTTP client containing raw `Client`, `BytesClient` and `ObjectClient`. diff --git a/go.mod b/go.mod index bb70e06..e16a768 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/vesoft-inc/go-pkg go 1.17 require ( + github.com/go-resty/resty/v2 v2.7.0 github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.7.1 ) @@ -11,6 +12,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect ) diff --git a/go.sum b/go.sum index c6c5a66..ffe3a6e 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY= +github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -15,6 +17,18 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f h1:oA4XRj0qtSt8Yo1Zms0CUlsT3KG69V2UGQWPBxujDmc= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/httpclient/bytes_client.go b/httpclient/bytes_client.go new file mode 100644 index 0000000..8fb3a8f --- /dev/null +++ b/httpclient/bytes_client.go @@ -0,0 +1,66 @@ +package httpclient + +import "github.com/go-resty/resty/v2" + +type ( + BytesClient interface { + Get(urlPath string, opts ...RequestOption) ([]byte, error) + Post(urlPath string, body interface{}, opts ...RequestOption) ([]byte, error) + Put(urlPath string, body interface{}, opts ...RequestOption) ([]byte, error) + Patch(urlPath string, body interface{}, opts ...RequestOption) ([]byte, error) + Delete(urlPath string, body interface{}, opts ...RequestOption) ([]byte, error) + Execute(method, urlPath string, body interface{}, opts ...RequestOption) ([]byte, error) + } + + defaultBytesClient struct { + client Client + } +) + +var _ BytesClient = (*defaultBytesClient)(nil) + +func NewBytesClient(addr string, opts ...RequestOption) BytesClient { + return NewBytesClientRaw(NewClient(addr, opts...)) +} + +func NewBytesClientRaw(cli Client) BytesClient { + return &defaultBytesClient{ + client: cli, + } +} + +func (c *defaultBytesClient) Get(urlPath string, opts ...RequestOption) ([]byte, error) { + return c.convertResponse(c.client.Get(urlPath, opts...)) +} + +func (c *defaultBytesClient) Post(urlPath string, body interface{}, opts ...RequestOption) ([]byte, error) { + return c.convertResponse(c.client.Post(urlPath, body, opts...)) +} + +func (c *defaultBytesClient) Put(urlPath string, body interface{}, opts ...RequestOption) ([]byte, error) { + return c.convertResponse(c.client.Put(urlPath, body, opts...)) +} + +func (c *defaultBytesClient) Patch(urlPath string, body interface{}, opts ...RequestOption) ([]byte, error) { + return c.convertResponse(c.client.Patch(urlPath, body, opts...)) +} + +func (c *defaultBytesClient) Delete(urlPath string, body interface{}, opts ...RequestOption) ([]byte, error) { + return c.convertResponse(c.client.Delete(urlPath, body, opts...)) +} + +func (c *defaultBytesClient) Execute(method, urlPath string, body interface{}, opts ...RequestOption) ([]byte, error) { + return c.convertResponse(c.client.Execute(method, urlPath, body, opts...)) +} + +func (*defaultBytesClient) convertResponse(resp *resty.Response, err error) ([]byte, error) { + if err != nil { + return nil, err + } + + if err = NewResponseErrorNotSuccess(resp); err != nil { + return nil, err + } + + return resp.Body(), err +} diff --git a/httpclient/bytes_client_test.go b/httpclient/bytes_client_test.go new file mode 100644 index 0000000..9e1487f --- /dev/null +++ b/httpclient/bytes_client_test.go @@ -0,0 +1,122 @@ +package httpclient + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-resty/resty/v2" + "github.com/stretchr/testify/assert" +) + +func TestBytesClient(t *testing.T) { + reqBody := []byte("testReqBody") + respBody := []byte("testRespBody") + reqFuncMap := map[string]func(BytesClient) ([]byte, error){ + resty.MethodGet: func(c BytesClient) ([]byte, error) { + return c.Get("") + }, + resty.MethodPost: func(c BytesClient) ([]byte, error) { + return c.Post("", reqBody) + }, + resty.MethodPut: func(c BytesClient) ([]byte, error) { + return c.Put("", reqBody) + }, + resty.MethodPatch: func(c BytesClient) ([]byte, error) { + return c.Patch("", reqBody) + }, + resty.MethodDelete: func(c BytesClient) ([]byte, error) { + return c.Delete("", reqBody) + }, + } + + for _, statusCode := range []int{200, 301, 404, 500, 502} { + for _, method := range []string{resty.MethodGet, resty.MethodPost, resty.MethodPut, resty.MethodPatch, resty.MethodDelete} { + t.Run(fmt.Sprintf("%s:%d", method, statusCode), func(t *testing.T) { + ast := assert.New(t) + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ast.Equal(method, r.Method) + if r.Method != resty.MethodGet { + body, err := ioutil.ReadAll(r.Body) + ast.NoError(err) + ast.Equal(reqBody, body) + } + ast.Equal(url.Values{ + "q0": []string{"qv0"}, + "q1": []string{"qv1"}, + "q2": []string{"qv2"}, + }, r.URL.Query()) + ast.Equal([]string{"hv0", "hv1", "hv2"}, []string{r.Header.Get("h0"), r.Header.Get("h1"), r.Header.Get("h2")}) + ast.Equal("Bearer AuthToken", r.Header.Get("Authorization")) + + w.WriteHeader(statusCode) + w.Write(respBody) + })) + + defer testServer.Close() + + checkHookFunc := func(resp *resty.Response, err error) { + if statusCode == 301 { + ast.Error(err) + ast.Contains(err.Error(), "301 response missing Location header") + } else { + ast.NoError(err) + ast.Equal(statusCode, resp.StatusCode()) + ast.Equal(respBody, resp.Body()) + } + } + checkFunc := func(respBytes []byte, err error) { + if statusCode == 301 { + ast.Error(err) + ast.Contains(err.Error(), "301 response missing Location header") + } else if statusCode != 200 { + ast.Nil(respBytes) + respErr, ok := AsResponseError(err) + ast.True(ok) + resp := respErr.GetResponse() + ast.NotNil(resp) + ast.Equal(statusCode, resp.StatusCode()) + ast.Equal(respBody, resp.Body()) + } else { + ast.NoError(err) + ast.Equal(respBody, respBytes) + } + } + + c := NewBytesClient(testServer.URL, + WithQueryParam("q0", "qv0"), + WithQueryParams(map[string]string{ + "q1": "qv1", + "q2": "qv2", + }), + WithHeader("h0", "hv0"), + WithHeaders(map[string]string{ + "h1": "hv1", + "h2": "hv2", + }), + WithAuthToken("AuthToken"), + WithAfterRequestHook(func(_ *resty.Request, resp *resty.Response, err error) { + checkHookFunc(resp, err) + }), + WithAfterRequestHook(func(_ *resty.Request, resp *resty.Response, err error) { + checkHookFunc(resp, err) + }), + ) + f := reqFuncMap[method] + if ast.NotNil(f) { + resp, err := f(c) + checkFunc(resp, err) + } + var executeBody interface{} + if method != resty.MethodGet { + executeBody = reqBody + } + resp, err := c.Execute(method, "", executeBody) + checkFunc(resp, err) + }) + } + } +} diff --git a/httpclient/client.go b/httpclient/client.go new file mode 100644 index 0000000..f4da932 --- /dev/null +++ b/httpclient/client.go @@ -0,0 +1,177 @@ +package httpclient + +import ( + "strings" + + "github.com/go-resty/resty/v2" +) + +var _ Client = (*defaultClient)(nil) + +type ( + Client interface { + Get(urlPath string, opts ...RequestOption) (*resty.Response, error) + Post(urlPath string, body interface{}, opts ...RequestOption) (*resty.Response, error) + Put(urlPath string, body interface{}, opts ...RequestOption) (*resty.Response, error) + Patch(urlPath string, body interface{}, opts ...RequestOption) (*resty.Response, error) + Delete(urlPath string, body interface{}, opts ...RequestOption) (*resty.Response, error) + Execute(method, urlPath string, body interface{}, opts ...RequestOption) (*resty.Response, error) + } + + defaultClient struct { + Addr string + client *resty.Client + initOptions []RequestOption + } + + RequestOption func(*requestOptions) + requestOptions struct { + beforeRequestHook func(*resty.Request) + afterRequestHook func(*resty.Request, *resty.Response, error) + } +) + +func NewClient(addr string, opts ...RequestOption) Client { + if addr != "" && !strings.HasPrefix(addr, "http") { + addr = "http://" + addr + } + return &defaultClient{ + Addr: addr, + client: resty.New().SetBaseURL(addr), + initOptions: opts, + } +} + +func WithBody(body interface{}) RequestOption { + return func(o *requestOptions) { + o.linkBeforeRequestHook(func(r *resty.Request) { + r.SetBody(body) + }) + } +} + +func WithQueryParam(param, value string) RequestOption { + return func(o *requestOptions) { + o.linkBeforeRequestHook(func(r *resty.Request) { + r.SetQueryParam(param, value) + }) + } +} + +func WithQueryParams(params map[string]string) RequestOption { + return func(o *requestOptions) { + o.linkBeforeRequestHook(func(r *resty.Request) { + r.SetQueryParams(params) + }) + } +} + +func WithHeader(header, value string) RequestOption { + return func(o *requestOptions) { + o.linkBeforeRequestHook(func(r *resty.Request) { + r.SetHeader(header, value) + }) + } +} + +func WithHeaders(headers map[string]string) RequestOption { + return func(o *requestOptions) { + o.linkBeforeRequestHook(func(r *resty.Request) { + r.SetHeaders(headers) + }) + } +} + +func WithAuthToken(token string) RequestOption { + return func(o *requestOptions) { + o.linkBeforeRequestHook(func(r *resty.Request) { + r.SetAuthToken(token) + }) + } +} + +func WithBeforeRequestHook(fn func(*resty.Request)) RequestOption { + return func(o *requestOptions) { + o.linkBeforeRequestHook(fn) + } +} + +func WithAfterRequestHook(fn func(*resty.Request, *resty.Response, error)) RequestOption { + return func(o *requestOptions) { + o.linkAfterRequestHook(fn) + } +} + +func (c *defaultClient) Get(urlPath string, opts ...RequestOption) (*resty.Response, error) { + return c.Execute(resty.MethodGet, urlPath, nil, opts...) +} + +func (c *defaultClient) Post(urlPath string, body interface{}, opts ...RequestOption) (*resty.Response, error) { + return c.Execute(resty.MethodPost, urlPath, body, opts...) +} + +func (c *defaultClient) Put(urlPath string, body interface{}, opts ...RequestOption) (*resty.Response, error) { + return c.Execute(resty.MethodPut, urlPath, body, opts...) +} + +func (c *defaultClient) Patch(urlPath string, body interface{}, opts ...RequestOption) (*resty.Response, error) { + return c.Execute(resty.MethodPatch, urlPath, body, opts...) +} + +func (c *defaultClient) Delete(urlPath string, body interface{}, opts ...RequestOption) (*resty.Response, error) { + return c.Execute(resty.MethodDelete, urlPath, body, opts...) +} + +func (c *defaultClient) Execute(method, urlPath string, body interface{}, opts ...RequestOption) (*resty.Response, error) { + if body != nil { + opts = append(opts, WithBody(body)) + } + return c.doRequest(method, urlPath, opts...) +} + +func (c *defaultClient) doRequest(method, urlPath string, opts ...RequestOption) (*resty.Response, error) { + o := defaultRequestOptions() + + for _, opt := range append(c.initOptions, opts...) { + opt(o) + } + + r := c.client.R() + if o.beforeRequestHook != nil { + o.beforeRequestHook(r) + } + + resp, err := r.Execute(method, urlPath) + if o.afterRequestHook != nil { + o.afterRequestHook(r, resp, err) + } + return resp, err +} + +func (o *requestOptions) linkBeforeRequestHook(fn func(*resty.Request)) { + if o.beforeRequestHook == nil { + o.beforeRequestHook = fn + return + } + preHook := o.beforeRequestHook + o.beforeRequestHook = func(r *resty.Request) { + preHook(r) + fn(r) + } +} + +func (o *requestOptions) linkAfterRequestHook(fn func(*resty.Request, *resty.Response, error)) { + if o.afterRequestHook == nil { + o.afterRequestHook = fn + return + } + preHook := o.afterRequestHook + o.afterRequestHook = func(r *resty.Request, resp *resty.Response, err error) { + preHook(r, resp, err) + fn(r, resp, err) + } +} + +func defaultRequestOptions() *requestOptions { + return &requestOptions{} +} diff --git a/httpclient/client_test.go b/httpclient/client_test.go new file mode 100644 index 0000000..2a94d1f --- /dev/null +++ b/httpclient/client_test.go @@ -0,0 +1,301 @@ +package httpclient + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-resty/resty/v2" + "github.com/stretchr/testify/assert" +) + +func TestNewClient(t *testing.T) { + tests := []struct { + name string + addr string + expected string + }{{ + name: "empty", + addr: "", + expected: "", + }, { + name: "localhost", + addr: "localhost", + expected: "http://localhost", + }, { + name: "localhost:8080", + addr: "localhost:8080", + expected: "http://localhost:8080", + }, { + name: "localhost:8080/", + addr: "localhost:8080/", + expected: "http://localhost:8080/", + }, { + name: "http://localhost:8080", + addr: "http://localhost:8080", + expected: "http://localhost:8080", + }, { + name: "https://localhost:8080", + addr: "https://localhost:8080", + expected: "https://localhost:8080", + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c := NewClient(test.addr) + cli, ok := c.(*defaultClient) + assert.True(t, ok) + assert.Equal(t, test.expected, cli.Addr) + assert.Equal(t, resty.New().SetBaseURL(test.expected).HostURL, cli.client.HostURL) + }) + } +} + +func TestWithBeforeRequestHook(t *testing.T) { + tests := []struct { + name string + updateFns []func(*resty.Request) + checkFn func(t *testing.T, r *resty.Request) + }{{ + name: "set 1 field", + updateFns: []func(*resty.Request){ + func(r *resty.Request) { + r.SetBody(1) + }, + }, + checkFn: func(t *testing.T, r *resty.Request) { + assert.Equal(t, 1, r.Body) + }, + }, { + name: "set 2 field", + updateFns: []func(*resty.Request){ + func(r *resty.Request) { + r.SetBody(1) + }, func(r *resty.Request) { + r.SetAuthToken("token") + }, + }, + checkFn: func(t *testing.T, r *resty.Request) { + assert.Equal(t, 1, r.Body) + assert.Equal(t, "token", r.Token) + }, + }, { + name: "set 2 field with rewrite", + updateFns: []func(*resty.Request){ + func(r *resty.Request) { + r.SetBody(1) + r.SetAuthToken("token") + }, func(r *resty.Request) { + r.SetBody("body") + }, + }, + checkFn: func(t *testing.T, r *resty.Request) { + assert.Equal(t, "body", r.Body) + assert.Equal(t, "token", r.Token) + }, + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var opts []RequestOption + for _, fn := range test.updateFns { + opts = append(opts, WithBeforeRequestHook(fn)) + } + + o := &requestOptions{} + for _, opt := range opts { + opt(o) + } + r := new(resty.Request) + if o.beforeRequestHook != nil { + o.beforeRequestHook(r) + } + test.checkFn(t, r) + }) + } +} + +func TestWithBeforeRequestHookSerial(t *testing.T) { + tests := []struct { + name string + ns []int + expected []int + }{{ + name: "nil", + ns: nil, + expected: nil, + }, { + name: "empty", + ns: []int{}, + expected: nil, + }, { + name: "1, 2, 3, 4, 5", + ns: []int{1, 2, 3, 4, 5}, + expected: []int{1, 2, 3, 4, 5}, + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var actual []int + var opts []RequestOption + + genOptionFn := func(n int) func(*resty.Request) { + return func(*resty.Request) { + actual = append(actual, n) + } + } + + for _, n := range test.ns { + opts = append(opts, WithBeforeRequestHook(genOptionFn(n))) + } + + o := &requestOptions{} + for _, opt := range opts { + opt(o) + } + if o.beforeRequestHook != nil { + o.beforeRequestHook(nil) + } + assert.Equal(t, test.expected, actual) + }) + } +} + +func TestWithBody(t *testing.T) { + tests := []struct { + name string + bodyList []interface{} + expected interface{} + }{{ + name: "nil", + bodyList: nil, + expected: nil, + }, { + name: "a", + bodyList: []interface{}{"a"}, + expected: "a", + }, { + name: "a,nil", + bodyList: []interface{}{"a", nil}, + expected: nil, + }, { + name: "a,nil,1", + bodyList: []interface{}{"a", nil, 1}, + expected: 1, + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var opts []RequestOption + + for _, body := range test.bodyList { + opts = append(opts, WithBody(body)) + } + + o := &requestOptions{} + for _, opt := range opts { + opt(o) + } + r := new(resty.Request) + if o.beforeRequestHook != nil { + o.beforeRequestHook(r) + } + assert.Equal(t, test.expected, r.Body) + }) + } +} + +func TestClient(t *testing.T) { + reqBody := []byte("testReqBody") + respBody := []byte("testRespBody") + reqFuncMap := map[string]func(Client) (*resty.Response, error){ + resty.MethodGet: func(c Client) (*resty.Response, error) { + return c.Get("") + }, + resty.MethodPost: func(c Client) (*resty.Response, error) { + return c.Post("", reqBody) + }, + resty.MethodPut: func(c Client) (*resty.Response, error) { + return c.Put("", reqBody) + }, + resty.MethodPatch: func(c Client) (*resty.Response, error) { + return c.Patch("", reqBody) + }, + resty.MethodDelete: func(c Client) (*resty.Response, error) { + return c.Delete("", reqBody) + }, + } + + for _, statusCode := range []int{200, 301, 404, 500, 502} { + for _, method := range []string{resty.MethodGet, resty.MethodPost, resty.MethodPut, resty.MethodPatch, resty.MethodDelete} { + t.Run(fmt.Sprintf("%s:%d", method, statusCode), func(t *testing.T) { + ast := assert.New(t) + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ast.Equal(method, r.Method) + if r.Method != resty.MethodGet { + body, err := ioutil.ReadAll(r.Body) + ast.NoError(err) + ast.Equal(reqBody, body) + } + ast.Equal(url.Values{ + "q0": []string{"qv0"}, + "q1": []string{"qv1"}, + "q2": []string{"qv2"}, + }, r.URL.Query()) + ast.Equal([]string{"hv0", "hv1", "hv2"}, []string{r.Header.Get("h0"), r.Header.Get("h1"), r.Header.Get("h2")}) + ast.Equal("Bearer AuthToken", r.Header.Get("Authorization")) + + w.WriteHeader(statusCode) + w.Write(respBody) + })) + + defer testServer.Close() + + checkFunc := func(resp *resty.Response, err error) { + if statusCode == 301 { + ast.Error(err) + ast.Contains(err.Error(), "301 response missing Location header") + } else { + ast.NoError(err) + ast.Equal(statusCode, resp.StatusCode()) + ast.Equal(respBody, resp.Body()) + } + } + + c := NewClient(testServer.URL, + WithQueryParam("q0", "qv0"), + WithQueryParams(map[string]string{ + "q1": "qv1", + "q2": "qv2", + }), + WithHeader("h0", "hv0"), + WithHeaders(map[string]string{ + "h1": "hv1", + "h2": "hv2", + }), + WithAuthToken("AuthToken"), + WithAfterRequestHook(func(_ *resty.Request, resp *resty.Response, err error) { + checkFunc(resp, err) + }), + WithAfterRequestHook(func(_ *resty.Request, resp *resty.Response, err error) { + checkFunc(resp, err) + }), + ) + f := reqFuncMap[method] + if ast.NotNil(f) { + resp, err := f(c) + checkFunc(resp, err) + } + var executeBody interface{} + if method != resty.MethodGet { + executeBody = reqBody + } + resp, err := c.Execute(method, "", executeBody) + checkFunc(resp, err) + }) + } + } +} diff --git a/httpclient/error.go b/httpclient/error.go new file mode 100644 index 0000000..2e624de --- /dev/null +++ b/httpclient/error.go @@ -0,0 +1,93 @@ +package httpclient + +import ( + "fmt" + "io" + + "github.com/go-resty/resty/v2" + "github.com/pkg/errors" +) + +var _ ResponseError = (*responseError)(nil) + +type ( + ResponseError interface { + error + GetResponse() *resty.Response + IsStatusCode(statusCode int) bool + } + + responseError struct { + error + resp *resty.Response + } +) + +func NewResponseError(resp *resty.Response, err error) error { + return errors.WithStack(&responseError{resp: resp, error: err}) +} + +func NewResponseErrorNotSuccess(resp *resty.Response) error { + if resp == nil || resp.IsSuccess() { + return nil + } + return NewResponseError(resp, nil) +} + +func AsResponseError(err error) (ResponseError, bool) { + if e := new(responseError); errors.As(err, &e) { + return e, true + } + return nil, false +} + +func IsResponseError(err error, statusCode ...int) bool { + hce, ok := AsResponseError(err) + if !ok { + return false + } + + switch len(statusCode) { + case 0: + return true + case 1: + return hce.IsStatusCode(statusCode[0]) + default: + return false + } +} + +func (e *responseError) GetResponse() *resty.Response { + return e.resp +} + +func (e *responseError) IsStatusCode(statusCode int) bool { + return e.GetResponse().StatusCode() == statusCode +} + +func (e *responseError) Error() string { + if e.error == nil { + return e.resp.Status() + } + return fmt.Sprintf("%s: %s", e.resp.Status(), e.error.Error()) +} + +func (e *responseError) Cause() error { return e.error } + +func (e *responseError) Unwrap() error { return e.error } + +func (e *responseError) Format(s fmt.State, verb rune) { + switch verb { + case 'v': + if s.Flag('+') && e.Cause() != nil { + _, _ = fmt.Fprintf(s, "%+v\n", e.Cause()) + _, _ = io.WriteString(s, e.Error()) + return + } + fallthrough + case 's': + _, _ = io.WriteString(s, e.Error()) + case 'q': + _, _ = fmt.Fprintf(s, "%q", e.Error()) + } +} diff --git a/httpclient/error_test.go b/httpclient/error_test.go new file mode 100644 index 0000000..2d9a09d --- /dev/null +++ b/httpclient/error_test.go @@ -0,0 +1,87 @@ +package httpclient + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-resty/resty/v2" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" +) + +func TestError(t *testing.T) { + ast := assert.New(t) + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == resty.MethodGet { + if r.URL.Path == "/get/ok" { + w.WriteHeader(http.StatusOK) + } else if r.URL.Path == "/get/error" { + w.WriteHeader(http.StatusNotFound) + _, err := w.Write([]byte("body")) + ast.NoError(err) + } + } + })) + + resp, err := resty.New().R().Get(testServer.URL + "/get/ok") + ast.NoError(err) + err = NewResponseErrorNotSuccess(resp) + ast.NoError(err) + + resp, err = resty.New().R().Get(testServer.URL + "/get/error") + ast.NoError(err) + err = NewResponseErrorNotSuccess(resp) + if ast.Error(err) { + ast.True(IsResponseError(err)) + ast.True(IsResponseError(err, http.StatusNotFound)) + ast.False(IsResponseError(err, http.StatusForbidden)) + ast.False(IsResponseError(err, http.StatusNotFound, http.StatusForbidden)) + respErr, ok := AsResponseError(err) + ast.True(ok) + ast.Equal(http.StatusNotFound, respErr.GetResponse().StatusCode()) + ast.True(respErr.IsStatusCode(http.StatusNotFound)) + + ast.Equal("404 Not Found", err.Error()) + fields := strings.Split(fmt.Sprintf("%+v", err), "\n") + if ast.True(len(fields) > 2, "%v", fields) { + ast.Equal("404 Not Found", fields[0], "%+v", err) + ast.Contains(fields[1], "httpclient.NewResponseError", "%+v", err) + } + ast.Equal("\"404 Not Found\"", fmt.Sprintf("%q", err)) + } + + err = NewResponseError(resp, fmt.Errorf("CauseError")) + if ast.Error(err) { + ast.True(IsResponseError(err)) + ast.True(IsResponseError(err, http.StatusNotFound)) + ast.False(IsResponseError(err, http.StatusForbidden)) + ast.False(IsResponseError(err, http.StatusNotFound, http.StatusForbidden)) + respErr, ok := AsResponseError(err) + ast.True(ok) + ast.Equal(http.StatusNotFound, respErr.GetResponse().StatusCode()) + ast.True(respErr.IsStatusCode(http.StatusNotFound)) + + ast.Equal("404 Not Found: CauseError", err.Error()) + fields := strings.Split(fmt.Sprintf("%+v", err), "\n") + if ast.True(len(fields) > 3, "%v", fields) { + ast.Equal("CauseError", fields[0], "%+v", err) + ast.Equal("404 Not Found: CauseError", fields[1], "%+v", err) + ast.Contains(fields[2], "httpclient.NewResponseError", "%+v", err) + } + ast.Equal("\"404 Not Found: CauseError\"", fmt.Sprintf("%q", err)) + + err1 := errors.Unwrap(err) + ast.Equal("404 Not Found: CauseError", err1.Error(), "%+v", err1) + err2 := errors.Unwrap(err1) + ast.Equal("CauseError", err2.Error(), "%+v", err2) + } + + err = &responseError{resp: resp} + ast.Equal("\"404 Not Found\"", fmt.Sprintf("%q", err)) + + ast.False(IsResponseError(errors.New("err"))) +} diff --git a/httpclient/object_client.go b/httpclient/object_client.go new file mode 100644 index 0000000..1c1137e --- /dev/null +++ b/httpclient/object_client.go @@ -0,0 +1,84 @@ +package httpclient + +import ( + "encoding/json" + + "github.com/go-resty/resty/v2" +) + +type ( + ObjectClient interface { + Get(urlPath string, responseObj interface{}, opts ...RequestOption) error + Post(urlPath string, body, responseObj interface{}, opts ...RequestOption) error + Put(urlPath string, body, responseObj interface{}, opts ...RequestOption) error + Patch(urlPath string, body, responseObj interface{}, opts ...RequestOption) error + Delete(urlPath string, body, responseObj interface{}, opts ...RequestOption) error + Execute(method, urlPath string, body, responseObj interface{}, opts ...RequestOption) error + } + + defaultObjectClient struct { + client Client + } +) + +var _ ObjectClient = (*defaultObjectClient)(nil) + +func NewObjectClient(addr string, opts ...RequestOption) ObjectClient { + return NewObjectClientRaw(NewClient(addr, opts...)) +} + +func NewObjectClientRaw(cli Client) ObjectClient { + return &defaultObjectClient{ + client: cli, + } +} + +func (c *defaultObjectClient) Get(urlPath string, responseObj interface{}, opts ...RequestOption) error { + resp, err := c.client.Get(urlPath, opts...) + return c.convertResponse(responseObj, resp, err) +} + +func (c *defaultObjectClient) Post(urlPath string, body, responseObj interface{}, opts ...RequestOption) error { + resp, err := c.client.Post(urlPath, body, opts...) + return c.convertResponse(responseObj, resp, err) +} + +func (c *defaultObjectClient) Put(urlPath string, body, responseObj interface{}, opts ...RequestOption) error { + resp, err := c.client.Put(urlPath, body, opts...) + return c.convertResponse(responseObj, resp, err) +} + +func (c *defaultObjectClient) Patch(urlPath string, body, responseObj interface{}, opts ...RequestOption) error { + resp, err := c.client.Patch(urlPath, body, opts...) + return c.convertResponse(responseObj, resp, err) +} + +func (c *defaultObjectClient) Delete(urlPath string, body, responseObj interface{}, opts ...RequestOption) error { + resp, err := c.client.Delete(urlPath, body, opts...) + return c.convertResponse(responseObj, resp, err) +} + +func (c *defaultObjectClient) Execute(method, urlPath string, body, responseObj interface{}, opts ...RequestOption) error { + resp, err := c.client.Execute(method, urlPath, body, opts...) + return c.convertResponse(responseObj, resp, err) +} + +func (*defaultObjectClient) convertResponse(responseObj interface{}, resp *resty.Response, err error) error { + if err != nil { + return err + } + + if err = NewResponseErrorNotSuccess(resp); err != nil { + return err + } + + if responseObj == nil { + return nil + } + + // Only support json response now + if err := json.Unmarshal(resp.Body(), responseObj); err != nil { + return NewResponseError(resp, err) + } + return nil +} diff --git a/httpclient/object_client_test.go b/httpclient/object_client_test.go new file mode 100644 index 0000000..4baaeb6 --- /dev/null +++ b/httpclient/object_client_test.go @@ -0,0 +1,134 @@ +package httpclient + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-resty/resty/v2" + "github.com/stretchr/testify/assert" +) + +func TestObjectClient(t *testing.T) { + reqBody := []byte("testReqBody") + reqFuncMap := map[string]func(ObjectClient, interface{}) error{ + resty.MethodGet: func(c ObjectClient, responseObj interface{}) error { + return c.Get("", &responseObj) + }, + resty.MethodPost: func(c ObjectClient, responseObj interface{}) error { + return c.Post("", reqBody, &responseObj) + }, + resty.MethodPut: func(c ObjectClient, responseObj interface{}) error { + return c.Put("", reqBody, &responseObj) + }, + resty.MethodPatch: func(c ObjectClient, responseObj interface{}) error { + return c.Patch("", reqBody, &responseObj) + }, + resty.MethodDelete: func(c ObjectClient, responseObj interface{}) error { + return c.Delete("", reqBody, &responseObj) + }, + } + + for _, statusCode := range []int{200, 301, 404, 500, 502} { + for _, method := range []string{resty.MethodGet, resty.MethodPost, resty.MethodPut, resty.MethodPatch, resty.MethodDelete} { + for respBodyIndex, respBody := range [][]byte{[]byte("{"), []byte("{\"k\": \"v\"}")} { + t.Run(fmt.Sprintf("%s:%d:%s", method, statusCode, respBody), func(t *testing.T) { + ast := assert.New(t) + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ast.Equal(method, r.Method) + if r.Method != resty.MethodGet { + body, err := ioutil.ReadAll(r.Body) + ast.NoError(err) + ast.Equal(reqBody, body) + } + ast.Equal(url.Values{ + "q0": []string{"qv0"}, + "q1": []string{"qv1"}, + "q2": []string{"qv2"}, + }, r.URL.Query()) + ast.Equal([]string{"hv0", "hv1", "hv2"}, []string{r.Header.Get("h0"), r.Header.Get("h1"), r.Header.Get("h2")}) + ast.Equal("Bearer AuthToken", r.Header.Get("Authorization")) + + w.WriteHeader(statusCode) + w.Write(respBody) + })) + + defer testServer.Close() + + checkHookFunc := func(resp *resty.Response, err error) { + if statusCode == 301 { + ast.Error(err) + ast.Contains(err.Error(), "301 response missing Location header") + } else { + ast.NoError(err) + ast.Equal(statusCode, resp.StatusCode()) + ast.Equal(respBody, resp.Body()) + } + } + + checkFunc := func(hasResponseObj bool, responseObj map[string]interface{}, err error) { + if statusCode == 301 { + ast.Error(err) + ast.Contains(err.Error(), "301 response missing Location header") + } else if statusCode != 200 || (respBodyIndex == 0 && hasResponseObj) { + ast.Nil(responseObj) + ast.Error(err) + respErr, ok := AsResponseError(err) + ast.True(ok) + resp := respErr.GetResponse() + ast.NotNil(resp) + ast.Equal(statusCode, resp.StatusCode()) + ast.Equal(respBody, resp.Body()) + } else { + ast.NoError(err) + if hasResponseObj { + ast.Equal(map[string]interface{}{"k": "v"}, responseObj) + } else { + ast.Nil(responseObj) + } + } + } + + c := NewObjectClient(testServer.URL, + WithQueryParam("q0", "qv0"), + WithQueryParams(map[string]string{ + "q1": "qv1", + "q2": "qv2", + }), + WithHeader("h0", "hv0"), + WithHeaders(map[string]string{ + "h1": "hv1", + "h2": "hv2", + }), + WithAuthToken("AuthToken"), + WithAfterRequestHook(func(_ *resty.Request, resp *resty.Response, err error) { + checkHookFunc(resp, err) + }), + WithAfterRequestHook(func(_ *resty.Request, resp *resty.Response, err error) { + checkHookFunc(resp, err) + }), + ) + var responseObj map[string]interface{} + f := reqFuncMap[method] + if ast.NotNil(f) { + err := f(c, &responseObj) + checkFunc(true, responseObj, err) + } + var executeBody interface{} + if method != resty.MethodGet { + executeBody = reqBody + } + responseObj = nil + err := c.Execute(method, "", executeBody, &responseObj) + checkFunc(true, responseObj, err) + + err = c.Execute(method, "", executeBody, nil) + checkFunc(false, nil, err) + }) + } + } + } +}