diff --git a/server/internal/http/endpoint/endpoint.go b/server/internal/http/endpoint/endpoint.go deleted file mode 100644 index 714ea0d..0000000 --- a/server/internal/http/endpoint/endpoint.go +++ /dev/null @@ -1,100 +0,0 @@ -package endpoint - -import ( - "encoding/json" - "fmt" - "net/http" - "runtime/debug" - - "github.com/go-chi/chi/middleware" - "github.com/rs/zerolog/log" -) - -type ( - Endpoint func(http.ResponseWriter, *http.Request) error - - ErrResponse struct { - Status int `json:"status,omitempty"` - Err string `json:"error,omitempty"` - Message string `json:"message,omitempty"` - Details string `json:"details,omitempty"` - Code string `json:"code,omitempty"` - RequestID string `json:"request,omitempty"` - } -) - -func Handle(handler Endpoint) http.HandlerFunc { - fn := func(w http.ResponseWriter, r *http.Request) { - if err := handler(w, r); err != nil { - WriteError(w, r, err) - } - } - - return http.HandlerFunc(fn) -} - -var nonErrorsCodes = map[int]bool{ - 404: true, -} - -func errResponse(input interface{}) *ErrResponse { - var res *ErrResponse - var err interface{} - - switch input.(type) { - case *HandlerError: - e := input.(*HandlerError) - res = &ErrResponse{ - Status: e.Status, - Err: http.StatusText(e.Status), - Message: e.Message, - } - err = e.Err - default: - res = &ErrResponse{ - Status: http.StatusInternalServerError, - Err: http.StatusText(http.StatusInternalServerError), - } - err = input - } - - if err != nil { - switch err.(type) { - case *error: - e := err.(error) - res.Details = e.Error() - default: - res.Details = fmt.Sprintf("%+v", err) - } - } - - return res -} - -func WriteError(w http.ResponseWriter, r *http.Request, err interface{}) { - hlog := log.With(). - Str("module", "http"). - Logger() - - res := errResponse(err) - - if reqID := middleware.GetReqID(r.Context()); reqID != "" { - res.RequestID = reqID - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(res.Status) - - if err := json.NewEncoder(w).Encode(res); err != nil { - hlog.Warn().Err(err).Msg("Failed writing json error response") - } - - if !nonErrorsCodes[res.Status] { - logEntry := middleware.GetLogEntry(r) - if logEntry != nil { - logEntry.Panic(err, debug.Stack()) - } else { - hlog.Error().Str("stack", string(debug.Stack())).Msgf("%+v", err) - } - } -} diff --git a/server/internal/http/endpoint/error.go b/server/internal/http/endpoint/error.go deleted file mode 100644 index 3fe3951..0000000 --- a/server/internal/http/endpoint/error.go +++ /dev/null @@ -1,17 +0,0 @@ -package endpoint - -import "fmt" - -type HandlerError struct { - Status int - Message string - Err error -} - -func (e *HandlerError) Error() string { - if e.Err != nil { - return fmt.Sprintf("%s: %s", e.Message, e.Err.Error()) - } - - return e.Message -} diff --git a/server/internal/http/http.go b/server/internal/http/http.go index 355f33c..533da49 100644 --- a/server/internal/http/http.go +++ b/server/internal/http/http.go @@ -7,10 +7,10 @@ import ( "os" "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" "github.com/rs/zerolog" "github.com/rs/zerolog/log" - "m1k1o/neko/internal/http/middleware" "m1k1o/neko/internal/types" "m1k1o/neko/internal/types/config" ) @@ -26,9 +26,9 @@ func New(conf *config.Server, webSocketHandler types.WebSocketHandler) *Server { logger := log.With().Str("module", "http").Logger() router := chi.NewRouter() - // router.Use(middleware.Recoverer) // Recover from panics without crashing server router.Use(middleware.RequestID) // Create a request ID for each request - router.Use(middleware.Logger) // Log API request calls + router.Use(middleware.RequestLogger(&logformatter{logger})) + router.Use(middleware.Recoverer) // Recover from panics without crashing server router.Get("/ws", func(w http.ResponseWriter, r *http.Request) { err := webSocketHandler.Upgrade(w, r) diff --git a/server/internal/http/logger.go b/server/internal/http/logger.go new file mode 100644 index 0000000..acc20ef --- /dev/null +++ b/server/internal/http/logger.go @@ -0,0 +1,74 @@ +package http + +import ( + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/middleware" + "github.com/rs/zerolog" +) + +type logformatter struct { + logger zerolog.Logger +} + +func (l *logformatter) NewLogEntry(r *http.Request) middleware.LogEntry { + req := map[string]interface{}{} + + if reqID := middleware.GetReqID(r.Context()); reqID != "" { + req["id"] = reqID + } + + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + + req["scheme"] = scheme + req["proto"] = r.Proto + req["method"] = r.Method + req["remote"] = r.RemoteAddr + req["agent"] = r.UserAgent() + req["uri"] = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI) + + fields := map[string]interface{}{} + fields["req"] = req + + return &logentry{ + fields: fields, + logger: l.logger, + } +} + +type logentry struct { + logger zerolog.Logger + fields map[string]interface{} + errors []map[string]interface{} +} + +func (e *logentry) Write(status, bytes int, header http.Header, elapsed time.Duration, extra interface{}) { + res := map[string]interface{}{} + res["time"] = time.Now().UTC().Format(time.RFC1123) + res["status"] = status + res["bytes"] = bytes + res["elapsed"] = float64(elapsed.Nanoseconds()) / 1000000.0 + + e.fields["res"] = res + e.fields["module"] = "http" + + if len(e.errors) > 0 { + e.fields["errors"] = e.errors + e.logger.Error().Fields(e.fields).Msgf("request failed (%d)", status) + } else { + e.logger.Debug().Fields(e.fields).Msgf("request complete (%d)", status) + } +} + +func (e *logentry) Panic(v interface{}, stack []byte) { + err := map[string]interface{}{} + err["message"] = fmt.Sprintf("%+v", v) + err["stack"] = string(stack) + + e.errors = append(e.errors, err) +} diff --git a/server/internal/http/middleware/logger.go b/server/internal/http/middleware/logger.go deleted file mode 100644 index ab19ba2..0000000 --- a/server/internal/http/middleware/logger.go +++ /dev/null @@ -1,86 +0,0 @@ -package middleware - -import ( - "fmt" - "net/http" - "time" - - "github.com/go-chi/chi/middleware" - "github.com/rs/zerolog/log" -) - -func Logger(next http.Handler) http.Handler { - fn := func(w http.ResponseWriter, r *http.Request) { - req := map[string]interface{}{} - - // ignore healthcheck - if r.RequestURI == "/health" { - next.ServeHTTP(w, r) - return - } - - if reqID := middleware.GetReqID(r.Context()); reqID != "" { - req["id"] = reqID - } - - scheme := "http" - if r.TLS != nil { - scheme = "https" - } - - req["scheme"] = scheme - req["proto"] = r.Proto - req["method"] = r.Method - req["remote"] = r.RemoteAddr - req["agent"] = r.UserAgent() - req["uri"] = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI) - - fields := map[string]interface{}{} - fields["req"] = req - - entry := &entry{ - fields: fields, - } - - ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) - t1 := time.Now() - - defer func() { - entry.Write(ww.Status(), ww.BytesWritten(), time.Since(t1)) - }() - - next.ServeHTTP(ww, r) - } - return http.HandlerFunc(fn) -} - -type entry struct { - fields map[string]interface{} - errors []map[string]interface{} -} - -func (e *entry) Write(status, bytes int, elapsed time.Duration) { - res := map[string]interface{}{} - res["time"] = time.Now().UTC().Format(time.RFC1123) - res["status"] = status - res["bytes"] = bytes - res["elapsed"] = float64(elapsed.Nanoseconds()) / 1000000.0 - - e.fields["res"] = res - e.fields["module"] = "http" - - if len(e.errors) > 0 { - e.fields["errors"] = e.errors - log.Error().Fields(e.fields).Msgf("request failed (%d)", status) - } else { - log.Debug().Fields(e.fields).Msgf("request complete (%d)", status) - } -} - -func (e *entry) Panic(v interface{}, stack []byte) { - err := map[string]interface{}{} - err["message"] = fmt.Sprintf("%+v", v) - err["stack"] = string(stack) - - e.errors = append(e.errors, err) -} diff --git a/server/internal/http/middleware/middleware.go b/server/internal/http/middleware/middleware.go deleted file mode 100644 index b151b9e..0000000 --- a/server/internal/http/middleware/middleware.go +++ /dev/null @@ -1,12 +0,0 @@ -package middleware - -// contextKey is a value for use with context.WithValue. It's used as -// a pointer so it fits in an interface{} without allocation. This technique -// for defining context keys was copied from Go 1.7's new use of context in net/http. -type ctxKey struct { - name string -} - -func (k *ctxKey) String() string { - return "neko/ctx/" + k.name -} diff --git a/server/internal/http/middleware/recover.go b/server/internal/http/middleware/recover.go deleted file mode 100644 index 65812b4..0000000 --- a/server/internal/http/middleware/recover.go +++ /dev/null @@ -1,24 +0,0 @@ -package middleware - -// The original work was derived from Goji's middleware, source: -// https://github.com/zenazn/goji/tree/master/web/middleware - -import ( - "net/http" - - "m1k1o/neko/internal/http/endpoint" -) - -func Recoverer(next http.Handler) http.Handler { - fn := func(w http.ResponseWriter, r *http.Request) { - defer func() { - if rvr := recover(); rvr != nil { - endpoint.WriteError(w, r, rvr) - } - }() - - next.ServeHTTP(w, r) - } - - return http.HandlerFunc(fn) -} diff --git a/server/internal/http/middleware/request.go b/server/internal/http/middleware/request.go deleted file mode 100644 index 00be35e..0000000 --- a/server/internal/http/middleware/request.go +++ /dev/null @@ -1,89 +0,0 @@ -package middleware - -import ( - "context" - "crypto/rand" - "encoding/base64" - "fmt" - "net/http" - "os" - "strings" - "sync/atomic" -) - -// Key to use when setting the request ID. -type ctxKeyRequestID int - -// RequestIDKey is the key that holds the unique request ID in a request context. -const RequestIDKey ctxKeyRequestID = 0 - -var prefix string -var reqid uint64 - -// A quick note on the statistics here: we're trying to calculate the chance that -// two randomly generated base62 prefixes will collide. We use the formula from -// http://en.wikipedia.org/wiki/Birthday_problem -// -// P[m, n] \approx 1 - e^{-m^2/2n} -// -// We ballpark an upper bound for $m$ by imagining (for whatever reason) a server -// that restarts every second over 10 years, for $m = 86400 * 365 * 10 = 315360000$ -// -// For a $k$ character base-62 identifier, we have $n(k) = 62^k$ -// -// Plugging this in, we find $P[m, n(10)] \approx 5.75%$, which is good enough for -// our purposes, and is surely more than anyone would ever need in practice -- a -// process that is rebooted a handful of times a day for a hundred years has less -// than a millionth of a percent chance of generating two colliding IDs. - -func init() { - hostname, err := os.Hostname() - if hostname == "" || err != nil { - hostname = "localhost" - } - var buf [12]byte - var b64 string - for len(b64) < 10 { - rand.Read(buf[:]) - b64 = base64.StdEncoding.EncodeToString(buf[:]) - b64 = strings.NewReplacer("+", "", "/", "").Replace(b64) - } - - prefix = fmt.Sprintf("%s/%s", hostname, b64[0:10]) -} - -// RequestID is a middleware that injects a request ID into the context of each -// request. A request ID is a string of the form "host.example.com/random-0001", -// where "random" is a base62 random string that uniquely identifies this go -// process, and where the last number is an atomically incremented request -// counter. -func RequestID(next http.Handler) http.Handler { - fn := func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - requestID := r.Header.Get("X-Request-Id") - if requestID == "" { - myid := atomic.AddUint64(&reqid, 1) - requestID = fmt.Sprintf("%s-%06d", prefix, myid) - } - ctx = context.WithValue(ctx, RequestIDKey, requestID) - next.ServeHTTP(w, r.WithContext(ctx)) - } - return http.HandlerFunc(fn) -} - -// GetReqID returns a request ID from the given context if one is present. -// Returns the empty string if a request ID cannot be found. -func GetReqID(ctx context.Context) string { - if ctx == nil { - return "" - } - if reqID, ok := ctx.Value(RequestIDKey).(string); ok { - return reqID - } - return "" -} - -// NextRequestID generates the next request ID in the sequence. -func NextRequestID() uint64 { - return atomic.AddUint64(&reqid, 1) -} diff --git a/server/internal/http/response/response.go b/server/internal/http/response/response.go deleted file mode 100644 index 73041ab..0000000 --- a/server/internal/http/response/response.go +++ /dev/null @@ -1,32 +0,0 @@ -package response - -import ( - "encoding/json" - "net/http" - - "m1k1o/neko/internal/http/endpoint" -) - -// JSON encodes data to rw in JSON format. Returns a pointer to a -// HandlerError if encoding fails. -func JSON(w http.ResponseWriter, data interface{}, status int) error { - w.WriteHeader(status) - w.Header().Set("Content-Type", "application/json") - - err := json.NewEncoder(w).Encode(data) - if err != nil { - return &endpoint.HandlerError{ - Status: http.StatusInternalServerError, - Message: "unable to write JSON response", - Err: err, - } - } - - return nil -} - -// Empty merely sets the response code to NoContent (204). -func Empty(w http.ResponseWriter) error { - w.WriteHeader(http.StatusNoContent) - return nil -}