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.

All topics

applearduinoarm64askmeawsberlinbookmarksbuildkitcgocoffeecontinuous profilingCOVID-19designdockerdynamodbe-paperenglishenumesp8266firefoxgithub actionsGogooglegraphqlhomelabIPv6k3skuberneteslinuxmacosmaterial designmDNSmusicndppdneondatabaseobjective-cpasskeyspostgresqlpprofprofeferandomraspberry pirusttravis civs codewaveshareµ-benchmarks