Session 12 · Project: a Todo REST API#
Watch the class#
The big idea#
A REST API is a set of URLs where the verb says what to do and the noun (the URL) says what
to do it to: GET /todos lists, POST /todos creates. Data goes in and out as JSON. This session
puts together everything so far (structs, JSON, slices, errors, HTTP) into a small but real service.
Everyday example: a shared shopping list on the fridge. Anyone can read it (GET) or add a line (POST). The list lives in one place, and everyone sees the same version.
In plain words
Split the program by job: models (the shapes of the data), storage (where it lives), handlers (translate HTTP to storage calls), main (wire the routes). Each handler: decode the request, do the work, encode the response with the right status code.
Reach for it when:
a front-end, mobile app or script needs your data
you want a clean split so storage can change later (memory → database)
The whole API in one file#
The course project spreads this over models.go, storage.go, handles.go and main.go. Here it is
in a single runnable file so you can read it top to bottom; the sections are marked and split the same way.
It starts a test server and calls it, so the output is real.
todo.go
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
)
// ---- models ----
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
}
type CreateTodoRequest struct {
Title string `json:"title"`
}
// ---- storage (in memory, safe for many requests at once) ----
type Store struct {
mu sync.Mutex
todos []Todo
nextID int
}
func NewStore() *Store { return &Store{nextID: 1, todos: []Todo{}} }
func (s *Store) List() []Todo {
s.mu.Lock()
defer s.mu.Unlock()
return append([]Todo{}, s.todos...)
}
func (s *Store) Add(title string) Todo {
s.mu.Lock()
defer s.mu.Unlock()
t := Todo{ID: s.nextID, Title: title}
s.nextID++
s.todos = append(s.todos, t)
return t
}
// ---- handlers ----
type API struct{ store *Store }
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 (a *API) Home(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "Welcome to the TODO api"})
}
func (a *API) Todos(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, a.store.List())
case http.MethodPost:
var req CreateTodoRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Title == "" {
http.Error(w, "title is required", http.StatusBadRequest)
return
}
writeJSON(w, http.StatusCreated, a.store.Add(req.Title))
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// ---- try it ----
func do(srv *httptest.Server, method, path, body string) {
req, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body))
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
text, _ := io.ReadAll(resp.Body)
fmt.Printf("%-5s %-7s %d %s", method, path, resp.StatusCode, text)
}
func main() {
api := &API{store: NewStore()}
mux := http.NewServeMux()
mux.HandleFunc("/", api.Home)
mux.HandleFunc("/todos", api.Todos)
srv := httptest.NewServer(mux)
defer srv.Close()
do(srv, "GET", "/", "")
do(srv, "GET", "/todos", "")
do(srv, "POST", "/todos", `{"title":"Learn Go"}`)
do(srv, "POST", "/todos", `{"title":"Build an API"}`)
do(srv, "POST", "/todos", `{}`)
do(srv, "GET", "/todos", "")
do(srv, "GET", "/nope", "")
}
GET / 200 {"message":"Welcome to the TODO api"}
GET /todos 200 []
POST /todos 201 {"id":1,"title":"Learn Go","completed":false}
POST /todos 201 {"id":2,"title":"Build an API","completed":false}
POST /todos 400 title is required
GET /todos 200 [{"id":1,"title":"Learn Go","completed":false},{"id":2,"title":"Build an API","completed":false}]
GET /nope 404 404 page not found
What to notice:
POSTanswers 201 Created and returns the new item including its server-chosenid.A
{}body gives 400 because the title is missing. Validate before you store.An unknown path gives 404. The
"/"route is a catch-all, soHomechecksr.URL.Path.The original code keeps
todosandnextIDas global variables. Here they live inside aStorestruct protected by async.Mutex: a server handles many requests at once, so unprotected shared data would be a race (Session 16).writeJSONis a tiny helper so every handler sets the header and status the same way.
To run it as a real service, replace httptest.NewServer(mux) with
http.ListenAndServe(":8081", mux) and use curl:
curl -X POST -d '{"title":"Learn Go"}' localhost:8081/todos
curl localhost:8081/todos
Where to go next#
The course continues by adding PUT /todos/{id} and DELETE /todos/{id}. Since Go 1.22 the router
understands patterns like these directly:
// no-run: fragment, not a full program
mux.HandleFunc("GET /todos/{id}", api.GetOne) // r.PathValue("id")
mux.HandleFunc("DELETE /todos/{id}", api.Delete)
Common mistakes#
Returning 200 for everything. Clients rely on 201, 400, 404.
Nil slice encodes as
null. That is whyNewStorestarts with[]Todo{}: the firstGETreturns[].Sharing the internal slice.
Listreturns a copy so callers cannot change the store behind its lock.Global state. Fine in a demo, painful in tests. A
Storestruct can be created fresh per test.Ignoring
Content-Type. Clients may not parse the body.IDs from
len(todos)+1. They repeat after a delete; keep a counter.
Hands-on exercises#
Exercise 1 · Mark complete. Add a Complete(id int) bool method to a store that sets Completed to true and reports whether the id existed.
Solution
package main
import "fmt"
type Todo struct {
ID int
Title string
Completed bool
}
type Store struct{ todos []Todo }
func (s *Store) Complete(id int) bool {
for i := range s.todos {
if s.todos[i].ID == id {
s.todos[i].Completed = true
return true
}
}
return false
}
func main() {
s := &Store{todos: []Todo{{1, "Learn Go", false}, {2, "Relax", false}}}
fmt.Println(s.Complete(2), s.Complete(9))
fmt.Printf("%+v\n", s.todos)
}
true false
[{ID:1 Title:Learn Go Completed:false} {ID:2 Title:Relax Completed:true}]
Index with s.todos[i], not the range copy, or the change would be lost.
Exercise 2 · Delete. Add Delete(id int) bool that removes an item from the slice.
Solution
package main
import "fmt"
type Todo struct {
ID int
Title string
}
type Store struct{ todos []Todo }
func (s *Store) Delete(id int) bool {
for i, t := range s.todos {
if t.ID == id {
s.todos = append(s.todos[:i], s.todos[i+1:]...)
return true
}
}
return false
}
func main() {
s := &Store{todos: []Todo{{1, "a"}, {2, "b"}, {3, "c"}}}
fmt.Println(s.Delete(2), s.Delete(7))
fmt.Println(s.todos)
}
true false
[{1 a} {3 c}]
Exercise 3 · Test a handler. Using httptest, send POST /todos with an empty title to a handler and check that the status is 400.
Solution
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
func create(w http.ResponseWriter, r *http.Request) {
var req struct{ Title string }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Title == "" {
http.Error(w, "title is required", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusCreated)
}
func main() {
for _, body := range []string{`{"title":""}`, `{"Title":"Learn Go"}`} {
rec := httptest.NewRecorder()
create(rec, httptest.NewRequest("POST", "/todos", strings.NewReader(body)))
fmt.Println(rec.Code)
}
}
400
201