LearnAI ToolsCareerPractice BuildsPlayContact
Go (Golang)Intermediate~2 hours

REST API

Build a full CRUD REST API for managing products using net/http.

net/httpJSONError Handling

Overview

Go's standard library ships a production-capable HTTP server in `net/http`, which is why so many real Go services are written with zero third-party web framework at all. An `http.HandlerFunc` is just a function with the signature `func(w http.ResponseWriter, r *http.Request)` — no decorators, no annotations, no reflection-based routing magic. You read the method and path off `r`, write a status code and JSON body to `w`, and that is the entire mental model.

By the end of this tutorial you will have a running HTTP server exposing a full CRUD API for a `Product` resource: `GET /products` and `POST /products` for listing and creating, `GET /products/{id}`, `PUT /products/{id}`, and `DELETE /products/{id}` for operating on one product at a time. You will also see how a real Go API distinguishes "malformed request" (400), "resource not found" (404), and "unsupported method" (405) from each other with actual HTTP status codes, rather than collapsing every failure into a generic error string.

What You'll Build
  • A `Product` struct with `ID`, `Name`, and `Price` fields, JSON-tagged for the API.
  • An in-memory `ProductStore` guarded by a `sync.Mutex` for safe concurrent access.
  • A `/products` handler supporting `GET` (list all) and `POST` (create) with a `switch` on `r.Method`.
  • A `/products/{id}` handler supporting `GET`, `PUT`, and `DELETE` on a single product.
  • A shared `writeJSONError()` helper that returns consistent `{"error": "..."}` JSON on every failure.
  • An `http.ServeMux` wiring both routes to their handlers and starting the server with `http.ListenAndServe`.

Prerequisites

  • Structs and JSON tags — the same struct-to-JSON mapping used in the CLI Task Manager project.
  • The `net/http` package — `http.HandlerFunc`, `http.ResponseWriter`, and `*http.Request`.
  • Error handling — `if err != nil` and returning early on failure.
  • Maps — `map[int]Product` for in-memory storage, and safe access with `sync.Mutex`.
  • HTTP fundamentals — methods (GET/POST/PUT/DELETE) and status codes (200, 201, 400, 404, 405).

Project Structure

The whole server lives in a single file, `main.go`. A `Product` struct models one resource, and a `ProductStore` struct wraps a `map[int]Product` together with a `sync.Mutex` and a `nextID` counter — bundling the data with the lock that protects it is what makes it safe for `net/http` to call the same handler concurrently from multiple goroutines, one per in-flight request, which is exactly how Go's HTTP server operates by default.

Two handler functions cover every route: `productsHandler` for the collection endpoint `/products` (GET to list, POST to create), and `productHandler` for the single-resource endpoint `/products/{id}` (GET, PUT, DELETE). Each one starts with a `switch r.Method` so unsupported methods on a valid path fall through to a `405 Method Not Allowed` response instead of silently doing nothing.

Step 1: Define the Product Model and Store

`ProductStore` holds a `sync.Mutex` by value (not a pointer), which is intentional — every method on `*ProductStore` locks it with `Lock()`/`defer Unlock()` before touching the map, and `defer` guarantees the unlock runs even if the method returns early or panics partway through. `nextID` mirrors the id-assignment pattern from the CLI Task Manager project, just backed by a map instead of a slice this time.

package main
import "sync"
// Product is the resource this API exposes over HTTP.
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
// ProductStore holds every product in memory, guarded by a mutex so
// concurrent goroutines (one per in-flight HTTP request) never read and
// write the map at the same time, which would otherwise corrupt it.
type ProductStore struct {
mu sync.Mutex
products map[int]Product
nextID int
}
// newProductStore returns a ready-to-use store with an empty product map.
func newProductStore() *ProductStore {
return &ProductStore{
products: make(map[int]Product),
nextID: 1,
}
}
// Create adds a new product, assigning it the next unused id, and returns
// the stored product (with its id filled in).
func (s *ProductStore) Create(name string, price float64) Product {
s.mu.Lock() // Only one goroutine may hold the lock at a time
defer s.mu.Unlock() // Guaranteed to run when Create returns, even on an early return
p := Product{ID: s.nextID, Name: name, Price: price}
s.products[p.ID] = p
s.nextID++
return p
}
// All returns a slice of every product currently stored.
func (s *ProductStore) All() []Product {
s.mu.Lock()
defer s.mu.Unlock()
result := make([]Product, 0, len(s.products)) // Pre-sized; at most len(s.products) items will be appended
for _, p := range s.products {
result = append(result, p)
}
return result
}

Step 2: Handle GET and POST on /products

