Skip to content

Instantly share code, notes, and snippets.

@byrnedo
Created May 12, 2020 07:07
Show Gist options
  • Save byrnedo/55a07e3545b5d7fe58e626213b0511d0 to your computer and use it in GitHub Desktop.
Save byrnedo/55a07e3545b5d7fe58e626213b0511d0 to your computer and use it in GitHub Desktop.
Comprehensive http client base in go
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