go / web framework
I build a Go web app as a thin layer over net/http: a monolith that
serves HTML with templates, reads
Postgres, and runs job queues. It is
the Go counterpart to my Ruby Rack framework.
Why a thin layer
The standard library has an HTTP server, routing, and cookies. I add a few hundred lines: a router, auth wrappers, a composition root, and middleware. A stack trace points at my code, and the whole package fits in a model's context window.
Exact-match router
The router maps a method and an exact path to a handler. No wildcards or path parameters:
type exactMux struct {
handlers map[string]map[string]http.Handler
}
func (m *exactMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
byMethod, ok := m.handlers[r.Method]
if !ok {
http.NotFound(w, r)
return
}
if handler, ok := byMethod[r.URL.Path]; ok {
handler.ServeHTTP(w, r)
return
}
http.NotFound(w, r)
}
An identifier goes in the query string (/events/show?id=42). The
router panics at boot on a wildcard or a trailing slash:
func validateExactRoutePath(path string) {
switch {
case !strings.HasPrefix(path, "/"):
panic(fmt.Sprintf("route path must start with '/': %q", path))
case strings.Contains(path, "{") || strings.Contains(path, "}"):
panic(fmt.Sprintf("route path must be literal (no wildcards): %q", path))
case path != "/" && strings.HasSuffix(path, "/"):
panic(fmt.Sprintf("route path must be exact: %q", path))
}
}
Auth at the registration site
The router wraps each route with its auth policy at registration:
func (rb *router) GET(path string, h http.Handler) {
rb.handle("GET", path, rb.server.RequireLogin(h))
}
func (rb *router) POST(path string, h http.Handler) {
rb.handle("POST", path, rb.server.RequireLogin(rb.csrf.Protect(h)))
}
func (rb *router) AdminGET(path string, h http.Handler) {
rb.handle("GET", path, rb.server.Admin(h))
}
func (rb *router) PublicGET(path string, h http.Handler) {
rb.handle("GET", path, h)
}
PublicGET and PublicPOST opt out for login pages, webhooks, and
health checks. The default requires login.
RequireLogin puts the user on the request context:
func (s *Server) RequireLogin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, err := s.currentUser(r)
if err != nil {
webutil.WriteServerError(w, err)
return
}
if u == nil || !u.Active {
if r.Method == "GET" {
pushReturnTo(r.URL.Path, r.URL.RawQuery, r)
}
redirect(w, r, "/login")
return
}
ctx := context.WithValue(r.Context(), contextKeyUser{}, u)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Composition root
I build one dependency bundle and hand it to every handler. A handler that needs more takes extra fields:
std := webdeps.Standard{
DB: db,
RenderView: renderView,
RenderPage: renderPage,
WriteHTML: webutil.WriteHTML,
WriteError: webutil.WriteError,
Redirect: redirect,
FlashError: flashError,
FlashSuccess: flashSuccess,
CurrentUserEmail: currentUserEmail,
}
srv.events = &handleevents.Handler{Standard: std, DownloadCSV: downloadCSV}
srv.people = &handlepeople.Handler{Standard: std}
This block is the whole dependency graph. The fields are function values, so a test injects a fake by assigning a field.
Standard lives in a leaf package, www/webdeps, so a feature package
imports it without an import cycle:
type Standard struct {
DB *pgdb.DB
RenderView RenderViewFunc
RenderPage RenderPageFunc
WriteHTML WriteHTMLFunc
WriteError WriteErrorFunc
Redirect RedirectFunc
FlashError FlashErrorFunc
FlashSuccess FlashSuccessFunc
CurrentUserEmail CurrentUserEmailFunc
}
Handlers
Each feature is a package: www/handleevents, www/handlepeople. It
exports one Handler that embeds Standard:
package handleevents
import "app/www/webdeps"
type Handler struct {
webdeps.Standard
DownloadCSV DownloadCSVFunc
}
The template engine takes map[string]any. Everything before that
boundary stays typed. I scan rows into a struct, then format each
field in Go:
type indexRow struct {
ID int64 `db:"id"`
Name string `db:"name"`
Score float64 `db:"score"`
LastMetOn pgtype.Date `db:"last_met_on"`
}
func (h *Handler) Index(w http.ResponseWriter, r *http.Request) {
if err := webutil.ValidateParams(r, "sort", "dir", "query"); err != nil {
h.WriteError(w, 400, err.Error())
return
}
dbRows, err := h.DB.Query(r.Context(), qFetchIndex, fuzzy(r))
if err != nil {
h.WriteError(w, 500, "query failed")
return
}
rows, err := pgx.CollectRows(dbRows, pgx.RowToStructByName[indexRow])
if err != nil {
h.WriteError(w, 500, "collect failed")
return
}
viewRows := make([]map[string]any, 0, len(rows))
for _, row := range rows {
viewRows = append(viewRows, map[string]any{
"id": row.ID,
"name": row.Name,
"score": fmt.Sprintf("%.1f", row.Score),
"url": fmt.Sprintf("/events/show?id=%d", row.ID),
"last_met_on": formatDate(row.LastMetOn),
})
}
html, err := h.RenderPage(r, "index", map[string]any{"rows": viewRows})
if err != nil {
h.WriteError(w, 500, err.Error())
return
}
h.WriteHTML(w, 200, html)
}
Every method has the same steps: validate params, fetch typed rows, format into view maps, render. A bad request fails before the query, and the compiler checks the formatting.
One table registers the routes:
r.GET("/events", http.HandlerFunc(s.events.Index))
r.GET("/events/show", http.HandlerFunc(s.events.Show))
r.POST("/events/create", http.HandlerFunc(s.events.Create))
Middleware stack
I wrap the router in http.Handler layers. A request enters at the
bottom of this list:
h = s.session.Wrap(h)
h = middleware.CSP(h, s.cfg.AppEnv)
h = middleware.CanonicalHost(h, s.cfg.AppEnv, s.cfg.CanonicalHost)
h = middleware.HTTPS(h, s.cfg.AppEnv)
h = http.TimeoutHandler(h, s.cfg.RequestTimeout, "request timed out")
h = s.requestLogMiddleware(h)
h = middleware.Recover(h, s.cfg.AppEnv, capturePanic)
Recover and the request logger sit outermost, so they see a request
that a redirect or timeout ends early. HTTPS redirects to https
and sets HSTS in production:
func HTTPS(next http.Handler, appEnv string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if appEnv != "production" {
next.ServeHTTP(w, r)
return
}
if requestScheme(r) == "http" {
http.Redirect(w, r, "https://"+r.Host+r.URL.RequestURI(), 301)
return
}
w.Header().Set("Strict-Transport-Security", HSTSValue)
next.ServeHTTP(w, r)
})
}
The server serves static assets before the CSRF and session layers, so an asset request does not write a session.
Open-redirect defense
Every redirect goes through one guard that accepts an internal path or a same-origin URL:
func redirect(w http.ResponseWriter, r *http.Request, location string) {
if !webutil.SameOriginOrInternalPath(r, location) {
w.WriteHeader(400)
return
}
http.Redirect(w, r, location, 303)
}
The shapes it rejects:
https://www.example.com.attacker.tld/x(prefix-match fool)//evil.com/x(protocol-relative)/\evil.com/x(some browsers normalize\to/)/@evil.com/x(parses as a userinfo authority)
InternalOnlyPath requires a leading / and rejects /, \, and
@ as the second character. The same check guards return_to before
a login redirect.
Content Security Policy
The middleware sets the CSP header on HTML responses. It starts from default-deny and names each host:
func cspHeaderValue(appEnv string) string {
return strings.Join([]string{
"base-uri 'none'",
"connect-src 'self' " + s3BucketURL(appEnv),
"font-src 'self' data:",
"frame-src 'self' https://app.sigmacomputing.com",
"object-src 'none'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
}, "; ")
}
I do not use 'unsafe-eval' or a wildcard like https: in
script-src. A test asserts the header value.
Sessions and cookies
The session is a signed cookie decoded into a per-request map. The server rewrites the cookie only when a write marked it dirty:
func sessionSet(r *http.Request, key, value string) {
st := currentSessionState(r)
st.values[key] = value
st.dirty = true
}
The cookie is HttpOnly, SameSite=Lax, and Secure in production.
I encrypt a remember token with AES-256-GCM:
func encryptRememberToken(plaintext string, key []byte) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, 12)
if _, err := rand.Read(nonce); err != nil {
return "", err
}
sealed := aead.Seal(nil, nonce, []byte(plaintext), nil)
return base64.RawURLEncoding.EncodeToString(append(nonce, sealed...)), nil
}
Route enumeration
Routes register into a plain map, so I can list every route without
serving a request. The wrappers touch their receivers only at request
time, so a Server with nil dependencies is enough:
func MountedRoutes() []MountedRoute {
s := &Server{}
mux := newExactMux()
r := &router{server: s, csrf: noopCSRF{}, mux: mux}
mountRoutes(r, s)
// walk mux.handlers into a sorted []MountedRoute
}
A comparison with the requests table finds dead routes: paths no
request has hit.
Tests
A test builds a real server on a test database and serves requests through the full middleware stack:
func ServerAsUser(t *testing.T) (*testutil.D, *Server) {
tu := testutil.NewDB(t)
user := tu.InsertUser()
return tu, NewServer(tu, SignedInAs(user.ID))
}
func (s *Server) Get(t testing.TB, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", path, nil)
resp := httptest.NewRecorder()
s.Handler().ServeHTTP(resp, req)
return resp
}
SignedInAs signs in as a fixture user row, so a test uses the
production auth path:
func TestEventsShow(t *testing.T) {
is := is.New(t)
tu, s := ServerAsUser(t)
id := tu.InsertEvent(EventArgs{Name: "Kickoff"})
resp := s.Get(t, fmt.Sprintf("/events/show?id=%d", id))
is.Eq(resp.Code, 200)
is.True(strings.Contains(resp.Body.String(), "Kickoff"))
}
The assertions are is.
HTTP methods
GET and POST only, because HTML forms submit only those two.