Decoding Requests
Last lesson gave you writeJSON and writeError — the only path a response
takes out of this service. This lesson builds the matching piece for the
other direction: the only path a request body takes in. Every handler that
creates or updates a task starts by turning a JSON body into a Go struct,
and just like the response side, that's a spot where a dozen handlers
written a dozen different ways will drift into a dozen different bugs.
The naive version, and why it's not enough
The obvious approach is json.NewDecoder(r.Body).Decode(&in) inline in each
handler. It compiles, it works for well-formed requests, and it's exactly
the kind of thing that looks fine until a client sends something you didn't
expect. Two failure modes matter here: the body isn't valid JSON at all
(a client bug, a dropped connection, a hand-typed curl with a missing
brace), and the body is valid JSON but doesn't match the shape you asked
for. Handling both consistently, in one place, is what decodeJSON is for.
Rejecting fields you didn't ask for
By default, encoding/json ignores fields in the input that don't match
any field on the destination struct. That's convenient for some use cases,
but for an API it's a trap: a client that means to set "title" but typos
"titel" gets a 200 OK and a task with no title, silently. The bug
surfaces later, far from its cause, and looks like a server problem.
dec.DisallowUnknownFields() turns that typo into an immediate 400 at
the point of decoding, with the client's own request as the evidence. This
is a small method call with an outsized payoff: it's the difference between
an API that tells clients about their mistakes and one that quietly stores
garbage. Call it on the decoder before Decode, right after
constructing it with json.NewDecoder(r.Body) — the decoder needs the
option set before it starts reading.
One helper, one return convention
decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool follows
the same shape you'll see throughout this service: a helper that either
succeeds silently or handles its own failure response and reports false.
That bool return is what makes handlers readable. Instead of every
handler carrying its own if err != nil { ...write an error... return }
block — with all the ways that can drift from handler to handler — you get
one line:
if !decodeJSON(w, r, &in) {
return
}
By the time that line finishes, either in is populated and valid JSON,
or the client already has a 400 and a body-shaped error, and the handler
just needs to stop. There's no error value to inspect and no response to
write — decodeJSON already did both jobs.
Don't leak the decoder's own words
dec.Decode returns real Go errors — *json.SyntaxError,
*json.UnmarshalTypeError, and a plain errors.New for unknown fields —
and it's tempting to pass err.Error() straight through to writeError
for a "helpful" message. Resist it. Those messages describe Go's internal
decoding process (byte offsets, Go type names), not anything a client
should have to parse, and exposing them is a minor information leak about
your server's implementation. decodeJSON uses one flat message,
"invalid request body", for every failure. The client already knows
which request it sent; it doesn't need Go's parser vocabulary to fix it.
A note on body size
There's one guardrail this lesson doesn't build: a cap on how large a
request body decodeJSON will read before giving up. An unbounded body
means a client — malicious or just buggy — can make your server read and
buffer an arbitrarily large payload before you ever get to check it.
Production services wrap r.Body in http.MaxBytesReader(w, r.Body, limit)
before decoding, so an oversized body fails fast instead of eating memory.
It's not needed for this exercise's scope, but it's worth knowing the name
for when you build a service that faces the open internet.
With writeJSON, writeError, and now decodeJSON in place, every
handler you write from here has a symmetric, one-line contract for both
directions of a request: decode in, write out, and never touch
r.Body or w directly again.
Practice
1. Decoding Requests
IntermediateHandlers that accept JSON all need the same guardrails, so wrap them once. You already have
writeJSON/writeErrorfrom the last lesson.Implement
decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool:- Decode
r.Bodyintodst. - Reject unknown fields — call
DisallowUnknownFields()on the decoder so a client's typo ("titel") is a 400, not a silently-ignored field. - On any decode error, respond
400viawriteErrorand returnfalse. - On success, return
true.
Handlers then read like:
if !decodeJSON(w, r, &in) { return }.package main import ( "encoding/json" "net/http" ) 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}) } // decodeJSON decodes the request body into dst. On any problem it writes a // 400 error response (via writeError) and returns false; otherwise true. // Unknown fields are rejected so typos in a client's payload don't pass // silently. func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { // TODO: build a json.NewDecoder(r.Body), call DisallowUnknownFields() on // it, then Decode into dst. On error, writeError(w, http.StatusBadRequest, // "invalid request body") and return false. On success, return true. return false } func main() {}Sign in to write and run your solution in the browser.
Sign in for hints and the solution.
- Decode