1. Handlers & ServeMux
An HTTP handler is anything satisfying http.Handler — in practice,
almost always written as a plain function matching
func(w http.ResponseWriter, r *http.Request), wrapped by
http.HandlerFunc.
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", healthHandler)
mux.HandleFunc("GET /users/{id}", getUserHandler)
log.Fatal(http.ListenAndServe(":8080", mux))
}
Since Go 1.22, ServeMux supports method matching
("GET /health") and path parameters
({'{'}id{'}'}, read with r.PathValue("id")) directly — a
real router (Week 11's chi) is worth reaching for once route groups and
richer patterns matter, but the standard library alone now covers a surprising
amount of ground.
2. Encoding & Decoding JSON
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
type UserResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
func createUserHandler(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return
}
user := UserResponse{ID: 1, Name: req.Name, Email: req.Email} // pretend this was saved
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
The backtick-delimited json:"name" annotations are struct
tags — metadata read by encoding/json via reflection, mapping
a Go field name to the JSON key it should read from and write to (independent of
Go's own exported-identifier naming rules from Week 8).
3. Status Codes & Headers
Headers must be set before WriteHeader is called — once
the status line is written, headers are locked in. This ordering trips up nearly
everyone the first time.
w.Header().Set("Content-Type", "application/json") // 1. headers first
w.WriteHeader(http.StatusCreated) // 2. then the status
json.NewEncoder(w).Encode(user) // 3. then the body
A quick reference for the status codes a REST API reaches for constantly:
200 OK (success), 201 Created (a POST that made
something new), 204 No Content (success, nothing to return),
400 Bad Request (the client sent something invalid),
404 Not Found, and 500 Internal Server Error (something
broke on your end). If a handler never calls WriteHeader explicitly,
Go defaults to 200 the moment the first byte is written to the body.
4. Graceful Shutdown
http.ListenAndServe alone offers no way to stop cleanly — a
Ctrl+C or a container orchestrator's stop signal kills every in-flight
request instantly. A real service listens for that signal and gives active requests
a chance to finish first.
func main() {
srv := &http.Server{Addr: ":8080", Handler: mux}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop // blocks until Ctrl+C or a container stop signal
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
log.Println("shutting down...")
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("forced shutdown: %v", err)
}
log.Println("server stopped cleanly")
}
srv.Shutdown(ctx) stops accepting new connections immediately, but lets
in-flight requests finish — up to the context's timeout, after which it gives up and
returns an error. This is exactly the context-driven cancellation
pattern from Week 7, applied to the server's own lifecycle.
5. Hands-on Exercise
Build a small REST API with net/http alone
A CRUD-ish API for a single resource, using nothing but the standard library.
Requirements:
- An in-memory
[]Taskstore (a slice or map is fine — a real database arrives in Week 12) behind a package boundary, per Week 8's structure. - Routes for
GET /tasks(list),POST /tasks(create from a JSON body), andGET /tasks/{id}(fetch one), usingnet/http's built-in method and path-value routing. - Correct status codes:
201on create,404for a missing ID,400for an invalid JSON body. - A graceful shutdown handler that responds to
Ctrl+C(orSIGTERM) and lets any in-flight request finish before exiting. - Test every route manually with
curl, including at least one deliberately invalid request per route to confirm the error status codes.
If a JSON response body comes back empty even though your handler calls json.NewEncoder(w).Encode(...), check the order of operations against this week's ordering rule — calling w.WriteHeader() (or writing any body bytes) before setting headers, or forgetting to set Content-Type at all, is a common and easy-to-miss mistake.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why does calling w.Header().Set(...) after w.WriteHeader(...) silently fail to have the intended effect?
Why does calling w.Header().Set(...) after w.WriteHeader(...) silently fail to have the intended effect?
Once WriteHeader is called (or the first body byte is written, which implicitly calls it with 200), the HTTP status line and headers are already sent to the client — headers set after that point have nothing left to attach to and are simply ignored. Headers always have to be set before the status is written.
Q2
What do the backtick struct tags like json:"name" actually control?
What do the backtick struct tags like json:"name" actually control?
They tell encoding/json which JSON key a Go struct field should be read from (when decoding) and written to (when encoding), independent of the field's own Go name — this is what lets a Go field named Name map to a JSON key like "name" or something else entirely, and is read via reflection at encode/decode time, not enforced by the compiler.
Q3
What real problem does srv.Shutdown(ctx) solve that just letting the process die on Ctrl+C does not?
What real problem does srv.Shutdown(ctx) solve that just letting the process die on Ctrl+C does not?
Killing the process outright drops every in-flight request instantly, mid-response, which a real client experiences as a broken or incomplete request. Shutdown stops accepting new connections but gives existing in-flight requests a bounded window (the context's timeout) to actually finish and respond properly before the process exits.
Q4
Why is a 404 Not Found the more correct response than a 500 Internal Server Error when GET /tasks/{id} is called with an ID that doesn't exist?
Why is a 404 Not Found the more correct response than a 500 Internal Server Error when GET /tasks/{id} is called with an ID that doesn't exist?
A 404 communicates that the request itself was well-formed and understood, but the specific resource requested doesn't exist — which is exactly the situation. A 500 signals something actually went wrong on the server's end (a bug, a crash, an unexpected failure), which misrepresents a perfectly normal "not found" case as a server malfunction.