August 13, 2022

Starter Http Client Abstraction In Go

Whenever I work with external Restful API’s in Go I almost always reach for some sort of abstraction around the http.Client. There are many useful and powerful libraries in the ecosystem that provide this abstraction, but with those libraries comes complexity from that packages API, and the need manage another dependency in your project. Often times, what you really need is just a little syntax sugar to make your code a little more readable and reduce the boilerplate for making requests.

This snippet has 3 main parts

The Struct

We’ll start by defining a Client struct that will contain all the fields that we’ll need to make requests. In some cases you may find it best to copy and paste this around and rename/extend it as needed, however you can also embed this into your own structs and keep the Client struct self-contained and reusable.

type Client struct {
	// the client is a pointer to the http.Client struct that is used for performing requests
    client      *http.Client
	// the baseURL is the base URL that is used for all requests, we also use this for a few
	// convenience methods that make it easier to construct the full URL for a request
    BaseURL     string
	// With most API's you'll need some sort of Bearer Token to authenticate with,
	// this is the field that you'll use to store that token
    BearerToken string
}

// New returns a new instance of the Client struct
func New(client *http.Client, base string) *Client {
	return &Client{
		client:  client,
		BaseURL: base,
	}
}

Request Receivers

The request receiver are the main syntax sugar around this pattern. We’ll define a Do receiver that will take in a point to a *http.Request and return a *http.Response and any associated errors. This gives us a single location to do any pre-processing of the request before performing it.

In this example we’ll set our headers.

func (c *Client) Do(req *http.Request) (*http.Response, error) {
	req.Header.Add("Authorization", "token "+c.BearerToken)
	req.Header.Add("Content-Type", "application/json")
	return c.client.Do(req)
}

Then we’ll define additional receivers for each http.Method that we need to support and call the Do receiver with the appropriate *http.Request.

func (c *Client) Get(url string) (*http.Response, error) {
	req, err := http.NewRequest(http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	return c.Do(req)
}

func (c *Client) Post(url string, payload []byte) (*http.Response, error) {
	req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(payload))
	if err != nil {
		return nil, err
	}
	return c.Do(req)
}

// etc...

Path Helpers

The last set of receivers we’ll use are some convenience functions that make constructing paths a little more convenient. We’ll define a Path function that accepts a URL string that will be safely joined with the passed URL and return the full URL. Similarly, we’ll define a Pathf function that accepts a format string and a variadic list of arguments that will be formatted and then joined with the base URL.

// Path will safely join the base URL and the provided path and return a string
// that can be used in a request.
func (c *Client) Path(url string) string {
    base := strings.TrimRight(c.BaseURL, "/")
    if url == "" {
        return base
    }
    return base + "/" + strings.TrimLeft(url, "/")
}
// Pathf will call fmt.Sprintf with the provided values and then pass them
// to client.Path as a convenience.
func (c *Client) Pathf(url string, v ...any) string {
    url = fmt.Sprintf(url, v...)
    return c.Path(url)
}

Final Thoughts

This is a pretty simple example, but it’s a good starting point for any sort of HTTP client abstraction you might need and in many cases can be an adequate stand-in when you don’t need a fully fledged HTTP client abstraction library.

Full Snippet

package client

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"strings"
)

type Client struct {
	client      *http.Client
	BaseURL     string
	BearerToken string
}

func New(client *http.Client, base string) *Client {
	return &Client{
		client:  client,
		BaseURL: base,
	}
}

func (c *Client) Get(url string) (*http.Response, error) {
	req, err := http.NewRequest(http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	return c.Do(req)
}

func (c *Client) Post(url string, payload []byte) (*http.Response, error) {
	req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(payload))
	if err != nil {
		return nil, err
	}
	return c.Do(req)
}

func (c *Client) Put(url string, payload []byte) (*http.Response, error) {
	req, err := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(payload))
	if err != nil {
		return nil, err
	}
	return c.Do(req)
}

func (c *Client) Delete(url string) (*http.Response, error) {
	req, err := http.NewRequest(http.MethodDelete, url, nil)
	if err != nil {
		return nil, err
	}
	return c.Do(req)
}

func (c *Client) Do(req *http.Request) (*http.Response, error) {
	req.Header.Add("Authorization", "token "+c.BearerToken)
	req.Header.Add("Content-Type", "application/json")
	return c.client.Do(req)
}

// Path will safely join the base URL and the provided path and return a string
// that can be used in a request.
func (c *Client) Path(url string) string {
	base := strings.TrimRight(c.BaseURL, "/")
	if url == "" {
		return base
	}
	return base + "/" + strings.TrimLeft(url, "/")
}

// Pathf will call fmt.Sprintf with the provided values and then pass them
// to Client.Path as a convenience.
func (c *Client) Pathf(url string, v ...any) string {
	url = fmt.Sprintf(url, v...)
	return c.Path(url)
}

func Decode(r *http.Response, val interface{}) error {
	decoder := json.NewDecoder(r.Body)
	if err := decoder.Decode(val); err != nil {
		return err
	}
	return nil
}