Building an HTTP Server in Go: Routing, Middleware and JSON APIs
Chapter 02 of 03

Building an HTTP Server in Go: Routing, Middleware and JSON APIs

A JSON product API with Go 1.22 routing, path parameters, middleware, thread-safe state, timeouts, tests and graceful shutdown.

Sabbir Ahmmed4 min read1
On this page

Go's standard library can serve production traffic without any framework. Since Go 1.22, its router (http.ServeMux) supports HTTP methods and path parameters, which removed the main reason people reached for third-party routers. In this article we build a small JSON product API with routing, middleware, validation and safe concurrent state.

Hello, server

main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hello, World!")
	})

	log.Println("listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

A handler receives an http.ResponseWriter (where you write the response) and an *http.Request (everything the client sent). Every request runs in its own goroutine, so one slow request never blocks the others. Keep that in mind: handlers run concurrently.

Routing with methods and path parameters (Go 1.22+)

Use your own ServeMux instead of the global default, and put the method in the pattern:

go
1
2
3
4
mux := http.NewServeMux()
mux.HandleFunc("GET /products", listProducts)
mux.HandleFunc("GET /products/{id}", getProduct)
mux.HandleFunc("POST /products", createProduct)
GET /products only matches GET (and HEAD). Other methods automatically get 405 Method Not Allowed.
{id} is a wildcard; read it with r.PathValue("id").

Before Go 1.22 you had to check r.Method manually in every handler (the original version of this project did exactly that).

A JSON response helper

Headers must be set before WriteHeader; after the status line is sent, header changes are ignored. A small helper keeps that order right:

go
1
2
3
4
5
6
7
func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	if err := json.NewEncoder(w).Encode(v); err != nil {
		log.Println("encode response:", err)
	}
}

State that is safe under concurrency

The original server kept products in a global slice and appended to it from the create handler. Because handlers run concurrently, two simultaneous POSTs could corrupt the slice: a data race (see the mutex article). Put the state in a struct with a lock:

store.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
type Product struct {
	ID    int     `json:"id"`
	Title string  `json:"title"`
	Price float64 `json:"price"`
}

type Store struct {
	mu       sync.RWMutex
	products []Product
}

func (s *Store) list(w http.ResponseWriter, r *http.Request) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	writeJSON(w, http.StatusOK, s.products)
}

func (s *Store) get(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 id"})
		return
	}

	s.mu.RLock()
	defer s.mu.RUnlock()
	for _, p := range s.products {
		if p.ID == id {
			writeJSON(w, http.StatusOK, p)
			return
		}
	}
	writeJSON(w, http.StatusNotFound, map[string]string{"error": "product not found"})
}

func (s *Store) create(w http.ResponseWriter, r *http.Request) {
	var p Product
	body := http.MaxBytesReader(w, r.Body, 1<<20) // reject bodies over 1 MB
	if err := json.NewDecoder(body).Decode(&p); err != nil || p.Title == "" {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid product"})
		return
	}

	s.mu.Lock()
	p.ID = len(s.products) + 1
	s.products = append(s.products, p)
	s.mu.Unlock()

	writeJSON(w, http.StatusCreated, p)
}

Methods on *Store are valid handler functions, so the store is passed in explicitly instead of living in a global variable. That makes the code testable.

Warning
Never ignore the error from Decode. The original code called decoder.Decode(&newProduct) without checking it, so malformed JSON created an empty product. Validate input and limit the body size with http.MaxBytesReader.

Middleware

Middleware is a function that takes a handler and returns a new handler that wraps it. It is the closure pattern from earlier in the series. Here is CORS handling, including the browser's preflight OPTIONS request:

go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func cors(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Access-Control-Allow-Origin", "*")
		w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
		w.Header().Set("Access-Control-Allow-Headers", "Content-Type")

		if r.Method == http.MethodOptions { // preflight: answer and stop
			w.WriteHeader(http.StatusNoContent)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
	})
}

Wrap the whole router to apply middleware to every route: handler := logging(cors(mux)).

Note
Access-Control-Allow-Origin: * is fine for a public, read-only API. If your API uses cookies or auth headers, allow only your real frontend origin.

Putting it together, with timeouts

main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func main() {
	store := &Store{products: []Product{{ID: 1, Title: "Keyboard", Price: 49.99}}}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /products", store.list)
	mux.HandleFunc("GET /products/{id}", store.get)
	mux.HandleFunc("POST /products", store.create)

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           logging(cors(mux)),
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       10 * time.Second,
		WriteTimeout:      10 * time.Second,
		IdleTimeout:       60 * time.Second,
	}

	log.Println("listening on :8080")
	log.Fatal(srv.ListenAndServe())
}

http.ListenAndServe uses a server with no timeouts, which makes it easy for slow or malicious clients to hold connections open. Configure an http.Server explicitly.

I tested this API in-process with httptest on Go 1.27:

Output
1
2
3
4
5
6
GET /products -> 200 [{"id":1,"title":"Keyboard","price":49.99}]
POST /products -> 201 {"id":2,"title":"Mouse","price":19.5}
GET /products/2 -> 200 {"id":2,"title":"Mouse","price":19.5}
GET /products/abc -> 400 {"error":"invalid id"}
DELETE /products -> 405 Method Not Allowed
OPTIONS /products -> 204

Testing handlers with httptest

store_test.go
1
2
3
4
5
6
7
8
9
10
11
12
func TestCreateProduct(t *testing.T) {
	store := &Store{}
	req := httptest.NewRequest(http.MethodPost, "/products",
		strings.NewReader(`{"title":"Mouse","price":19.5}`))
	rec := httptest.NewRecorder()

	store.create(rec, req)

	if rec.Code != http.StatusCreated {
		t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body)
	}
}

No network, no ports: httptest.NewRecorder captures what the handler writes.

Graceful shutdown

In production, stop accepting new requests and let in-flight ones finish when the process receives SIGTERM (for example, during a deploy):

go
1
2
3
4
5
6
7
8
9
10
11
12
13
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

go func() {
	if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Fatal(err)
	}
}()

<-ctx.Done() // wait for the signal
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx) // finish in-flight requests, then exit

Key takeaways

Every request runs in its own goroutine; protect shared state with a mutex.
Go 1.22+ ServeMux supports "GET /path/{id}" patterns and r.PathValue.
Set headers before WriteHeader; use a writeJSON helper.
Validate and size-limit request bodies; never ignore Decode errors.
Middleware = func(http.Handler) http.Handler.
Use an explicit http.Server with timeouts and graceful shutdown.
#Golang#HTTP#Web#REST API#Middleware

Last updated on

Discussion0