Created
May 12, 2020 07:07
-
-
Save byrnedo/55a07e3545b5d7fe58e626213b0511d0 to your computer and use it in GitHub Desktop.
Comprehensive http client base in go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package client | |
import ( | |
"bytes" | |
"context" | |
"crypto/tls" | |
"encoding/json" | |
"fmt" | |
"github.com/pkg/errors" | |
"io" | |
"io/ioutil" | |
"net/http" | |
"net/http/httputil" | |
"net/url" | |
"strings" | |
"time" | |
) | |
const ( | |
mimeJSON = "application/json" | |
mimeFormURLEncoded = "application/x-www-form-urlencoded" | |
// DefaultURL is the default api url | |
DefaultURL = "https://api-sandbox.downstream.com" | |
) | |
type Scope string | |
type Scopes []Scope | |
func (sc *Scopes) UnmarshalJSON(d []byte) error { | |
strVal := "" | |
if err := json.Unmarshal(d, &strVal); err != nil { | |
return err | |
} | |
scopes := strings.Split(strVal, "") | |
for _, s := range scopes { | |
*sc = append(*sc, Scope(s)) | |
} | |
return nil | |
} | |
func (sc Scopes) UrlEncode() string { | |
var scopes []string | |
for _, s := range sc { | |
scopes = append(scopes, string(s)) | |
} | |
return strings.Join(scopes, "%20") | |
} | |
func (sc Scopes) String() string { | |
var scopes []string | |
for _, s := range sc { | |
scopes = append(scopes, string(s)) | |
} | |
return strings.Join(scopes, " ") | |
} | |
type GrantType string | |
const ( | |
GrantTypeAuthorizationCode GrantType = "authorization_code" | |
GrantTypeRefreshToken GrantType = "refresh_token" | |
) | |
var ( | |
defaultTimeout = time.Duration(20 * time.Second) | |
defaultHeaders = map[string]string{ | |
"Accept": mimeJSON, | |
"User-Agent": "Go-http-client/1.1", | |
} | |
) | |
//AccessTokenOptions are options used when creating access tokens | |
type AccessTokenOptions struct { | |
BaseURL string | |
HTTPClient *http.Client | |
} | |
// ClientOptions for creating the main client | |
type ClientOptions struct { | |
// Users's access token (obtained by user when they add our integration) | |
AccessToken string | |
BaseURL string | |
HTTPClient *http.Client | |
TraceCB func(string) | |
} | |
type ClientFace interface { | |
//FILL ME | |
SetBaseURL(string) | |
} | |
// Client for api calls | |
type Client struct { | |
clientOptions *ClientOptions | |
} | |
// OptionsFunc sig for customising options | |
type OptionsFunc func(o *ClientOptions) error | |
// WithURL helper for changing base url | |
func WithURL(url string) OptionsFunc { | |
return func(o *ClientOptions) error { | |
o.BaseURL = url | |
return nil | |
} | |
} | |
func WithTracer(cb func(string)) OptionsFunc { | |
return func(opts *ClientOptions) error { | |
opts.TraceCB = cb | |
return nil | |
} | |
} | |
func WithTLSConfig(c *tls.Config) OptionsFunc { | |
return func(opts *ClientOptions) error { | |
opts.HTTPClient.Transport = &http.Transport{ | |
TLSClientConfig: c, | |
} | |
return nil | |
} | |
} | |
// NewClient creates a new client | |
func NewClient(optionsFuncs ...OptionsFunc) (*Client, error) { | |
c := &http.Client{Timeout: defaultTimeout} | |
o := &ClientOptions{ | |
BaseURL: DefaultURL, | |
HTTPClient: c, | |
TraceCB: func(string) {}, | |
} | |
for _, f := range optionsFuncs { | |
if err := f(o); err != nil { | |
return nil, err | |
} | |
} | |
return &Client{ | |
clientOptions: o, | |
}, nil | |
} | |
func (c *Client) SetBaseURL(url string) { | |
c.clientOptions.BaseURL = url | |
} | |
func (c *Client) _makeURL(base string, path string) (*url.URL, error) { | |
u, err := url.Parse(base) | |
if err != nil { | |
return nil, err | |
} | |
u2, err := url.Parse(path) | |
if err != nil { | |
return nil, err | |
} | |
return u.ResolveReference(u2), nil | |
} | |
func (c *Client) makeURL(section string) (*url.URL, error) { | |
return c._makeURL(c.clientOptions.BaseURL, section) | |
} | |
func (c *Client) deleteResource(ctx context.Context, resource string) error { | |
return c.requestJson(ctx, "DELETE", resource, nil, nil) | |
} | |
type inputs struct { | |
Headers http.Header | |
Query url.Values | |
Body interface{} | |
Cookies []*http.Cookie | |
} | |
func (c *Client) requestJson(ctx context.Context, method, resource string, inputs *inputs, result interface{}) error { | |
u, err := c.makeURL(resource) | |
if err != nil { | |
return err | |
} | |
var bodyBuffer io.Reader | |
bodyBuffer = http.NoBody | |
var headers http.Header | |
var cookies []*http.Cookie | |
if inputs != nil { | |
cookies = inputs.Cookies | |
headers = inputs.Headers | |
if headers == nil { | |
headers = http.Header{} | |
} | |
if len(inputs.Query) > 0 { | |
u.RawQuery = inputs.Query.Encode() | |
} | |
contentType := headers.Get("Content-type") | |
if contentType == "" { | |
headers.Set("Content-Type", mimeJSON) | |
} | |
// hack to specify not to send content-type | |
if contentType == "-" { | |
headers.Del("Content-Type") | |
} | |
if c.clientOptions.AccessToken != "" { | |
headers.Set("Authorization", "Bearer "+strings.TrimSpace(c.clientOptions.AccessToken)) | |
} | |
lowerMethod := strings.ToLower(method) | |
if lowerMethod != "delete" && lowerMethod != "get" && inputs.Body != nil { | |
bBuf := new(bytes.Buffer) | |
json.NewEncoder(bBuf).Encode(inputs.Body) | |
bodyBuffer = bBuf | |
} | |
} | |
return c.requestAndUnmarshal(ctx, c.clientOptions.HTTPClient, headers, method, u.String(), bodyBuffer, result, cookies...) | |
} | |
// Our internal remote error holder | |
type DownstreamError struct { | |
HTTPStatus int | |
Code string | |
Message string | |
} | |
// Error pretty print error | |
func (f DownstreamError) Error() string { | |
return fmt.Sprintf("%s - %s", f.Code, f.Message) | |
} | |
func (c *Client) request(ctx context.Context, headers http.Header, method, url string, data io.Reader, cookies ...*http.Cookie) (*http.Response, error) { | |
req, err := http.NewRequest(method, url, data) | |
if err != nil { | |
return nil, errors.Wrap(err, "error creating request") | |
} | |
for _, c := range cookies { | |
req.AddCookie(c) | |
} | |
req = req.WithContext(ctx) | |
for k, v := range defaultHeaders { | |
req.Header[k] = []string{v} | |
} | |
for k, v := range headers { | |
req.Header[k] = v | |
} | |
b, _ := httputil.DumpRequest(req, true) | |
c.clientOptions.TraceCB("REQUEST:\n" + string(b)) | |
resp, err := c.clientOptions.HTTPClient.Do(req) | |
if err != nil { | |
return nil, errors.Wrap(err, "error sending request") | |
} | |
b, _ = httputil.DumpResponse(resp, true) | |
c.clientOptions.TraceCB("RESPONSE:\n" + string(b)) | |
return resp, nil | |
} | |
func (c *Client) drainBody(body io.ReadCloser) { | |
_, _ = io.CopyN(ioutil.Discard, body, 64) | |
_ = body.Close() | |
} | |
func (c *Client) requestAndUnmarshal(ctx context.Context, client *http.Client, headers http.Header, method, url string, data io.Reader, result interface{}, cookies ...*http.Cookie) error { | |
resp, err := c.request(ctx, headers, method, url, data, cookies...) | |
if err != nil { | |
return err | |
} | |
// trick to drain body | |
defer c.drainBody(resp.Body) | |
switch resp.StatusCode { | |
case 200, 201: | |
bodyPreview, _ := getRespBodyPreview(resp, 30) | |
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { | |
return errors.Wrap(err, "failed to decode json from response ["+bodyPreview+"]") | |
} | |
return nil | |
case 204: | |
return nil | |
default: | |
bodyStr, _ := ioutil.ReadAll(io.LimitReader(resp.Body, 4096)) | |
return DownstreamError{HTTPStatus: resp.StatusCode, Code: fmt.Sprintf("%d", resp.StatusCode), Message: string(bodyStr)} | |
} | |
} | |
// Get a preview of the body without affecting the resp.Body reader | |
func getRespBodyPreview(resp *http.Response, len int64) (string, error) { | |
part, err := ioutil.ReadAll(io.LimitReader(resp.Body, len)) | |
if err != nil { | |
return "", err | |
} | |
// recombine the buffered part of the body with the rest of the stream | |
resp.Body = ioutil.NopCloser(io.MultiReader(bytes.NewReader(part), resp.Body)) | |
return string(part), nil | |
} | |
func (c *Client) SetToken(token string) *Client { | |
c.clientOptions.AccessToken = token | |
return c | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment