🗂️ Analogy
A REST API treats your data as resources at stable addresses — /todos, /todos/42 — and uses the HTTP verb to say what to do: GET to read, POST to create, PUT/PATCH to update, DELETE to remove. It’s the filing cabinet metaphor: the URL names the drawer and folder, the method is the action.
Resources, methods, status codes
graph TD
A["GET /todos"] --> R1["200 · list"]
B["POST /todos"] --> R2["201 · created"]
C["GET /todos/{id}"] --> R3["200 · one / 404 · missing"]
D["DELETE /todos/{id}"] --> R4["204 · gone"]Map each method to the right status: 2xx success (200 OK, 201 Created, 204 No Content), 4xx the client’s fault (400 Bad Request, 401/403 auth, 404 Not Found, 409 Conflict, 422 Unprocessable), 5xx the server’s fault (500). Returning the correct code is half of a good API.
| Method | Purpose | Idempotent? | Success |
|---|---|---|---|
GET | read | yes (safe) | 200 |
POST | create | no | 201 (+ Location) |
PUT | replace | yes | 200 / 204 |
PATCH | partial update | no | 200 |
DELETE | remove | yes | 204 |
Idempotency matters for retries: a client or proxy may safely re-send a GET/PUT/DELETE, but re-sending a POST can create duplicates.
Reading a resource
Decode JSON in, encode JSON out, pick the status — driven in-process so it runs anywhere:
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
)
type Todo struct {
ID int "json:\"id\""
Title string "json:\"title\""
Done bool "json:\"done\""
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
func main() {
store := map[int]Todo{1: {ID: 1, Title: "write Go", Done: false}}
mux := http.NewServeMux()
mux.HandleFunc("GET /todos/{id}", func(w http.ResponseWriter, r *http.Request) {
var id int
fmt.Sscanf(r.PathValue("id"), "%d", &id)
t, ok := store[id]
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
writeJSON(w, http.StatusOK, t)
})
// hit /todos/1 in-process
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/todos/1", nil))
fmt.Println("GET /todos/1 ->", rec.Code)
fmt.Print(rec.Body.String()) // {"id":1,"title":"write Go","done":false}
// a miss returns a JSON 404
rec2 := httptest.NewRecorder()
mux.ServeHTTP(rec2, httptest.NewRequest("GET", "/todos/9", nil))
fmt.Println("GET /todos/9 ->", rec2.Code)
fmt.Print(rec2.Body.String()) // {"error":"not found"}
}
Creating a resource
A POST handler is the mirror image: decode, validate, store, 201. Validation is non-optional — Decode happily accepts partial or unexpected JSON, so check the result and return 422 on bad input. Set a Location header pointing at the new resource:
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
type Todo struct {
ID int "json:\"id\""
Title string "json:\"title\""
}
func main() {
nextID := 2
mux := http.NewServeMux()
mux.HandleFunc("POST /todos", func(w http.ResponseWriter, r *http.Request) {
var in Todo
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "{\"error\":\"bad json\"}", http.StatusBadRequest)
return
}
if strings.TrimSpace(in.Title) == "" { // validate!
http.Error(w, "{\"error\":\"title required\"}", http.StatusUnprocessableEntity)
return
}
in.ID = nextID
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Location", fmt.Sprintf("/todos/%d", in.ID))
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(in)
})
// valid create → 201 + Location
rec := httptest.NewRecorder()
body := strings.NewReader("{\"title\":\"ship it\"}")
mux.ServeHTTP(rec, httptest.NewRequest("POST", "/todos", body))
fmt.Println("status: ", rec.Code) // 201
fmt.Println("location:", rec.Header().Get("Location")) // /todos/2
fmt.Print("body: ", rec.Body.String()) // {"id":2,"title":"ship it"}
// empty title → 422
rec2 := httptest.NewRecorder()
mux.ServeHTTP(rec2, httptest.NewRequest("POST", "/todos", strings.NewReader("{}")))
fmt.Println("\nempty title ->", rec2.Code) // 422
}
A consistent error envelope
Pick one error shape and use it everywhere, so clients decode responses uniformly:
type APIError struct {
Error string `json:"error"` // machine-stable code or message
Detail string `json:"detail,omitempty"`
}
func writeErr(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, APIError{Error: msg})
}
Return it with the matching status (writeErr(w, 404, "todo not found")), and never leak internal details — see web security.
Reference
| Concern | Choice |
|---|---|
| Route | mux.HandleFunc("GET /todos/{id}", h) |
| Decode body | json.NewDecoder(r.Body).Decode(&v) + validate |
| Encode response | json.NewEncoder(w).Encode(v) with Content-Type |
| Cap body | http.MaxBytesReader(w, r.Body, n) |
| Reject unknown fields | dec.DisallowUnknownFields() |
| Created | 201 + Location header |
| Client error | 400 / 404 / 409 / 422 |
| Wrong method | 405 (mux automatic) |
| Versioning | prefix routes (/v1/...) |
⚠️ Validate input, limit the body, version your API
Decoding succeeds on partial or unexpected JSON, so always validate the decoded value before trusting it. Cap request bodies with http.MaxBytesReader so a giant payload can’t exhaust memory, and consider decoder.DisallowUnknownFields() for strict APIs. Put cross-cutting concerns (auth, logging, recovery) in middleware, keep handlers thin, and version your routes (/v1/...) from day one — it’s far easier than migrating clients later.
See also
- HTTP server — handlers, the mux,
httptest. - routing & middleware — auth, logging, recovery around handlers.
- web security — validation, body limits, safe errors.
- gRPC — a typed, high-performance alternative to JSON/HTTP.
- encoding/json — the marshaling underneath.
Next: typed, high-performance APIs with gRPC.
Related topics
Serving HTTP in net/http — handlers and HandlerFunc, the ResponseWriter and Request, the Go 1.22 method+pattern ServeMux with PathValue, decoding request bodies, and a production-shaped http.Server with timeouts.
httpRouting & MiddlewareDispatch and cross-cutting concerns — the Go 1.22 ServeMux (method+path patterns, wildcards, precedence), path values, middleware as Handler-wrapping-Handler, chaining order, and recovery/logging middleware.
apisgRPCTyped, fast service-to-service calls — Protocol Buffers, the four RPC kinds, code generation with protoc, and HTTP/2 streaming over plain REST.
Check your understanding
Score: 0 / 51. Which status code should a successful resource creation (POST) return?
200 OK is fine for reads and updates; a successful POST that creates a resource should return 201 Created. 204 No Content suits a successful DELETE; 400/404/409/422 cover client errors; 500 is a server fault.
2. How do you decode a JSON request body into a struct in a handler?
Stream the body straight into your struct with json.NewDecoder(r.Body).Decode(&v). Always check the error (malformed JSON, wrong types) and validate the result before using it.
3. Why return errors as JSON instead of plain text from a JSON API?
A predictable error envelope like {"error":"..."} lets every client decode responses the same way. Set Content-Type: application/json and an accurate status code on errors too.
4. What does it mean that PUT and DELETE are idempotent but POST is not?
PUT /todos/42 (full replace) and DELETE /todos/42 reach the same state no matter how many times you send them. POST /todos creates a new resource each time, so a client/proxy must not blindly retry it. Idempotency is what makes safe retries possible.
5. A request hits a known path with an unsupported method (e.g. PATCH on a GET-only route). What's the right response?
404 means the resource path doesn't exist; 405 means the path exists but not for that method. Since Go 1.22 the standard mux distinguishes these and returns 405 (with an Allow header) when a path matches but the method doesn't.
Comments
Sign in with GitHub to join the discussion.