Almost four years ago, in April 2014, I wrote a small Go microservice for serving files directly from an OpenStack Swift installation that wasn’t directly exposed on the internet.

From this, I extracted a small Go package “go-cachewrapper”, which provides a simple HTTP wrapper that adds cache headers to responses.

It was one of the earliest Go packages I’d written, and looking back on it (and wanting to reuse it for another service), I decided to modernise it.

In the past four years, my understanding of Go has increased a lot, and others have shared newer techniques, as we learn more and more about where the syntax can be shaped, and made idiomatic.

Functional Options

A few months after that code was committed, Dave Cheney wrote “Functional options for friendly APIs”.

The short-version is that it involves passing closures to functions, which provide arguments for the call.

At the time, I had been using Sean Treadway’s streadyway/amqp package, which is really helpful for getting Go talking to a RabbitMQ service, but the API leaves a fair bit to be desired…

The Channel.Consume method is one of many that is hard to read.

func (ch *Channel) Consume(queue, consumer string, autoAck, exclusive,
                           noLocal, noWait bool, args Table) (<-chan Delivery, error)

The example code for using the package contains this single call:

	deliveries, err := c.channel.Consume(
		queue.Name, // name
		c.tag,      // consumerTag,
		false,      // noAck
		false,      // exclusive
		false,      // noLocal
		false,      // noWait
		nil,        // arguments
	)

You need to know the order of all the options, and what type they are, irrespective of whether or not you need the options (note all the false values).

Last year, while building an HTTP-based client for an internal service, refactoring got us to a method…

func (c Client) call(method, path string, status int, body []byte, v interface{},
		     opts ...RequestOption) error {
	req, err := c.createRequest(method, path, body)
	if err != nil {
		return errors.Wrap(err, "failed to createRequest")
	}

	for _, o := range opts {
		o(req)
	}

	...
	resp, err := http.DefaultClient.Do(req)
	...

The opts argument takes a variadic number of RequestOptions, which are declared as:

type RequestOption func(r *http.Request)

They take a *http.Request and are responsible for modifying it.

This allowed me to write custom requests for some calls including

// WithSecurityToken will add a request header with the provided token to
// requests.
func WithSecurityToken(t string) RequestOption {
	return func(r *http.Request) {
		r.Header.Set("X-SecurityHeader", t)
	}
}

And, this means that it’s easy for requests that need to make that call, to append the option, and the code doesn’t need arguments for every possible option.

Modernising go-cachewrapper

In the 2014 version of the code, the wrapper function accepted a struct with all the possible options, and it was actually fairly easy to configure the bits you needed.

package main

import (
	"fmt"
	"log"
	"net/http"
	"time"

	cw "github.com/bigkevmcd/go-cachewrapper"
)

func helloWorld(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "Hello, World!\n")
}

const cacheTime = time.Minute * 60

func main() {
	options := cw.CacheOptions{MaxAge: cacheTime, NoTransform: true}
	http.Handle("/", cw.Cached(http.HandlerFunc(helloWorld), options))

	log.Fatal(http.ListenAndServe(":8000", nil))
}

And so, I looked to take what I’ve learned in the past four years, and see what I’d change to improve the API.

Of course, starting with a test (or a table of them).

var cacheOptionFuncTests = []struct {
	f optionFunc
	h string
}{
	{Immutable(), "max-age=31536000"},
	{Private(), "private"},
	{MaxAge(time.Hour * 24 * 13), "max-age=1123200"},
	{NoCache(), "no-cache"},
	{NoStore(), "no-store"},
	{MustRevalidate(), "must-revalidate"},
	{ProxyRevalidate(), "proxy-revalidate"},
	{SharedMaxAge(time.Hour * 13), "s-maxage=46800"},
}

func TestOptionFuncs(t *testing.T) {
	for _, tt := range cacheOptionFuncTests {
		co := CacheOptions{}
		tt.f(&co)
		if msg := co.String(); tt.h != msg {
			t.Errorf("got '%s', wanted '%s'", msg, tt.h)
		}
	}
}

cacheOptionFuncTests is a table test with functions, and the stringified version of the pragmas that are generated.

The test creates a CacheOptions value, and applies the closure, to it, and compares the string.

One of the changes since 2014, is using t.Errorf instead of t.Fatalf in table tests, that was a mistake at the time, I think table tests should test all the elements in the table, and report the failing ones, rather than failing fast.

Implementing the optionFuncs for the tests is fairly trivial, I’ll cover two of them here…

// MaxAge configures the maximum-age cache option.
func MaxAge(d time.Duration) optionFunc {
	return func(o *CacheOptions) {
		o.MaxAge = d
	}
}

// NoTransform configures the the no-transform pragma.
func NoTransform() optionFunc {
	return func(o *CacheOptions) {
		o.NoTransform = true
	}
}

The original TestCacheControl test needs to be modernised, to pass in the configuration options.

func TestCacheControl(t *testing.T) {
	w := httptest.NewRecorder()
	r, _ := http.NewRequest("POST", "http://example.com/foo", nil)
	handler := func(w http.ResponseWriter, r *http.Request) {}
	opts := []optionFunc{MaxAge(time.Hour * 24 * 13), NoTransform()}
	cached := Cached(http.HandlerFunc(handler), opts...)

	cached.ServeHTTP(w, r)

	wanted := "no-transform, max-age=1123200"
	if pragmas := w.Header().Get("Cache-Control"); pragmas != wanted {
		t.Fatalf("Cache-Control header: got %s, wanted '%s'", pragmas, wanted)
	}
}

Note that I can create a slice-literal with the closures, and pass them in as variadic parameters, with the opts... syntax.

Backwards compatiblity

These changes are not backwards compatible with the previous code, if this was important, it would be simple to add an option that accepts the entire configuration.

// Config takes a CacheOptions value and replaces the existing options.
func Config(co CacheOptions) optionFunc {
	return func(o *CacheOptions) {
		*o = co
	}
}

Additional testing

Originally when I wrote this, I intended for handlers to be able to overwrite the cache control headers, but I didn’t explicitly write a test for this.

func TestMaintainsExistingCacheOptions(t *testing.T) {
	w := httptest.NewRecorder()
	r, _ := http.NewRequest("POST", "http://example.com/foo", nil)
	handler := func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Cache-Control", "must-revalidate") }
	}
	opts := []optionFunc{MaxAge(time.Hour * 24 * 13), NoTransform()}
	cached := Cached(http.HandlerFunc(handler), opts...)

	cached.ServeHTTP(w, r)

	wanted := "must-revalidate"
	if pragmas := w.Header().Get("Cache-Control"); pragmas != wanted {
		t.Fatalf("Cache-Control header: got %s, wanted '%s'", pragmas, wanted)
	}
}

This works because I set the headers before calling the wrapped handler.

// ServeHTTP sets the header and passes the request and response to the
// wrapped http.Handler
func (c *CacheControl) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Cache-Control", c.options.String())
	c.handler.ServeHTTP(w, r)
}

All this is a reminder that Go’s http.Handler abstraction is really powerful, and belies the simplicity of the exposed interface.