DEV Community

Chad Kunde
Chad Kunde

Posted on

Middleware...for the Client?

HTTP middleware is a common concept on the server side, with multiple packages and ecosystems. These providing common functionality like logging, tracing, authentication, routing, and more.

At the heart of server-side middleware is a simple interface in the "net/http" package of the standard library:

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}
Enter fullscreen mode Exit fullscreen mode

Middleware signatures then wrap the handler, to call the next in the chain:

func Middleware(h http.Handler) http.Handler {
    return http.HandleFunc(func(w http.ResponseWriter, r *http.Request) {
        // before processing actions
        h.ServeHTTP(w,r) // process request  
        // after processing actions
    })
}
Enter fullscreen mode Exit fullscreen mode

Client side

For every concern on the server side, a similar concern exists on client side. Authentication, tracing, metrics, headers, etc end up duplicated for each request.

req := http.NewRequest(http.GET, "https://google.com", nil)
// Set standard headers
req.Header.Set("Accept", "application/json")
req.Header.Set("Api-Key", "<some api key>")
req.SetBasicAuth(user, pass)
// Metrics injection
ctx := httptrace.WithClientTrace(context.Background(), newTrace())
// Set timeout
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
// Execute
req = req.WithContext(ctx)
resp, err := http.Do(req)
Enter fullscreen mode Exit fullscreen mode

As we look through the request building code, nothing between NewRequest and http.Do are specific to the endpoint being called. That means it's more likely to be forgotten or make the logic less readable because it's cluttered with boilerplate.

Middleware to the rescue

Server side has a single, small interface that is used for loading middleware. Client side has a similar, but less known, interface http.RoundTripper:

type RoundTripper interface {
    RoundTrip(*Request) (*Response, error)
}
Enter fullscreen mode Exit fullscreen mode

Using this interface in a similar manner to http.Handler gives us the freedom to pull headers and other standard request building operations out of the business logic code.

Let's see how it works.

Basic Auth

type BasicAuth struct {
    User string
    Pass string
    Next http.RoundTripper
}

func (b BasicAuth) RoundTrip(r *http.Request) (*http.Response, error) {
    r.SetBasicAuth(b.User, b.Pass)
    return b.Next.RoundTrip(r)
}

client := http.Client{
    Transport: BasicAuth{User: "username", Pass: "password", Next: http.DefaultTransport},
}
Enter fullscreen mode Exit fullscreen mode

Now, every request sent by client will have the basic auth header set.

Chaining Middleware

Now that we have our first middleware, let's make it easier to chain multiple.

type BasicAuth struct {
    User string
    Pass string
    Next http.RoundTripper
}

func (b BasicAuth) RoundTrip(r *http.Request) (*http.Response, error) {
    r.SetBasicAuth(b.User, b.Pass)
    return b.Next.RoundTrip(r)
}

func (b BasicAuth) Register(rt http.RoundTripper) http.RoundTripper {
    b.Next = rt
    return b
}

auth := BasicAuth{User: "username", Pass: "password"}
client := http.Client{
    Transport: auth.Register(http.DefaultTransport),
}
Enter fullscreen mode Exit fullscreen mode

Functional Middleware

While a struct with a method works well for a middleware like basic auth, it's cumbersome for standalone middleware actions. Setting req.Header.Set("Accept", "application/json") doesn't have a need for it.

Here we can take a lesson from http.HandlerFunc on the server side to create our own RoundTripFunc:

type RoundTripFunc func(r *http.Request) (*http.Response, error)
func (f RoundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
    return f(r)
}
Enter fullscreen mode Exit fullscreen mode

I understand if this looks like a strange method definition. We're defining a method on a function which implements the interface.

With that defined, our accept json middleware is as simple as:

func AcceptJSON(rt http.RoundTripper) http.RoundTripper {
    return RoundTripFunc(func(r *http.Request) (*http.Response, error) {
        r.Header.Set("Accept", "application/json")
        return rt.RoundTrip(r)
    })
}

