Little things of Go HTTP handlers
Every time I sketch an HTTP API in Go, I wrap the code of request handlers around these small but very convenient bits.
My handlers are methods or functions, that serve a request and, either write a (positive) response or return an error.
// HandlerFunc is an HTTP handler function, that handles an HTTP request.
// It writes the response to http.ResponseWriter or returns an error.
type HandlerFunc func(w http.ResponseWriter, r *http.Request) error
// Handler is an adaptor for HandlerFunc, that converts the handler into http.Handler.
// It makes sure all errors returned from h are handled in a consistent manner.
func Handler(h HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := h(w, r)
if err != nil {
handleError(w, r, err)
}
})
}
An error returned from HandlerFunc
can be an indicator of a failure in request processing, statusError
, or a general “something didn’t work”-error. The later can contain the internal details, that the API must never expose to the user.
// StatusError wraps an error err and contains the suggestion regarding to
// how the error should be communicated to the user.
//
// code must be a valid HTTP status code; text is the message to reply to user.
func StatusError(code int, text string, err error) error {
return &statusError{
Code: code,
Text: text,
Err: err,
}
}
type statusError struct {
Code int
Text string
Err error
}
func (s statusError) Error() string {
return s.Text
}
func (s statusError) Unwrap() error {
return s.Err
}
handleError
is a helper function, which makes sure all errors returned from HandlerFunc
are handled and replied to the user consistently. The internal details — the cause of the error — aren’t exposed to the user, but the helper can provide a unified logging and metrics, which would be convenient when debugging the error later:
var ErrNotFound = StatusError(http.StatusNotFound, "Nothing found", nil)
func handleError(w http.ResponseWriter, r *http.Request, err error) {
var statusErr *statusError
if !errors.As(err, &statusErr) {
statusErr = &statusError{Code: http.StatusInternalServerError, Text: "Internal server error", Err: err}
}
rid := RequestIDFromContext(r.Context())
resp := errResponse{
RequestID: rid,
Error: statusErr.Text,
}
replyJSON(w, resp, statusErr.Code)
// underlying error can be nil, as a special case, when the error is a client-side problem
if err := errors.Unwrap(statusErr); err != nil {
log.Errorw("request failed", "request-id", rid, "uri", r.RequestURI, "error", err)
}
}
replyJSON
is a helper function, which writes a JSON string to http.ResponseWriter
, setting the proper HTTP headers.
func replyJSON(w http.ResponseWriter, v interface{}, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
err := json.NewEncoder(w).Encode(v)
if err != nil {
io.WriteString(w, `{"code":500,"error":`+strconv.Quote(err.Error())+`}`)
}
}
How does it look in practice?
Below is an extract from a hypothetical API, with one single route /api/login
, that takes an email, and replies with JSON, that contains this account’s ID.
func setupRoutes(mux *http.ServeMux) {
authHandler := NewAuthHandler(···)
mux.Handle("/api/login", Handler(authHandler.HandleLogin))
}
type AuthHandler {
// internal dependencies
}
func (h *AuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
req, err := DecodeLoginRequest(r)
if err != nil {
return fmt.Errorf("decode login request: %w", err)
}
if req.Email == "" {
return StatusError(http.StatusBadRequest, "email is required", nil)
}
accID, err := h.datastore.GetAccountByEmail(ctx, req.Email)
if errors.Is(err, ErrNotExists) {
return StatusError(http.StatusForbidden, "account does not exist", err)
}
if err != nil {
return fmt.Errorf("get account for %q: %w", req.Email, err)
}
resp := struct {
ID string `json:"id"`
}{
ID: accID,
}
// ReplyJSON is a wrapper around internal replyJSON, that always responses with http.StatusOK
ReplyJSON(w, resp)
return nil
}
Do you have your own little things, that help you to lay out the boilerplate? Discuss this note on Twitter or Reddit.