Building a REST API

Reviewed & published by Brayan K

A REST API exposes resources over HTTP, and Go's standard net/http — with the 1.22 method-and-wildcard routing in http.ServeMux — gives you everything you need to route requests, read path values, decode and encode JSON, and return the right status codes.

Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

1️⃣ Routing by method and path value

http.NewServeMux is the standard router. Since Go 1.22 a pattern can name a method and use wildcards: GET /items/{id} matches only GETs and captures id, which you read with r.PathValue("id"). We exercise the handlers entirely with httptest so it runs with no port.

package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "net/http/httptest"
)

type Item struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

// In-memory "database".
var items = map[int]Item{
    1: {ID: 1, Name: "Notebook"},
    2: {ID: 2, Name: "Pen"},
}

func main() {
    mux := http.NewServeMux()

    // Go 1.22+ patterns include the METHOD and named wildcards like {id}.
    mux.HandleFunc("GET /items", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        list := []Item{items[1], items[2]} // stable order for a clean demo
        json.NewEncoder(w).Encode(list)
    })

    mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
        id := r.PathValue("id") // pull the {id} segment from the path
        var key int
        fmt.Sscanf(id, "%d", &key)
        item, ok := items[key]
        if !ok {
            http.Error(w, "not found", http.StatusNotFound)
            return
        }
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(item)
    })

    // Drive the handlers with httptest.NewRecorder — no port, it just records
    // what the handler writes so we can print it.
    for _, path := range []string{"/items", "/items/2", "/items/99"} {
        req := httptest.NewRequest(http.MethodGet, path, nil)
        rec := httptest.NewRecorder()
        mux.ServeHTTP(rec, req)
        res := rec.Result()
        fmt.Printf("GET %s -> %d %s", path, res.StatusCode, rec.Body.String())
    }
}

2️⃣ Creating resources: decode, validate, 201

A POST handler reads the JSON body with json.NewDecoder(r.Body).Decode(&in), validates it, then writes a response. Set the status with w.WriteHeader(http.StatusCreated) before encoding the body — once you write bytes, the status is locked to whatever was sent.

package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "net/http/httptest"
    "strings"
)

type Item struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

func main() {
    nextID := 3
    mux := http.NewServeMux()

    // Decode the JSON body, validate, assign an ID, return 201 Created.
    mux.HandleFunc("POST /items", func(w http.ResponseWriter, r *http.Request) {
        var in Item
        if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
            http.Error(w, "bad json", http.StatusBadRequest)
            return
        }
        if in.Name == "" {
            http.Error(w, "name is required", http.StatusBadRequest)
            return
        }
        in.ID = nextID
        nextID++

        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusCreated) // 201 — set status BEFORE the body
        json.NewEncoder(w).Encode(in)
    })

    // Valid create.
    req := httptest.NewRequest(http.MethodPost, "/items", strings.NewReader(`{"name":"Stapler"}`))
    rec := httptest.NewRecorder()
    mux.ServeHTTP(rec, req)
    fmt.Printf("POST valid   -> %d %s", rec.Code, rec.Body.String())

    // Missing name -> 400.
    req2 := httptest.NewRequest(http.MethodPost, "/items", strings.NewReader(`{}`))
    rec2 := httptest.NewRecorder()
    mux.ServeHTTP(rec2, req2)
    fmt.Printf("POST no name -> %d %s", rec2.Code, rec2.Body.String())
}

3️⃣ Keeping handlers DRY

Every handler repeats the same three lines: set the content type, write the status, encode JSON. Factor that into a tiny writeJSON helper. Note 204 No Content for a successful DELETE — a body-less success — which never calls Encode at all.

package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "net/http/httptest"
)

// writeJSON centralises the boilerplate: set the header, write the status,
// encode the value. Every handler can call it instead of repeating itself.
func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(v)
}

