← thecodex.expert · The Codex Family of Knowledge
Tier 3 · Upper-Intermediate · Go Project

REST API

Build a JSON REST API with proper routes and methods — create, read, update, delete records over HTTP. The backbone of modern apps.

🧠 Teaches how to think spoonfed, every age Last verified:

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.

Where this shows up: every mobile app backend, every single-page web app, every microservice, every public API (Stripe, Twitter, GitHub). REST is the lingua franca of the internet. Understanding it is essential to backend work.

2 How to Think About It

Think about how REST maps actions to HTTP, before any code:

The plan — in plain English
1. Each record lives at a URL like /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.

GET

POST

PUT

DELETE

Request arrives

Which method?

Return records as JSON

Create a record

Update a record

Remove a record

Send JSON response

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.

Goapi.go
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)
}
⚠ No in-browser playground here
Go compiles to a real binary, so unlike the Python version of this project there is no editor above you can run in the browser. Copy the code below and run it on your own machine — it takes seconds once Go is installed.
What each part does — in plain words
type Todo struct { ... \`json:"id"\`, ... } — a struct is Go’s equivalent of Python’s dict-shaped record, but typed. The backtick tags tell 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.
Common mistakes — and how to avoid them
✗ Sharing a plain map[int]Todo across requests with no lock — two simultaneous POSTs can corrupt the map or panic with “concurrent map writes”.
✓ Guard shared state with a sync.Mutex, locked for the shortest time possible, as the Store methods do.
✗ Forgetting to set the Content-Type header before WriteHeader — headers must be set before the status code is written, or Go silently ignores them.
✓ Always call w.Header().Set(...) before w.WriteHeader(...), exactly in that order.
✗ Using the older 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.
✓ On Go 1.22+, write the method into the pattern: "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.

Creating a todo assigns an ID and stores it
Deleting a todo removes it
Deleting a missing todo reports not-found
Goapi_test.go
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.

INPUTPOST /todoscreate a todo (JSON body)
What it expects
{"task": "Buy milk"}
OUTPUTResponse (201)the created todo
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.

Run it locally
go run api.go
Then 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.

What you should see when it works
Terminala real run
$ 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}
If it breaks — how to fix it
🚨 listen tcp 127.0.0.1:8000: bind: address already in use
Something else is already using port 8000 — stop it, or change the port in http.ListenAndServe.
🚨 The client gets {"error":"invalid JSON body"}.
Check the request actually sends a JSON body with a "task" field, e.g. -d '{"task":"x"}'.
🚨 panic: concurrent map writes (if you remove the mutex)
Go detects unsynchronized concurrent map access and crashes loudly rather than silently corrupting data. Put the sync.Mutex back around every read and write to the map.
GroovyJenkinsfile
// 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.' }
    }
}
🎯 Try this next — make it yours
  1. Add PUT. Implement "PUT /todos/{id}" to update a todo’s done status. (Teaches: the update half of CRUD.)
  2. Persist to disk. Save todos to JSON so they survive restarts, combining this with the to-do list project. (Teaches: combining projects.)
  3. Write an HTTP-level test. Use net/http/httptest to send a real request through the handler without a live server. (Teaches: Go’s dedicated HTTP testing tool.)
What you learned
You built a real REST API: routing by HTTP method and path with Go 1.22’s router, JSON request/response bodies, meaningful status codes, and safe concurrent access with 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.