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 Todo API in layers main wires routes to handlers; handlers decode JSON and call the store; the store owns the data behind a lock. main.go routes + ListenAndServe handlers.go decode · call · encode storage.go []Todo + mutex models.go Todo, CreateTodoRequest POST /todos {"title":"Learn Go"} → 201 {"id":1,…} Keep each file about one job: easy to read, and the store can later be swapped for a database without touching the handlers.
main wires routes to handlers; handlers decode JSON and call the store; the store owns the data behind a lock.

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:

  • POST answers 201 Created and returns the new item including its server-chosen id.

  • A {} body gives 400 because the title is missing. Validate before you store.

  • An unknown path gives 404. The "/" route is a catch-all, so Home checks r.URL.Path.

  • The original code keeps todos and nextID as global variables. Here they live inside a Store struct protected by a sync.Mutex: a server handles many requests at once, so unprotected shared data would be a race (Session 16).

  • writeJSON is 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 why NewStore starts with []Todo{}: the first GET returns [].

  • Sharing the internal slice. List returns a copy so callers cannot change the store behind its lock.

  • Global state. Fine in a demo, painful in tests. A Store struct 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