Generic HTTP Handlers
Explore how we leveraged generics in Go to create a library for HTTP handlers that allow us to write more efficient, type-safe, reusable, and readable code.
Join the DZone community and get the full member experience.
Join For FreeThe Go programming language is the primary language used by Vaunt. Our developers typically gravitate towards Golang for its simplicity, performance, joyful syntax, and standard library capabilities. In March of 2022, Go added its support for generics, a highly anticipated and often controversial feature that has been long-awaited by the community. Generics enable developers to create functions, methods, and data structures that can operate on any type rather than being limited to a specific type.
In this blog post, we'll explore how we leveraged generics in Go to create a library for HTTP handlers. Specifically, we'll look at how we created a framework that can handle requests for any data type, allowing us to write more efficient, type-safe, reusable, and readable code.
Let's delve into the usage of generics in HTTP handlers by utilizing our generic, lightweight handler framework called GLHF.
Common Marshalling Patterns
Go's standard library has excellent support for building REST APIs. However, there are often common patterns that nearly all HTTP handlers have to implement, such as marshaling and unmarshaling requests and responses. This creates areas of code redundancy, which can lead to errors.
Let's take a look at a simple HTTP server that implements a few routes using the standard HTTP library and Gorilla Mux for basic URL parameter parsing.
Warning: Gorilla Mux has been archived. We still leverage Gorilla Mux within Vaunt, but we recommend looking at alternatives, such as HTTPRouter.
Below is a simple example HTTP API that implements creating and retrieving a todo.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"time"
"github.com/VauntDev/glhf/example/pb"
"github.com/gorilla/mux"
"golang.org/x/sync/errgroup"
"golang.org/x/sys/unix"
)
type TodoService struct {
todos map[string]*pb.Todo
}
func (ts *TodoService) Add(t *pb.Todo) error {
ts.todos[t.Id] = t
return nil
}
func (ts *TodoService) Get(id string) (*pb.Todo, error) {
t, ok := ts.todos[id]
if !ok {
return nil, fmt.Errorf("no todo")
}
return t, nil
}
type Handlers struct {
service *TodoService
}
func (h *Handlers) LookupTodo(w http.ResponseWriter, r *http.Request) {
p := mux.Vars(r)
id, ok := p["id"]
if !ok {
w.WriteHeader(http.StatusInternalServerError)
return
}
todo, err := h.service.Get(id)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
b, err := json.Marshal(todo)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write(b)
}
func (h *Handlers) CreateTodo(w http.ResponseWriter, r *http.Request) {
todo := &pb.Todo{}
decode := json.NewDecoder(r.Body)
if err := decode.Decode(todo); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if err := h.service.Add(todo); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func main() {
TodoService := &TodoService{
todos: make(map[string]*pb.Todo),
}
h := &Handlers{service: TodoService}
mux := mux.NewRouter()
mux.HandleFunc("/todo/{id}", h.LookupTodo)
mux.HandleFunc("/todo", h.CreateTodo)
server := http.Server{
Addr: ":8080",
Handler: mux,
}
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error {
log.Println("starting server")
if err := server.ListenAndServe(); err != nil {
return nil
}
return nil
})
g.Go(func() error {
sigs := make(chan os.Signal, 1)
// We'll accept graceful shutdowns when quit via SIGINT (Ctrl+C)
// SIGKILL, SIGQUIT or SIGTERM (Ctrl+/) will not be caught.
signal.Notify(sigs, os.Interrupt, unix.SIGTERM)
select {
case <-ctx.Done():
log.Println("ctx done, shutting down server")
case <-sigs:
log.Println("caught sig, shutting down server")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
return fmt.Errorf("error in server shutdown: %w", err)
}
return nil
})
if err := g.Wait(); err != nil {
log.Fatal(err)
}
}
The two primary functions we will be focusing on are LookupTodo
and CreateTodo
.
At first glance, these functions are simple enough. They use the request to either look up a Todo in our system or create the Todo. In both cases, we are using JSON as the expected content-type
.
Now that we have a basic flow let's add the ability for the handlers to receive/respond with either JSON or Protobuf.
func (h *Handlers) LookupTodo(w http.ResponseWriter, r *http.Request) {
p := mux.Vars(r)
id, ok := p["id"]
if !ok {
w.WriteHeader(http.StatusInternalServerError)
return
}
todo, err := h.service.Get(id)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
switch r.Header.Get("accept") {
case "application/json":
b, err := json.Marshal(todo)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write(b)
case "application/proto":
b, err := proto.Marshal(todo)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write(b)
}
}
func (h *Handlers) CreateTodo(w http.ResponseWriter, r *http.Request) {
todo := &pb.Todo{}
switch r.Header.Get("content-type") {
case "application/json":
decode := json.NewDecoder(r.Body)
if err := decode.Decode(todo); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
case "application/proto":
b, err := ioutil.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if err := proto.Unmarshal(b, todo); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
default:
w.WriteHeader(http.StatusBadRequest)
return
}
if err := h.service.Add(todo); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
The above example begins to highlight common marshaling patterns. In this case, we have implemented a route that can return two different formats, JSON and Protobuf.
You may be asking, why we would want to do this?
This is a fairly common use case if you want to use a non-human readable format, like Protobuf, to communicate between applications. This often creates a more compact and optimized wire format compared to a more human-readable format such as JSON.
While this is just a small example, it starts to lay out the redundant marshaling logic. Now imagine supporting dozens or hundreds of API routes that all conditionally support unique formats.
That's a lot of marshaling...
Possible Solution
There are many ways to solve the above redundancy. However, our approach uses Go generics to solve the above problem.
Let's take a look at how we can leverage GLHF to reduce the marshaling logic in our Handlers.
func (h *Handlers) LookupTodo(r *glhf.Request[glhf.EmptyBody], w *glhf.Response[pb.Todo]) {
p := mux.Vars(r.HTTPRequest())
id, ok := p["id"]
if !ok {
w.SetStatus(http.StatusInternalServerError)
return
}
todo, err := h.service.Get(id)
if err != nil {
w.SetStatus(http.StatusNotFound)
return
}
w.SetBody(todo)
w.SetStatus(http.StatusOK)
}
func (h *Handlers) CreateTodo(r *glhf.Request[pb.Todo], w *glhf.Response[glhf.EmptyBody]) {
if r.Body() == nil {
w.SetStatus(http.StatusBadRequest)
return
}
if err := h.service.Add(r.Body()); err != nil {
w.SetStatus(http.StatusInternalServerError)
return
}
w.SetStatus(http.StatusOK)
}
That's it! The above is all it takes to write the same HTTP handler that now supports marshaling both JSON and Protobuff based on the Headers found in the request.
Let's step through GLHF and unpack a few more details.
Generic Handlers
GLHF works by wrapping Go's standard library HTTP handler functions. This ensures that we can work with the standard HTTP package. To do this, we needed to create a few basic types.
First, we start by defining a new Request
and Response
an object that is specific to GLHF.
// Body is the request's body.
type Body any
// A Request represents an HTTP request received by a server
type Request[T Body] struct {
r *http.Request
body *T
}
// Response represents the response from an HTTP request.
type Response[T any] struct {
w http.ResponseWriter
statusCode int
body *T
marshal MarshalFunc[T]
}
The key to both of these structs is the use of our generic Body
type. We are currently not using additional type constraints on the Body
but defined it as a type for readability and future type constraints that may be added.
The Request
and Response
type defines the type of Body
during initialization.
The second component of GLHF is a handle function definition that leverages our generic Request
and Response
structs.
type HandleFunc[I Body, O Body] func(*Request[I], *Response[O])
This gives us the ability to write functions that resemble the standard library handler functions.
Lastly, we have defined a set of functions that take a GLHF HandleFunc
and return a standard library http.handleFunc
.
Here is an example of our Get
implementation:
func Get[I EmptyBody, O any](fn HandleFunc[I, O], options ...Options) http.HandlerFunc {
opts := defaultOptions()
for _, opt := range options {
opt.Apply(opts)
}
return func(w http.ResponseWriter, r *http.Request) {
var errResp *errorResponse
if r.Method != http.MethodGet {
errResp = &errorResponse{
Code: http.StatusMethodNotAllowed,
Message: "invalid method used, expected GET found " + r.Method,
}
}
req := &Request[I]{r: r}
response := &Response[O]{w: w}
// call the handler
fn(req, response)
if response.body != nil {
var bodyBytes []byte
// if there is a custom marshaler, prioritize it
if response.marshal != nil {
b, err := response.marshal(*response.body)
if err != nil {
errResp = &errorResponse{
Code: http.StatusInternalServerError,
Message: "failed to marshal response with custom marhsaler",
}
}
bodyBytes = b
} else {
// client preferred content-type
b, err := marshalResponse(r.Header.Get(Accept), response.body)
if err != nil {
// server preferred content-type
contentType := response.w.Header().Get(ContentType)
if len(contentType) == 0 {
contentType = opts.defaultContentType
}
b, err = marshalResponse(contentType, response.body)
if err != nil {
errResp = &errorResponse{
Code: http.StatusInternalServerError,
Message: "failed to marshal response with content-type: " + contentType,
}
}
}
bodyBytes = b
}
// Response failed to marshal
if errResp != nil {
w.WriteHeader(errResp.Code)
if opts.verbose {
b, _ := json.Marshal(errResp)
w.Write(b)
}
return
}
// ensure user supplied status code is valid
if validStatusCode(response.statusCode) {
w.WriteHeader(response.statusCode)
}
if len(bodyBytes) > 0 {
w.Write(bodyBytes)
}
return
}
}
}
There is a lot to this function, but at its core, it acts much like a simple middleware. GLHF takes care of the common marshaling flows by attempting to marshal the HTTP request and response into the correct format based on the HTTP Header values for Content-type
and Accept
.
Ultimately, we can leverage this pattern in a variety of HTTP Routers to simply our HTTP handler logic.
Here is an example of GLHF being used with Gorilla Mux.
mux := mux.NewRouter()
mux.HandleFunc("/todo", glhf.Post(h.CreateTodo))
mux.HandleFunc("/todo/{id}", glhf.Get(h.LookupTodo))
Closing Thoughts
Hopefully, this gives you a sense of how generics can be used within Go and what problems they can help solve. In short, using generics for HTTP handlers in Golang can provide several benefits, including:
- Reusability: Using generics allows you to write code that can work with a variety of types without having to write duplicate code for each type. This makes it easier to reuse code and reduces the amount of boilerplate code that you need to write.
- Type Safety: Generics allow you to specify constraints on the types that your code can work with. This can help catch errors at compile time rather than at runtime, improving the overall safety of your code.
- Flexibility: Using generics allows you to write code that can work with different types of data, including different data structures and data formats. This can be especially useful when working with data that is coming from external sources.
- Performance: Generics can improve the performance of your code by reducing the amount of memory that is used and reducing the number of unnecessary type conversions that are performed.
While generics in Go offer many benefits, there are also some potential drawbacks to consider:
- Increased Complexity: Generics can make code more complex and harder to understand, especially for developers unfamiliar with the concept of type parameters.
- Increased Maintenance: Generics can make code more abstract, which may require more maintenance and testing to ensure that the code works correctly with all possible types.
- Learning Curve: The introduction of generics to Go may require some additional learning for developers who are not familiar with the concept.
- Compatibility: The addition of generics to Go may create compatibility issues with existing code that was written before the introduction of generics.
Despite these potential drawbacks, the benefits of generics in Go often outweigh the drawbacks, and many developers are excited about the new possibilities that generics offer.
We have found a lot of value in GLHF and hope others do as well! Head over to our GitHub if you are interested in learning more about GLHF. This blog's source code can be found in the example directory of GLHF.
We have many exciting features and updates planned for GLHF, so stay tuned for upcoming releases!
To stay in the loop on future development, follow us on Twitter or join our Discord! Don't hesitate to make feature requests.
Good luck, and have fun!
Published at DZone with permission of Simon Cheng. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments