use errx and splinter packages

This commit is contained in:
juancwu 2026-04-25 20:41:52 +00:00
commit 75a4590f18
7 changed files with 95 additions and 26 deletions

5
go.mod
View file

@ -1,3 +1,8 @@
module git.juancwu.dev/juancwu/lightmux module git.juancwu.dev/juancwu/lightmux
go 1.26.2 go 1.26.2
require (
git.juancwu.dev/juancwu/errx v0.1.0
git.juancwu.dev/juancwu/splinter v0.1.0
)

4
go.sum Normal file
View file

@ -0,0 +1,4 @@
git.juancwu.dev/juancwu/errx v0.1.0 h1:92yA0O1BkKGXcoEiWtxwH/ztXCjoV1KSTMtKpm3gd2w=
git.juancwu.dev/juancwu/errx v0.1.0/go.mod h1:7jNhBOwcZ/q7zDD6mln3QCJBYZ8T6h+dAdxVfykprTk=
git.juancwu.dev/juancwu/splinter v0.1.0 h1:ZGvvzyi24hZw/yFAwpUsHtj+q+fh9I2KIGmOAILWD5Q=
git.juancwu.dev/juancwu/splinter v0.1.0/go.mod h1:dAYsRQfS6tqWynEGz8xMCtIJUN7+KIp3jLE7kgO3yKE=

View file

@ -1,9 +1,10 @@
package middleware package middleware
import ( import (
"log"
"net/http" "net/http"
"time" "time"
"git.juancwu.dev/juancwu/splinter"
) )
type statusRecorder struct { type statusRecorder struct {
@ -33,6 +34,11 @@ func Logger(next http.Handler) http.Handler {
start := time.Now() start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r) next.ServeHTTP(rec, r)
log.Printf("%s %s %d %s", r.Method, r.URL.Path, rec.status, time.Since(start)) splinter.Info("http.request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration", time.Since(start),
)
}) })
} }

View file

@ -2,18 +2,29 @@ package middleware
import ( import (
"bytes" "bytes"
"log"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"git.juancwu.dev/juancwu/splinter"
) )
func TestLogger(t *testing.T) { func captureSplinter(t *testing.T) *bytes.Buffer {
t.Helper()
var buf bytes.Buffer var buf bytes.Buffer
orig := log.Default().Writer() logger := splinter.New(splinter.WithStream(splinter.NewConsoleStream(
log.Default().SetOutput(&buf) splinter.ConsoleJSON,
defer log.Default().SetOutput(orig) splinter.LevelDebug,
splinter.ConsoleWriter(&buf),
)))
prev := splinter.SetDefault(logger)
t.Cleanup(func() { splinter.SetDefault(prev) })
return &buf
}
func TestLogger(t *testing.T) {
buf := captureSplinter(t)
h := Logger(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := Logger(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot) w.WriteHeader(http.StatusTeapot)
@ -26,23 +37,22 @@ func TestLogger(t *testing.T) {
t.Errorf("status code = %d, want 418", rr.Code) t.Errorf("status code = %d, want 418", rr.Code)
} }
out := buf.String() out := buf.String()
if !strings.Contains(out, "GET /foo 418") { for _, want := range []string{`"method":"GET"`, `"path":"/foo"`, `"status":418`} {
t.Errorf("log output missing expected fields: %q", out) if !strings.Contains(out, want) {
t.Errorf("log output missing %s\nfull output: %s", want, out)
}
} }
} }
func TestLoggerDefaultStatusOK(t *testing.T) { func TestLoggerDefaultStatusOK(t *testing.T) {
var buf bytes.Buffer buf := captureSplinter(t)
orig := log.Default().Writer()
log.Default().SetOutput(&buf)
defer log.Default().SetOutput(orig)
h := Logger(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := Logger(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hi")) w.Write([]byte("hi"))
})) }))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
if !strings.Contains(buf.String(), "200") { if !strings.Contains(buf.String(), `"status":200`) {
t.Errorf("expected default 200 in log, got %q", buf.String()) t.Errorf("expected default status 200 in log, got %q", buf.String())
} }
} }

View file

@ -4,15 +4,27 @@ import (
"log" "log"
"net/http" "net/http"
"runtime/debug" "runtime/debug"
"git.juancwu.dev/juancwu/errx"
) )
const recovererOp = "middleware.Recoverer"
func Recoverer(next http.Handler) http.Handler { func Recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() { defer func() {
if rec := recover(); rec != nil { rec := recover()
log.Printf("panic: %v\n%s", rec, debug.Stack()) if rec == nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError) return
} }
var err error
if e, ok := rec.(error); ok {
err = errx.Wrap(recovererOp, e)
} else {
err = errx.Newf(recovererOp, "panic: %v", rec)
}
log.Printf("%v\n%s", err, debug.Stack())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}() }()
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })

View file

@ -2,6 +2,7 @@ package middleware
import ( import (
"bytes" "bytes"
"errors"
"log" "log"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@ -9,11 +10,17 @@ import (
"testing" "testing"
) )
func TestRecovererCatchesPanic(t *testing.T) { func captureLog(t *testing.T) *bytes.Buffer {
t.Helper()
var buf bytes.Buffer var buf bytes.Buffer
orig := log.Default().Writer() orig := log.Default().Writer()
log.Default().SetOutput(&buf) log.Default().SetOutput(&buf)
defer log.Default().SetOutput(orig) t.Cleanup(func() { log.Default().SetOutput(orig) })
return &buf
}
func TestRecovererCatchesStringPanic(t *testing.T) {
buf := captureLog(t)
h := Recoverer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := Recoverer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
panic("boom") panic("boom")
@ -25,8 +32,27 @@ func TestRecovererCatchesPanic(t *testing.T) {
if rr.Code != http.StatusInternalServerError { if rr.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", rr.Code) t.Errorf("status = %d, want 500", rr.Code)
} }
if !strings.Contains(buf.String(), "panic: boom") { out := buf.String()
t.Errorf("expected panic log, got %q", buf.String()) for _, want := range []string{"middleware.Recoverer", "panic: boom"} {
if !strings.Contains(out, want) {
t.Errorf("log missing %q\nfull: %s", want, out)
}
}
}
func TestRecovererWrapsErrorPanic(t *testing.T) {
buf := captureLog(t)
cause := errors.New("db down")
h := Recoverer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
panic(cause)
}))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
out := buf.String()
if !strings.Contains(out, "middleware.Recoverer: db down") {
t.Errorf("expected errx-wrapped breadcrumb, got: %s", out)
} }
} }

View file

@ -1,6 +1,12 @@
package router package router
import "strings" import (
"strings"
"git.juancwu.dev/juancwu/errx"
)
const groupOp = "router.Group"
func splitPattern(pattern string) (method, host, path string) { func splitPattern(pattern string) (method, host, path string) {
rest := pattern rest := pattern
@ -22,10 +28,10 @@ func validateGroupPrefix(p string) {
return return
} }
if strings.ContainsAny(p, " \t") { if strings.ContainsAny(p, " \t") {
panic("lightmux: group prefix must not contain whitespace (no method or host allowed): " + p) panic(errx.Newf(groupOp, "prefix must not contain whitespace (no method or host allowed): %q", p))
} }
if !strings.HasPrefix(p, "/") { if !strings.HasPrefix(p, "/") {
panic("lightmux: group prefix must start with '/': " + p) panic(errx.Newf(groupOp, "prefix must start with '/': %q", p))
} }
} }
@ -53,7 +59,7 @@ func joinPath(prefix, sub string) string {
return prefix + "/" return prefix + "/"
} }
if !strings.HasPrefix(sub, "/") { if !strings.HasPrefix(sub, "/") {
panic("lightmux: route path must start with '/': " + sub) panic(errx.Newf(groupOp, "route path must start with '/': %q", sub))
} }
return prefix + sub return prefix + sub
} }