client := &http.Client{
    Transport: AcceptJSON(http.DefaultTransport),
}
Enter fullscreen mode Exit fullscreen mode

Basic Auth part deux

Revisiting the basic auth example above, the functional approach can remove the need for a defined struct entirely.

func BasicAuth(rt http.RoundTripper, user, pass string) http.RoundTripper {
    return RoundTripFunc(func(r *http.Request) (*http.Response, error) {
        r.SetBasicAuth(user, pass)
        return rt.RoundTrip(r)
    })
}

client := http.Client{
    Transport: BasicAuth(http.DefaultTransport, "username", "password"),
}
Enter fullscreen mode Exit fullscreen mode

One level deeper moves the configuration away from the registration. Note the parallels to the struct we defined earlier:

// Configure the auth, return the registration func
func BasicAuth(user, pass string) func(rt http.RoundTripper) http.RoundTripper {
    // BasicAuth.Register
    return func(rt http.RoundTripper) http.RoundTripper {
        // BasicAuth.RoundTrip
        return RoundTripFunc(func(r *http.Request) (*http.Response, error) {
            r.SetBasicAuth(user, pass)
            return rt.RoundTrip(r)
        })
    }
}

auth := BasicAuth("username", "password")
client2 := http.Client{
    Transport: auth(http.DefaultTransport),
}
Enter fullscreen mode Exit fullscreen mode

This is especially useful to reduce boilerplate when integrating on a REST API, whether it's internal or external. Similarly helpful when building SDKs, keeping the endpoint methods limited to the logic that is specific the endpoint being called.

Example Time

We've gone through all of the parts, let's see how it works together.

package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
    "time"
)

func main() {
    auth := BasicAuth("username", "password")
    client := http.Client{
        Transport: Timing(auth(LogHeaders(http.DefaultTransport))),
    }
    resp, err := client.Get("https://google.com")
    if err != nil {
        fmt.Println("error: ", err)
        return
    }
    fmt.Println("status: ", resp.Status)
    buf, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        fmt.Println("body error: ", err)
        return
    }
    fmt.Println("response body size: ", len(buf))
}

type RoundTripFunc func(r *http.Request) (*http.Response, error)

func (f RoundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
    return f(r)
}

func BasicAuth(user, pass string) func(rt http.RoundTripper) http.RoundTripper {
    return func(rt http.RoundTripper) http.RoundTripper {
        return RoundTripFunc(func(r *http.Request) (*http.Response, error) {
            r.SetBasicAuth(user, pass)
            return rt.RoundTrip(r)
        })
    }
}

func LogHeaders(rt http.RoundTripper) http.RoundTripper {
    return RoundTripFunc(func(r *http.Request) (*http.Response, error) {
        fmt.Println("URL:", r.URL, "headers: ", r.Header)
        return rt.RoundTrip(r)
    })
}

func Timing(rt http.RoundTripper) http.RoundTripper {
    return RoundTripFunc(func(r *http.Request) (*http.Response, error) {
        defer func(t time.Time) {
            fmt.Printf("request to %q took %v\n", r.URL, time.Since(t))
        }(time.Now())
        return rt.RoundTrip(r)
    })
}
Enter fullscreen mode Exit fullscreen mode

Output:

URL: https://google.com headers:  map[Authorization:[Basic dXNlcm5hbWU6cGFzc3dvcmQ=]]
request to "https://google.com" took 215.103005ms
URL: https://www.google.com/ headers:  map[Authorization:[Basic dXNlcm5hbWU6cGFzc3dvcmQ=] Referer:[https://google.com]]
request to "https://www.google.com/" took 156.457554ms
status:  200 OK
response body size:  14219
Enter fullscreen mode Exit fullscreen mode

As you can see, the middleware were executed on the original https://google.com and the redirect http://google.com/ request.

Top comments (0)