1 The Problem
We want a REST API: a web service that lets clients create, read, update, and delete records (a to-do list, say) using standard HTTP methods (GET, POST, PUT, DELETE) and JSON. It teaches the conventions of REST — how the entire web of apps and services talks to itself.
2 How to Think About It
Think about how REST maps actions to HTTP, before any code:
/todos/3. → 2. The HTTP method says what to do: GET reads, POST creates, DELETE removes. → 3. The body carries JSON data. → 4. The server routes each request to the right handler and returns JSON. This method-plus-URL convention is REST.
3 The Build — explained part by part
Here is a complete REST API using only Go’s built-in net/http — no framework needed. Go 1.22+’s router lets you write the HTTP method right into the route pattern, which keeps this genuinely simple. Each part is explained below.
package main
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"sync"
)
// Todo is one record. The JSON tags control the field names the client sees.
type Todo struct {
ID int `json:"id"`
Task string `json:"task"`
Done bool `json:"done"`
}
// Store holds the todos in memory. A mutex guards it because HTTP handlers
// can run concurrently — this is the piece Python's single-threaded
// http.server example does not need to worry about.
type Store struct {
mu sync.Mutex
todos map[int]Todo
nextID int
}
func newStore() *Store {
return &Store{todos: make(map[int]Todo), nextID: 1}
}
func (s *Store) create(task string) Todo {
s.mu.Lock()
defer s.mu.Unlock()
todo := Todo{ID: s.nextID, Task: task, Done: false}
s.todos[todo.ID] = todo
s.nextID++
return todo
}
func (s *Store) list() []Todo {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Todo, 0, len(s.todos))
for _, t := range s.todos {
out = append(out, t)
}
return out
}
func (s *Store) delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.todos[id]; !ok {
return false
}
delete(s.todos, id)
return true
}
func sendJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func main() {
store := newStore()
mux := http.NewServeMux()
// GET /todos -> list every todo.
mux.HandleFunc("GET /todos", func(w http.ResponseWriter, r *http.Request) {
sendJSON(w, http.StatusOK, store.list())
})
// POST /todos -> create a todo from the JSON body.
mux.HandleFunc("POST /todos", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Task string `json:"task"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
sendJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
todo := store.create(body.Task)
sendJSON(w, http.StatusCreated, todo)
})
// DELETE /todos/{id} -> remove that todo.
mux.HandleFunc("DELETE /todos/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
sendJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
if store.delete(id) {
sendJSON(w, http.StatusOK, map[string]int{"deleted": id})
} else {
sendJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
}
})
fmt.Println("Listening on http://localhost:8000")
http.ListenAndServe("localhost:8000", mux)
}
encoding/json exactly what field name to use in the JSON, so ID (capitalised, so other files can see it) still serializes as lowercase "id".type Store struct { mu sync.Mutex; ... } — Go’s HTTP server handles requests concurrently by default (each request gets its own goroutine), so anything shared between requests — here, the map of todos — needs a
sync.Mutex to prevent two requests from corrupting it at the same time. Python’s simple single-threaded http.server example does not have to think about this at all; a production Go server always does.mux.HandleFunc("GET /todos", ...) — since Go 1.22, the standard router understands
"METHOD /path" patterns directly, so you do not need a framework or an if-chain on r.Method to get proper REST routing.r.PathValue("id") — the
{id} in the route pattern "DELETE /todos/{id}" is a path parameter; this reads whatever the caller put there, as a string, which we then convert with strconv.Atoi.json.NewEncoder(w).Encode(data) — write JSON straight to the response stream, no intermediate string needed — a small idiom that is more efficient than building a JSON string and then writing it.
map[int]Todo across requests with no lock — two simultaneous POSTs can corrupt the map or panic with “concurrent map writes”.sync.Mutex, locked for the shortest time possible, as the Store methods do.Content-Type header before WriteHeader — headers must be set before the status code is written, or Go silently ignores them.w.Header().Set(...) before w.WriteHeader(...), exactly in that order.mux.HandleFunc("/todos", ...) style and checking r.Method by hand — it works, but the method-in-pattern style above is clearer and less error-prone."POST /todos".4 Test & Prove Each Part
We test the data operations — create and delete — directly, separate from the HTTP layer, so tests are fast and need no running server.
package main
import "testing"
func TestCreate(t *testing.T) {
s := newStore()
todo := s.create("Buy milk")
if todo.ID != 1 {
t.Errorf("todo.ID = %d; want 1", todo.ID)
}
if todo.Task != "Buy milk" {
t.Errorf("todo.Task = %q; want %q", todo.Task, "Buy milk")
}
if todo.Done {
t.Errorf("todo.Done = true; want false for a new todo")
}
}
func TestDelete(t *testing.T) {
s := newStore()
todo := s.create("Walk the dog")
if !s.delete(todo.ID) {
t.Errorf("delete(%d) = false; want true", todo.ID)
}
if len(s.list()) != 0 {
t.Errorf("list() after delete = %v; want empty", s.list())
}
}
func TestDeleteMissing(t *testing.T) {
s := newStore()
if s.delete(99) {
t.Errorf("delete(99) on empty store = true; want false")
}
}
Run with go test -v ./.... The tests call Store’s methods directly — no server, no HTTP, no curl. This is the same technique the Python version uses: keep the data logic separate from the HTTP plumbing, so the important part is fast and easy to test, and the handlers become thin wrappers you can trust.
5 The Interface
The API’s endpoints, documented like any professional REST service.
What it expects
{"task": "Buy milk"}What it returns
{"id":1,"task":"Buy milk","done":false}6 Run It & Automate It
Save the code as api.go and run it with go run api.go — Go compiles and executes in one step, no separate build needed while you are experimenting.
go run api.goThen in another terminal:
curl -X POST localhost:8000/todos -d '{"task":"Buy milk"}' to create, and curl localhost:8000/todos to list.A CI tool like Jenkins runs go test automatically whenever the code changes — every line below has a plain explanation.
$ curl -X POST localhost:8000/todos -d '{"task":"Buy milk"}'
{"id":1,"task":"Buy milk","done":false}
$ curl localhost:8000/todos
[{"id":1,"task":"Buy milk","done":false}]
$ curl -X DELETE localhost:8000/todos/1
{"deleted":1}http.ListenAndServe."task" field, e.g. -d '{"task":"x"}'.sync.Mutex back around every read and write to the map.// Jenkinsfile — runs the tests automatically every time the code changes.
pipeline {
agent any // run on any available machine
stages {
stage('Get the code') {
steps { checkout scm } // download the latest code
}
stage('Set up Go') {
steps {
sh 'go version' // confirm Go is installed
sh 'test -f go.mod || go mod init rest_api' // create a module if none exists
}
}
stage('Run the tests') {
steps {
sh 'go vet ./...' // catch obvious mistakes before running
sh 'go test -v ./...' // run every test, show each result
}
}
}
post {
success { echo 'All tests passed.' }
failure { echo 'A test failed — look above.' }
}
}
- Add PUT. Implement
"PUT /todos/{id}"to update a todo’s done status. (Teaches: the update half of CRUD.) - Persist to disk. Save todos to JSON so they survive restarts, combining this with the to-do list project. (Teaches: combining projects.)
- Write an HTTP-level test. Use
net/http/httptestto send a real request through the handler without a live server. (Teaches: Go’s dedicated HTTP testing tool.)
sync.Mutex — the conventions every web service shares, plus the concurrency safety Go asks you to think about that Python’s simple example does not. Related: HTTP Servers, JSON & Encoding.