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)
  • curl or 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.
  • *.test and *.out exclude Go test binaries and coverage output files.
  • .DS_Store, *.log, and tmp/ 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 bookapi creates a go.mod file that declares the module path as bookapi. 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

  • Book struct: The data model with JSON struct tags. Tags control the JSON field names in API responses (lowercase, matching REST conventions).
  • BookStore: Holds a map[int]Book for O(1) lookups by ID, a nextID counter for monotonic ID generation, and a sync.Mutex to protect concurrent access. The mutex is required because net/http serves 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 with make([]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.RWMutex to 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 any BookStore instance.
  • Register(): Uses Go 1.22’s method-based routing syntax ("GET /books", "POST /books", etc.) on an http.ServeMux. This eliminates the need for third-party routers. The {id} wildcard is a Go 1.22 path parameter, read with r.PathValue("id").
  • bookRequest: A dedicated struct for decoding request bodies. Separating the request type from the Book model prevents clients from setting the ID field directly.
  • createBook: Decodes JSON, validates required fields, calls store.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 the Content-Type header 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 PORT from the environment, defaulting to 8080. 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.Fatal logs 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

  • help as default target: Running make with no arguments prints a formatted list of targets by scanning for ## comments. This satisfies the Makefile default target rule.
  • build: Compiles the server binary into bin/bookapi. Using a bin/ directory keeps compiled output separate from source files.
  • run: Depends on build, so it always compiles before starting. This prevents running a stale binary.
  • test: Runs all tests across all packages (./...) with -v for verbose output and -count=1 to disable test caching, ensuring tests always execute.
  • clean: Removes the bin/ 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 from All().
  • 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 new ServeMux. This isolates every test completely.
  • httptest.NewRequest / httptest.NewRecorder: The standard library’s test infrastructure. NewRequest builds an *http.Request without opening a network connection. NewRecorder captures 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 with go version and 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 with PORT=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