diff --git a/pkg/httpclient/auth.go b/pkg/httpclient/auth.go new file mode 100644 index 0000000..37a06d5 --- /dev/null +++ b/pkg/httpclient/auth.go @@ -0,0 +1,27 @@ +package httpclient + +import ( + "encoding/base64" + "fmt" +) + +type Authorization interface { + Token() string +} + +type BasicAuth struct { + Username string + Password string +} + +type BearerAuth struct { + AccessToken string +} + +func (auth *BasicAuth) Token() string { + return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(auth.Username+":"+auth.Password))) +} + +func (auth *BearerAuth) Token() string { + return fmt.Sprintf("Bearer %s", auth.AccessToken) +} diff --git a/pkg/httpclient/client.go b/pkg/httpclient/client.go new file mode 100644 index 0000000..1b7f8cd --- /dev/null +++ b/pkg/httpclient/client.go @@ -0,0 +1,174 @@ +package httpclient + +import ( + "crypto/tls" + "io" + "net" + "net/http" + "net/http/cookiejar" + "strings" + "time" +) + +type ( + BeforeRequest func(req *http.Request) (err error) + AfterRequest func(req *http.Request, res *http.Response) (err error) + + Client struct { + baseUrl string + Authorization Authorization + client *http.Client + cookieJar *cookiejar.Jar + interceptorRequest []BeforeRequest + interceptorResponse []AfterRequest + } +) + +var ( + DefaultClient = &http.Client{ + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + Proxy: http.ProxyFromEnvironment, + ForceAttemptHTTP2: true, + MaxIdleConns: 64, + MaxIdleConnsPerHost: 8, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + Timeout: time.Second * 30, + } +) + +func (client *Client) stashUri(urlPath string) string { + var ( + pos int + ) + if len(urlPath) == 0 { + return client.baseUrl + } + if pos = strings.Index(urlPath, "//"); pos == -1 { + if client.baseUrl != "" { + if urlPath[0] != '/' { + urlPath = "/" + urlPath + } + return client.baseUrl + urlPath + } + } + return urlPath +} + +func (client *Client) BeforeRequest(cb BeforeRequest) *Client { + client.interceptorRequest = append(client.interceptorRequest, cb) + return client +} + +func (client *Client) AfterRequest(cb AfterRequest) *Client { + client.interceptorResponse = append(client.interceptorResponse, cb) + return client +} + +func (client *Client) SetBaseUrl(s string) *Client { + client.baseUrl = strings.TrimSuffix(s, "/") + return client +} + +func (client *Client) SetCookieJar(cookieJar *cookiejar.Jar) *Client { + client.client.Jar = cookieJar + return client +} + +func (client *Client) SetClient(httpClient *http.Client) *Client { + client.client = httpClient + if client.cookieJar != nil { + client.client.Jar = client.cookieJar + } + return client +} + +func (client *Client) SetTransport(transport http.RoundTripper) *Client { + client.client.Transport = transport + return client +} + +func (client *Client) Get(urlPath string) *Request { + return newRequest(http.MethodGet, client.stashUri(urlPath), client) +} + +func (client *Client) Put(urlPath string) *Request { + return newRequest(http.MethodPut, client.stashUri(urlPath), client) +} + +func (client *Client) Post(urlPath string) *Request { + return newRequest(http.MethodPost, client.stashUri(urlPath), client) +} + +func (client *Client) Delete(urlPath string) *Request { + return newRequest(http.MethodDelete, client.stashUri(urlPath), client) +} + +func (client *Client) execute(r *Request) (res *http.Response, err error) { + var ( + n int + reader io.Reader + ) + if r.contentType == "" && r.body != nil { + r.contentType = r.detectContentType(r.body) + } + if r.body != nil { + if reader, err = r.readRequestBody(r.contentType, r.body); err != nil { + return + } + } + if r.rawRequest, err = http.NewRequest(r.method, r.uri, reader); err != nil { + return + } + for k, vs := range r.header { + for _, v := range vs { + r.rawRequest.Header.Add(k, v) + } + } + if r.contentType != "" { + r.rawRequest.Header.Set("Content-Type", r.contentType) + } + if client.Authorization != nil { + r.rawRequest.Header.Set("Authorization", client.Authorization.Token()) + } + if r.context != nil { + r.rawRequest = r.rawRequest.WithContext(r.context) + } + n = len(client.interceptorRequest) + for i := n - 1; i >= 0; i-- { + if err = client.interceptorRequest[i](r.rawRequest); err != nil { + return + } + } + if r.rawResponse, err = client.client.Do(r.rawRequest); err != nil { + return nil, err + } + n = len(client.interceptorResponse) + for i := n - 1; i >= 0; i-- { + if err = client.interceptorResponse[i](r.rawRequest, r.rawResponse); err != nil { + _ = r.rawResponse.Body.Close() + return + } + } + return r.rawResponse, err +} + +func New() *Client { + client := &Client{ + client: DefaultClient, + interceptorRequest: make([]BeforeRequest, 0, 10), + interceptorResponse: make([]AfterRequest, 0, 10), + } + client.cookieJar, _ = cookiejar.New(nil) + client.client.Jar = client.cookieJar + return client +} diff --git a/pkg/httpclient/codec.go b/pkg/httpclient/codec.go new file mode 100644 index 0000000..7067edd --- /dev/null +++ b/pkg/httpclient/codec.go @@ -0,0 +1,28 @@ +package httpclient + +import ( + "bytes" + "encoding/json" + "io" + "strings" +) + +func encodeBody(data any) (r io.Reader, contentType string, err error) { + var ( + buf []byte + ) + switch v := data.(type) { + case string: + r = strings.NewReader(v) + contentType = "x-www-form-urlencoded" + case []byte: + r = bytes.NewReader(v) + contentType = "x-www-form-urlencoded" + default: + if buf, err = json.Marshal(v); err == nil { + r = bytes.NewReader(buf) + contentType = "application/json" + } + } + return +} diff --git a/pkg/httpclient/method.go b/pkg/httpclient/method.go new file mode 100644 index 0000000..91190f3 --- /dev/null +++ b/pkg/httpclient/method.go @@ -0,0 +1,162 @@ +package httpclient + +import ( + "context" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" +) + +func doHttpRequest(req *http.Request, opts *options) (res *http.Response, err error) { + if opts.human { + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36 Edg/111.0.1661.54") + } + if req.Header.Get("Referer") == "" { + req.Header.Set("Referer", req.URL.String()) + } + } + return opts.client.Do(req) +} + +// Get performs a GET request to the specified URL with optional parameters and headers. +func Get(ctx context.Context, urlString string, cbs ...Option) (res *http.Response, err error) { + var ( + uri *url.URL + req *http.Request + ) + opts := newOptions() + for _, cb := range cbs { + cb(opts) + } + if uri, err = url.Parse(urlString); err != nil { + return + } + if opts.params != nil { + qs := uri.Query() + for k, v := range opts.params { + qs.Set(k, v) + } + uri.RawQuery = qs.Encode() + } + if req, err = http.NewRequestWithContext(ctx, http.MethodGet, uri.String(), nil); err != nil { + return + } + if opts.header != nil { + for k, v := range opts.header { + req.Header.Set(k, v) + } + } + return doHttpRequest(req, opts) +} + +// Post performs a POST request to the specified URL with optional parameters, headers, and data. +func Post(ctx context.Context, urlString string, cbs ...Option) (res *http.Response, err error) { + var ( + uri *url.URL + req *http.Request + contentType string + reader io.Reader + ) + opts := newOptions() + for _, cb := range cbs { + cb(opts) + } + if uri, err = url.Parse(urlString); err != nil { + return + } + if opts.params != nil { + qs := uri.Query() + for k, v := range opts.params { + qs.Set(k, v) + } + uri.RawQuery = qs.Encode() + } + if opts.body != nil { + if reader, contentType, err = encodeBody(opts.body); err != nil { + return + } + } + if req, err = http.NewRequestWithContext(ctx, http.MethodPost, uri.String(), reader); err != nil { + return + } + if opts.header != nil { + for k, v := range opts.header { + req.Header.Set(k, v) + } + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + return doHttpRequest(req, opts) +} + +// Do performs a request to the specified URL with optional parameters, headers, and data. +func Do(ctx context.Context, urlString string, result any, cbs ...Option) (err error) { + var ( + contentType string + reader io.Reader + uri *url.URL + res *http.Response + req *http.Request + ) + opts := newOptions() + for _, cb := range cbs { + cb(opts) + } + if uri, err = url.Parse(urlString); err != nil { + return + } + if opts.params != nil { + qs := uri.Query() + for k, v := range opts.params { + qs.Set(k, v) + } + uri.RawQuery = qs.Encode() + } + if opts.body != nil { + if reader, contentType, err = encodeBody(opts.body); err != nil { + return + } + } + if req, err = http.NewRequestWithContext(ctx, opts.method, uri.String(), reader); err != nil { + return + } + if opts.header != nil { + for k, v := range opts.header { + req.Header.Set(k, v) + } + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if res, err = doHttpRequest(req, opts); err != nil { + return + } + defer func() { + _ = res.Body.Close() + }() + if res.StatusCode != http.StatusOK { + err = fmt.Errorf("unexpected status %s(%d)", res.Status, res.StatusCode) + return + } + //don't care response + if result == nil { + return nil + } + contentType = strings.ToLower(res.Header.Get("Content-Type")) + extName := path.Ext(req.URL.String()) + if strings.Contains(contentType, JSON) || extName == ".json" { + err = json.NewDecoder(res.Body).Decode(result) + } else if strings.Contains(contentType, XML) || extName == ".xml" { + err = xml.NewDecoder(res.Body).Decode(result) + } else { + err = fmt.Errorf("unsupported content type: %s", contentType) + } + return +} diff --git a/pkg/httpclient/request.go b/pkg/httpclient/request.go new file mode 100644 index 0000000..9369694 --- /dev/null +++ b/pkg/httpclient/request.go @@ -0,0 +1,235 @@ +package httpclient + +import ( + "bytes" + "context" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "reflect" + "regexp" + "strings" +) + +const ( + JSON = "application/json" + XML = "application/xml" + + plainTextType = "text/plain; charset=utf-8" + jsonContentType = "application/json" + formContentType = "application/x-www-form-urlencoded" +) + +var ( + jsonCheck = regexp.MustCompile(`(?i:(application|text)/(json|.*\+json|json\-.*)(;|$))`) + xmlCheck = regexp.MustCompile(`(?i:(application|text)/(xml|.*\+xml)(;|$))`) +) + +type Request struct { + context context.Context + method string + uri string + url *url.URL + body any + query url.Values + formData url.Values + header http.Header + contentType string + authorization Authorization + client *Client + rawRequest *http.Request + rawResponse *http.Response +} + +func (r *Request) detectContentType(body any) string { + contentType := plainTextType + kind := reflect.Indirect(reflect.ValueOf(body)).Type().Kind() + switch kind { + case reflect.Struct, reflect.Map: + contentType = jsonContentType + case reflect.String: + contentType = plainTextType + default: + if b, ok := body.([]byte); ok { + contentType = http.DetectContentType(b) + } else if kind == reflect.Slice { + contentType = jsonContentType + } + } + return contentType +} + +func (r *Request) readRequestBody(contentType string, body any) (reader io.Reader, err error) { + var ( + ok bool + s string + buf []byte + ) + kind := reflect.Indirect(reflect.ValueOf(body)).Type().Kind() + if reader, ok = r.body.(io.Reader); ok { + return reader, nil + } + if buf, ok = r.body.([]byte); ok { + goto __end + } + if s, ok = r.body.(string); ok { + buf = []byte(s) + goto __end + } + if jsonCheck.MatchString(contentType) && (kind == reflect.Struct || kind == reflect.Map || kind == reflect.Slice) { + buf, err = json.Marshal(r.body) + goto __end + } + if xmlCheck.MatchString(contentType) && (kind == reflect.Struct) { + buf, err = xml.Marshal(r.body) + goto __end + } + err = fmt.Errorf("unmarshal content type %s", contentType) +__end: + if err == nil { + if len(buf) > 0 { + return bytes.NewReader(buf), nil + } + } + return +} + +func (r *Request) SetContext(ctx context.Context) *Request { + r.context = ctx + return r +} + +func (r *Request) AddQuery(k, v string) *Request { + r.query.Add(k, v) + return r +} + +func (r *Request) SetQuery(vs map[string]string) *Request { + for k, v := range vs { + r.query.Set(k, v) + } + return r +} + +func (r *Request) AddFormData(k, v string) *Request { + r.contentType = formContentType + r.formData.Add(k, v) + return r +} + +func (r *Request) SetFormData(vs map[string]string) *Request { + r.contentType = formContentType + for k, v := range vs { + r.formData.Set(k, v) + } + return r +} + +func (r *Request) SetBody(v any) *Request { + r.body = v + return r +} + +func (r *Request) SetContentType(v string) *Request { + r.contentType = v + return r +} + +func (r *Request) AddHeader(k, v string) *Request { + r.header.Add(k, v) + return r +} + +func (r *Request) SetHeader(h http.Header) *Request { + r.header = h + return r +} + +func (r *Request) Do() (res *http.Response, err error) { + var s string + s = r.formData.Encode() + if len(s) > 0 { + r.body = s + } + r.url.RawQuery = r.query.Encode() + r.uri = r.url.String() + return r.client.execute(r) +} + +func (r *Request) Response(v any) (err error) { + var ( + res *http.Response + buf []byte + contentType string + ) + if res, err = r.Do(); err != nil { + return + } + defer func() { + _ = res.Body.Close() + }() + if res.StatusCode/100 != 2 { + if buf, err = io.ReadAll(res.Body); err == nil && len(buf) > 0 { + err = fmt.Errorf("http response %s(%d): %s", res.Status, res.StatusCode, string(buf)) + } else { + err = fmt.Errorf("http response %d: %s", res.StatusCode, res.Status) + } + return + } + contentType = strings.ToLower(res.Header.Get("Content-Type")) + extName := path.Ext(r.rawRequest.URL.String()) + if strings.Contains(contentType, JSON) || extName == ".json" { + err = json.NewDecoder(res.Body).Decode(v) + } else if strings.Contains(contentType, XML) || extName == ".xml" { + err = xml.NewDecoder(res.Body).Decode(v) + } else { + err = fmt.Errorf("unsupported content type: %s", contentType) + } + return +} + +func (r *Request) Download(s string) (err error) { + var ( + fp *os.File + res *http.Response + ) + if res, err = r.Do(); err != nil { + return + } + defer func() { + _ = res.Body.Close() + }() + if fp, err = os.OpenFile(s, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644); err != nil { + return + } + defer func() { + _ = fp.Close() + }() + _, err = io.Copy(fp, res.Body) + return +} + +func newRequest(method string, uri string, client *Client) *Request { + var ( + err error + ) + r := &Request{ + context: context.Background(), + method: method, + uri: uri, + header: make(http.Header), + formData: make(url.Values), + client: client, + } + if r.url, err = url.Parse(uri); err == nil { + r.query = r.url.Query() + } else { + r.query = make(url.Values) + } + return r +} diff --git a/pkg/httpclient/types.go b/pkg/httpclient/types.go new file mode 100644 index 0000000..398be68 --- /dev/null +++ b/pkg/httpclient/types.go @@ -0,0 +1,71 @@ +package httpclient + +import ( + "maps" + "net/http" +) + +type ( + options struct { + url string + method string + header map[string]string + params map[string]string + body any + human bool + client *http.Client + } + Option func(o *options) +) + +func WithUrl(s string) Option { + return func(o *options) { + o.url = s + } +} + +func WithMethod(s string) Option { + return func(o *options) { + o.method = s + } +} + +func WithHuman() Option { + return func(o *options) { + o.human = true + } +} + +func WithClient(c *http.Client) Option { + return func(o *options) { + o.client = c + } +} + +func WithHeader(h map[string]string) Option { + return func(o *options) { + if o.header == nil { + o.header = make(map[string]string) + } + maps.Copy(o.header, h) + } +} + +func WithParams(h map[string]string) Option { + return func(o *options) { + o.params = h + } +} + +func WithBody(v any) Option { + return func(o *options) { + o.body = v + } +} + +func newOptions() *options { + return &options{ + client: DefaultClient, + method: http.MethodGet, + } +}