A JSON REST API for managing a book collection using only the Go standard library. The API supports full CRUD operations, uses Go 1.22 method-based routing, and includes an in-memory store protected by a mutex for concurrent access.
Prerequisites
- Go 1.22 or later
- A Unix-like terminal (macOS, Linux, or WSL on Windows)
curlor a similar HTTP client for manual testing- No third-party packages required
Step 1: Set up the project structure
Create the file
mkdir -p rest-api
touch rest-api/.gitignore
Add the code: rest-api/.gitignore
bin/
*.test
*.out
.DS_Store
*.log
tmp/
Detailed breakdown
bin/excludes compiled binaries produced by the build step.*.testand*.outexclude Go test binaries and coverage output files..DS_Store,*.log, andtmp/cover common OS and temporary artifacts.
Step 2: Initialize the Go module
Create the file
cd rest-api
go mod init bookapi
Detailed breakdown
go mod init bookapicreates ago.modfile that declares the module path asbookapi. All import paths within the project use this prefix.- Since the project uses only the standard library, no dependencies are added to
go.mod.
Step 3: Define the book model and store
This file defines the Book struct and an in-memory store with thread-safe CRUD operations.
Create the file
mkdir -p rest-api/internal/store
touch rest-api/internal/store/book.go
Add the code: rest-api/internal/store/book.go
package store
import (
"fmt"
"sync"
)
type Book struct {
ID int `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
Year int `json:"year"`
}
type BookStore struct {
mu sync.Mutex
books map[int]Book
nextID int
}
func New() *BookStore {
return &BookStore{
books: make(map[int]Book),
nextID: 1,
}
}
func (s *BookStore) All() []Book {
s.mu.Lock()
defer s.mu.Unlock()
result := make([]Book, 0, len(s.books))
for _, b := range s.books {
result = append(result, b)
}
return result
}
func (s *BookStore) Get(id int) (Book, error) {
s.mu.Lock()
defer s.mu.Unlock()
b, ok := s.books[id]
if !ok {
return Book{}, fmt.Errorf("book %d not found", id)
}
return b, nil
}
func (s *BookStore) Create(title, author string, year int) Book {
s.mu.Lock()
defer s.mu.Unlock()
b := Book{
ID: s.nextID,
Title: title,
Author: author,
Year: year,
}
s.books[s.nextID] = b
s.nextID++
return b
}
func (s *BookStore) Update(id int, title, author string, year int) (Book, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.books[id]; !ok {
return Book{}, fmt.Errorf("book %d not found", id)
}
b := Book{ID: id, Title: title, Author: author, Year: year}
s.books[id] = b
return b, nil
}
func (s *BookStore) Delete(id int) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.books[id]; !ok {
return fmt.Errorf("book %d not found", id)
}
delete(s.books, id)
return nil
}
Detailed breakdown
Bookstruct: The data model with JSON struct tags. Tags control the JSON field names in API responses (lowercase, matching REST conventions).BookStore: Holds amap[int]Bookfor O(1) lookups by ID, anextIDcounter for monotonic ID generation, and async.Mutexto protect concurrent access. The mutex is required becausenet/httpserves each request in its own goroutine.New(): Constructor that initializes the map. Go maps must be initialized before use; a nil map panics on write.All(): Returns a slice copy of all books. Pre-allocating withmake([]Book, 0, len(s.books))avoids repeated slice growth.Get()/Update()/Delete(): Return an error when the ID is not found. The handler layer translates these into HTTP 404 responses.Create(): Assigns the next ID, stores the book, and increments the counter. The ID is never reused after deletion.- Every method locks the mutex for its full duration. This is simple and correct for a tutorial; a production store might use
sync.RWMutexto allow concurrent reads.
Step 4: Build the HTTP handlers
The handler file maps HTTP methods and paths to store operations, handling JSON encoding and error responses.
Create the file
mkdir -p rest-api/internal/handler
touch rest-api/internal/handler/book.go
Add the code: rest-api/internal/handler/book.go
package handler
import (
"encoding/json"
"net/http"
"strconv"
"bookapi/internal/store"
)
type BookHandler struct {
store *store.BookStore
}
func NewBookHandler(s *store.BookStore) *BookHandler {
return &BookHandler{store: s}
}
func (h *BookHandler) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /books", h.listBooks)
mux.HandleFunc("POST /books", h.createBook)
mux.HandleFunc("GET /books/{id}", h.getBook)
mux.HandleFunc("PUT /books/{id}", h.updateBook)
mux.HandleFunc("DELETE /books/{id}", h.deleteBook)
}
func (h *BookHandler) listBooks(w http.ResponseWriter, r *http.Request) {
books := h.store.All()
writeJSON(w, http.StatusOK, books)
}
func (h *BookHandler) getBook(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid book ID"})
return
}
book, err := h.store.Get(id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, book)
}
type bookRequest struct {
Title string `json:"title"`
Author string `json:"author"`
Year int `json:"year"`
}
func (h *BookHandler) createBook(w http.ResponseWriter, r *http.Request) {
var req bookRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
if req.Title == "" || req.Author == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title and author are required"})
return
}
book := h.store.Create(req.Title, req.Author, req.Year)
writeJSON(w, http.StatusCreated, book)
}
func (h *BookHandler) updateBook(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid book ID"})
return
}
var req bookRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
if req.Title == "" || req.Author == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title and author are required"})
return
}
book, err := h.store.Update(id, req.Title, req.Author, req.Year)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, book)
}
func (h *BookHandler) deleteBook(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid book ID"})
return
}
if err := h.store.Delete(id); err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
Detailed breakdown
BookHandler: Groups all route handlers and holds a reference to the store. This avoids global state and makes the handler testable with anyBookStoreinstance.Register(): Uses Go 1.22’s method-based routing syntax ("GET /books","POST /books", etc.) on anhttp.ServeMux. This eliminates the need for third-party routers. The{id}wildcard is a Go 1.22 path parameter, read withr.PathValue("id").bookRequest: A dedicated struct for decoding request bodies. Separating the request type from theBookmodel prevents clients from setting theIDfield directly.createBook: Decodes JSON, validates required fields, callsstore.Create, and returns 201 Created. The validation is intentionally minimal — title and author must be non-empty.updateBook: Same validation as create, but also extracts the ID from the path and returns 404 if the book does not exist.deleteBook: Returns 204 No Content on success with no response body, following REST conventions.writeJSON: A helper that sets theContent-Typeheader and encodes any value as JSON. Centralizing this avoids repeated boilerplate in every handler.
Step 5: Create the server entry point
Create the file
mkdir -p rest-api/cmd/bookapi
touch rest-api/cmd/bookapi/main.go
Add the code: rest-api/cmd/bookapi/main.go
package main
import (
"fmt"
"log"
"net/http"
"os"
"bookapi/internal/handler"
"bookapi/internal/store"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
s := store.New()
h := handler.NewBookHandler(s)
mux := http.NewServeMux()
h.Register(mux)
addr := fmt.Sprintf(":%s", port)
log.Printf("listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
Detailed breakdown
- Port configuration: Reads
PORTfrom the environment, defaulting to8080. This makes the server configurable in deployment without code changes. - Dependency wiring: Creates the store, passes it to the handler, and registers routes on a new
ServeMux. This explicit wiring makes the dependency chain visible and avoids init-time side effects. http.ListenAndServe: Starts the server and blocks. If it returns (due to a bind error, for example),log.Fatallogs the error and exits with code 1.
Step 6: Add a Makefile
Create the file
touch rest-api/Makefile
Add the code: rest-api/Makefile
.DEFAULT_GOAL := help
BINARY := bin/bookapi
.PHONY: help build run test clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*## "}; {printf " %-12s %s\n", $$1, $$2}'
build: ## Build the server binary
go build -o $(BINARY) ./cmd/bookapi
run: build ## Build and run the server
./$(BINARY)
test: ## Run all tests
go test ./... -v -count=1
clean: ## Remove build artifacts
rm -rf bin/
Detailed breakdown
helpas default target: Runningmakewith no arguments prints a formatted list of targets by scanning for##comments. This satisfies the Makefile default target rule.build: Compiles the server binary intobin/bookapi. Using abin/directory keeps compiled output separate from source files.run: Depends onbuild, so it always compiles before starting. This prevents running a stale binary.test: Runs all tests across all packages (./...) with-vfor verbose output and-count=1to disable test caching, ensuring tests always execute.clean: Removes thebin/directory containing compiled output.
Step 7: Write tests
Create the file
touch rest-api/internal/store/book_test.go
touch rest-api/internal/handler/book_test.go
Add the code: rest-api/internal/store/book_test.go
package store
import (
"testing"
)
func TestCreateAndAll(t *testing.T) {
s := New()
b1 := s.Create("The Go Programming Language", "Donovan & Kernighan", 2015)
b2 := s.Create("Concurrency in Go", "Katherine Cox-Buday", 2017)
if b1.ID != 1 || b2.ID != 2 {
t.Fatalf("expected IDs 1 and 2, got %d and %d", b1.ID, b2.ID)
}
all := s.All()
if len(all) != 2 {
t.Fatalf("expected 2 books, got %d", len(all))
}
}
func TestGet(t *testing.T) {
s := New()
created := s.Create("Test Book", "Author", 2020)
got, err := s.Get(created.ID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Title != "Test Book" {
t.Fatalf("expected title 'Test Book', got '%s'", got.Title)
}
}
func TestGetNotFound(t *testing.T) {
s := New()
_, err := s.Get(99)
if err == nil {
t.Fatal("expected error for missing book")
}
}
func TestUpdate(t *testing.T) {
s := New()
s.Create("Old Title", "Old Author", 2000)
updated, err := s.Update(1, "New Title", "New Author", 2024)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if updated.Title != "New Title" || updated.Author != "New Author" || updated.Year != 2024 {
t.Fatalf("update did not apply: %+v", updated)
}
}
func TestUpdateNotFound(t *testing.T) {
s := New()
_, err := s.Update(99, "Title", "Author", 2020)
if err == nil {
t.Fatal("expected error for missing book")
}
}
func TestDelete(t *testing.T) {
s := New()
s.Create("To Delete", "Author", 2020)
if err := s.Delete(1); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(s.All()) != 0 {
t.Fatal("expected empty store after delete")
}
}
func TestDeleteNotFound(t *testing.T) {
s := New()
if err := s.Delete(42); err == nil {
t.Fatal("expected error for missing book")
}
}
func TestIDsIncrementAfterDelete(t *testing.T) {
s := New()
s.Create("A", "Author", 2020)
s.Create("B", "Author", 2021)
s.Delete(1)
b := s.Create("C", "Author", 2022)
if b.ID != 3 {
t.Fatalf("expected ID 3 after deletion, got %d", b.ID)
}
}
Detailed breakdown
TestCreateAndAll: Creates two books and verifies sequential ID assignment and the total count fromAll().TestGet/TestGetNotFound: Verifies that a valid ID returns the correct book and an invalid ID returns an error.TestUpdate: Creates a book, updates all fields, and verifies the returned book reflects the changes.TestDelete: Confirms the store is empty after deleting the only book.TestIDsIncrementAfterDelete: Verifies that the ID counter does not reuse deleted IDs. After deleting ID 1, the next book gets ID 3.- Each test creates a fresh
New()store, so tests are fully isolated with no shared state.
Add the code: rest-api/internal/handler/book_test.go
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"bookapi/internal/store"
)
func setupRouter() *http.ServeMux {
s := store.New()
h := NewBookHandler(s)
mux := http.NewServeMux()
h.Register(mux)
return mux
}
func TestListBooksEmpty(t *testing.T) {
mux := setupRouter()
req := httptest.NewRequest(http.MethodGet, "/books", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var books []store.Book
json.NewDecoder(rec.Body).Decode(&books)
if len(books) != 0 {
t.Fatalf("expected empty list, got %d books", len(books))
}
}
func TestCreateAndGetBook(t *testing.T) {
mux := setupRouter()
// Create
body := `{"title":"The Go Programming Language","author":"Donovan & Kernighan","year":2015}`
req := httptest.NewRequest(http.MethodPost, "/books", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", rec.Code)
}
var created store.Book
json.NewDecoder(rec.Body).Decode(&created)
if created.ID != 1 || created.Title != "The Go Programming Language" {
t.Fatalf("unexpected book: %+v", created)
}
// Get
req = httptest.NewRequest(http.MethodGet, "/books/1", nil)
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var fetched store.Book
json.NewDecoder(rec.Body).Decode(&fetched)
if fetched.Title != "The Go Programming Language" {
t.Fatalf("unexpected title: %s", fetched.Title)
}
}
func TestGetBookNotFound(t *testing.T) {
mux := setupRouter()
req := httptest.NewRequest(http.MethodGet, "/books/99", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
}
func TestCreateBookValidation(t *testing.T) {
mux := setupRouter()
body := `{"title":"","author":"","year":2020}`
req := httptest.NewRequest(http.MethodPost, "/books", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
}
func TestUpdateBook(t *testing.T) {
mux := setupRouter()
// Create first
body := `{"title":"Old Title","author":"Old Author","year":2000}`
req := httptest.NewRequest(http.MethodPost, "/books", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
// Update
body = `{"title":"New Title","author":"New Author","year":2024}`
req = httptest.NewRequest(http.MethodPut, "/books/1", bytes.NewBufferString(body))
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var updated store.Book
json.NewDecoder(rec.Body).Decode(&updated)
if updated.Title != "New Title" {
t.Fatalf("expected 'New Title', got '%s'", updated.Title)
}
}
func TestUpdateBookNotFound(t *testing.T) {
mux := setupRouter()
body := `{"title":"Title","author":"Author","year":2020}`
req := httptest.NewRequest(http.MethodPut, "/books/99", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
}
func TestDeleteBook(t *testing.T) {
mux := setupRouter()
// Create first
body := `{"title":"To Delete","author":"Author","year":2020}`
req := httptest.NewRequest(http.MethodPost, "/books", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
// Delete
req = httptest.NewRequest(http.MethodDelete, "/books/1", nil)
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d", rec.Code)
}
// Verify gone
req = httptest.NewRequest(http.MethodGet, "/books/1", nil)
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404 after delete, got %d", rec.Code)
}
}
func TestDeleteBookNotFound(t *testing.T) {
mux := setupRouter()
req := httptest.NewRequest(http.MethodDelete, "/books/42", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
}
func TestInvalidJSON(t *testing.T) {
mux := setupRouter()
req := httptest.NewRequest(http.MethodPost, "/books", bytes.NewBufferString("not json"))
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
}
func TestInvalidBookID(t *testing.T) {
mux := setupRouter()
req := httptest.NewRequest(http.MethodGet, "/books/abc", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
}
Detailed breakdown
setupRouter(): Creates a fresh store and handler for each test, wired to a newServeMux. This isolates every test completely.httptest.NewRequest/httptest.NewRecorder: The standard library’s test infrastructure.NewRequestbuilds an*http.Requestwithout opening a network connection.NewRecordercaptures the response status, headers, and body for assertions.TestListBooksEmpty: Verifies GET /books returns 200 with an empty JSON array when no books exist.TestCreateAndGetBook: Exercises the create-then-read flow. Verifies POST returns 201 with the assigned ID, and a subsequent GET returns the same book.TestGetBookNotFound/TestDeleteBookNotFound: Verify 404 responses for non-existent IDs.TestCreateBookValidation: Sends empty title and author fields, expects 400.TestUpdateBook: Creates a book, updates it via PUT, and checks the response reflects the new values.TestDeleteBook: Creates a book, deletes it, confirms 204, then verifies a follow-up GET returns 404.TestInvalidJSON: Sends a malformed body to POST, expects 400.TestInvalidBookID: Sends a non-numeric ID in the path, expects 400.
Step 7: Run and validate
Run the full test suite:
cd rest-api
make test
Expected output:
=== RUN TestCreateAndAll
--- PASS: TestCreateAndAll
=== RUN TestGet
--- PASS: TestGet
=== RUN TestGetNotFound
--- PASS: TestGetNotFound
=== RUN TestUpdate
--- PASS: TestUpdate
=== RUN TestUpdateNotFound
--- PASS: TestUpdateNotFound
=== RUN TestDelete
--- PASS: TestDelete
=== RUN TestDeleteNotFound
--- PASS: TestDeleteNotFound
=== RUN TestIDsIncrementAfterDelete
--- PASS: TestIDsIncrementAfterDelete
=== RUN TestListBooksEmpty
--- PASS: TestListBooksEmpty
=== RUN TestCreateAndGetBook
--- PASS: TestCreateAndGetBook
=== RUN TestGetBookNotFound
--- PASS: TestGetBookNotFound
=== RUN TestCreateBookValidation
--- PASS: TestCreateBookValidation
=== RUN TestUpdateBook
--- PASS: TestUpdateBook
=== RUN TestUpdateBookNotFound
--- PASS: TestUpdateBookNotFound
=== RUN TestDeleteBook
--- PASS: TestDeleteBook
=== RUN TestDeleteBookNotFound
--- PASS: TestDeleteBookNotFound
=== RUN TestInvalidJSON
--- PASS: TestInvalidJSON
=== RUN TestInvalidBookID
--- PASS: TestInvalidBookID
Verify the default Makefile target:
make
Expected output:
help Show this help screen
build Build the server binary
run Build and run the server
test Run all tests
clean Remove build artifacts
Build and start the server, then test with curl:
make run &
# Create a book
curl -s -X POST http://localhost:8080/books \
-H "Content-Type: application/json" \
-d '{"title":"The Go Programming Language","author":"Donovan & Kernighan","year":2015}'
# List all books
curl -s http://localhost:8080/books
# Get a single book
curl -s http://localhost:8080/books/1
# Update a book
curl -s -X PUT http://localhost:8080/books/1 \
-H "Content-Type: application/json" \
-d '{"title":"The Go Programming Language","author":"Alan Donovan & Brian Kernighan","year":2015}'
# Delete a book
curl -s -X DELETE http://localhost:8080/books/1 -w "\n%{http_code}\n"
# Stop the server
kill %1
Troubleshooting
pattern ... overlaps with ...compile error: You are running a Go version older than 1.22. Check withgo versionand upgrade if needed. Method-based routing ("GET /books") was added in Go 1.22.address already in use: Another process is using port 8080. Either stop it or set a different port withPORT=9090 make run.- Empty response from GET /books: The endpoint returns
[](empty JSON array) when no books have been created. This is expected, not an error.
Recap
This article built a REST API with five endpoints (list, get, create, update, delete) using only the Go standard library. The project is organized into internal/store for data persistence, internal/handler for HTTP routing, and cmd/bookapi for the entry point. Go 1.22 method-based routing eliminates the need for third-party routers, and httptest provides full integration testing without starting a real server.
Next improvements to consider:
- Add middleware for request logging and panic recovery
- Replace the in-memory store with SQLite using
database/sql - Add pagination to the list endpoint with
?page=and?limit=query parameters