diff --git a/net/http/httputil/chain.go b/net/http/httputil/chain.go new file mode 100644 index 0000000..f3e854f --- /dev/null +++ b/net/http/httputil/chain.go @@ -0,0 +1,19 @@ +package httputil + +import "net/http" + +// Chain applies middlewares to a http.Handler +func Chain(h http.Handler, ff ...func(http.Handler) http.Handler) http.Handler { + for _, f := range ff { + h = f(h) + } + return h +} + +// ChainFunc applies middlewares to a http.HandlerFunc +func ChainFunc(hf http.HandlerFunc, ff ...func(http.HandlerFunc) http.HandlerFunc) http.HandlerFunc { + for _, f := range ff { + hf = f(hf) + } + return hf +} diff --git a/net/http/httputil/hlog/log.go b/net/http/httputil/hlog/log.go new file mode 100644 index 0000000..2ca0203 --- /dev/null +++ b/net/http/httputil/hlog/log.go @@ -0,0 +1,50 @@ +package hlog + +import ( + "log/slog" + "net/http" + "time" + + "go.adoublef.dev/sdk/net/http/httputil" +) + +// Log wraps a http.Handler with a request logger. +func Log(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rw := httputil.Wrap(w, r) + + start := time.Now() + defer func() { + duration := time.Since(start) + + slog.LogAttrs( + r.Context(), + statusLevel(rw.Status()), + "http request", + slog.String("method", r.Method), + slog.Int64("time_ms", int64(duration/time.Millisecond)), + slog.String("path", r.URL.Path), + slog.Int("status", rw.Status()), + slog.String("duration", duration.String()), + ) + }() + + h.ServeHTTP(rw, r) + }) +} + +func statusLevel(status int) slog.Level { + switch { + case status <= 0: + return slog.LevelWarn + case status < 400: // for codes in 100s, 200s, 300s + return slog.LevelInfo + case status >= 400 && status < 500: + // switching to info level to be less noisy + return slog.LevelInfo + case status >= 500: + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/net/http/httputil/response_writer.go b/net/http/httputil/response_writer.go new file mode 100644 index 0000000..9941294 --- /dev/null +++ b/net/http/httputil/response_writer.go @@ -0,0 +1,107 @@ +package httputil + +import ( + "bufio" + "errors" + "net" + "net/http" +) + +type ResponseWriter interface { + http.ResponseWriter + http.Flusher + // Status returns the status code of the response or 0 if the response has not been written. + Status() int + // Written returns whether or not the ResponseWriter has been written. + Written() bool + // Size returns the size of the response body. + Size() int + Unwrap() http.ResponseWriter +} + +type response struct { + http.ResponseWriter + method string + status int + size int +} + +// Size implements ResponseWriter. +func (rw *response) Size() int { + return rw.size +} + +// Unwrap implements ResponseWriter. +func (rw *response) Unwrap() http.ResponseWriter { + return rw.ResponseWriter +} + +// Written implements ResponseWriter. +func (rw *response) Written() bool { + return rw.status != 0 +} + +// Status implements ResponseWriter. +func (rw *response) Status() int { + return rw.status +} + +// Write implements ResponseWriter. +// Subtle: this method shadows the method (ResponseWriter).Write of response.ResponseWriter. +func (rw *response) Write(b []byte) (size int, err error) { + if !rw.Written() { + // The status will be StatusOK if WriteHeader has not been called yet + rw.WriteHeader(http.StatusOK) + } + if rw.method != http.MethodHead { + size, err = rw.ResponseWriter.Write(b) + rw.size += size + } + return size, err +} + +// WriteHeader implements ResponseWriter. +// Subtle: this method shadows the method (ResponseWriter).WriteHeader of response.ResponseWriter. +func (rw *response) WriteHeader(s int) { + // Avoid panic if status code is not a valid HTTP status code + if s < 100 || s > 999 { + rw.ResponseWriter.WriteHeader(500) + rw.status = 500 + return + } + + rw.ResponseWriter.WriteHeader(s) + rw.status = s +} + +func (rw *response) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hijacker, ok := rw.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, ErrHijackUnsupported + } + + conn, brw, err := hijacker.Hijack() + if err == nil { + rw.status = -1 + } + + return conn, brw, err +} + +func (rw *response) Flush() { + if flusher, ok := rw.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} + +// Wrap +func Wrap(w http.ResponseWriter, r *http.Request) ResponseWriter { + if rw, ok := w.(ResponseWriter); ok { + return rw + } + return &response{w, r.Method, 0, 0} +} + +var ( + ErrHijackUnsupported = errors.New("the ResponseWriter doesn't support the Hijacker interface") +)