HTTP handlers using go 1.18 type parameters
If you’ve been using go to build some HTTP services you know that some things like parsing the incoming requests and validating parameters can be quite repetitive.
Indeed, and even if you’ve been using frameworks like echo, gin, or even buffalo, you’ll always end up parsing the incoming body to JSON (assuming you’re using JSON), validating this one, and, if everything is fine, proceed with the actual business logic.
The thing is that up until now, and because of the lack of generics, we had no choice but to manually implements these checks in every single endpoints and/or rely on dirty type casting if using request context and middlewares.
A simple endpoint example
Let’s checkout what a simple HTTP handler currently looks like without the new type parameters.
We want a server with a /api/v1/echo
endpoint that’ll simply grab a value passed in a JSON body and reply the same value.
package main
import (
"fmt"
"net/http"
"encoding/json"
)
func main() {
http.HandleFunc("/api/v1/echo", EchoHdl)
http.ListenAndServe(":8080", nil)
}
type EchoPayload struct {
Value string `json:"value"`
}
type EchoResponse struct {
Value string `json:"value"`
}
func EchoHdl(w http.ResponseWriter, r *http.Request) {
var v EchoPayload
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(EchoResponse{Value: v.Value})
}
Now let’s imagine that we want to add some sort of validation to the incoming payload. Say we want it to be required and with a minimum length of 3 characters.
In order to do that we’ll use the go validator library which provided us with over 100 validators. A bit overkill for our use case obviously but it’s a convenient library.
package main
// ...
type EchoPayload struct {
Value string `json:"value" validate:"required,gte=3"`
}
// ...
var validate = validator.New()
func EchoHdl(w http.ResponseWriter, r *http.Request) {
var v EchoPayload
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if err := validate.Struct(v); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
json.NewEncoder(w).Encode(EchoResponse{Value: v.Value})
}
We now validate the input of our endpoint and reply with the appropriate status code if the payload isn’t valid JSON format or if the provide value doesn’t comply with our validation rules.
But imagine now that we want to add another endpoint /api/v1/echodouble
that would double the passed in value before replying. We would basically have to go through the same boring thing all over again … Boring…
package main
// ...
type EchoPayload struct {
Value string `json:"value" validate:"required,gte=3"`
}
// ...
var validate = validator.New()
func EchoDoubleHdl(w http.ResponseWriter, r *http.Request) {
var v EchoPayload
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if err := validate.Struct(v); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
json.NewEncoder(w).Encode(EchoResponse{Value: fmt.Sprintf("%v %v", v.Value)})
}
Handle the boring stuff using type parameters
Let’s now try to use go 1.18.beta1’s new type parameters to get rid of this boring boilerplate code when implementing new endpoints
package main
type Validator interface {
Validate(interface{}) error
}
type (
NoBody struct { NoValidate }
NoParams struct { NoValidate }
NoResponse struct { NoValidate }
NoValidate struct {}
ValidatorV10 struct {}
)
// Validate implements the validator interface
func (NoValidate) Validate(b interface{}) error {
return nil
}
// Validate implements the Validator interface using go validator v10
func (g ValidatorV10) Validate(b interface{}) error {
validate := validator.New()
return validate.Struct(b)
}
// HTTPHandler
type HTTPHandler[BODY Validator, QUERYPARAMS any] struct {
parsedBody BODY
parsedQueryParams QUERYPARAMS
}
// NewHTTPHandler returns a new HTTPHandler.
func NewHTTPHandler[BODY Validator, QUERYPARAMS any]() *HTTPHandler[BODY, REQUEST, RESPONSE] {
return &HTTPHandler[BODY, QUERYPARAMS]{}
}
// Handle is a simple HTTP request handler that takes care of parsing the incoming request
// and validating the passed in parameters leaving the caller to handle the business logic.
func (h *HTTPHandler[BODY, QUERYPARAMS]) Handle(wr http.ResponseWriter, r *http.Request, fn func(b BODY, p QUERYPARAMS)) {
if err := h.parseAndValidateBody(r); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
fn(h.parsedBody, h.parsedRequest)
}
// parseAndValidateBody parses the request body using a JSON decoder.
func (h *HTTPHandler[BODY, QUERYPARAMS]) parseAndValidateBody(r *http.Request) error {
var body BODY
switch (interface{})(body).(type) {
case NoBody:
return nil
default:
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
return fmt.Errorf("failed to decode body: %w", err)
}
if err := body.Validate(body); err != nil {
return err
}
h.parsedBody = body
return nil
}
}
// parseAndValidateQueryParams parses the request's query parameters.
func (h *HTTPHandler[BODY, QUERYPARAMS]) parseAndValidateQueryParams(r *http.Request) error {
// TODO: not implemented yet
}
Let’s have a deeper look at what’s going on here.
As you’ll recall from our previous post we can’t use methods with type parameters and therefore have to resort to a facilitator
pattern.
We then start by declaring a custom HTTPHandler[BODY Validator, QUERYPARAMS any]
struct onto which we’ll declare a Handle(wr http.ResponseWriter, r *http.Request, fn func(b BODY, p QUERYPARAMS))
method.
By doing so we’ll be able to migrate our handlers little by little without having to go through a complicated refactor. As always, keep it simple stupid.
You’ll also notice that we’ve pre-declared some helper struct at the very top for requests that do not require any type of input validation and/or input parsing.
Now let’s update our simple handler and use our use HTTPHandler
.
package main
import (
"fmt"
"net/http"
"encoding/json"
)
func main() {
http.HandleFunc("/api/v1/echo", EchoHdl)
http.HandleFunc("/api/v1/echodouble", EchoDoubleHdl)
http.ListenAndServe(":8080", nil)
}
type EchoPayload struct {
Value string `json:"value" validate:"required,gte=3"`
api.ValidatorV10
}
type EchoResponse struct {
Value string `json:"value"`
}
func EchoHdl(w http.ResponseWriter, r *http.Request) {
NewHTTPHandler[EchoPayload, NoParams]().Handle(w, r, func(body EchoPayload, params NoParams) {
json.NewEncoder(w).Encode(EchoResponse{Value: body.Value})
})
}
func EchoDoubleHdl(w http.ResponseWriter, r *http.Request) {
NewHTTPHandler[EchoPayload, NoParams]().Handle(w, r, func(body EchoPayload, params NoParams) {
json.NewEncoder(w).Encode(EchoResponse{Value: fmt.Sprintf("%v %v", body.Value)})
})
}
As you can see we can shave off quite some boilerplate code by using type parameters to handle payload parsing and validation.
Conclusion
Go’s new type parameters feature we’ll undoubtedly help us shave off some of the boring stuff that we need to go through when writing HTTP services and I’m pretty confident that it’ll help attract many more developers to the go programming language !