diff --git a/netutil/httpreq/README.md b/netutil/httpreq/README.md new file mode 100644 index 000000000..fd7805d4a --- /dev/null +++ b/netutil/httpreq/README.md @@ -0,0 +1,129 @@ +# HTTP Request + +`httpreq` provide an simple http requester and some useful util functions. + +- provide a simple and useful HTTP request client +- provide some useful http utils functions + +## Install + +```bash +go get github.com/gookit/goutil/netutil/httpreq +``` + +## Go docs + +- [Go docs](https://pkg.go.dev/github.com/gookit/goutil/netutil/httpreq) + +## Usage + +```go +package main + +import ( + "fmt" + + "github.com/gookit/goutil/netutil/httpreq" +) + +func main() { + // Send a GET request + resp, err := httpreq.Get("http://httpbin.org/get") + fmt.Println(httpreq.ResponseToString(resp), err) + + // Send a POST request + resp, err = httpreq.Post("http://httpbin.org/post", `{"name":"inhere"}`, httpreq.WithJSONType) + fmt.Println(httpreq.ResponseToString(resp), err) +} +``` + +## HTTP Client + +```go +package main + +import ( + "fmt" + + "github.com/gookit/goutil/netutil/httpreq" +) + +func main() { + // create a client + client := httpreq.New("http://httpbin.org") + + // Send a GET request + resp, err := client.Get("/get") + fmt.Println(httpreq.ResponseToString(resp), err) + + // Send a POST request + resp, err = client.Post("/post", `{"name":"inhere"}`, httpreq.WithJSONType) + fmt.Println(httpreq.ResponseToString(resp), err) +} +``` + +## Package docs + +```go + +func AddHeaderMap(req *http.Request, headerMap map[string]string) +func AddHeaders(req *http.Request, header http.Header) +func AppendQueryToURL(reqURL *url.URL, uv url.Values) error +func AppendQueryToURLString(urlStr string, query url.Values) string +func BuildBasicAuth(username, password string) string +func Config(fn func(hc *http.Client)) +func Delete(url string, optFns ...OptionFn) (*http.Response, error) +func Get(url string, optFns ...OptionFn) (*http.Response, error) +func HeaderToString(h http.Header) string +func HeaderToStringMap(rh http.Header) map[string]string +func IsClientError(statusCode int) bool +func IsForbidden(statusCode int) bool +func IsNoBodyMethod(method string) bool +func IsNotFound(statusCode int) bool +func IsOK(statusCode int) bool +func IsRedirect(statusCode int) bool +func IsServerError(statusCode int) bool +func IsSuccessful(statusCode int) bool +func MakeBody(data any, cType string) io.Reader +func MakeQuery(data any) url.Values +func MustResp(r *http.Response, err error) *http.Response +func MustSend(method, url string, optFns ...OptionFn) *http.Response +func Post(url string, data any, optFns ...OptionFn) (*http.Response, error) +func Put(url string, data any, optFns ...OptionFn) (*http.Response, error) +func RequestToString(r *http.Request) string +func ResponseToString(w *http.Response) string +func Send(method, url string, optFns ...OptionFn) (*http.Response, error) +func SendRequest(req *http.Request, opt *Option) (*http.Response, error) +func SetTimeout(ms int) +func ToQueryValues(data any) url.Values +func ToRequestBody(data any, cType string) io.Reader +func WithJSONType(opt *Option) +type AfterSendFn func(resp *http.Response, err error) +type BasicAuthConf struct{ ... } +type Client struct{ ... } + func New(baseURL ...string) *Client + func NewClient(timeout int) *Client + func NewWithDoer(d Doer) *Client + func Std() *Client +type Option struct{ ... } + func MakeOpt(opt *Option) *Option + func NewOpt(fns ...OptionFn) *Option + func NewOption(fns []OptionFn) *Option +type OptionFn func(opt *Option) + func WithData(data any) OptionFn +type RespX struct{ ... } + func MustRespX(r *http.Response, err error) *RespX + func NewResp(hr *http.Response) *RespX +``` + +## Testings + +```shell +go test -v ./netutil/httpreq/... +``` + +Test limit by regexp: + +```shell +go test -v -run ^TestSetByKeys ./netutil/httpreq/... +``` diff --git a/netutil/httpreq/client.go b/netutil/httpreq/client.go index c2b1e76b6..02403013c 100644 --- a/netutil/httpreq/client.go +++ b/netutil/httpreq/client.go @@ -1,203 +1,204 @@ -// Package httpreq an simple http requester package httpreq import ( - "bytes" "context" "io" "net/http" "strings" "github.com/gookit/goutil/netutil/httpctype" + "github.com/gookit/goutil/strutil" ) -// ReqOption alias of Option -type ReqOption = Option - -// Option struct -type Option struct { - // Method for request - Method string - // HeaderMap data. eg: traceid - HeaderMap map[string]string - // Timeout unit: ms - Timeout int - // TCancelFunc will auto set it on Timeout > 0 - TCancelFunc context.CancelFunc - // ContentType header - ContentType string - // EncodeJSON req body - EncodeJSON bool - // Logger for request - Logger ReqLogger - // Context for request - Context context.Context -} - -// NewOpt create a new Option -func NewOpt(opt *Option) *Option { - if opt == nil { - opt = &Option{} - } - return opt -} - -// Req alias of ReqClient -// -// Deprecated: rename to ReqClient -type Req = ReqClient - -// ReqClient an simple http request client. -type ReqClient struct { +// Client a simple http request client. +type Client struct { client Doer - // some config for request + // default config for request method string baseURL string + timeout int // unit: ms // custom set headers headerMap map[string]string - // request body. - // eg: strings.NewReader("name=inhere") - body io.Reader + // beforeSend callback beforeSend func(req *http.Request) - afterSend func(resp *http.Response) + afterSend AfterSendFn } -// ConfigStd req client -func ConfigStd(fn func(hc *http.Client)) { - fn(std.client.(*http.Client)) -} - -// Std instance -func Std() *ReqClient { return std } - -// Get quick send a GET request by default client -func Get(url string, opt *ReqOption) (*http.Response, error) { - return std.Method(http.MethodGet).SendWithOpt(url, opt) -} +// New instance with base URL +func New(baseURL ...string) *Client { + h := NewWithDoer(&http.Client{}) -// Post quick send a POS request by default client -func Post(url string, data any, opt *ReqOption) (*http.Response, error) { - return std.Method(http.MethodPost).AnyBody(data).SendWithOpt(url, opt) + if len(baseURL) > 0 && baseURL[0] != "" { + h.baseURL = baseURL[0] + } + return h } -// New instance with base URL -func New(baseURL ...string) *ReqClient { - h := &ReqClient{ +// NewWithDoer instance with custom http client +func NewWithDoer(d Doer) *Client { + return &Client{ + client: d, method: http.MethodGet, - client: http.DefaultClient, // init map headerMap: make(map[string]string), } +} - if len(baseURL) > 0 { - h.baseURL = baseURL[0] - } +// Doer get the http client +func (h *Client) Doer() Doer { + return h.client +} + +// Client custom http client doer +func (h *Client) Client(c Doer) *Client { + h.client = c return h } -// BaseURL with base URL -func (h *ReqClient) BaseURL(baseURL string) *ReqClient { +// BaseURL set request base URL +func (h *Client) BaseURL(baseURL string) *Client { h.baseURL = baseURL return h } -// Method with custom method -func (h *ReqClient) Method(method string) *ReqClient { +// DefaultMethod set default request method +func (h *Client) DefaultMethod(method string) *Client { if method != "" { h.method = method } return h } -// WithHeader with custom header -func (h *ReqClient) WithHeader(key, val string) *ReqClient { +// ContentType set default content-Type header. +func (h *Client) ContentType(cType string) *Client { + return h.DefaultHeader(httpctype.Key, cType) +} + +// DefaultHeader set default header for all requests +func (h *Client) DefaultHeader(key, val string) *Client { h.headerMap[key] = val return h } -// WithHeaders with custom headers -func (h *ReqClient) WithHeaders(kvMap map[string]string) *ReqClient { +// DefaultHeaderMap set default headers for all requests +func (h *Client) DefaultHeaderMap(kvMap map[string]string) *Client { for k, v := range kvMap { h.headerMap[k] = v } return h } -// ContentType with custom content-Type header. -func (h *ReqClient) ContentType(cType string) *ReqClient { - return h.WithHeader(httpctype.Key, cType) -} - // OnBeforeSend add callback before send. -func (h *ReqClient) OnBeforeSend(fn func(req *http.Request)) *ReqClient { +func (h *Client) OnBeforeSend(fn func(req *http.Request)) *Client { h.beforeSend = fn return h } // OnAfterSend add callback after send. -func (h *ReqClient) OnAfterSend(fn func(resp *http.Response)) *ReqClient { +func (h *Client) OnAfterSend(fn AfterSendFn) *Client { h.afterSend = fn return h } +// +// build request options +// + +// WithOption with custom request options +func (h *Client) WithOption(optFns ...OptionFn) *Option { + return NewOption(optFns).WithClient(h) +} + +func newOptWithClient(cli *Client) *Option { + return &Option{cli: cli} +} + +// WithData with custom request data +func (h *Client) WithData(data any) *Option { + return newOptWithClient(h).WithData(data) +} + // WithBody with custom body -func (h *ReqClient) WithBody(r io.Reader) *ReqClient { - h.body = r - return h +func (h *Client) WithBody(r io.Reader) *Option { + return newOptWithClient(h).WithBody(r) } // BytesBody with custom bytes body -func (h *ReqClient) BytesBody(bs []byte) *ReqClient { - h.body = bytes.NewReader(bs) - return h +func (h *Client) BytesBody(bs []byte) *Option { + return newOptWithClient(h).BytesBody(bs) } -// JSONBytesBody with custom bytes body, and set JSON content type -func (h *ReqClient) JSONBytesBody(bs []byte) *ReqClient { - h.body = bytes.NewReader(bs) - h.ContentType(httpctype.JSON) - return h +// StringBody with custom string body +func (h *Client) StringBody(s string) *Option { + return newOptWithClient(h).StringBody(s) } -// StringBody with custom string body -func (h *ReqClient) StringBody(s string) *ReqClient { - h.body = strings.NewReader(s) - return h +// FormBody with custom form data body +func (h *Client) FormBody(data any) *Option { + return newOptWithClient(h).FormBody(data) +} + +// JSONBody with custom JSON data body +func (h *Client) JSONBody(data any) *Option { + return newOptWithClient(h).WithJSON(data) +} + +// JSONBytesBody with custom bytes body, and set JSON content type +func (h *Client) JSONBytesBody(bs []byte) *Option { + return newOptWithClient(h).JSONBytesBody(bs) } // AnyBody with custom body. // // Allow type: // - string, []byte, map[string][]string/url.Values, io.Reader(eg: bytes.Buffer, strings.Reader) -func (h *ReqClient) AnyBody(data any) *ReqClient { - h.body = ToRequestBody(data) - return h +func (h *Client) AnyBody(data any) *Option { + return newOptWithClient(h).AnyBody(data) } -// Client custom http client -func (h *ReqClient) Client(c Doer) *ReqClient { - h.client = c - return h +// +// send request with options +// + +// Get send GET request with options, return http response +func (h *Client) Get(url string, optFns ...OptionFn) (*http.Response, error) { + return h.Send(http.MethodGet, url, optFns...) +} + +// Post send POST request with options, return http response +func (h *Client) Post(url string, data any, optFns ...OptionFn) (*http.Response, error) { + opt := NewOption(optFns).WithMethod(http.MethodPost).AnyBody(data) + return h.SendWithOpt(url, opt) +} + +// Put send PUT request with options, return http response +func (h *Client) Put(url string, data any, optFns ...OptionFn) (*http.Response, error) { + opt := NewOption(optFns).WithMethod(http.MethodPut).AnyBody(data) + return h.SendWithOpt(url, opt) +} + +// Delete send DELETE request with options, return http response +func (h *Client) Delete(url string, optFns ...OptionFn) (*http.Response, error) { + return h.Send(http.MethodDelete, url, optFns...) +} + +// Send request with option func, return http response +func (h *Client) Send(method, url string, optFns ...OptionFn) (*http.Response, error) { + return h.SendWithOpt(url, NewOption(optFns).WithMethod(method)) } // MustSend request, will panic on error -func (h *ReqClient) MustSend(url string) *http.Response { - resp, err := h.Send(url) +func (h *Client) MustSend(method, url string, optFns ...OptionFn) *http.Response { + resp, err := h.SendWithOpt(url, NewOption(optFns).WithMethod(method)) if err != nil { panic(err) } - return resp } -// Send request and return http response -func (h *ReqClient) Send(url string) (*http.Response, error) { - return h.SendWithOpt(url, nil) -} - // SendWithOpt request and return http response -func (h *ReqClient) SendWithOpt(url string, opt *ReqOption) (*http.Response, error) { +func (h *Client) SendWithOpt(url string, opt *Option) (*http.Response, error) { cli := h if len(cli.baseURL) > 0 { if !strings.HasPrefix(url, "http") { @@ -207,41 +208,71 @@ func (h *ReqClient) SendWithOpt(url string, opt *ReqOption) (*http.Response, err } } + opt = MakeOpt(opt) + ctx := opt.Context + if ctx == nil { + ctx = context.Background() + } + // create request - req, err := http.NewRequest(cli.method, url, cli.body) + method := strings.ToUpper(strutil.OrElse(opt.Method, cli.method)) + + if opt.Data != nil { + if IsNoBodyMethod(method) { + url = AppendQueryToURLString(url, MakeQuery(opt.Data)) + opt.Body = nil + } else if opt.Body == nil { + cType := strutil.OrElse(h.headerMap[httpctype.Key], opt.ContentType) + opt.Body = MakeBody(opt.Data, cType) + } + } + + req, err := http.NewRequestWithContext(ctx, method, url, opt.Body) if err != nil { return nil, err } - return h.SendRequest(req, opt) + + return h.sendRequest(req, opt) } -// SendRequest request and return http response -func (h *ReqClient) SendRequest(req *http.Request, opt *ReqOption) (*http.Response, error) { - cli := h - opt = NewOpt(opt) +// SendRequest send request and return http response +func (h *Client) SendRequest(req *http.Request, opt *Option) (*http.Response, error) { + return h.sendRequest(req, opt) +} - if opt.Timeout > 0 { - cli = NewClient(opt.Timeout) +// send request and return http response +func (h *Client) sendRequest(req *http.Request, opt *Option) (*http.Response, error) { + // apply default headers + if len(h.headerMap) > 0 { + for k, v := range h.headerMap { + req.Header.Set(k, v) + } + } + + // apply options + if opt.ContentType != "" { + req.Header.Set(httpctype.Key, opt.ContentType) } - if len(cli.headerMap) > 0 { - for k, v := range cli.headerMap { + // - apply header map + if len(opt.HeaderMap) > 0 { + for k, v := range opt.HeaderMap { req.Header.Set(k, v) } } - if cli.beforeSend != nil { - cli.beforeSend(req) + cli := h // if timeout changed, create new client + if opt.Timeout > 0 && opt.Timeout != cli.timeout { + cli = NewClient(opt.Timeout) + } + + if h.beforeSend != nil { + h.beforeSend(req) } resp, err := cli.client.Do(req) - if err == nil && cli.afterSend != nil { - cli.afterSend(resp) + if h.afterSend != nil { + h.afterSend(resp, err) } return resp, err } - -// Doer get the http client -func (h *ReqClient) Doer() Doer { - return h.client -} diff --git a/netutil/httpreq/client_test.go b/netutil/httpreq/client_test.go index c1bf06851..fdc428612 100644 --- a/netutil/httpreq/client_test.go +++ b/netutil/httpreq/client_test.go @@ -3,21 +3,25 @@ package httpreq_test import ( "io" "net/http" + "strings" "testing" "github.com/gookit/goutil/dump" "github.com/gookit/goutil/jsonutil" "github.com/gookit/goutil/netutil/httpctype" "github.com/gookit/goutil/netutil/httpreq" + "github.com/gookit/goutil/testutil" "github.com/gookit/goutil/testutil/assert" ) -func TestHttpReq_Send(t *testing.T) { - resp, err := httpreq.New("https://httpbin.org"). - StringBody("hi"). +func TestClient_Send(t *testing.T) { + resp, err := httpreq.New(testSrvAddr). ContentType(httpctype.JSON). - WithHeaders(map[string]string{"coustom1": "value1"}). - Send("/get") + DefaultMethod(http.MethodPost). + DefaultHeaderMap(map[string]string{"coustom1": "value1"}). + Send("POST", "/json", func(opt *httpreq.Option) { + opt.Body = io.NopCloser(strings.NewReader(`{"name": "inhere"}`)) + }) assert.NoErr(t, err) sc := resp.StatusCode @@ -35,19 +39,86 @@ func TestHttpReq_Send(t *testing.T) { dump.P(retMp) } +func TestClient_RSET(t *testing.T) { + cli := httpreq.New(testSrvAddr). + ContentType(httpctype.JSON). + DefaultHeader("custom1", "value1"). + DefaultHeaderMap(map[string]string{ + "custom2": "value2", + }) + + t.Run("Get", func(t *testing.T) { + resp, err := cli.Get("/get", httpreq.WithData("name=inhere&age=18")) + assert.NoErr(t, err) + sc := resp.StatusCode + assert.True(t, httpreq.IsOK(sc)) + assert.True(t, httpreq.IsSuccessful(sc)) + + rr := testutil.ParseRespToReply(resp) + assert.Equal(t, "GET", rr.Method) + assert.Equal(t, "value1", rr.Headers["Custom1"]) + assert.Equal(t, "value2", rr.Headers["Custom2"]) + assert.Equal(t, "inhere", rr.Query["name"]) + // dump.P(rr) + }) + + t.Run("Post", func(t *testing.T) { + resp, err := cli.Post("/post", `{"name": "inhere"}`, httpreq.WithJSONType) + assert.NoErr(t, err) + sc := resp.StatusCode + assert.True(t, httpreq.IsOK(sc)) + assert.True(t, httpreq.IsSuccessful(sc)) + + rr := testutil.ParseRespToReply(resp) + assert.Equal(t, "POST", rr.Method) + assert.Equal(t, "value1", rr.Headers["Custom1"]) + assert.StrContains(t, rr.Headers["Content-Type"].(string), httpctype.MIMEJSON) + assert.Eq(t, `{"name": "inhere"}`, rr.Body) + dump.P(rr) + }) + + t.Run("Put", func(t *testing.T) { + resp, err := cli.Put("/put", `{"name": "inhere"}`, httpreq.WithJSONType) + assert.NoErr(t, err) + sc := resp.StatusCode + assert.True(t, httpreq.IsOK(sc)) + assert.True(t, httpreq.IsSuccessful(sc)) + + rr := testutil.ParseRespToReply(resp) + assert.Equal(t, "PUT", rr.Method) + assert.Equal(t, "value1", rr.Headers["Custom1"]) + assert.StrContains(t, rr.Headers["Content-Type"].(string), httpctype.MIMEJSON) + assert.Eq(t, `{"name": "inhere"}`, rr.Body) + // dump.P(rr) + }) + + t.Run("Delete", func(t *testing.T) { + resp, err := cli.Delete("/delete", httpreq.WithData("name=inhere&age=18")) + assert.NoErr(t, err) + sc := resp.StatusCode + assert.True(t, httpreq.IsOK(sc)) + assert.True(t, httpreq.IsSuccessful(sc)) + + rr := testutil.ParseRespToReply(resp) + assert.Equal(t, "DELETE", rr.Method) + assert.Equal(t, "value1", rr.Headers["Custom1"]) + assert.Equal(t, "value2", rr.Headers["Custom2"]) + assert.Equal(t, "inhere", rr.Query["name"]) + // dump.P(rr) + }) +} + func TestHttpReq_MustSend(t *testing.T) { cli := httpreq.New().OnBeforeSend(func(req *http.Request) { assert.Eq(t, http.MethodPost, req.Method) - }).OnAfterSend(func(resp *http.Response) { + }).OnAfterSend(func(resp *http.Response, err error) { bodyStr, _ := io.ReadAll(resp.Body) assert.StrContains(t, string(bodyStr), "hi,goutil") }) - resp := cli. - BaseURL("https://httpbin.org"). + resp := cli.BaseURL(testSrvAddr). BytesBody([]byte("hi,goutil")). - Method("POST"). - MustSend("/post") + MustSend("POST", "/post") sc := resp.StatusCode assert.True(t, httpreq.IsOK(sc)) diff --git a/netutil/httpreq/contract.go b/netutil/httpreq/contract.go deleted file mode 100644 index a7b23816f..000000000 --- a/netutil/httpreq/contract.go +++ /dev/null @@ -1,22 +0,0 @@ -package httpreq - -import "net/http" - -// Doer interface for http client. -type Doer interface { - Do(req *http.Request) (*http.Response, error) -} - -// DoerFunc implements the Doer -type DoerFunc func(req *http.Request) (*http.Response, error) - -// Do send request and return response. -func (do DoerFunc) Do(req *http.Request) (*http.Response, error) { - return do(req) -} - -// ReqLogger interface -type ReqLogger interface { - Infof(format string, args ...any) - Errorf(format string, args ...any) -} diff --git a/netutil/httpreq/default.go b/netutil/httpreq/default.go new file mode 100644 index 000000000..68e8fba14 --- /dev/null +++ b/netutil/httpreq/default.go @@ -0,0 +1,60 @@ +package httpreq + +import "net/http" + +// default standard client instance +var std = NewClient(500) + +// Std instance +func Std() *Client { return std } + +// SetTimeout set default timeout(ms) for std client +// +// Note: timeout unit is millisecond +func SetTimeout(ms int) { + std = NewClient(ms) +} + +// Config std http client +func Config(fn func(hc *http.Client)) { + fn(std.client.(*http.Client)) +} + +// +// send request by default client +// + +// Get quick send a GET request by default client +func Get(url string, optFns ...OptionFn) (*http.Response, error) { + return std.Get(url, optFns...) +} + +// Post quick send a POST request by default client +func Post(url string, data any, optFns ...OptionFn) (*http.Response, error) { + return std.Post(url, data, optFns...) +} + +// Put quick send a PUT request by default client +func Put(url string, data any, optFns ...OptionFn) (*http.Response, error) { + return std.Put(url, data, optFns...) +} + +// Delete quick send a DELETE request by default client +func Delete(url string, optFns ...OptionFn) (*http.Response, error) { + return std.Delete(url, optFns...) +} + +// Send quick send a request by default client +func Send(method, url string, optFns ...OptionFn) (*http.Response, error) { + return std.Send(method, url, optFns...) +} + +// MustSend quick send a request by default client +func MustSend(method, url string, optFns ...OptionFn) *http.Response { + return std.MustSend(method, url, optFns...) +} + +// SendRequest quick send a request by default client +func SendRequest(req *http.Request, opt *Option) (*http.Response, error) { + return std.SendRequest(req, opt) +} diff --git a/netutil/httpreq/httpreq.go b/netutil/httpreq/httpreq.go index 1b298bfde..548eaa2df 100644 --- a/netutil/httpreq/httpreq.go +++ b/netutil/httpreq/httpreq.go @@ -1,32 +1,87 @@ +// Package httpreq provide an simple http requester and some useful util functions. package httpreq import ( "net/http" "sync" "time" + + "github.com/gookit/goutil/netutil/httpctype" ) -// default std client -var std = NewClient(500) +// AfterSendFn callback func +type AfterSendFn func(resp *http.Response, err error) + +// Doer interface for http client. +type Doer interface { + Do(req *http.Request) (*http.Response, error) +} + +// DoerFunc implements the Doer +type DoerFunc func(req *http.Request) (*http.Response, error) + +// Do send request and return response. +func (do DoerFunc) Do(req *http.Request) (*http.Response, error) { + return do(req) +} -// global lock -var _gl = sync.Mutex{} +// ReqLogger request logger interface +type ReqLogger interface { + Infof(format string, args ...any) + Errorf(format string, args ...any) +} -// client map -var cs = map[int]*ReqClient{} +var ( + // global lock + _gl = sync.Mutex{} -// NewClient create a new http client -func NewClient(timeout int) *ReqClient { + // client cache map + cs = map[int]*Client{} +) + +// NewClient create a new http client and cache it. +// +// Note: timeout unit is millisecond +func NewClient(timeout int) *Client { _gl.Lock() cli, ok := cs[timeout] if !ok { - cli = New().Client(&http.Client{ + cli = NewWithDoer(&http.Client{ Timeout: time.Duration(timeout) * time.Millisecond, }) + cli.timeout = timeout cs[timeout] = cli } _gl.Unlock() return cli } + +// MustResp check error and return response +func MustResp(r *http.Response, err error) *http.Response { + if err != nil { + panic(err) + } + return r +} + +// MustRespX check error and create a new RespX instance +func MustRespX(r *http.Response, err error) *RespX { + if err != nil { + panic(err) + } + return NewResp(r) +} + +// WithJSONType set request content type to JSON +func WithJSONType(opt *Option) { + opt.ContentType = httpctype.JSON +} + +// WithData set request data +func WithData(data any) OptionFn { + return func(opt *Option) { + opt.Data = data + } +} diff --git a/netutil/httpreq/httpreq_test.go b/netutil/httpreq/httpreq_test.go new file mode 100644 index 000000000..91432edc2 --- /dev/null +++ b/netutil/httpreq/httpreq_test.go @@ -0,0 +1,72 @@ +package httpreq_test + +import ( + "fmt" + "testing" + + "github.com/gookit/goutil/dump" + "github.com/gookit/goutil/netutil/httpreq" + "github.com/gookit/goutil/testutil" + "github.com/gookit/goutil/testutil/assert" +) + +var testSrvAddr string + +func TestMain(m *testing.M) { + s := testutil.NewEchoServer() + defer s.Close() + testSrvAddr = "http://" + s.Listener.Addr().String() + fmt.Println("Test server listen on:", testSrvAddr) + + m.Run() +} + +func TestStdClient(t *testing.T) { + resp, err := httpreq.Send("head", testSrvAddr+"/head") + assert.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + rr := testutil.ParseRespToReply(resp) + // dump.P(rr) + assert.Equal(t, "HEAD", rr.Method) + + resp = httpreq.MustSend("options", testSrvAddr+"/options") + assert.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + rr = testutil.ParseRespToReply(resp) + dump.P(rr) + assert.Eq(t, "OPTIONS", rr.Method) + + t.Run("get", func(t *testing.T) { + resp, err := httpreq.Get(testSrvAddr + "/get") + assert.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + rr := testutil.ParseBodyToReply(resp.Body) + assert.Equal(t, "GET", rr.Method) + }) + + t.Run("post", func(t *testing.T) { + resp, err := httpreq.Post(testSrvAddr+"/post", "hi") + assert.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + rr := testutil.ParseBodyToReply(resp.Body) + assert.Equal(t, "POST", rr.Method) + assert.Equal(t, "hi", rr.Body) + }) + + t.Run("put", func(t *testing.T) { + resp, err := httpreq.Put(testSrvAddr+"/put", "hi") + assert.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + rr := testutil.ParseBodyToReply(resp.Body) + assert.Equal(t, "PUT", rr.Method) + assert.Equal(t, "hi", rr.Body) + }) + + t.Run("delete", func(t *testing.T) { + resp, err := httpreq.Delete(testSrvAddr + "/delete") + assert.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + rr := testutil.ParseBodyToReply(resp.Body) + assert.Equal(t, "DELETE", rr.Method) + }) +} diff --git a/netutil/httpreq/options.go b/netutil/httpreq/options.go new file mode 100644 index 000000000..5eda1877e --- /dev/null +++ b/netutil/httpreq/options.go @@ -0,0 +1,217 @@ +package httpreq + +import ( + "bytes" + "context" + "io" + "net/http" + "strings" + + "github.com/gookit/goutil/basefn" + "github.com/gookit/goutil/netutil/httpctype" +) + +// Options alias of Option +type Options = Option + +// Option struct +type Option struct { + cli *Client + // Timeout for request. unit: ms + Timeout int + // Method for request + Method string + // HeaderMap data. eg: traceid + HeaderMap map[string]string + // ContentType header + ContentType string + + // Logger for request + Logger ReqLogger + // Context for request + Context context.Context + + // Data for request. can be used on any request method. + // + // type allow: + // string, []byte, io.Reader, map[string]string, ... + Data any + // Body data for request. used on POST, PUT, PATCH method. + // + // eg: strings.NewReader("name=inhere") + Body io.Reader +} + +// OptionFn option func type +type OptionFn func(opt *Option) + +// MakeOpt create a new Option +func MakeOpt(opt *Option) *Option { + if opt == nil { + opt = &Option{} + } + return opt +} + +// NewOpt create a new Option +func NewOpt(fns ...OptionFn) *Option { + return NewOption(fns) +} + +// NewOption create a new Option and set option func +func NewOption(fns []OptionFn) *Option { + opt := &Option{} + return opt.WithOptionFn(fns...) +} + +// WithOptionFn set option func +func (o *Option) WithOptionFn(fns ...OptionFn) *Option { + return o.WithOptionFns(fns) +} + +// WithOptionFns set option func +func (o *Option) WithOptionFns(fns []OptionFn) *Option { + for _, fn := range fns { + if fn != nil { + fn(o) + } + } + return o +} + +// WithClient set client +func (o *Option) WithClient(cli *Client) *Option { + o.cli = cli + return o +} + +// WithMethod set method +func (o *Option) WithMethod(method string) *Option { + if method != "" { + o.Method = method + } + return o +} + +// WithContentType set content type +func (o *Option) WithContentType(ct string) *Option { + o.ContentType = ct + return o +} + +// WithHeaderMap set header map +func (o *Option) WithHeaderMap(m map[string]string) *Option { + if o.HeaderMap == nil { + o.HeaderMap = make(map[string]string) + } + for k, v := range m { + o.HeaderMap[k] = v + } + return o +} + +// WithHeader set header +func (o *Option) WithHeader(key, val string) *Option { + if o.HeaderMap == nil { + o.HeaderMap = make(map[string]string) + } + o.HeaderMap[key] = val + return o +} + +// WithData with custom data +func (o *Option) WithData(data any) *Option { + o.Data = data + return o +} + +// WithBody with custom body +func (o *Option) WithBody(r io.Reader) *Option { + o.Body = r + return o +} + +// AnyBody with custom body. +// +// Allow type: +// - string, []byte, map[string][]string/url.Values, io.Reader(eg: bytes.Buffer, strings.Reader) +func (o *Option) AnyBody(data any) *Option { + o.Body = ToRequestBody(data, o.ContentType) + return o +} + +// BytesBody with custom bytes body +func (o *Option) BytesBody(bs []byte) *Option { + o.Body = bytes.NewReader(bs) + return o +} + +// FormBody with custom form body data +func (o *Option) FormBody(data any) *Option { + o.ContentType = httpctype.Form + o.Body = ToRequestBody(data, o.ContentType) + return o +} + +// WithJSON with custom JSON body +func (o *Option) WithJSON(data any) *Option { + o.ContentType = httpctype.JSON + o.Body = ToRequestBody(data, o.ContentType) + return o +} + +// JSONBytesBody with custom bytes body, and set JSON content type +func (o *Option) JSONBytesBody(bs []byte) *Option { + o.ContentType = httpctype.JSON + return o.WithBody(bytes.NewReader(bs)) +} + +// StringBody with custom string body +func (o *Option) StringBody(s string) *Option { + o.Body = strings.NewReader(s) + return o +} + +// +// send request with options +// + +// Get send GET request and return http response +func (o *Option) Get(url string, fns ...OptionFn) (*http.Response, error) { + return o.Send(http.MethodGet, url, fns...) +} + +// Post send POST request and return http response +func (o *Option) Post(url string, data any, fns ...OptionFn) (*http.Response, error) { + return o.AnyBody(data).Send(http.MethodPost, url, fns...) +} + +// Put send PUT request and return http response +func (o *Option) Put(url string, data any, fns ...OptionFn) (*http.Response, error) { + return o.AnyBody(data).Send(http.MethodPut, url, fns...) +} + +// Delete send DELETE request and return http response +func (o *Option) Delete(url string, fns ...OptionFn) (*http.Response, error) { + return o.Send(http.MethodDelete, url, fns...) +} + +// Send request and return http response +func (o *Option) Send(method, url string, fns ...OptionFn) (*http.Response, error) { + cli := basefn.OrValue(o.cli != nil, o.cli, std) + o.WithOptionFns(fns).WithMethod(method) + + return cli.SendWithOpt(url, o) +} + +// MustSend request, will panic on error +func (o *Option) MustSend(method, url string, fns ...OptionFn) *http.Response { + cli := basefn.OrValue(o.cli != nil, o.cli, std) + o.WithOptionFns(fns).WithMethod(method) + + resp, err := cli.SendWithOpt(url, o) + if err != nil { + panic(err) + } + return resp +} diff --git a/netutil/httpreq/options_test.go b/netutil/httpreq/options_test.go new file mode 100644 index 000000000..bf344f9e0 --- /dev/null +++ b/netutil/httpreq/options_test.go @@ -0,0 +1,92 @@ +package httpreq_test + +import ( + "testing" + + "github.com/gookit/goutil/dump" + "github.com/gookit/goutil/jsonutil" + "github.com/gookit/goutil/netutil/httpctype" + "github.com/gookit/goutil/netutil/httpreq" + "github.com/gookit/goutil/testutil" + "github.com/gookit/goutil/testutil/assert" +) + +func TestOption_Send(t *testing.T) { + resp, err := httpreq.New(testSrvAddr). + StringBody(`{"name": "inhere"}`). + WithContentType(httpctype.JSON). + WithHeaderMap(map[string]string{"coustom1": "value1"}). + Send("POST", "/json") + + assert.NoErr(t, err) + sc := resp.StatusCode + assert.True(t, httpreq.IsOK(sc)) + assert.True(t, httpreq.IsSuccessful(sc)) + assert.False(t, httpreq.IsRedirect(sc)) + assert.False(t, httpreq.IsForbidden(sc)) + assert.False(t, httpreq.IsNotFound(sc)) + assert.False(t, httpreq.IsClientError(sc)) + assert.False(t, httpreq.IsServerError(sc)) + + retMp := make(map[string]any) + err = jsonutil.DecodeReader(resp.Body, &retMp) + assert.NoErr(t, err) + dump.P(retMp) +} + +func TestOptions_REST(t *testing.T) { + opt := httpreq.New(testSrvAddr).WithOption(). + WithContentType(httpctype.Form). + WithHeader("custom1", "value1"). + WithHeaderMap(map[string]string{ + "custom2": "value2", + }) + + t.Run("Get", func(t *testing.T) { + resp, err := opt.Get("/get", httpreq.WithData("name=inhere&age=18")) + assert.NoErr(t, err) + sc := resp.StatusCode + assert.True(t, httpreq.IsOK(sc)) + assert.True(t, httpreq.IsSuccessful(sc)) + + rr := testutil.ParseRespToReply(resp) + assert.Equal(t, "GET", rr.Method) + assert.Equal(t, "value1", rr.Headers["Custom1"]) + assert.Equal(t, "value2", rr.Headers["Custom2"]) + }) + + t.Run("Post", func(t *testing.T) { + resp, err := opt.Post("/post", "name=inhere&age=18") + assert.NoErr(t, err) + sc := resp.StatusCode + assert.True(t, httpreq.IsOK(sc)) + assert.True(t, httpreq.IsSuccessful(sc)) + + rr := testutil.ParseRespToReply(resp) + assert.Equal(t, "POST", rr.Method) + assert.Equal(t, "value1", rr.Headers["Custom1"]) + assert.Equal(t, "value2", rr.Headers["Custom2"]) + dump.P(rr) + }) + + t.Run("Put", func(t *testing.T) { + resp, err := opt.Put("/put", "name=inhere&age=18") + assert.NoErr(t, err) + sc := resp.StatusCode + assert.True(t, httpreq.IsOK(sc)) + assert.True(t, httpreq.IsSuccessful(sc)) + + rr := testutil.ParseRespToReply(resp) + assert.Equal(t, "PUT", rr.Method) + assert.Equal(t, "value1", rr.Headers["Custom1"]) + assert.Equal(t, "value2", rr.Headers["Custom2"]) + }) + + t.Run("Delete", func(t *testing.T) { + resp, err := opt.Delete("/delete", httpreq.WithData("name=inhere&age=18")) + assert.NoErr(t, err) + sc := resp.StatusCode + assert.True(t, httpreq.IsOK(sc)) + assert.True(t, httpreq.IsSuccessful(sc)) + }) +} diff --git a/netutil/httpreq/response.go b/netutil/httpreq/respx.go similarity index 63% rename from netutil/httpreq/response.go rename to netutil/httpreq/respx.go index e66e5059d..16214a5de 100644 --- a/netutil/httpreq/response.go +++ b/netutil/httpreq/respx.go @@ -10,57 +10,60 @@ import ( "github.com/gookit/goutil/netutil/httpctype" ) -// Resp struct -type Resp struct { +// Resp alias of RespX +type Resp = RespX + +// RespX wrap http.Response and add some useful methods. +type RespX struct { *http.Response // CostTime for a request-response CostTime int64 } // NewResp instance -func NewResp(hr *http.Response) *Resp { - return &Resp{Response: hr} +func NewResp(hr *http.Response) *RespX { + return &RespX{Response: hr} } // IsFail check -func (r *Resp) IsFail() bool { +func (r *RespX) IsFail() bool { return r.StatusCode != http.StatusOK } // IsOk check -func (r *Resp) IsOk() bool { +func (r *RespX) IsOk() bool { return r.StatusCode == http.StatusOK } // IsSuccessful check -func (r *Resp) IsSuccessful() bool { +func (r *RespX) IsSuccessful() bool { return IsSuccessful(r.StatusCode) } // IsEmptyBody check response body is empty -func (r *Resp) IsEmptyBody() bool { +func (r *RespX) IsEmptyBody() bool { return r.ContentLength <= 0 } // ContentType get response content type -func (r *Resp) ContentType() string { +func (r *RespX) ContentType() string { return r.Header.Get(httpctype.Key) } // BodyString get body as string. -func (r *Resp) String() string { +func (r *RespX) String() string { return ResponseToString(r.Response) } // BodyString get body as string. -func (r *Resp) BodyString() string { +func (r *RespX) BodyString() string { return r.BodyBuffer().String() } // BodyBuffer read body to buffer. // // NOTICE: must close resp body. -func (r *Resp) BodyBuffer() *bytes.Buffer { +func (r *RespX) BodyBuffer() *bytes.Buffer { buf := &bytes.Buffer{} // prof: assign memory before read if r.ContentLength > bytes.MinRead { @@ -68,7 +71,7 @@ func (r *Resp) BodyBuffer() *bytes.Buffer { } // NOTICE: must close resp body. - defer r.QuiteCloseBody() + defer r.SafeCloseBody() _, err := buf.ReadFrom(r.Body) if err != nil { panic(err) @@ -77,12 +80,13 @@ func (r *Resp) BodyBuffer() *bytes.Buffer { return buf } -// BindJSONOnOk body data on status is 200 +// BindJSONOnOk body data on response status is 200. +// if ptr is nil, will discard body data. // // NOTICE: must close resp body. -func (r *Resp) BindJSONOnOk(ptr any) error { +func (r *RespX) BindJSONOnOk(ptr any) error { // NOTICE: must close resp body. - defer r.QuiteCloseBody() + defer r.SafeCloseBody() if r.IsFail() { _, _ = io.Copy(io.Discard, r.Body) // <-- add this line @@ -96,27 +100,27 @@ func (r *Resp) BindJSONOnOk(ptr any) error { return json.NewDecoder(r.Body).Decode(ptr) } -// BindJSON body data to a ptr +// BindJSON body data to a ptr, will don't check status code. +// if ptr is nil, will discard body data. // // NOTICE: must close resp body. -func (r *Resp) BindJSON(ptr any) error { +func (r *RespX) BindJSON(ptr any) error { // NOTICE: must close resp body. - defer r.QuiteCloseBody() + defer r.SafeCloseBody() if ptr == nil { _, _ = io.Copy(io.Discard, r.Body) // <-- add this line return nil } - return json.NewDecoder(r.Body).Decode(ptr) } // CloseBody close resp body -func (r *Resp) CloseBody() error { +func (r *RespX) CloseBody() error { return r.Body.Close() } -// QuiteCloseBody close resp body, ignore error -func (r *Resp) QuiteCloseBody() { +// SafeCloseBody close resp body, ignore error +func (r *RespX) SafeCloseBody() { _ = r.Body.Close() } diff --git a/netutil/httpreq/respx_test.go b/netutil/httpreq/respx_test.go new file mode 100644 index 000000000..f9b58e86b --- /dev/null +++ b/netutil/httpreq/respx_test.go @@ -0,0 +1,32 @@ +package httpreq_test + +import ( + "fmt" + "testing" + + "github.com/gookit/goutil/netutil/httpctype" + "github.com/gookit/goutil/netutil/httpreq" + "github.com/gookit/goutil/testutil/assert" +) + +func TestRespX_String(t *testing.T) { + rx := httpreq.MustRespX(httpreq.Get(testSrvAddr + "/get")) + assert.NotNil(t, rx) + assert.Equal(t, 200, rx.StatusCode) + assert.True(t, rx.IsOk()) + assert.True(t, rx.IsSuccessful()) + assert.False(t, rx.IsFail()) + assert.False(t, rx.IsEmptyBody()) + assert.Equal(t, httpctype.JSON, rx.ContentType()) + + s := rx.String() + fmt.Println(s) + assert.StrContains(t, s, "GET") + + rx = httpreq.MustRespX(httpreq.Post(testSrvAddr+"/post", "hi")) + assert.NotNil(t, rx) + assert.True(t, rx.IsOk()) + s = rx.BodyString() + // fmt.Println(s) + assert.StrContains(t, s, `"hi"`) +} diff --git a/netutil/httpreq/util.go b/netutil/httpreq/util.go index 504f48097..7d26982a1 100644 --- a/netutil/httpreq/util.go +++ b/netutil/httpreq/util.go @@ -9,6 +9,7 @@ import ( "net/url" "strings" + "github.com/gookit/goutil/netutil/httpctype" "github.com/gookit/goutil/strutil" ) @@ -71,6 +72,11 @@ func IsServerError(statusCode int) bool { return statusCode >= http.StatusInternalServerError && statusCode <= 600 } +// IsNoBodyMethod check +func IsNoBodyMethod(method string) bool { + return method != "POST" && method != "PUT" && method != "PATCH" +} + // BuildBasicAuth returns the base64 encoded username:password for basic auth. // Then set to header "Authorization". // @@ -110,24 +116,49 @@ func HeaderToStringMap(rh http.Header) map[string]string { return mp } +// MakeQuery make query string, convert data to url.Values +func MakeQuery(data any) url.Values { + return ToQueryValues(data) +} + // ToQueryValues convert string-map or any-map to url.Values +// +// data support: +// - url.Values +// - []byte +// - string +// - map[string]string +// - map[string]any func ToQueryValues(data any) url.Values { - // use url.Values directly if we have it - if uv, ok := data.(url.Values); ok { - return uv - } - uv := make(url.Values) - if strMp, ok := data.(map[string]string); ok { - for k, v := range strMp { + + switch typData := data.(type) { + // use url.Values directly if we have it + case url.Values: + return typData + case map[string][]string: + return typData + case []byte: + m, err := url.ParseQuery(string(typData)) + if err != nil { + return uv + } + return m + case string: + m, err := url.ParseQuery(typData) + if err != nil { + return uv + } + return m + case map[string]string: + for k, v := range typData { uv.Add(k, v) } - } else if kvMp, ok := data.(map[string]any); ok { - for k, v := range kvMp { + case map[string]any: + for k, v := range typData { uv.Add(k, strutil.QuietString(v)) } } - return uv } @@ -162,12 +193,12 @@ func AppendQueryToURLString(urlStr string, query url.Values) string { return urlStr + "?" + query.Encode() } -// IsNoBodyMethod check -func IsNoBodyMethod(method string) bool { - return method != "POST" && method != "PUT" && method != "PATCH" +// MakeBody make request body, convert data to io.Reader +func MakeBody(data any, cType string) io.Reader { + return ToRequestBody(data, cType) } -// ToRequestBody convert handle +// ToRequestBody make request body, convert data to io.Reader // // Allow type for data: // - string @@ -175,39 +206,56 @@ func IsNoBodyMethod(method string) bool { // - map[string]string // - map[string][]string/url.Values // - io.Reader(eg: bytes.Buffer, strings.Reader) -func ToRequestBody(data any) io.Reader { - var reqBody io.Reader +func ToRequestBody(data any, cType string) io.Reader { + if data == nil { + return nil // nobody + } + + var reader io.Reader + kind := httpctype.ToKind(cType, "") + switch typVal := data.(type) { case io.Reader: - reqBody = typVal + reader = typVal + case []byte: + reader = bytes.NewBuffer(typVal) + case string: + reader = bytes.NewBufferString(typVal) + case url.Values: + reader = bytes.NewBufferString(typVal.Encode()) case map[string]string: - reqBody = bytes.NewBufferString(ToQueryValues(typVal).Encode()) + if kind == httpctype.KindJSON { + reader = toJSONReader(data) + } else { + reader = bytes.NewBufferString(ToQueryValues(typVal).Encode()) + } case map[string][]string: - reqBody = bytes.NewBufferString(url.Values(typVal).Encode()) - case url.Values: - reqBody = bytes.NewBufferString(typVal.Encode()) - case string: - reqBody = bytes.NewBufferString(typVal) - case []byte: - reqBody = bytes.NewBuffer(typVal) + if kind == httpctype.KindJSON { + reader = toJSONReader(data) + } else { + reader = bytes.NewBufferString(url.Values(typVal).Encode()) + } default: - // auto encode body data to json - if data != nil { - buf := &bytes.Buffer{} - enc := json.NewEncoder(buf) - // close escape &, <, > TO \u0026, \u003c, \u003e - enc.SetEscapeHTML(false) - if err := enc.Encode(data); err != nil { - panic("auto encode data error=" + err.Error()) - } - - reqBody = buf + // encode body data to json + if kind == httpctype.KindJSON { + reader = toJSONReader(data) + } else { + panic("invalid body data type for request") } - - // nobody } - return reqBody + return reader +} + +func toJSONReader(data any) io.Reader { + buf := &bytes.Buffer{} + enc := json.NewEncoder(buf) + // close escape &, <, > TO \u0026, \u003c, \u003e + enc.SetEscapeHTML(false) + if err := enc.Encode(data); err != nil { + panic("encode data as json fail. error=" + err.Error()) + } + return buf } // HeaderToString convert http Header to string @@ -248,6 +296,10 @@ func RequestToString(r *http.Request) string { // ResponseToString convert http Response to string func ResponseToString(w *http.Response) string { + if w == nil { + return "" + } + buf := &bytes.Buffer{} buf.WriteString(w.Proto) buf.WriteByte(' ') @@ -266,6 +318,7 @@ func ResponseToString(w *http.Response) string { if w.Body != nil { buf.WriteByte('\n') _, _ = buf.ReadFrom(w.Body) + _ = w.Body.Close() } return buf.String() diff --git a/netutil/httpreq/util_test.go b/netutil/httpreq/util_test.go index 369392a3e..3c46595cf 100644 --- a/netutil/httpreq/util_test.go +++ b/netutil/httpreq/util_test.go @@ -60,6 +60,12 @@ func TestToQueryValues(t *testing.T) { vs = httpreq.ToQueryValues(vs) assert.Eq(t, "field1=234&field2=value2", vs.Encode()) + + vs = httpreq.MakeQuery(map[string][]string{ + "field1": {"234"}, + "field2": {"value2"}, + }) + assert.StrContains(t, vs.Encode(), "field1=234") } func TestRequestToString(t *testing.T) { @@ -70,10 +76,11 @@ func TestRequestToString(t *testing.T) { "custom-key0": []string{"val0"}, }) - vs := httpreq.ToQueryValues(map[string]string{"field1": "value1", "field2": "value2"}) + vs := httpreq.ToQueryValues(map[string]string{ + "field1": "value1", "field2": "value2", + }) req.Body = io.NopCloser(strings.NewReader(vs.Encode())) - str := httpreq.RequestToString(req) dump.P(str)