func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
    })

    mux.HandleFunc("DELETE /items/{id}", func(w http.ResponseWriter, r *http.Request) {
        // 204 No Content: success, nothing to return in the body.
        w.WriteHeader(http.StatusNoContent)
    })

    for _, tc := range []struct{ method, path string }{
        {"GET", "/health"},
        {"DELETE", "/items/5"},
    } {
        req := httptest.NewRequest(tc.method, tc.path, nil)
        rec := httptest.NewRecorder()
        mux.ServeHTTP(rec, req)
        fmt.Printf("%s %s -> %d %q\n", tc.method, tc.path, rec.Code, rec.Body.String())
    }
}

🎯 Your Turn

Register a route and dispatch a request to it. Fill in the two blanks marked ___, then run it.

package main

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

func main() {
    mux := http.NewServeMux()

    // 🎯 YOUR TURN — fill in the blanks marked ___

    // 1) Register a handler for GET requests to /ping
    mux.HandleFunc("___", func(w http.ResponseWriter, r *http.Request) { // 👉 "GET /ping"
        fmt.Fprint(w, "pong")
    })

    req := httptest.NewRequest(http.MethodGet, "/ping", nil)
    rec := httptest.NewRecorder()
    // 2) Run the request through the mux into the recorder
    mux.___(rec, req) // 👉 ServeHTTP
    fmt.Printf("%d %s", rec.Code, rec.Body.String())

    // ✅ Expected output:
    //    200 pong
}

Common Errors (and the fix)

❌ Calling w.WriteHeader after writing the body — the status is already 200; the second call logs "superfluous WriteHeader".

✅ Set headers and status before the first Write/Encode.

❌ Patterns without a method on Go < 1.22, then expecting method routing.

✅ Use Go 1.22+ and write "GET /path"; the mux returns 405 for other methods automatically.

❌ Ignoring the Decode error — a malformed body leaves your struct half-filled.

✅ Check it and return 400; validate required fields too.

❌ Not setting Content-Type — clients may not treat the body as JSON.

✅ w.Header().Set("Content-Type", "application/json") before writing.

🧠 Quick Recall

pattern "POST /items"; a GET arrives -> status?

405 Method Not Allowed — the path matches but the method doesn't, and the 1.22 mux rejects it for you.

read {id} from "GET /items/{id}"?

r.PathValue("id") — it returns the matched segment as a string; convert with strconv.Atoi if you need a number.

🎯 Mini-Challenge: A Greeting Endpoint

Register GET /greet/{lang}, look up the language in the map, and write the greeting — or respond 404 for an unknown one. Then dispatch /greet/fr and /greet/de.

package main

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

var greetings = map[string]string{
    "en": "Hello",
    "fr": "Bonjour",
}

func main() {
    // 🎯 MINI-CHALLENGE: a greeting endpoint with a path value
    // Register GET /greet/{lang}. Read the {lang} path value, look it up in
    // greetings, and write it. If the language is unknown, respond 404.
    // Then send requests for /greet/fr and /greet/de and print the results.
    //
    // ✅ Expected output:
    //    GET /greet/fr -> 200 Bonjour
    //    GET /greet/de -> 404 unknown language

    mux := http.NewServeMux()
    fmt.Println("languages loaded:", len(greetings))
    // your code here:
    //   mux.HandleFunc("GET /greet/{lang}", func(w, r){ ... r.PathValue("lang") ... })
    //   then build two httptest requests and print rec.Code + rec.Body.String()
}

🎉 Lesson Complete!

Practice quiz

Which http.ServeMux pattern matches only GET requests for a single item by id (Go 1.22+)?

  • "/items/{id}"
  • "GET /items/:id"
  • "GET /items/{id}"
  • "/items/(id)"

Answer: "GET /items/{id}". Since Go 1.22 a pattern can include the method and a named wildcard like {id}.

How do you read the {id} wildcard inside the handler?

  • r.PathValue("id")
  • r.URL.Query().Get("id")
  • r.FormValue("id")
  • mux.Param("id")

Answer: r.PathValue("id"). r.PathValue("id") returns the matched path segment as a string.

A request with the wrong HTTP method to a matched path returns what status automatically?

  • 400 Bad Request
  • 404 Not Found
  • 500 Internal Server Error
  • 405 Method Not Allowed

Answer: 405 Method Not Allowed. The 1.22 mux returns 405 when the path matches but the method does not.

