Day 19 — Templates, httptest & Stage V Gate
Day 19 — Templates, httptest & Stage V Gate
Day 48 — templates & regexp
Stage V · ~3h
Goal: Use text/template and html/template correctly, compile regexp once, and complete one real text task (report rendering + field extraction) with tests.
Why this day exists
Not every interface is JSON. You will:
- Emit emails, CLI reports, config snippets →
text/template
- Emit HTML →
html/template(auto-escaping)
- Validate/extract tokens →
regexp
Mixing up HTML vs text templates is an XSS footgun. Compiling regexps in hot loops is a performance footgun.
Theory 1 — text/template
const reportTmpl = `Items: {{len .Items}}
{{range .Items}}- {{.Name}} ({{.ID}})
{{end}}`
t, err := template.New("report").Parse(reportTmpl)
if err != nil {
return err
}
var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil {
return err
}Actions (core)
| Action | Meaning |
|---|---|
{{.Field}} |
Print field |
{{if .OK}}...{{end}} |
Conditional |
{{range .Items}}...{{end}} |
Iterate |
{{with .User}}...{{end}} |
Set dot |
{{define "x"}}...{{end}} |
Named template |
{{template "x" .}} |
Invoke |
FuncMap
t, err := template.New("r").Funcs(template.FuncMap{
"upper": strings.ToUpper,
"ms": func(d time.Duration) int64 { return d.Milliseconds() },
}).Parse(`{{upper .Name}} took {{ms .Dur}}ms`)Functions must be registered before Parse.
Theory 2 — html/template auto-escape
import htmltemplate "html/template"
t, err := htmltemplate.New("page").Parse(`<h1>{{.Title}}</h1><p>{{.Body}}</p>`)If Body is <script>alert(1)</script>, it is escaped for HTML context. Context-aware escaping also handles attributes, JS, CSS URLs—still do not disable escaping casually.
// Dangerous — only for trusted, already-sanitized HTML:
htmltemplate.HTML("<b>trusted</b>")Rule: user content → normal pipeline; never cast to HTML without a sanitizer policy.
Theory 3 — regexp
var tokenRE = regexp.MustCompile(`^[a-z][a-z0-9_-]{2,31}$`)
if !tokenRE.MatchString(s) {
return fmt.Errorf("invalid token")
}| API | Use |
|---|---|
Compile / MustCompile |
Once at init |
MatchString |
Boolean |
FindStringSubmatch |
Capture groups |
ReplaceAllString |
Rewrite |
Split |
Divide |
Submatches
re := regexp.MustCompile(`(?P<user>[^@]+)@(?P<host>[^@]+)`)
m := re.FindStringSubmatch("ada@example.com")
// m[0] full; m[1] user; m[2] host
// names: re.SubexpNames()Performance & safety
- Prefer simple
stringsfunctions when regex is overkill.
- Avoid catastrophic backtracking patterns on untrusted input (pathological regex DoS).
MustCompileat package init panics on bad patterns—good for static patterns.
Theory 4 — When not to use either
| Problem | Prefer |
|---|---|
| Structured API output | encoding/json |
| Simple prefix/suffix | strings.Cut* |
| Binary protocols | custom codecs / protobuf later |
| SQL | query builders / sqlc—not templates for values |
Never assemble SQL with templates/string concat for user input.
Worked example — inventory report + ID extract
package report
import (
"bytes"
"fmt"
"regexp"
"text/template"
"time"
)
var idRE = regexp.MustCompile(`\bid=([A-Za-z0-9_-]+)\b`)
type Item struct {
ID string
Name string
Qty int
}
type Data struct {
GeneratedAt time.Time
Items []Item
}
const tmpl = `Inventory report {{.GeneratedAt.Format "2006-01-02 15:04:05 MST"}}
Count: {{len .Items}}
{{range .Items}}- {{.Name}} [{{.ID}}] qty={{.Qty}}
{{end}}`
func Render(data Data) (string, error) {
t, err := template.New("inv").Parse(tmpl)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
func ExtractIDs(text string) []string {
matches := idRE.FindAllStringSubmatch(text, -1)
out := make([]string, 0, len(matches))
for _, m := range matches {
if len(m) > 1 {
out = append(out, m[1])
}
}
return out
}
func ValidateID(id string) error {
if !regexp.MustCompile(`^[A-Za-z0-9_-]+$`).MatchString(id) {
return fmt.Errorf("bad id")
}
return nil
}Improve ValidateID by lifting the regexp to package level (lab requirement).
Lab 1 — Setup
mkdir -p ~/lab/90daysofx/01-go/day48
cd ~/lab/90daysofx/01-go/day48
go mod init example.com/day48Lab 2 — Text report
- Parse template once (
sync.Onceor packageinit/Must).
- Render sample inventory.
- Table test: empty items still prints count 0.
- FuncMap:
qtyStatusreturns"low"if qty < 3 else"ok".
Lab 3 — HTML page (escape proof)
// title/body from "user"
// assert output does not contain raw <script>Render with html/template; input Body=<script>alert(1)</script>; assert escaped entities in output.
Lab 4 — Regexp extract
Given log lines:
level=info id=abc-01 msg=created
level=error id=xyz_2 msg=failed
Extract all ids with compiled regexp; test expected slice.
Lab 5 — CLI
go run . -in items.json -format text|html > outJSON in → template out. Reuse Day 41 skills lightly.
Common gotchas
| Gotcha | Fix |
|---|---|
text/template for HTML |
Use html/template |
HTML cast on user data |
Don’t |
| FuncMap after Parse | Register before Parse |
| Compile regexp per request | Package-level MustCompile |
| Greedy catastrophic patterns | Simplify; bound input |
| Template SQL | Parameterized queries only |
Forgetting Execute error |
Always check |
Checkpoint
- Text report template with range/if
- FuncMap used
- HTML auto-escape proven by test
- Regexp compiled once; extract + validate
- Can explain text vs html package choice
- CLI or library API documented
Commit
git add .
git commit -m "day48: templates and regexp report pipeline"Write three personal gotchas before continuing.
Tomorrow
Day 49 — httptest: table-driven handler tests for your API—status, headers, body, and middleware behavior without a real port.
Day 49 — httptest handler tests
Stage V · ~3h
Goal: Fully test HTTP handlers with net/http/httptest—table-driven routes, status/body/header assertions, middleware checks—without flaky port binds.
Why this day exists
Manual curl does not scale. Regression suites need:
- Fast in-process requests
- Deterministic status and JSON bodies
- Coverage of 400/404/405/500 paths
- No race on
:8080already in use
httptest is the stdlib way. Combined with Day 47 slog and Day 45 routing, this is the testing backbone for Stage V gate and beyond.
Theory 1 — ResponseRecorder
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
if res.StatusCode != http.StatusOK {
t.Fatalf("status %d body=%s", res.StatusCode, body)
}Recorder implements http.ResponseWriter and records code, headers, body. Prefer rec.Result() when you need a real *http.Response (trailers, etc.); rec.Code / rec.Body are fine for simple asserts.
Theory 2 — NewRequest vs context
req := httptest.NewRequest(http.MethodPost, "/items", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
req = req.WithContext(ctx)Path and query: "/items?limit=10"—available as r.URL.Query(). For Go 1.22+ method muxes, the path must match the registered pattern (GET /items/{id}).
httptest.NewRequest panics on bad method/URL in older mental models—still validate inputs in helpers if you build URLs dynamically.
Theory 3 — Table-driven handler matrix
tests := []struct {
name string
method string
path string
body string
wantStatus int
wantSubstr string
}{
{"health", http.MethodGet, "/healthz", "", 200, `"status"`},
{"missing", http.MethodGet, "/items/nope", "", 404, `not found`},
{"bad json", http.MethodPost, "/items", `{`, 400, ``},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var r io.Reader
if tc.body != "" {
r = strings.NewReader(tc.body)
}
req := httptest.NewRequest(tc.method, tc.path, r)
if tc.body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
if tc.wantSubstr != "" && !strings.Contains(rec.Body.String(), tc.wantSubstr) {
t.Fatalf("body=%s", rec.Body.String())
}
})
}Rule: each t.Run gets its own handler/server state when handlers are mutable.
Theory 4 — Testing Server vs Recorder
| Tool | When |
|---|---|
httptest.NewRecorder + ServeHTTP |
Unit/handler tests (default) |
httptest.NewServer |
Need real URL, clients, redirects, TLS optional |
httptest.NewTLSServer |
TLS client tests |
ts := httptest.NewServer(handler)
t.Cleanup(ts.Close)
resp, err := ts.Client().Get(ts.URL + "/healthz")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()ts.Client() is preconfigured for that server (including TLS when used). Prefer Recorder unless you need a real http.Client path (Day 46 skill).
Theory 5 — Middleware and path values
Exercise middleware by asserting headers:
if rec.Header().Get("X-Request-ID") == "" {
t.Fatal("missing request id")
}For Go 1.22+ mux, path patterns work through the same mux you productionize—test the assembled handler, not only leaf functions, so routing stays covered.
Leaf unit tests still useful for pure decoding logic. Setting PathValue manually is awkward; prefer full mux: GET /items/abc.
// Good: production wiring
h := api.New(log).Handler() // includes mux + middleware
// Avoid: only testing leaf with hand-built PathValue unless necessaryTheory 6 — Assertions that age well
| Prefer | Avoid |
|---|---|
| Status code + JSON fields | Exact full body strings only |
json.Unmarshal into structs |
Brittle substring soup for every field |
| Header canonical names | Typos in mixed-case keys |
Stable error code |
Free-form message text alone |
var env struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatal(err)
}
if env.Error.Code != "not_found" {
t.Fatalf("code=%q", env.Error.Code)
}Worked example — test helper package
package api_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"example.com/day49/api"
)
func newTestHandler(t *testing.T) http.Handler {
t.Helper()
return api.New().Handler()
}
func doJSON(t *testing.T, h http.Handler, method, path, body string) (int, map[string]any) {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, r)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var m map[string]any
if rec.Body.Len() > 0 {
if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil {
t.Fatalf("json: %v body=%s", err, rec.Body.String())
}
}
return rec.Code, m
}
func TestCreateAndGet(t *testing.T) {
h := newTestHandler(t)
code, m := doJSON(t, h, http.MethodPost, "/items", `{"name":"widget"}`)
if code != http.StatusCreated {
t.Fatalf("create code=%d m=%v", code, m)
}
id, _ := m["id"].(string)
if id == "" {
t.Fatal("missing id")
}
code, m = doJSON(t, h, http.MethodGet, "/items/"+id, "")
if code != http.StatusOK || m["name"] != "widget" {
t.Fatalf("get code=%d m=%v", code, m)
}
}Lab 1 — Project
mkdir -p ~/lab/90daysofx/01-go/day49
cd ~/lab/90daysofx/01-go/day49
go mod init example.com/day49Bring forward (or reimplement) Day 45/47 API surface: health, list, get, create + middleware (request ID + access log optional).
Lab 2 — Coverage matrix
Minimum cases:
- Health 200
- Create 201/200 + returned id
- Get existing 200
- Get missing 404
- Create invalid body 400
- Create empty name 400
- Method not allowed on patterned route (e.g. DELETE /healthz)
- Request ID header present after middleware
go test ./... -count=1
go test ./... -coverAim for meaningful cover on api package (not vanity 100%).
Lab 3 — Parallel tests
t.Parallel()Ensure handler state is either per-test server instance (preferred) or mutex-safe. Catch races:
go test ./... -raceIf a test shares a global map store, either protect it or construct a fresh Server per test.
Lab 4 — httptest.NewServer client path
One test uses NewServer + real http.Client Get to prove client/server wiring (ties Day 46). Assert status and a JSON field. Cleanup with t.Cleanup(ts.Close).
Lab 5 — Golden JSON (optional)
For stable list responses, compare pretty-printed JSON with testdata/list.golden (Day 35 skill). Update with -update flag pattern if you like. Prefer golden for stable sorted lists; avoid for timestamps unless you stub clocks.
Common gotchas
| Gotcha | Fix |
|---|---|
| Forgetting Content-Type on POST | Set header in helper |
| Reusing one Server mutable state across parallel tests | New server per test |
| Testing only happy path | Matrix includes 4xx |
| Asserting exact log format only | Assert status/body first |
| Using live port in unit tests | Recorder/Server |
| Ignoring middleware in tests | Hit full Handler() chain |
Not closing res.Body from Result() |
defer res.Body.Close() |
| Path mismatch with Go 1.22 patterns | Register and request the same shape |
Checkpoint
- Table-driven tests for all routes
- 4xx paths covered
- Middleware header asserted
-raceclean
- Helper reduces boilerplate
- Optional NewServer client test
Commit
git add .
git commit -m "day49: httptest table-driven API tests"Write three personal gotchas before continuing.
Tomorrow
Day 50 — Stage V gate: ship a coherent HTTP service with slog, tests, and graceful shutdown—the bar for leaving stdlib I/O stage.
Day 50 — Stage V gate
Stage V · ~3h (integration / ship)
Goal: Close Stage V by shipping a stdlib HTTP service with structured logging, handler tests, timeouts, and signal-based graceful shutdown.
Why this day exists
Days 39–49 were skills in isolation. Gates force integration:
- Does the service start and stop cleanly?
- Can someone else run tests and understand the README?
- Are I/O, JSON, slog, and http habits consistent?
You do not need a database yet (Stage VI). You need a trustworthy process that looks like something you would leave running overnight.
Gate bar (definition of done)
| Requirement | Evidence |
|---|---|
| HTTP JSON API (≥3 endpoints) | Code + curl examples |
| Go 1.22+ method patterns | ServeMux routes |
| Middleware (≥2): request ID + access log or recover | Code |
log/slog (JSON flag optional) |
Logs on request |
http.Server timeouts set |
ReadHeaderTimeout required |
| Graceful shutdown on SIGINT/SIGTERM | Demo or documented script |
| Tests via httptest | go test ./... green |
| README: run, test, endpoints | File present |
| Race clean | go test -race |
Stretch: limit request bodies; DisallowUnknownFields; /readyz vs /healthz.
Theory 1 — Graceful shutdown
srv := &http.Server{
Addr: addr,
Handler: h,
ReadHeaderTimeout: 5 * time.Second,
}
errCh := make(chan error, 1)
go func() {
errCh <- srv.ListenAndServe()
}()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
select {
case err := <-errCh:
if err != nil && err != http.ErrServerClosed {
return err
}
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
_ = srv.Close() // force
return err
}
}What Shutdown does
- Stops accepting new conns
- Waits for in-flight handlers (until context done)
- Returns; then process can exit
Long handlers should respect r.Context() cancellation so Shutdown can finish.
Why timeouts on the Server
| Field | Protects against |
|---|---|
ReadHeaderTimeout |
Slowloris-style header drip |
ReadTimeout |
Slow body uploads |
WriteTimeout |
Stuck clients on response |
IdleTimeout |
Lingering keep-alives |
Never ship ListenAndServe without at least ReadHeaderTimeout.
Theory 2 — Health endpoints
| Path | Meaning |
|---|---|
/healthz or /livez |
Process up |
/readyz |
Ready to take traffic (deps OK later) |
For Stage V, both can be static OK; structure them separately so Stage VI can diverge (DB ping on ready only).
func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) readyz(w http.ResponseWriter, _ *http.Request) {
// later: ping DB; today: same as live
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}Theory 3 — README as interface
Minimum sections:
- What it is
- Go version (
go 1.26ingo.mod)
go run/go test
- Endpoint table
- Shutdown behavior
- Design notes (in-memory store, etc.)
A gate without a README fails the “someone else can run it” bar.
Theory 4 — Process boundaries
cmd/service/main.go → wiring only
api/ → HTTP + middleware
internal/store/ → in-memory repo (mutex)
Rules of thumb:
mainparses flags, builds logger, constructs server, blocks on signal
- No business logic in
mainbeyond wiring
- Tests import
api/store, notmain
Architecture sketch
cmd/service/main.go
→ config (flags/env)
→ slog setup
→ api.New(...)
→ http.Server + signal shutdown
api/
server.go // routes, middleware
handlers.go
server_test.go
Stdlib-first: no web framework.
Lab — Ship the gate service
mkdir -p ~/lab/90daysofx/01-go/day50
cd ~/lab/90daysofx/01-go/day50
go mod init example.com/day50Suggested endpoint set
| Method | Path | Behavior |
|---|---|---|
| GET | /healthz |
liveness |
| GET | /readyz |
readiness |
| GET | /items |
list |
| GET | /items/{id} |
get |
| POST | /items |
create |
| DELETE | /items/{id} |
delete (optional) |
Shutdown drill
go run ./cmd/service &
pid=$!
curl -s localhost:8080/healthz
kill -TERM $pid
wait $pid
echo exit:$?Confirm process exits without needing kill -9, and in-flight requests get a chance to finish (optional sleep handler test).
Test drill
go test ./... -race -count=1Self-review checklist
- No
http.DefaultClientoutbound without timeouts (if any client exists)
- No unlimited
ReadAllon request bodies (io.LimitReader)
- Mutex around in-memory maps
- JSON Content-Type consistent
- Errors are JSON, not only plain text
ReadHeaderTimeoutset
Worked main skeleton
package main
import (
"context"
"flag"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"example.com/day50/api"
)
func main() {
addr := flag.String("addr", ":8080", "listen address")
jsonLogs := flag.Bool("json-log", false, "JSON logs")
flag.Parse()
var handler slog.Handler
if *jsonLogs {
handler = slog.NewJSONHandler(os.Stdout, nil)
} else {
handler = slog.NewTextHandler(os.Stdout, nil)
}
log := slog.New(handler)
srv := &http.Server{
Addr: *addr,
Handler: api.New(log).Handler(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 20 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
log.Info("listening", "addr", *addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error("listen", "err", err)
os.Exit(1)
}
}()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
log.Info("shutting down")
shCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shCtx); err != nil {
log.Error("shutdown", "err", err)
os.Exit(1)
}
log.Info("bye")
}Recover middleware (stretch / recommended)
func recoverMW(log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Error("panic", "recover", rec)
http.Error(w, `{"error":{"code":"internal","message":"internal error"}}`,
http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}Common gotchas
| Gotcha | Fix |
|---|---|
Only ListenAndServe in main |
Signal + Shutdown |
| Shutdown timeout too short | 10s+ or tune |
Handlers ignore r.Context() |
Check ctx on slow work |
| Tests require manual server | httptest |
| README missing run steps | Write them last, verify once |
| Leaking goroutines on exit | Shutdown; avoid bare go without lifecycle |
Missing ReadHeaderTimeout |
Always set |
| Data races on map store | sync.Mutex / RWMutex |
Checkpoint (gate)
- All gate bar rows satisfied
go test ./... -racegreen
- SIGTERM demo works
- README complete
- Personal note: three Stage V skills you will reuse in Stage VI
Commit
git add .
git commit -m "day50: stage V gate HTTP service slog tests shutdown"Write a short retrospective (5–10 lines) in your journal: what was hardest—io composition, http routing, or testing?
Tomorrow
Stage VI begins — Day 51 database/sql: persistence, pooling, QueryContext, and a repository over SQLite or Postgres.