Skip to content

Day 089 — JSON Request/Response Helpers

Month 4 · Week 1 · ⬅ Day 088 · Day 090 ➡ · Journal index

🎯 Learning Objective

Write the writeJSON/decodeJSON helpers every Go service needs: correct headers and status, strict and bounded decoding, and a uniform error envelope.

📚 Topics

  • json.Encoder/Decoder, struct tags, DisallowUnknownFields
  • http.MaxBytesReader, SetEscapeHTML(false), error mapping to status codes

📖 Reading / Sources

📝 Notes

  • writeJSON: set Content-Type: application/json; charset=utf-8 before WriteHeader(status), then json.NewEncoder(w).Encode(v). Headers freeze on first write, so order matters → [[http-handler]]. The Encoder appends a trailing newline.
  • Call enc.SetEscapeHTML(false) for APIs: the default escapes <, >, & to < etc., which is pointless noise outside HTML.
  • decodeJSON: bound the body with http.MaxBytesReader(w, r.Body, 1<<20) so a giant payload can't exhaust memory (it triggers *http.MaxBytesError). Then dec.DisallowUnknownFields() so a client typo 400s instead of being silently dropped.
  • Reject multiple JSON values: after a successful Decode, check dec.More() — a well-formed request body holds exactly one value.
  • Map decode errors to status codes with errors.As/errors.Is: *http.MaxBytesError → 413, io.EOF (empty body) → 400, *json.SyntaxError / *json.UnmarshalTypeError → 400. Validation failures (e.g. missing required field) → 422 Unprocessable Entity → [[error-wrapping]].
  • Struct tags drive the wire shape: json:"name", json:"qty,omitempty", json:"-" to skip. Unexported fields are never (de)serialised.
  • The server owns server-assigned fields (ids, timestamps): ignore any client-supplied id and set it yourself.
  • A generic decodeJSON[T any] returns a typed value with no casting — clean and reusable → [[generics]].

💻 Code Examples

func writeJSON(w http.ResponseWriter, status int, v any) error {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.WriteHeader(status) // after headers, before body
    enc := json.NewEncoder(w)
    enc.SetEscapeHTML(false)
    return enc.Encode(v)
}

func decodeJSON[T any](w http.ResponseWriter, r *http.Request) (T, error) {
    var v T
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // cap at 1 MiB
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields() // reject typo'd fields
    if err := dec.Decode(&v); err != nil {
        return v, err
    }
    if dec.More() { // body must hold a single JSON value
        return v, errors.New("body must contain a single JSON object")
    }
    return v, nil
}

Full code: examples/month-04/json-api/main.go · Run: go run ./examples/month-04/json-api

🏋️ Exercises / Practice

Exercise Status Link
JSON/Error response helpers (httptest) exercises/month-04/week-1/respond

🐛 Mistakes Made

  • Called w.WriteHeader(201) before w.Header().Set("Content-Type", …) — the Content-Type never made it onto the response. Set headers first.
  • Used json.Unmarshal(io.ReadAll(r.Body)) with no size cap; switched to a Decoder + MaxBytesReader to bound memory and get streaming decode.

❓ Open Questions

  • Worth surfacing the exact offset from *json.SyntaxError to clients, or is a generic "malformed JSON" safer? (Leaning generic to avoid leaking internals.)

🧠 Active Recall (answer without looking)

  1. Q: Why call DisallowUnknownFields on the decoder?
A So a client field that doesn't match the struct (often a typo) returns a 400 instead of being silently ignored.
  1. Q: What must you do before WriteHeader when sending JSON, and why?
A Set the `Content-Type` header — headers freeze once the status line is written, so any header set after `WriteHeader` is lost.

🪶 Feynman Reflection

Two small funnels handle all JSON I/O: one writes a value with the right header and status, the other reads one value strictly and safely. Bounding the body and disallowing unknown fields turns sloppy client input into clear 4xx errors instead of silent bugs.

🕳️ Knowledge Gaps

  • Streaming very large JSON arrays with Decoder.Token() instead of decoding the whole document at once.

✅ Summary

I have reusable, correct JSON helpers: proper headers/status, escape-free output, size-bounded strict decoding, and a clean mapping from decode errors to status codes.

⏭️ Next Steps / Prep for Tomorrow

  • Day 090: graceful shutdown & server timeouts.

Time spent Difficulty Confidence
90 min 🟦🟦⬜⬜⬜ 🟦🟦🟦🟦⬜

Suggested commit: feat(examples): JSON request/response helpers (day 089)