When must you call w.WriteHeader(status)?

  • After the first Write/Encode
  • Before writing any body bytes
  • Only inside a defer
  • It is optional and ignored

Answer: Before writing any body bytes. Once you write bytes the status is locked to 200, so set it before the first write.

Which status code is conventional after a successful POST that creates a resource?

  • 201 Created
  • 200 OK
  • 202 Accepted
  • 204 No Content

Answer: 201 Created. 201 Created (http.StatusCreated) signals a new resource was created.

Which status fits a successful DELETE that returns no body?

  • 200 OK
  • 201 Created
  • 204 No Content
  • 404 Not Found

Answer: 204 No Content. 204 No Content means success with nothing to return in the body.

How do you decode a JSON request body straight from the stream?

  • json.Unmarshal(r.Body, &v)
  • json.NewDecoder(r.Body).Decode(&v)
  • json.Decode(r.Body, &v)
  • r.Body.JSON(&v)

Answer: json.NewDecoder(r.Body).Decode(&v). json.NewDecoder(r.Body).Decode(&v) reads JSON directly from the body stream.

How do you test a handler without binding a port?

  • http.ListenAndServe on :0
  • exec.Command("curl")
  • net.Dial to localhost
  • httptest.NewRecorder with httptest.NewRequest

Answer: httptest.NewRecorder with httptest.NewRequest. httptest.NewRecorder is an in-memory ResponseWriter you drive via ServeHTTP.

What does http.NewServeMux return?

  • A *http.Client
  • A request router (*http.ServeMux)
  • An http.ResponseWriter
  • A net.Listener

Answer: A request router (*http.ServeMux). http.NewServeMux creates a new request multiplexer (router).

Why set w.Header().Set("Content-Type", "application/json")?

  • It is required to compile
  • It selects the HTTP method
  • So clients treat the body as JSON
  • It enables gzip automatically

Answer: So clients treat the body as JSON. Setting the Content-Type tells clients the response body is JSON.

Continue this course

Frequently asked questions

What changed about routing in Go 1.22?

Before 1.22 the standard http.ServeMux only matched on path prefixes, so people reached for third-party routers for method-based routing and URL parameters. Go 1.22 added both to the standard mux: patterns can include an HTTP method ('GET /items') and named wildcards ('GET /items/{id}'), and you read the wildcard with r.PathValue("id"). For many APIs you no longer need an external router at all.

How do I read a URL path parameter like {id}?

Register the route with a wildcard segment — mux.HandleFunc("GET /items/{id}", handler) — then inside the handler call r.PathValue("id"). It returns the matched segment as a string, so convert it (strconv.Atoi or fmt.Sscanf) if you need a number, and handle the error or a missing value gracefully.

Why must I call w.WriteHeader before writing the body?

Once you write any bytes to the ResponseWriter, Go automatically sends a 200 OK status and the headers, and you can't change them afterwards. So set your headers with w.Header().Set(...) and choose your status with w.WriteHeader(201) BEFORE the first Write or Encode. If you only ever want 200, you can skip WriteHeader entirely.

How do I test a handler without starting a server?

Use httptest. Build a fake request with httptest.NewRequest(method, path, body), create an httptest.NewRecorder() (an in-memory ResponseWriter), then call your handler or mux with mux.ServeHTTP(rec, req). Afterwards inspect rec.Code, rec.Body.String(), and rec.Header(). No port is bound, so tests are fast and isolated.

Which status codes should a REST API return?

Common ones: 200 OK for a successful GET, 201 Created after a successful POST that creates a resource, 204 No Content for a successful DELETE with nothing to return, 400 Bad Request for malformed input, 404 Not Found when the resource doesn't exist, and 500 Internal Server Error for unexpected failures. Use http.StatusOK, http.StatusCreated, etc. instead of magic numbers.

Should I decode JSON with Decode or Unmarshal?

For a request body, json.NewDecoder(r.Body).Decode(&v) reads straight from the stream, which is convenient and memory-friendly. json.Unmarshal works on a byte slice you already have. For encoding the response, json.NewEncoder(w).Encode(v) streams directly to the ResponseWriter. All three are fine; pick whichever matches whether you're holding bytes or a stream.