diff --git a/netutil/http_client.go b/netutil/http_client.go new file mode 100644 index 0000000..3e97d2c --- /dev/null +++ b/netutil/http_client.go @@ -0,0 +1,228 @@ +package netutil + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "reflect" + "regexp" + "strings" + "time" + + "github.com/duke-git/lancet/slice" +) + +// HttpRequest struct is a composed http request +type HttpRequest struct { + RawURL string + Method string + Headers http.Header + QueryParams url.Values + FormData url.Values + Body []byte +} + +// HttpClientConfig contains some configurations for http client +type HttpClientConfig struct { + SSLEnabled bool + TLSConfig *tls.Config + Compressed bool + HandshakeTimeout time.Duration + ResponseTimeout time.Duration + Verbose bool +} + +// defaultHttpClientConfig defalut client config +var defaultHttpClientConfig = &HttpClientConfig{ + Compressed: false, + HandshakeTimeout: 20 * time.Second, + ResponseTimeout: 40 * time.Second, +} + +// HttpClient is used for sending http request +type HttpClient struct { + *http.Client + TLS *tls.Config + Request *http.Request + Config HttpClientConfig +} + +// NewHttpClient make a HttpClient instance +func NewHttpClient() *HttpClient { + client := &HttpClient{ + Client: &http.Client{ + Transport: &http.Transport{ + TLSHandshakeTimeout: defaultHttpClientConfig.HandshakeTimeout, + ResponseHeaderTimeout: defaultHttpClientConfig.ResponseTimeout, + DisableCompression: !defaultHttpClientConfig.Compressed, + }, + }, + Config: *defaultHttpClientConfig, + } + + return client +} + +// NewHttpClientWithConfig make a HttpClient instance with pass config +func NewHttpClientWithConfig(config *HttpClientConfig) *HttpClient { + if config == nil { + config = defaultHttpClientConfig + } + + client := &HttpClient{ + Client: &http.Client{ + Transport: &http.Transport{ + TLSHandshakeTimeout: config.HandshakeTimeout, + ResponseHeaderTimeout: config.ResponseTimeout, + DisableCompression: !config.Compressed, + }, + }, + Config: *config, + } + + if config.SSLEnabled { + client.TLS = config.TLSConfig + } + + return client +} + +// SendRequest send http request +func (client *HttpClient) SendRequest(request *HttpRequest) (*http.Response, error) { + err := validateRequest(request) + if err != nil { + return nil, err + } + + rawUrl := request.RawURL + + req, err := http.NewRequest(request.Method, rawUrl, bytes.NewBuffer(request.Body)) + if err != nil { + return nil, err + } + + client.setTLS(rawUrl) + client.setHeader(req, request.Headers) + client.setQueryParam(req, rawUrl, request.QueryParams) + + if request.FormData != nil { + client.setFormData(req, request.FormData) + } + + client.Request = req + + resp, err := client.Client.Do(req) + if err != nil { + return nil, err + } + + return resp, nil +} + +// DecodeResponse decode response into target object +func (client *HttpClient) DecodeResponse(resp *http.Response, target interface{}) error { + if resp == nil { + return errors.New("invalid target param") + } + defer resp.Body.Close() + return json.NewDecoder(resp.Body).Decode(target) +} + +// setTLS set http client transport TLSClientConfig +func (client *HttpClient) setTLS(rawUrl string) { + if strings.HasPrefix(rawUrl, "https") { + if transport, ok := client.Client.Transport.(*http.Transport); ok { + transport.TLSClientConfig = client.TLS + } + } +} + +// setHeader set http rquest header +func (client *HttpClient) setHeader(req *http.Request, headers http.Header) { + if headers == nil { + headers = make(http.Header) + } + + if _, ok := headers["Accept"]; !ok { + headers["Accept"] = []string{"*/*"} + } + if _, ok := headers["Accept-Encoding"]; !ok && client.Config.Compressed { + headers["Accept-Encoding"] = []string{"deflate, gzip"} + } + + req.Header = headers +} + +// setQueryParam set http request query string param +func (client *HttpClient) setQueryParam(req *http.Request, reqUrl string, queryParam url.Values) error { + if queryParam != nil { + if !strings.Contains(reqUrl, "?") { + reqUrl = reqUrl + "?" + queryParam.Encode() + } else { + reqUrl = reqUrl + "&" + queryParam.Encode() + } + u, err := url.Parse(reqUrl) + if err != nil { + return err + } + req.URL = u + } + return nil +} + +func (client *HttpClient) setFormData(req *http.Request, values url.Values) { + formData := []byte(values.Encode()) + req.Body = ioutil.NopCloser(bytes.NewReader(formData)) + req.ContentLength = int64(len(formData)) +} + +// validateRequest check if a request has url, and valid method. +func validateRequest(req *HttpRequest) error { + if req.RawURL == "" { + return errors.New("invalid request url") + } + + // common HTTP methods + methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", + "HEAD", "CONNECT", "OPTIONS", "TRACE"} + + if !slice.Contain(methods, strings.ToUpper(req.Method)) { + return errors.New("invalid request method") + } + + return nil +} + +// StructToUrlValues convert struct to url valuse, +// only convert the field which is exported and has `json` tag +func StructToUrlValues(targetStruct interface{}) url.Values { + rv := reflect.ValueOf(targetStruct) + rt := reflect.TypeOf(targetStruct) + + if rt.Kind() == reflect.Ptr { + rt = rt.Elem() + } + if rt.Kind() != reflect.Struct { + panic(fmt.Errorf("data type %T not support, shuld be struct or pointer to struct", targetStruct)) + } + + result := url.Values{} + + fieldNum := rt.NumField() + pattern := `^[A-Z]` + regex := regexp.MustCompile(pattern) + for i := 0; i < fieldNum; i++ { + name := rt.Field(i).Name + tag := rt.Field(i).Tag.Get("json") + if regex.MatchString(name) && tag != "" { + result.Add(tag, fmt.Sprintf("%v", rv.Field(i).Interface())) + } + } + + return result +} diff --git a/netutil/http_client_test.go b/netutil/http_client_test.go new file mode 100644 index 0000000..fba2e5a --- /dev/null +++ b/netutil/http_client_test.go @@ -0,0 +1,95 @@ +package netutil + +import ( + "io/ioutil" + "log" + "net/http" + "net/url" + "testing" + + "github.com/duke-git/lancet/internal" +) + +func TestHttpClient_Get(t *testing.T) { + assert := internal.NewAssert(t, "TestHttpClient_Get") + + request := &HttpRequest{ + RawURL: "https://jsonplaceholder.typicode.com/todos/1", + Method: "GET", + } + + httpClient := NewHttpClient() + resp, err := httpClient.SendRequest(request) + if err != nil || resp.StatusCode != 200 { + log.Fatal(err) + } + + type Todo struct { + UserId int `json:"userId"` + Id int `json:"id"` + Title string `json:"title"` + Completed bool `json:"completed"` + } + + var todo Todo + httpClient.DecodeResponse(resp, &todo) + + assert.Equal(1, todo.Id) +} + +func TestHttpClent_Post(t *testing.T) { + header := http.Header{} + header.Add("Content-Type", "multipart/form-data") + + postData := url.Values{} + postData.Add("userId", "1") + postData.Add("title", "testItem") + + request := &HttpRequest{ + RawURL: "https://jsonplaceholder.typicode.com/todos", + Method: "POST", + Headers: header, + FormData: postData, + } + + httpClient := NewHttpClient() + resp, err := httpClient.SendRequest(request) + if err != nil { + log.Fatal(err) + } + + body, _ := ioutil.ReadAll(resp.Body) + t.Log("response: ", resp.StatusCode, string(body)) +} + +func TestStructToUrlValues(t *testing.T) { + assert := internal.NewAssert(t, "TestStructToUrlValues") + + type TodoQuery struct { + Id int `json:"id"` + UserId int `json:"userId"` + } + todoQuery := TodoQuery{ + Id: 1, + UserId: 1, + } + todoValues := StructToUrlValues(todoQuery) + + assert.Equal("1", todoValues.Get("id")) + assert.Equal("1", todoValues.Get("userId")) + + request := &HttpRequest{ + RawURL: "https://jsonplaceholder.typicode.com/todos", + Method: "GET", + QueryParams: todoValues, + } + + httpClient := NewHttpClient() + resp, err := httpClient.SendRequest(request) + if err != nil || resp.StatusCode != 200 { + log.Fatal(err) + } + + body, _ := ioutil.ReadAll(resp.Body) + t.Log("response: ", string(body)) +}