Validating Input
Chapter 3's createTask (and decodeJSON from Chapter 1's Fundamentals HTTP
work) already handles one kind of bad request: a body that isn't valid JSON,
or has a field of the wrong type. decodeJSON catches that and responds
400 Bad Request before the handler ever sees the decoded value. This lesson
is about the request that is valid JSON but still shouldn't be accepted —
{"title":"","priority":99} decodes cleanly into the Title/Priority
struct createTask expects, but an empty title and a priority of 99 are both
nonsense. That's a different failure, and it gets a different status code.
Two failure modes, two status codes
- Malformed body — can't even be parsed into the expected shape. That's
decodeJSON's job, and it already returns400. - Semantically invalid values — parses fine, but violates a rule about
what the values are allowed to be. That's
422 Unprocessable Entity: the server understood the request and the content type, but the values inside it are unprocessable.
Conflating the two is a common mistake — some APIs return 400 for both.
The distinction matters to a client: a 400 usually means "you built the
request wrong" (fix your serialization), while a 422 means "the request
was well-formed, but check these specific fields." Different bugs, different
fixes, different status code.
Collecting every problem, not just the first
A validator that returns on the first bad field is a worse experience than one that reports everything up front. Imagine a form with two bad fields: fail-fast validation makes the client fix one, resubmit, then discover the second problem and resubmit again. Collecting every violation into a slice lets the client fix both in one round trip:
func validate(title string, priority int) []string {
var problems []string
if title == "" {
problems = append(problems, "...")
}
if priority < 1 || priority > 3 {
problems = append(problems, "...")
}
return problems
}
The shape is deliberate: independent if statements, not if/else if.
Each check runs regardless of whether an earlier one failed, so an input
that's wrong in every way possible comes back with every problem named at
once. An empty problems slice (nil, in practice — append to a nil slice
just works) means the input is valid; that's the signal createTask checks.
A consistent error envelope — two shapes, two uses
writeError from the skeleton wraps a single message as
{"error":"..."} — that's what a 404 or the 400 from a bad body already
returns. Validation is different: there can be several independent problems
at once, so it gets its own shape, {"errors":[...]}, built directly with
writeJSON:
if problems := validate(in.Title, in.Priority); len(problems) > 0 {
writeJSON(w, http.StatusUnprocessableEntity, map[string][]string{"errors": problems})
return
}
Both shapes coexist in this API on purpose: error (singular) when there's
exactly one thing to say, errors (plural) when there can be several. A
client parsing responses only needs to know which shape a given status code
implies — 422 always means errors.
Why validate is a plain function
validate takes a string and an int and returns a []string — no
http.ResponseWriter, no *http.Request, nothing HTTP-shaped at all. That's
why the hidden tests can call validate("", 9) directly and check the
returned slice, without constructing a request or a recorder. Keeping
validation logic decoupled from the transport it's used behind is worth
protecting: it's easier to test exhaustively (every combination of good and
bad title/priority, with no HTTP boilerplate per case), and it's reusable if
a second entry point — a CLI import tool, a batch job — ever needs the same
rules without going through HTTP at all.
Nothing invalid gets stored
Look at where the validate call sits in createTask: before
s.store.Create runs. If problems is non-empty, the handler returns
immediately with 422 and never touches the store. An invalid task is never
assigned an ID, never appears in List, and never occupies a slot a client
might later try to GET. Rejection has to happen strictly before creation,
or "invalid" and "not persisted" quietly drift apart.
Practice
1. Validating Input
IntermediateMalformed JSON already gets a 400. But
{"title":"","priority":99}is perfectly valid JSON with nonsense values — that deserves a422 Unprocessable Entity, not a 400, and definitely not a stored task.Implement
validate(title string, priority int) []string: return a slice of problem messages (empty means valid).- title must not be empty → a message containing "title"
- priority must be between 1 and 3 inclusive → a message containing "priority"
Report all problems, not just the first — a client fixing one field at a time is a bad experience. The
createTaskhandler is already wired to callvalidateand return422with{"errors":[...]}when you find problems.package main import ( "encoding/json" "net/http" "strconv" "sync" ) type Task struct { ID string `json:"id"` Title string `json:"title"` Done bool `json:"done"` Priority int `json:"priority"` } // Store is the persistence seam the HTTP layer depends on. *TaskStore // satisfies it; a database-backed store could too, without touching handlers. type Store interface { Create(title string, priority int) Task Get(id string) (Task, bool) List() []Task Update(id, title string, done bool, priority int) (Task, bool) Delete(id string) bool } type TaskStore struct { mu sync.Mutex items map[string]Task order []string nextID int } func NewTaskStore() *TaskStore { return &TaskStore{items: make(map[string]Task), nextID: 1} } func (s *TaskStore) Create(title string, priority int) Task { s.mu.Lock() defer s.mu.Unlock() id := strconv.Itoa(s.nextID) s.nextID++ t := Task{ID: id, Title: title, Done: false, Priority: priority} s.items[id] = t s.order = append(s.order, id) return t } func (s *TaskStore) Get(id string) (Task, bool) { s.mu.Lock() defer s.mu.Unlock() t, ok := s.items[id] return t, ok } func (s *TaskStore) List() []Task { s.mu.Lock() defer s.mu.Unlock() out := make([]Task, 0, len(s.order)) for _, id := range s.order { out = append(out, s.items[id]) } return out } func (s *TaskStore) Update(id, title string, done bool, priority int) (Task, bool) { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.items[id]; !ok { return Task{}, false } t := Task{ID: id, Title: title, Done: done, Priority: priority} s.items[id] = t return t, true } func (s *TaskStore) Delete(id string) bool { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.items[id]; !ok { return false } delete(s.items, id) for i, oid := range s.order { if oid == id { s.order = append(s.order[:i], s.order[i+1:]...) break } } return true } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() if err := dec.Decode(dst); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return false } return true } type server struct { store Store } // validate returns a list of human-readable problems with the input, // or an empty slice if it is valid. func validate(title string, priority int) []string { // TODO: check title is non-empty and priority is between 1 and 3 // inclusive, appending a problem message for each check that fails. return nil } func (s *server) createTask(w http.ResponseWriter, r *http.Request) { var in struct { Title string `json:"title"` Priority int `json:"priority"` } if !decodeJSON(w, r, &in) { return } if problems := validate(in.Title, in.Priority); len(problems) > 0 { writeJSON(w, http.StatusUnprocessableEntity, map[string][]string{"errors": problems}) return } t := s.store.Create(in.Title, in.Priority) w.Header().Set("Location", "/tasks/"+t.ID) writeJSON(w, http.StatusCreated, t) } func main() {}Sign in to write and run your solution in the browser.
Sign in for hints and the solution.