Resource Handlers
Map GET, POST, PUT and DELETE onto one resource with ServeMux patterns, and keep each handler thin.
mux.HandleFunc("GET /notes/{id}", h) // then id := r.PathValue("id")A resource is one kind of thing your API exposes: notes, users, orders. REST maps HTTP methods onto that resource instead of inventing a URL per action. GET reads, POST creates, PUT replaces, PATCH changes part of it, DELETE removes it. The path names the thing; the method says what to do to it.
Routing one resource
Since Go 1.22, a ServeMux pattern may begin with a method and contain a wildcard, a name in braces that matches one path segment: "GET /notes/{id}". Inside the handler, r.PathValue("id") returns what that segment matched. If a request's path matches but its method does not, the mux answers 405 Method Not Allowed by itself and sets an Allow header.
| Pattern | Does | Replies |
|---|---|---|
| GET /notes | list a page | 200 |
| POST /notes | create | 201 plus Location |
| GET /notes/{id} | read one | 200 or 404 |
| PUT /notes/{id} | replace | 200 or 404 |
| DELETE /notes/{id} | remove | 204 or 404 |
Thin handlers
A handler does five things: decode the body, check the shape, call one service method, turn any error into a status code, encode the reply. mux.HandleFunc(p, f) is shorthand for mux.Handle(p, http.HandlerFunc(f)) — the same registration, written two ways.
What trips people up
POST is not safe to retry: a client that times out and sends again creates two notes. PUT and DELETE are idempotent, meaning the same request twice leaves the same end state, so pick one answer for deleting something already gone and keep it.
Example
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
type Note struct {
ID string `json:"id"`
Text string `json:"text"`
}
// A map stands in for a database here. Wiring a real store is the next lesson.
var notes = map[string]Note{}
func createNote(w http.ResponseWriter, r *http.Request) {
var n Note
if err := json.NewDecoder(r.Body).Decode(&n); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
n.ID = "1" // a real store assigns this
notes[n.ID] = n
w.Header().Set("Location", "/notes/"+n.ID)
w.WriteHeader(http.StatusCreated)
}
func getNote(w http.ResponseWriter, r *http.Request) {
n, ok := notes[r.PathValue("id")]
if !ok {
http.Error(w, "not found", http.StatusNotFound)
return
}
body, err := json.Marshal(n)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write(body); err != nil {
return
}
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("POST /notes", createNote)
mux.HandleFunc("GET /notes/{id}", getNote)
// httptest drives the mux in memory, so no port is opened.
rec := httptest.NewRecorder()
body := strings.NewReader(`{"text":"buy milk"}`)
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/notes", body))
fmt.Println(rec.Code, rec.Header().Get("Location"))
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/notes/1", nil))
fmt.Printf("%d %s\n", rec.Code, rec.Body.String())
}
// Output:
// 201 /notes/1
// 200 {"id":"1","text":"buy milk"}When to use it
- Exposing a notes table as /notes and /notes/{id} with the right status code for each method.
- Returning 201 with a Location header so a client can fetch what it just created.
- Replacing a third-party router with net/http now that method patterns exist.
More examples
Every route for one resource in one place
Reading this function tells you the whole public surface of the resource. Both {id} and {$} arrived with Go 1.22. A PATCH request to /notes/1 gets 405 and an Allow header listing GET, PUT and DELETE, from the mux, with no code of yours involved. Curl it with: curl -i -X DELETE localhost:8080/notes/1
// routes is the only place that knows the URL shape of the API.
func routes(a api) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /notes", a.listNotes)
mux.HandleFunc("POST /notes", a.createNote)
mux.HandleFunc("GET /notes/{id}", a.getNote)
mux.HandleFunc("PUT /notes/{id}", a.replaceNote)
mux.HandleFunc("DELETE /notes/{id}", a.deleteNote)
// {$} matches only the exact path "/", so this route cannot swallow
// requests to /notes. Without {$}, "/" would match everything.
mux.HandleFunc("GET /{$}", a.index)
return mux
}A thin create handler
Nine lines of HTTP and one call into the service. The rules about what text is acceptable live in Create, so the same check applies to any caller. Passing r.Context() means the service stops working if the client hangs up mid-request.
func (a api) createNote(w http.ResponseWriter, r *http.Request) {
// A separate input struct means a client cannot set fields it must not
// control, such as the id or an owner.
var in struct {
Text string `json:"text"`
}
dec := json.NewDecoder(r.Body)
// Reject "txt" instead of silently ignoring the typo.
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
n, err := a.svc.Create(r.Context(), in.Text)
if err != nil {
writeError(w, err) // one place maps errors to status codes
return
}
w.Header().Set("Location", "/notes/"+n.ID)
w.WriteHeader(http.StatusCreated)
}HandleFunc and its longhand
These two lines do exactly the same thing, so use one or the other: registering the same pattern twice makes the mux panic. You need the longhand when you already hold an http.Handler rather than a function, which is what middleware and http.StripPrefix hand you.
func health(w http.ResponseWriter, r *http.Request) {
if _, err := w.Write([]byte("ok")); err != nil {
return
}
}
mux := http.NewServeMux()
// Shorthand.
mux.HandleFunc("GET /health", health)
// Longhand: identical. http.HandlerFunc is a type whose ServeHTTP method
// calls the function, which is how a plain func becomes an http.Handler.
mux.Handle("GET /health", http.HandlerFunc(health))
Discussion