rgoussu@goussu: ~/library/go/protocols
~/library/go/protocols cat rest.md

REST & HTTP APIs in Go

# Building REST services on net/http and chi — JSON realities, validation, OpenAPI tooling, error conventions, clients and middleware.

Conceptsaved 2026-08-09 #go#protocols#rest#api-design#frameworks

Overview

REST in Go starts and often ends with the standard library: net/http is a full production server, and since 1.22 its ServeMux handles methods and path wildcards, which removed the last routine reason to import a router. The ecosystem's job is therefore not to replace the stdlib but to fill the gaps around it — validation, OpenAPI, error shapes — and the dominant choices there are small libraries that compose with http.Handler rather than frameworks that own the request lifecycle.

Key points

  • Base layer: net/http + the 1.22 ServeMux (mux.HandleFunc("GET /items/{id}", h)) or chi when you want route groups and middleware mounting — chi is 100% net/http-compatible, so nothing else changes.
  • JSON: encoding/json v1 does the job but has sharp edges — struct tags drive everything, omitempty cannot distinguish "absent" from "zero", and case-insensitive key matching surprises. The encoding/json/v2 effort (experimental behind GOEXPERIMENT=jsonv2) addresses exactly these.
  • Validation: go-playground/validator is the de-facto choice — struct tags (validate:"required,email,gte=18"), used under the hood by gin's binding.
  • OpenAPI, spec-first: oapi-codegen generates server interfaces + typed clients from an OpenAPI 3 document for stdlib/chi/echo/gin/fiber targets; the contract stays the source of truth.
  • OpenAPI, code-first: swag generates a spec from handler comments (Swagger 2 legacy feel); huma and fuego are the OpenAPI-native newcomers — define handlers with typed request/response structs and the spec falls out correct by construction.
  • Errors: no stdlib problem-details type; RFC 7807/9457 application/problem+json via a small lib (e.g. moogar0880/problems) or a hand-rolled struct — the important part is one error shape across the API.
  • Clients: http.Client with explicit timeouts is idiomatic; resty when you want retries, hooks and fluent request building without writing the plumbing.
  • Middleware: plain func(http.Handler) http.Handler decorators — otelhttp for traces/metrics, rs/cors for CORS, chi's middleware set for request ID, recovery, timeouts.

Details

JSON in practice

encoding/json marshals by reflection over exported fields; struct tags control names, omission and string-encoding (json:"id,string"). Pitfalls worth memorising: omitempty omits zero values, so 0, false and "" vanish — use pointer fields or json.RawMessage when "not provided" must differ from "zero"; unknown fields are silently ignored unless you set Decoder.DisallowUnknownFields(); and json.NewDecoder(r.Body) happily accepts trailing garbage unless you check for a second token. json/v2 fixes the semantics (omitzero, case-sensitive matching, streaming-first API) and is the direction of travel — one line worth knowing, not yet the default.

OpenAPI: two workflows

Approach Tool Flow Fit
Spec-first oapi-codegen .yaml → generated server interface + client; you implement the interface Teams where the contract is reviewed before code; multi-language consumers
Code-first (comments) swag Annotated handler comments → swagger.json Retrofitting docs onto an existing service
Code-first (types) huma, fuego Typed handler signatures → OpenAPI 3.1 at runtime Greenfield services wanting the contract for free, validation included

huma sits on any router (chi, gin, stdlib) and validates requests against the schema it generates; fuego is similar with a net/http-native slant. Both are young but growing — the Go answer to Spring's springdoc, without annotations.

Error shape

Adopt RFC 7807 problem details early: {"type", "title", "status", "detail", "instance"} plus extension fields. Map internal errors to it in one place (a top-level middleware that recovers panics and converts sentinel/wrapped errors), never in individual handlers — errors.Is/errors.As against domain error types keeps handler code clean.

Versioning and cross-cutting concerns

Path versioning (/v1/) is the pragmatic default and trivial with route mounting (mux.Handle("/v1/", http.StripPrefix("/v1", v1mux))). Cross-cutting concerns stack as middleware in one obvious place; otelhttp wraps both server handlers and client transports, so traces cross service boundaries with no framework support needed.

Examples

mux := http.NewServeMux()
mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    item, err := store.Get(r.Context(), id)
    if err != nil {
        writeProblem(w, http.StatusNotFound, "item not found", id)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(item)
})

srv := &http.Server{
    Addr:         ":8080",
    Handler:      otelhttp.NewHandler(mux, "api"),
    ReadTimeout:  5 * time.Second,
    WriteTimeout: 10 * time.Second,
}
log.Fatal(srv.ListenAndServe())

Spec-first codegen:

oapi-codegen -generate types,chi-server -package api openapi.yaml > api/gen.go

Related