`productsHandler` is the collection-level handler for `/products`: `switch r.Method` routes `GET` to listing every product and `POST` to creating one, with a `default` case returning `405 Method Not Allowed` for anything else (`DELETE /products` with no id, for instance). Decoding the POST body uses `json.NewDecoder(r.Body).Decode(&input)` — reading directly from the request body stream — rather than buffering the whole body into a byte slice first, which is the idiomatic way to decode a JSON request in Go.

import (
"encoding/json"
"net/http"
)
// productsHandler serves the collection endpoint: GET lists every product,
// POST creates a new one from a JSON request body.
func productsHandler(store *ProductStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
products := store.All()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(products) // Defaults to 200 OK since WriteHeader was never called explicitly
case http.MethodPost:
var input struct { // Anonymous struct: only used here to decode the request body, no need to name it
Name string `json:"name"`
Price float64 `json:"price"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid JSON body") // 400: the client sent malformed JSON
return
}
if input.Name == "" || input.Price < 0 {
writeJSONError(w, http.StatusBadRequest, "name is required and price must be non-negative")
return
}
product := store.Create(input.Name, input.Price)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) // 201: a new resource was created
json.NewEncoder(w).Encode(product)
default:
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed on /products") // 405
}
}
}
Example Request

Click Run to see what this code prints.

Step 3: Handle GET, PUT, and DELETE on /products/{id}

The single-resource path carries its id as a URL segment, so `productHandler` first has to parse `/products/3` down to the integer `3` with `strconv.Atoi` before it can look anything up — a non-numeric id is a client error, not a server one, so that failure also becomes a `400`, not a crash. `Get`, `Update`, and `Delete` on `ProductStore` all return a boolean (or the zero-value `Product` plus `false`) instead of an error, since "no product with that id" is an expected, everyday outcome here, not an exceptional one.

import "strconv"
// Get returns the product with the given id and true, or a zero Product
// and false if no product with that id exists.
func (s *ProductStore) Get(id int) (Product, bool) {
s.mu.Lock()
defer s.mu.Unlock()
p, ok := s.products[id] // Map "comma ok" idiom: ok is false if the key isn't present
return p, ok
}
// Update replaces the product with the given id, returning false if it
// doesn't exist. The id itself is preserved from the existing record.
func (s *ProductStore) Update(id int, name string, price float64) (Product, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.products[id]; !ok {
return Product{}, false // Zero-value Product: caller must check the bool, not just the value
}
updated := Product{ID: id, Name: name, Price: price}
s.products[id] = updated
return updated, true
}
// Delete removes the product with the given id, returning false if it
// didn't exist in the first place.
func (s *ProductStore) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.products[id]; !ok {
return false
}
delete(s.products, id) // Built-in delete(): removes a key from a map, no-op if the key is absent
return true
}
// productHandler serves the single-resource endpoint /products/{id}.
func productHandler(store *ProductStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/products/") // Everything after the prefix is the id segment
id, err := strconv.Atoi(idStr)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid product id") // 400: id in the URL isn't a number
return
}
switch r.Method {
case http.MethodGet:
product, ok := store.Get(id)
if !ok {
writeJSONError(w, http.StatusNotFound, "product not found") // 404: id is well-formed but unknown
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(product)
case http.MethodPut:
var input struct {
Name string `json:"name"`
Price float64 `json:"price"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid JSON body")
return
}
updated, ok := store.Update(id, input.Name, input.Price)
if !ok {
writeJSONError(w, http.StatusNotFound, "product not found")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(updated)
case http.MethodDelete:
if !store.Delete(id) {
writeJSONError(w, http.StatusNotFound, "product not found")
return
}
w.WriteHeader(http.StatusNoContent) // 204: deleted successfully, no body to return
default:
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed on /products/{id}")
}
}
}

Step 4: Write JSON Error Responses

Every error branch above calls the same `writeJSONError()` helper instead of writing `http.Error()` plain text, so every failure — whatever the status code — comes back as the same predictable `{"error": "..."}` shape a client-side developer can parse without special-casing which endpoint failed. Setting the `Content-Type` header and calling `WriteHeader` before writing the body matters: once any bytes are written to `w`, Go's `net/http` locks in a `200` status implicitly, so `WriteHeader` must always run first.

// writeJSONError writes a consistent {"error": "..."} JSON body with the
// given HTTP status code. Centralizing this in one function is what keeps
// every handler's error responses in the same shape.
func writeJSONError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) // Must be called before any body bytes are written, or it has no effect
json.NewEncoder(w).Encode(map[string]string{"error": message})
}

Step 5: Register Routes and Start the Server

`http.NewServeMux()` maps URL patterns to handlers; registering `"/products/"` (with a trailing slash) makes it match any path under that prefix, which is what lets `productHandler` receive requests for `/products/1`, `/products/2`, and so on through the single route. `http.ListenAndServe` blocks forever serving requests, spawning a new goroutine per incoming connection automatically — this is why `ProductStore`'s mutex from Step 1 is not optional, it is the only thing preventing two simultaneous requests from corrupting the map.

