The Create Handler
The previous lesson covered the read side: listTasks and getTask, both of
which only ever translate a store lookup into a response. createTask is the
first handler that changes state, and the first that has to decode a request
body — the decodeJSON helper from Chapter 1 does the actual work, so this
lesson only needs to show it wired into a handler, not re-explain how it
decodes JSON or why it calls DisallowUnknownFields.
The shape of a create handler
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
}
t := s.store.Create(in.Title, in.Priority)
w.Header().Set("Location", "/tasks/"+t.ID)
writeJSON(w, http.StatusCreated, t)
}
Three steps, in order: decode into a small anonymous struct that mirrors only
the fields a client is allowed to send, hand those fields to the store, then
respond. Notice what's not in in: no ID, no Done. Those are assigned
by the server — TaskStore.Create generates the id, and a newly created task
is never done. Letting a client set either through the request body would mean
trusting it with data it has no business supplying. Keeping the decode target
narrow is a form of validation for free, before Chapter 4 gets to validation
proper.
decodeJSON returns false after it has already written the 400 response,
so createTask's job on failure is just return — no double-write, no extra
error handling. That's the same contract every handler in this book will use
whenever it decodes a body.
Why 201, not 200
getTask and listTasks both answer with 200 OK because they don't change
anything — the resource they describe already existed. createTask brings a
new resource into being, and HTTP has a status code for exactly that: 201 Created. Returning 200 from a handler that just created something isn't
wrong in the sense of breaking a client, but it throws away information a
well-behaved API is expected to convey — that this response describes
something new, not something that was already there. Reserve 201
specifically for successful creation; every other CRUD handler in this
chapter reports 200, 204, or an error code instead.
The Location header
201 pairs with a header convention: Location names the URL of the
resource that was just created. A client that POSTs a task doesn't have to
guess how to GET it afterward — the response tells it directly:
w.Header().Set("Location", "/tasks/"+t.ID)
t.ID comes back from s.store.Create, which is the only place that knows
what id got assigned. The handler doesn't generate or guess the id; it just
reads it off the value the store handed back and builds the path from it.
Order matters here, and it's worth calling back to Chapter 1's discussion of
ResponseWriter: headers can only be set before the first call to
WriteHeader (which writeJSON makes internally when it writes the status
line). Set Location first, then call writeJSON. Reverse the two and the
header silently fails to take effect — net/http doesn't error, it just
ignores a header set after the response has started, which makes this exactly
the kind of bug that's easy to miss until a test catches it.
The response body
The last line, writeJSON(w, http.StatusCreated, t), sends back the full
task — not just an id, not just {"status": "created"}. t is the value
s.store.Create returned, so it already carries the server-assigned id
alongside the title and priority the client sent and the done: false
default. Returning the complete resource means a client doesn't need a
follow-up GET just to learn what id it was assigned; the create response is
self-sufficient.
What this handler does not check
createTask trusts that in.Title and in.Priority decoded successfully —
that the body was well-formed JSON matching the expected shape. It does not
check whether in.Title is empty, or whether in.Priority falls in some
sane range. Those are two different kinds of "invalid": a malformed body
(wrong shape, bad syntax) is a 400, and decodeJSON already handles it.
Bad values in an otherwise well-formed body — an empty title, a negative
priority — are a different failure mode, with its own status code
(422 Unprocessable Entity) and its own chapter. For now, any title string
and any int priority are accepted as-is; teaching the handler to reject bad
values is next chapter's job.
Practice
1. The Create Handler
IntermediateThe write side starts with create. Implement
(s *server) createTask(w, r):- Decode the body —
{"title": "...", "priority": N}— with thedecodeJSONhelper (which already 400s a malformed body for you). - Create the task in the store.
- Respond
201 Created, set aLocation: /tasks/{id}header pointing at the new resource, and return the created task (with its assigned id) as the body.
Set the
Locationheader before callingwriteJSON— oncewriteJSONwrites the status line, headers are locked.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 } func (s *server) createTask(w http.ResponseWriter, r *http.Request) { // TODO: decode {"title": "...", "priority": N} into an anonymous struct // with decodeJSON(w, r, &in) (return early if it fails); create the task // via s.store.Create; set the Location header to "/tasks/"+t.ID; then // respond 201 with the created task. } func main() {}Sign in to write and run your solution in the browser.
Sign in for hints and the solution.
- Decode the body —