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:

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.

← All articles