Skip to content

Reasoning

Tal edited this page Apr 22, 2020 · 4 revisions

Interceptors are a powerful tool that enables you to log, monitor, mutate, redirect and sometimes even fail a request or response. The beauty of interceptors is, that to the user everything looks as a regular *http.Client.

In this article I will try to reason on how to build one.

Let's look at logging as an example, there are functions in "net/http/httputil" that allows you to dump requests and responses. Here is an example how one might use them

client := http.DefaultClient
request, _ := http.NewRequest(http.MethodGet, "https://www.golang.org", nil) // you should check for error here
// ### Outgoing Request Dump ###
bytes, _ := httputil.DumpRequestOut(request, true) // and here, don't ignore error
log.Printf("Request:\n\n%s\n", bytes)
// call remote
if response, err := client.Do(request); err == nil {
	// ### Response Dump ###
	bytes, _ = httputil.DumpResponse(response, true) // remember, don't ignore error
	log.Printf("%s\n", bytes)
	// do something with the response
...
}

Above example shows how one can use existing tools to log/debug what is sent and received when calling remote HTTP service. However you will need to write this code every time when using *http.Client ...

To solve this annoyance let's examine http.Client

type Client struct {
	// Transport specifies the mechanism by which individual
	// HTTP requests are made.
	// If nil, DefaultTransport is used.
	Transport RoundTripper
...

This http.RoundTripper interface is the mechanism responsible for HTTP requests and it's defined as

type RoundTripper interface {
	// RoundTrip executes a single HTTP transaction, returning
	// a Response for the provided Request.
	//
        RoundTrip(*Request) (*Response, error)
}

Documentation was omitted for the purpose of this article, but you are strongly encouraged to read it.

That's exactly what we are looking for, a function with *http.Request as an input and *http.Response as output. If we want to reuse our logging code from above we can do something that is similar to

type DumpingRoundTripper struct {
	transport http.RoundTripper
}

func (dumper *DumpingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
	bytes, _ := httputil.DumpRequestOut(req, true) // don't ignore error
	log.Printf("%s\n", bytes)
	response, err := dumper.transport.RoundTrip(req)
	if err == nil {
		bytes, _ = httputil.DumpResponse(response, true) // remember, don't ignore error
		log.Printf("%s\n", bytes)
	}
	return response, err
}

func main() {
	client := &http.Client{
		Transport: &DumpingRoundTripper{
			transport: http.DefaultTransport,
		},
	}
	request, _ := http.NewRequest(http.MethodGet, "https://www.golang.org", nil) // you should check for error here
	client.Do(request)
}

Congratulations! we just created an HTTP Client Interceptor, but it's tightly coupled to http.RoundTripper

Let's examine what we actually did. We implemented a function that receives a *http.Request returns a *http.Response and we also had a http.RoundTripper that we could use to execute an actual call. Then we configured *http.Client to use that wrapper as a Transport.

But what if we need different kinds of http.RoundTripper or Interceptors ? If we continue with the above example we will need to 'merge' all of them into a single one. Let us think of a way to get around that.

We want to be able to define an interceptor as a standalone function so we can easily reuse it or swap between them. Our interceptor need to have access to

  • Request
  • Function that receives a Request and outputs a Response

To help us let's define 2 function types

  • Handler
type Handler func(*http.Request) (*http.Response, error)
  • Interceptor
type Interceptor func(*http.Request, Handler) (*http.Response, error)

Let's rewrite our above example with these 2 types in mind

type Handler func(*http.Request) (*http.Response, error)

type Interceptor func(*http.Request, Handler) (*http.Response, error)

type GeneralRoundTripper struct {
	transport   http.RoundTripper
	interceptor Interceptor
}

func (general *GeneralRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
	return general.interceptor(req, general.transport.RoundTrip)
}

As you can see, our Interceptor type function is not coupled to http.RoundTripper, hence we can introduce an interceptor function that is "stand-alone"

func DumpingInterceptor(req *http.Request, handler Handler) (*http.Response, error) {
	bytes, _ := httputil.DumpRequestOut(req, true) // don't ignore error
	log.Printf("%s\n", bytes)
	response, err := handler(req)
	if err == nil {
		bytes, _ = httputil.DumpResponse(response, true) // remember, don't ignore error
		log.Printf("%s\n", bytes)
	}
	return response, err
}

func main() {
	client := &http.Client{
		Transport: &GeneralRoundTripper{
			transport:   http.DefaultTransport,
			interceptor: DumpingInterceptor,
		},
	}

	request, _ := http.NewRequest(http.MethodGet, "https://www.golang.org", nil) // you should check for error here
	client.Do(request)
}

func DumpingInterceptor is a function that is unaware of what a http.RoundTripper is. Look above, at the beginning we showed how http.RoundTripper is defined. It has one function

RoundTrip(*Request) (*Response, error)

This is exactly how we defined our Handler type. You can see how it's used here

...
func (general *GeneralRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
	return general.interceptor(req, general.transport.RoundTrip)
}
...

That's it, now we have a way to define a standalone Interceptor.

But what about multiple Interceptors? We need to define a way to "chain" them so that our GeneralRoundTripper will still work. Lucky for you there is a library to do just that...

Clone this wiki locally