generic handler

Generic handler #

I wrote a piece of code a few years ago that I kept writing slightly differently for different tests and endpoints. With the introduction of generics in go 1.18, I wrote this generic way of writing HTTP handlers. This page explains the problem I was trying to solve and how to use the solution.

Usage #

Add the generichandler dependency to your mod file:

go get github.com/Isnor/generichandler

View the documentation at https://pkg.go.dev/github.com/Isnor/generichandler

Problem #

The signature of http.HandlerFunc makes writing and testing APIs cumbersome, because of the way we read the request and return a response:

func handleEndpoint(w http.ResponseWriter, request *http.Request)

isn’t the way we would normally write a function in Go. For example:

func handleEndpoint(request *SomeInputStruct) (*SomeDataStruct, error)

is a more idiomatic way to write a function than unmarshaling the body of an *http.Request into the expected type and writing a response to an http.ResponseWriter:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func handleEndpoint(w http.ResponseWriter, request *http.Request) {
  pet := &SomeInputStruct{}
  if err := json.NewDecoder(r.Body).Decode(pet); err != nil {
    w.WriteHeader(http.StatusBadRequest)
    return
  }

  // We don't actually know what the user provided; if we have a required field like "name",
  // Unmarshal isn't going to throw an error when `name` isn't provided. This leads to more
  // redundant validation
  addedPet, err := addPet(pet)
  
  // handle errors encountered
  if err != nil {
    w.WriteHeader(http.StatusInternalServerError)
    json.NewEncoder(w).Encode(Error{Text: "failed added pet"})
    return
  }

  // handle success
  json.NewEncoder(w).Encode(pet)
  w.WriteHeader(http.StatusOK)
}

In my experience, this almost always adds bloat and annoying, redundant code to every handler, making even slightly complex APIs a bother to write, refactor, and especially to test.

Goal #

The goal of this library is to simplify the development and accelerate the testing of REST APIs. It tries to do this by providing generic wrappers that convert more idiomatic functions into http.HandlerFuncs, and it’s able to do that by making some assumptions about the APIs we’re developing:

  • The client is sending JSON and expecting JSON in return;
  • RequestTypes for each endpoint should implement Validate(context.Context) error
  • all the information that the handler requires can be passed to it via the (context, RequestType) it receives.
    • because of this, the programmer needs to be able to control how the request information is deserialized. If they want to decode auth information and store it in the context, fine; if they want to store it on the request object, so be it.

Design #

This module is intended to be a small, http-compatible, set of guidelines or suggestions for effectively managing handlers.

generichandler views REST endpoints as a composition of 3 functions:

  • A decoder function - something that reads the HTTP request and returns the body data
type HTTPDecoder[RequestType any] func(*http.Request) (*RequestType, error)
  • An encoder function - something that writes the APIs response onto the HTTP response
type HTTPEncoder[ResponseType any] func(http.ResponseWriter, *ResponseType) error
  • The handler function - the actions that the endpoint actually takes
type APIEndpoint[RequestType, ResponseType any] func(context.Context, *RequestType) (*ResponseType, error)

These functions are composed in generichandler.ToHandlerFunc[RequestType, ResponseType any](encoder, endpoint, decoder) http.HandlerFunc using what I hope are fairly sensible defaults to otherwise configurable parameters. Because the motivation for writing this was testing APIs that had a large variety of JSON inputs, there is a convenience function that uses the default JSON encoder and decoder to wrap an APIEndpoint function

func DefaultJSONHandlerFunc[RequestType, ResponseType any](endpoint APIEndpoint[RequestType, ResponseType]) http.HandlerFunc

ToHandlerFunc tries its best to work the way we “expect” a typical endpoint to work and any additional validation that should be required, such as a required field name, can be implemented and called automatically by adding a Validate() error function to the RequestType struct:

// Validate returns an error if Name, Owner, or Age is not set
func (p pet) Validate(context.Context) error {
  if len(p.Name) == 0 {
    return errors.WithMessage(generichandler.ErrorInvalidRequest, "the pet must have a name")
  }
  if len(p.Owner) == 0 {
    return errors.WithMessage(generichandler.ErrorInvalidRequest, "the pet must have an owner")
  }
  if p.Age == 0 {
    return errors.WithMessage(generichandler.ErrorInvalidRequest, "the pet must be at least 1 year old")
  }

  return nil
}

Documentation #

You can view the library on GitHub at github.com/Isnor/generichandler, or you can take a look at a small example at github.com/Isnor/ghexample.