import (
"fmt"
"log"
)
func main() {
store := newProductStore()
mux := http.NewServeMux()
mux.HandleFunc("/products", productsHandler(store)) // Exact match: collection endpoint
mux.HandleFunc("/products/", productHandler(store)) // Trailing slash: matches /products/{anything}
port := ":8080"
fmt.Println("Server listening on http://localhost" + port)
log.Fatal(http.ListenAndServe(port, mux)) // Blocks forever; log.Fatal exits the program if the server fails to start
}

Complete Code

Here is the full server assembled in one file, ready to save as `main.go` and run with `go run main.go`.

package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"sync"
)
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
type ProductStore struct {
mu sync.Mutex
products map[int]Product
nextID int
}
func newProductStore() *ProductStore {
return &ProductStore{
products: make(map[int]Product),
nextID: 1,
}
}
func (s *ProductStore) Create(name string, price float64) Product {
s.mu.Lock()
defer s.mu.Unlock()
p := Product{ID: s.nextID, Name: name, Price: price}
s.products[p.ID] = p
s.nextID++
return p
}
func (s *ProductStore) All() []Product {
s.mu.Lock()
defer s.mu.Unlock()
result := make([]Product, 0, len(s.products))
for _, p := range s.products {
result = append(result, p)
}
return result
}
func (s *ProductStore) Get(id int) (Product, bool) {
s.mu.Lock()
defer s.mu.Unlock()
p, ok := s.products[id]
return p, ok
}
func (s *ProductStore) Update(id int, name string, price float64) (Product, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.products[id]; !ok {
return Product{}, false
}
updated := Product{ID: id, Name: name, Price: price}
s.products[id] = updated
return updated, true
}
func (s *ProductStore) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.products[id]; !ok {
return false
}
delete(s.products, id)
return true
}
func writeJSONError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": message})
}
func productsHandler(store *ProductStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
products := store.All()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(products)
case http.MethodPost:
var input struct {
Name string `json:"name"`
Price float64 `json:"price"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if input.Name == "" || input.Price < 0 {
writeJSONError(w, http.StatusBadRequest, "name is required and price must be non-negative")
return
}
product := store.Create(input.Name, input.Price)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(product)
default:
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed on /products")
}
}
}
func productHandler(store *ProductStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/products/")
id, err := strconv.Atoi(idStr)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid product id")
return
}
switch r.Method {
case http.MethodGet:
product, ok := store.Get(id)
if !ok {
writeJSONError(w, http.StatusNotFound, "product not found")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(product)
case http.MethodPut:
var input struct {
Name string `json:"name"`
Price float64 `json:"price"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid JSON body")
return
}
updated, ok := store.Update(id, input.Name, input.Price)
if !ok {
writeJSONError(w, http.StatusNotFound, "product not found")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(updated)
case http.MethodDelete:
if !store.Delete(id) {
writeJSONError(w, http.StatusNotFound, "product not found")
return
}
w.WriteHeader(http.StatusNoContent)
default:
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed on /products/{id}")
}
}
}
func main() {
store := newProductStore()
mux := http.NewServeMux()
mux.HandleFunc("/products", productsHandler(store))
mux.HandleFunc("/products/", productHandler(store))
port := ":8080"
fmt.Println("Server listening on http://localhost" + port)
log.Fatal(http.ListenAndServe(port, mux))
}

Sample Run

Sample Run

Click Run to see what this code prints.

Extend This Project

  • Swap the `map[int]Product` for a real database using `database/sql` with the `postgres` or `sqlite` driver, keeping the `ProductStore` method signatures unchanged.
  • Add pagination to `GET /products` with `?limit=` and `?offset=` query parameters read from `r.URL.Query()`.
  • Add a `PATCH /products/{id}` handler that updates only the fields present in the request body, instead of requiring the full object like `PUT`.
  • Write middleware that logs every request's method, path, and duration by wrapping the `http.Handler` before it reaches `mux`.
  • Add request validation with a dedicated package like `go-playground/validator` instead of the manual `if input.Name == ""` checks.

Summary

You built a working CRUD REST API using nothing but Go's standard library: `net/http` for routing and request handling, `encoding/json` for decoding request bodies and encoding responses, and `sync.Mutex` to make the in-memory store safe under Go's one-goroutine-per-request concurrency model. The pattern of a `switch r.Method` per route, a consistent JSON error shape, and status codes chosen deliberately for each failure case is the same shape you will reach for building any HTTP API in Go, whether or not you eventually reach for a framework on top of it.