Skip to content

Instantly share code, notes, and snippets.

@nilium
Last active January 17, 2018 02:26
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save nilium/f2ec7dcd54accd23532e82b04f1df7de to your computer and use it in GitHub Desktop.
Save nilium/f2ec7dcd54accd23532e82b04f1df7de to your computer and use it in GitHub Desktop.
// Copyright 2016 Noel Cower. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be found
// at the end of the file.
// This file is intended to demonstrate writing a general-purpose HTTP handler
// where the input and output are known to be JSON, follow a particular error
// convention, and so on. It falls apart in particular with GET requests where
// request bodies are uncommon, at best.
//
// The responder handlers mimic gRPC's error code/response format, but does not
// do much else in that respect (e.g., no trailing headers or custom headers,
// HTTP/2 just a property of the client and server, etc.).
//
// Basically, this is meant to show, more than anything else, how you can
// simplify some boilerplate for HTTP handlers by making careful use of
// interfaces.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"net/http/httputil"
"path"
"reflect"
"strconv"
"strings"
)
// ErrorResponse is the type of error message sent back when a Responder returns an error. If the
// Responder's error isn't an *ErrorResponse, one is allocated with a Code of Unknown and the
// error's description.
type ErrorResponse struct {
Code uint `json:"code"`
Desc string `json:"desc"`
}
func (e *ErrorResponse) Error() string {
if e == nil {
return "<nil>"
}
return fmt.Sprint(e.Code, ": ", e.Desc)
}
func mkErrResponse(err error) *ErrorResponse {
if err == nil {
return nil
}
er, _ := err.(*ErrorResponse)
if er != nil {
return er
}
return &ErrorResponse{Unknown, err.Error()}
}
// ErrorCode returns the error code of an *ErrorResponse, otherwise Unknown.
func ErrorCode(err error) uint {
if e, _ := err.(*ErrorResponse); e != nil {
return e.Code
}
return Unknown
}
func httpErrorCode(err error) int {
switch ErrorCode(err) {
case InvalidArgument, BadRequest:
return http.StatusBadRequest
case NotFound:
return http.StatusNotFound
case Unimplemented:
return http.StatusNotImplemented
default:
return http.StatusInternalServerError
}
}
// Error codes for ServeJSON to return an ErrorResponse with.
const (
InvalidArgument = 0
Unknown = 1
NotFound = 2
Unimplemented = 3
BadRequest = 4
Invalid = 31
// Etc. -- mimicking gRPC here
)
// Protocols
// A NewResponder returns a new responder for a given *http.Request.
type NewResponder interface {
NewResponder(*http.Request) (Responder, error)
}
// NewResponderFunc is a function type NewResponder. This is a convenience type.
type NewResponderFunc func(*http.Request) (Responder, error)
func (fn NewResponderFunc) NewResponder(req *http.Request) (Responder, error) {
return fn(req)
}
// A Responder receives an HTTP request and returns an HTTP status code and a message to be sent,
// encoded as JSON, in response to the request. If an error is returned, it is converted to an
// *ErrorResponse if not already one and the HTTP status code is determined by the *ErrorResponse's
// Code field (which defaults to Unknown, producing an internal server error code).
//
// A Responder is typically an instance of an input request to be fulfilled.
type Responder interface {
ServeJSON(*http.Request) (httpStatus int, msg interface{}, err error)
}
// ResponderMessage is an optional interface that a Responder may implement to provide an
// alternative body to unmarshal incoming JSON into.
type ResponderMessage interface {
Message() interface{}
}
// Handlers
func allocResponder(msg Responder) Responder {
t := reflect.TypeOf(msg)
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
return reflect.New(t).Interface().(Responder)
}
// JSONResponderByPrototype creates a JSONResponder handler that simply creates a NewResponder that
// allocates a new instance of msg each time. The new msg instance is zeroed.
func JSONResponderByPrototype(msg Responder) http.Handler {
if ra, ok := msg.(NewResponder); ok {
return JSONResponder(ra)
}
return JSONResponderFunc(func(*http.Request) (Responder, error) {
return allocResponder(msg), nil
})
}
// JSONResponderFunc is a convenience function for calling JSONResponder with a NewResponderFunc.
func JSONResponderFunc(fn NewResponderFunc) http.Handler {
return JSONResponder(fn)
}
type responderKey string
func (r *responderKey) GoString() string { return string(*r) }
var rawMessageKey = new(responderKey)
func init() { *rawMessageKey = "responder:msg" }
// InputMessage returns the message used to store an incoming request body. This can be useful if
// a Responder implements ResponderMessage but does not store it.
func InputMessage(ctx context.Context) interface{} {
return ctx.Value(rawMessageKey)
}
func withInputMessage(ctx context.Context, msg interface{}) context.Context {
return context.WithValue(ctx, rawMessageKey, msg)
}
type jsonResponseHandler struct {
gen NewResponder
}
func (h *jsonResponseHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
var (
body, resp interface{}
code int
err error
)
// Insert correct content type handling here and such or somewhere downstream
responder, err := h.gen.NewResponder(req)
body = responder
if err != nil {
goto replyWithError
}
if rm, ok := body.(ResponderMessage); ok {
body = rm.Message()
}
if body == nil {
err = &ErrorResponse{Unimplemented, "not implemented"}
goto replyWithError
}
if err = json.NewDecoder(req.Body).Decode(body); err != nil {
err = &ErrorResponse{BadRequest, err.Error()}
goto replyWithError
}
req = req.WithContext(withInputMessage(req.Context(), body))
code, resp, err = responder.ServeJSON(req)
replyWithError:
if err != nil {
code = httpErrorCode(err)
err = mkErrResponse(err)
resp = err
} else if code == 0 {
code = http.StatusOK
}
respBody, err := json.Marshal(resp)
if err != nil {
log.Print("error encoding %T response (from %T): %v", resp, responder, err)
w.Header().Set("Content-Length", "0")
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Length", strconv.Itoa(len(respBody)))
// TODO: Handle Accept-Encoding header here or downstream (i.e., buffer and encode
// later)
w.WriteHeader(code)
if _, err := w.Write(respBody); err != nil {
// Log error? Probably connection died.
log.Print("error writing %T response (from %T): %v", resp, responder, err)
}
}
// JSONResponder creates an http.Handler that allocates a new message, decodes a request body into
// it, and serves a JSON response. It modifies the Content-Type and Content-Length headers of the
// response and, in error cases, may write an HTTP status code other than the one returned for each
// responder message.
func JSONResponder(gen NewResponder) http.Handler {
return &jsonResponseHandler{gen}
}
// Implementations of protocols
type NewPersonResponse struct {
RequestId string `json:"requestId"`
Request *NewPersonRequest `json:"request"`
}
type NewPersonRequest struct {
Name string `json:"name"`
Age int `json:"age"`
}
func (req *NewPersonRequest) ServeJSON(*http.Request) (code int, msg interface{}, err error) {
return http.StatusCreated, &NewPersonResponse{RequestId: "request-id", Request: req}, nil
}
type ReturnError uint
func (r ReturnError) NewResponder(*http.Request) (Responder, error) { return r, nil }
func (ReturnError) Message() interface{} { return new(json.RawMessage) }
func (r ReturnError) ServeJSON(req *http.Request) (int, interface{}, error) {
enc, _ := json.Marshal(InputMessage(req.Context()))
log.Print("Request: ", string(enc))
return 0, nil, &ErrorResponse{uint(r), "error"}
}
type YieldError uint
func (r *YieldError) ServeJSON(*http.Request) (int, interface{}, error) {
return http.StatusOK, &ErrorResponse{Code: uint(*r), Desc: "error code"}, nil
}
func main() {
http.Handle("/person", JSONResponderByPrototype(new(NewPersonRequest)))
http.Handle("/error", JSONResponder(ReturnError(Unimplemented)))
http.Handle("/error2", JSONResponderByPrototype(new(YieldError)))
type testCase struct {
Path string
Body string
}
cases := []testCase{
{
Path: "/person",
Body: `{"name": "Johnny", "age": 637}`,
},
{
Path: "/error",
Body: `{"arbitrary": [1, 2, 3, "bepis"], "really now": true}`,
},
{
Path: "/error2",
Body: `0`,
},
{
Path: "/error2",
Body: `1`,
},
{
Path: "/error2",
Body: `2`,
},
{
Path: "/error2",
Body: `3`,
},
}
for i, c := range cases {
// Committing a sin here for the sake of brevity: errors are not checked in this loop.
if i > 0 {
fmt.Print("\n\n\n")
}
fmt.Print(i+1, ":\n")
req, _ := http.NewRequest("GET", "http://127.0.0.1:8080/", strings.NewReader(c.Body))
req.URL.Path = path.Join(req.URL.Path, c.Path)
reqBuf, _ := httputil.DumpRequest(req, true)
fmt.Print("< ", strings.Replace(string(reqBuf), "\n", "\n< ", -1), "\n\n")
rec := httptest.NewRecorder()
http.DefaultServeMux.ServeHTTP(rec, req)
resp := rec.Result()
respBuf, _ := httputil.DumpResponse(resp, true)
fmt.Print("> ", strings.Replace(string(respBuf), "\n", "\n> ", -1), "\n")
}
}
// Copyright (c) 2016, Noel Cower
// All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// 1. Redistributions of source code must retain the above copyright notice,
// this list of conditions and the following disclaimer.
//
// 2. Redistributions in binary form must reproduce the above copyright notice,
// this list of conditions and the following disclaimer in the documentation
// and/or other materials provided with the distribution.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment