Axum-style Magic Handler Functions in Go
Table of Contents
Introduction⌗
Rustaceans using the axum framework can employ the “magic function” pattern to write very descriptive handler functions with little boilerplate.
Can we imitate it in Go? Let’s find out!
Why, though?⌗
¯\_(ツ)_/¯
Axum’s magic functions⌗
axum is a Rust web framework that focuses on ergonomics and modularity. One particularly impressive feature of axum is that the signature of handler functions can declaratively dictate how the request is parsed, and how the response is constructed:
// in main()
let app = Router::new().route("/users", post(create_user));
async fn create_user(Json(payload): Json<CreateUser>) -> (StatusCode, Json<User>) {
(StatusCode::CREATED, Json(User { id: 1337, username: payload.username }))
}
The Json<CreateUser>
argument is an extractor; telling axum that the request body is JSON
and should be deserialized as the CreateUser
struct. Output is Json<User>
, which serializes
the response body from the User
struct to the JSON format. There is no imperative boilerplate
code for these operations.
Alex Puschinsky elegantly demystifies how it works
here,
TL;DR each extractor implements the FromContext
trait to describe how to extract it from the request.
These functions are very flexible; you can extract some JSON from the body, other fields from the query, and get access to the database instance from the state at the same time:
async fn get_products(State(db): State<Db>, Query(query): Query<CompanyInfo>, Json(body): Json<ProductFilters>) -> String {
...
}
The way Go does it⌗
Compare the first example to the vanilla Go code that does the same thing:
// in main()
mux := http.NewServeMux()
mux.HandleFunc("POST /users", createUser)
func createUser(w http.ResponseWriter, r *http.Request) {
// parse input arguments
var payload CreateUser
defer func() { _ = r.Body.Close() }()
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// business logic
code, user := http.StatusCreated, User{ID: 1337, Username: payload.Username}
// write the response
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(user)
}
I mean, this is not awful.
I cut some corners in terms of observability and proper error handling for the sake of brevity, but still most of the function is boilerplate.
A minor improvement⌗
Before I move on to other things, there is a small helper function we can
immediately extract here. We will use it later, and it’s better to address that
before things get complicated. See the defer
statement there? That will be delayed
until the end of the handler; instead of closing the body right after it’s read.
Let’s put the decoding logic in its own function then:
func createUser(w http.ResponseWriter, r *http.Request) {
var payload CreateUser
if err := decodeBodyWith(r, json.NewDecoder, &payload); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
code, user := http.StatusCreated, User{ID: 1337, Username: payload.Username}
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(user)
}
func decodeBodyWith[D interface {
Decode(any) error // duck-typing *json.Decoder, *xml.Decoder etc.
}](r *http.Request, newDecoderFn func(io.Reader) D, dest any) (err error) {
if r.Body != nil {
defer func() {
// also join any possible errors from closing the body
err = errors.Join(err, r.Body.Close())
}()
err = newDecoderFn(r.Body).Decode(&dest)
}
return
}
Not a huge win for now, but we’ll make use of this function later.
Where we want to end up⌗
Ideally, we want our createUser
function to contain the business logic only:
func createUser(payload JSON[CreateUser]) (response JSON[User], _ error) {
response.V = User{ID: 1337, Username: payload.V.Username}
return
}
The
JSON
type is as follows:type JSON[Inner any] struct{ V Inner }
Note that we cannot embed
Inner
here; Go is not (yet?) capable of this. We have to putInner
as a member field, so I chose a short name at least. We will implementJSON
’s behavior a little later.
We need a higher-order function to wrap and transform our custom-signature handlers into http.HandlerFunc
s.
The tools at hand⌗
The implementation in axum
makes good use of Rust concepts like generics,
traits and associated types. In Go, we have interfaces instead of traits.
Our generics functionality is limited; methods cannot be generic on an extra
type, type erasure does not exist, constraints suck.
So, right off the bat, we want to write a wrapper function that will wrap our custom-signature
handlers, right? There isn’t an in-built Func
constraint; so we can’t do something like:
func Handler(handler Func) http.HandlerFunc { ... }
We cannot describe it ourselves either. If you try to define a constraint like:
type Func[R, X, Y, Z, T any] interface { func(X) (R, error) | func(X, Y) (R, error) | func(X, Y, Z) (R, error) | func(X, Y, Z, T) (R, error) // let's say 4 is enough for now }
then you have to spell out all of
[R, X, Y, Z, T]
everywhere you try to use it. Go cannot infer them for you; because if you passfunc(int) (string, error)
here; what areY, Z, T
? You can’t omit them either, because there is no type erasure in Go.
So we immediately steer away from generics here. It is to be used sparingly at best.
Another option would be code generation, but I’d rather not introduce tools to the build pipeline right now. Dealing with Go AST also kind of sucks the fun out of this kind of self-challenge; it feels like accepting defeat.
Our third option is to switch to the dark side, and embrace reflection. I hate reflection, but I can’t escape from it. Many essential Go functionalities (including JSON encoding/decoding) use it anyway, so let’s give it a try.
Calling a function with reflection⌗
With reflection, we should get the handler
type as any
, so the first check should be,
“is this type really a function”?
func Handler(handler any) http.HandlerFunc {
fnVal := reflect.ValueOf(handler)
if fnVal.Kind() != reflect.Func {
panic(PanicReasonHandlerExpectsAFunc)
}
...
}
const PanicReasonHandlerExpectsAFunc = "Handler parameter should be a function"
Now that we have a reflect.Value
in our hands, we can invoke the function using:
// reflect/value.go
func (v Value) Call(in []Value) []Value
So, the flow becomes:
- Construct each input and wrap them into
Value
s, Call
the function using reflection,- Convert the output
Value
slice into expected types. - Render the response using the output data.
func Handler(handler any) http.HandlerFunc {
fnVal := reflect.ValueOf(handler)
if fnVal.Kind() != reflect.Func {
panic(PanicReasonHandlerExpectsAFunc)
}
fnType := fnVal.Type()
extractInputs := toExtractorFn(fnType) // 0
convertOutputs := toOutputHandlerFn(fnType) //
return func(w http.ResponseWriter, r *http.Request) {
var response any
inputs, err := extractInputs(r) // 1
if err == nil {
outputs := fnVal.Call(inputs) // 2
response, err = convertOutputs(outputs) // 3
}
if err == nil {
_ = WriteResponse(w, response) // 4
} else { //
_ = writeErrResp(w, err) //
}
}
}
The 0th step above is preparing the input/output conversion functions ahead-of-time. We can do this because we have the handler function’s type before it is actually called. We can also make type verifications in this stage. It is a good place to start from.
Since we can get any number of inputs, let’s first handle the output conversion.
Converting the outputs⌗
We expect the function’s output to either be an error
, or (T, error)
; meaning
an arbitrary response type (that we will receive as any
) and an error
. Any other
output is unexpected, and should cause a panic.
func toOutputHandlerFn(fnType reflect.Type) func([]reflect.Value) (any, error) {
switch numOut := fnType.NumOut(); {
case numOut == 1 && fnType.Out(0).Implements(errType):
return handleOneOutput // func(*) error
case numOut == 2 && fnType.Out(1).Implements(errType):
return handleTwoOutputs // func(*) (T, error)
default:
panic(PanicReasonHandlerUnexpectedNumberOfReturns)
}
}
var errType = reflect.TypeFor[error]()
const PanicReasonHandlerUnexpectedNumberOfReturns = "Handler should return either error, or (T, error)"
func handleOneOutput(v []reflect.Value) (_ any, err error) {
err, _ = v[0].Interface().(error)
return
}
func handleTwoOutputs(v []reflect.Value) (any, error) {
err, _ := v[1].Interface().(error)
return v[0].Interface(), err
}
Now we have a function that returns our handler’s output to (any, error)
.
Parsing the inputs⌗
Implementing the extractors for the input parameters is a whole other story. First of all, we define the extractor behavior as an interface:
type Extractor interface {
Extract(*http.Request) (any, error)
}
This interface is unusual, and I’ll tell you why in a moment. But let’s first try to use it.
We should iterate each of the function type’s inputs, and verify that they implement the Extractor
interface.
The method might have a pointer receiver, so we’ll handle that case as well.
Since it’s so common, I also accept context.Context
as a valid argument here. It will be
filled with the request’s context.
func toExtractorFn(fnType reflect.Type) func(*http.Request) ([]reflect.Value, error) {
numIn := fnType.NumIn()
funcs := make([]func(*http.Request) (reflect.Value, error), numIn)
for i := range numIn {
// extType is an interface; do the check with Implements
if arg := fnType.In(i); arg.Implements(extType) {
funcs[i] = extractFuncOfType(arg)
// also check the pointer of the type for implementing Extractor
} else if argPtr := reflect.PointerTo(arg); argPtr.Implements(extType) {
funcs[i] = extractFuncOfType(argPtr)
// accept context.Context as a valid argument type as well
} else if arg.Implements(ctxType) {
funcs[i] = extractCtx
// anything else is grounds for a panic
} else {
panic(PanicReasonUnknownArgType)
}
}
// return a function that extracts ALL inputs at once
return func(r *http.Request) (values []reflect.Value, err error) {
values = make([]reflect.Value, numIn)
for i := 0; err == nil && i < numIn; i++ {
values[i], err = funcs[i](r)
}
return
}
}
func extractFuncOfType(arg reflect.Type) func(*http.Request) (reflect.Value, error) {
zero := reflect.Zero(arg).Interface().(Extractor)
return func(r *http.Request) (reflect.Value, error) {
v, err := zero.Extract(r)
return reflect.ValueOf(v), err
}
}
func extractCtx(r *http.Request) (reflect.Value, error) {
return reflect.ValueOf(r.Context()), nil
}
var extType = reflect.TypeFor[Extractor]()
var ctxType = reflect.TypeFor[context.Context]()
const PanicReasonUnknownArgType = "Cannot determine how to extract handler argument"
Writing the response⌗
I’m sure there are better, less fragile ways of doing this. This part bores me because it’s not strictly a part what we are trying to achieve, so I’ll half-ass it and write the shortest version that I can.
We want individual types to be able to control how they are written into the response.
Also, our original example returned 201
instead of 200
; so we want to be able to change
the status code based on the type of success. Finally, our handlers should be able to take
wrapper types into account. Something like this will do for now:
type Responder interface {
Response(http.ResponseWriter) error
}
type StatusCoder interface{ StatusCode() int }
func WriteResponse(w http.ResponseWriter, resp any) (err error) {
if s, ok := resp.(Responder); ok {
return s.Response(w)
}
return WriteJSONResponse(w, resp, StatusCodeFrom(resp))
}
func StatusCodeFrom(resp any) (code int) {
if s, ok := resp.(StatusCoder); ok {
code = s.StatusCode()
}
return
}
func WriteJSONResponse(w http.ResponseWriter, resp any, code int) error {
w.Header().Set("Content-Type", "application/json")
if code > 0 {
w.WriteHeader(code)
}
return json.NewEncoder(w).Encode(resp)
}
Nothing much to comment on, really. If the type is Responder
, use the
custom response function. Otherwise, write it as JSON. Both functions are
public because we want to use these in other places soon.
Similarly, error cases should be able to dictate their own error codes:
type errResp struct { Error string `json:"error"` }
type errWithCode struct {
code int
error
}
func writeErrResp(w http.ResponseWriter, err error) error {
statusCode := http.StatusInternalServerError
if coded := new(errWithCode); errors.As(err, coded) {
statusCode = coded.code
}
return WriteJSONResponse(w, errResp{Error: err.Error()}, statusCode)
}
func WithStatusCode(err error, code int) error {
if err != nil && code > 0 {
err = errWithCode{code: code, error: err}
}
return err
}
For example, with this we can modify toExtractorFn
to return 400
when the arguments cannot be extracted:
return func(r *http.Request) (values []reflect.Value, err error) {
values = make([]reflect.Value, numIn)
for i := 0; err == nil && i < numIn; i++ {
values[i], err = funcs[i](r)
}
// this line is added
err = WithStatusCode(err, http.StatusBadRequest)
return
}
Implementing the JSON type⌗
Let’s implement the JSON
’s extractor functionality:
func (v *JSON[T]) Extract(r *http.Request) (any, error) {
err := decodeBodyWith(r, json.NewDecoder, &v.V)
return v, err
}
We already have the decodeBodyWith
helper, so the implementation is trivial.
Testing things so far⌗
Time for a smoke test!
func TestHandler(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("POST /users", Handler(createUser))
// success case
req, _ := http.NewRequest(http.MethodPost, "/users",
strings.NewReader(`{"username": "abc"}`))
resp := httptest.NewRecorder()
mux.ServeHTTP(resp, req)
fmt.Printf("%d: %s\n", resp.Code, resp.Body.String())
// error case
req, _ = http.NewRequest(http.MethodPost, "/users",
strings.NewReader(`{{`))
resp = httptest.NewRecorder()
mux.ServeHTTP(resp, req)
fmt.Printf("%d: %s\n", resp.Code, resp.Body.String())
}
If we-
=== RUN TestHandler
--- FAIL: TestHandler (0.00s)
panic: runtime error: invalid memory address or nil pointer dereference [recovered]
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x2 addr=0x0 pc=0x102f2e594]
…oh. Smoke.
The problem with Extractor⌗
Let’s take another look at the Extractor
interface.
type Extractor interface {
Extract(*http.Request) (any, error)
}
It defines a method for a type that constructs that type. How can we call the method of a value that doesn’t yet exist?
Well, this is how we did call it:
func extractFuncOfType(arg reflect.Type) func(*http.Request) (reflect.Value, error) {
zero := reflect.Zero(arg).Interface().(Extractor)
return func(r *http.Request) (reflect.Value, error) {
v, err := zero.Extract(r)
return reflect.ValueOf(v), err
}
}
Were it not for the reflection boilerplate, this is basically equivalent to:
var zero T
value, err := any(zero).(Extractor).Extract(r)
Or in our example;
value, err := (*JSON[T])(nil).Extract(r)
We get a panic because v
is nil
.
Normally, this is a kind of function that would be defined on the type, instead of an instance of the type. Rust allows this through traits; but Go does not have the mechanism.
We could “fix” this by making the receiver function pass-by-value instead. However,
the extractor functions are reused, and so the zero value passed to them is never garbage collected.
That’s why we want to use the pointer, because T
can be an arbitrarily large struct.
Instead, we must be careful not to perform read/write operations on the receiver instance. Which is not that hard normally; just don’t even name the receiver in the method:
// keep the receiver unnamed, and we won't run the risk of using it
func (*JSON[T]) Extract(r *http.Request) (any, error) {
var v JSON[T] // create a new JSON[T], and work on that
return v, decodeBodyWith(r, json.NewDecoder, &v.V)
}
If we try again…
=== RUN TestHandler
200: {"V":{"id":1337,"username":"abc"}}
400: {"error":"invalid character '{' looking for beginning of object key string"}
--- PASS: TestHandler (0.00s)
PASS
The test passes!
Rendering the response properly⌗
…except the output is a bit weird. We want the JSON
type to be transparent.
That’s an easy fix though:
func (v JSON[T]) Response(w http.ResponseWriter) error {
return WriteJSONResponse(w, v.V, StatusCodeFrom(v.V))
}
Also, we want the success case to return 201
instead:
func (User) StatusCode() int { return http.StatusCreated }
Finally we have:
=== RUN TestHandler
201: {"id":1337,"username":"abc"}
400: {"error":"invalid character '{' looking for beginning of object key string"}
--- PASS: TestHandler (0.00s)
PASS
Summary⌗
Man, we wrote a lot of code! So perhaps it’s better to list only the user code here, hiding the functionality that would normally be a part of a library:
func main() {
mux := http.NewServeMux()
mux.HandleFunc("POST /users", Handler(createUser))
...
}
func createUser(payload JSON[CreateUser]) (response JSON[User], _ error) {
response.V = User{ID: 1337, Username: payload.V.Username}
return
}
type CreateUser struct {
Username string `json:"username"`
}
type User struct {
ID uint `json:"id"`
Username string `json:"username"`
}
func (User) StatusCode() int { return http.StatusCreated }
type JSON[Inner any] struct{ V Inner }
func (*JSON[T]) Extract(r *http.Request) (any, error) {
var v JSON[T]
return v, decodeBodyWith(r, json.NewDecoder, &v.V)
}
func (v JSON[T]) Response(w http.ResponseWriter) error {
return WriteJSONResponse(w, v.V, StatusCodeFrom(v.V))
}
Now we can implement other extractors like Headers
, Query
and so on:
type Headers[Inner any] struct { V Inner }
// usage
var _ = Headers[struct {
X string `header:"x"`
Y int `header:"y"`
}]
I won’t show the implementations for these, because the post is getting long. Long story short, we want to
check that Inner
is a struct, then iterate its fields for the header
tag and extract the relevant header
from the request. The same logic appears in the
gin library.
Similarly, I think we can implement a State
type that fetches custom
data from the request’s context, to implement things like DB
.
A final touch⌗
In Rust, you can make use of RAII and implement the Drop
trait for your extractors when they need cleaning up.
In Go, we can use a Close() error
function, provided by the io.Closer
interface. We simply have to check for all
input types at the end of the handler like this:
if err == nil {
_ = WriteResponse(w, response) // 4
} else { //
_ = writeErrResp(w, err) //
}
// code below is added
for _, e := range inputs {
if e.IsValid() { // must check if the value is initialized
if c, ok := e.Interface().(io.Closer); ok {
_ = c.Close()
}
}
}
Here is an example extractor that has a closer:
type Logger struct{ *slog.Logger }
func (*Logger) Extract(r *http.Request) (any, error) {
log := slog.Default().With("route", r.URL.Path, "request_id", uuid.NewString())
log.Info("endpoint start")
return Logger{Logger: log}, nil
}
func (l Logger) Close() error {
l.Logger.Info("endpoint end")
return nil
}
...
func createUser(l Logger, payload JSON[CreateUser]) (response JSON[User], _ error) {
response.V = User{ID: 1337, Username: payload.V.Username}
l.Info("created", "user", payload.V.Username)
return
}
We can see the logs:
=== RUN TestHandler
2024/12/17 02:24:35 INFO endpoint start route=/users request_id=ce9f5c48-c9af-4492-a862-cb4ca94aadb5
2024/12/17 02:24:35 INFO created route=/users request_id=ce9f5c48-c9af-4492-a862-cb4ca94aadb5 user=abc
2024/12/17 02:24:35 INFO endpoint end route=/users request_id=ce9f5c48-c9af-4492-a862-cb4ca94aadb5
201: {"id":1337,"username":"abc"}
2024/12/17 02:24:35 INFO endpoint start route=/users request_id=16046b80-1de9-4f32-a242-03237aff81c6
2024/12/17 02:24:35 INFO endpoint end route=/users request_id=16046b80-1de9-4f32-a242-03237aff81c6
400: {"error":"invalid character '{' looking for beginning of object key string"}
That’s all I have for this post. Go now.