From 7a2cef00c3a58aa23b891555b9d6795970a3d445 Mon Sep 17 00:00:00 2001 From: Aleksey Shakhmatov Date: Sun, 22 Mar 2026 21:47:45 +0300 Subject: [PATCH] Add server WriteJSON and WriteError response helpers Eliminates repeated marshal-set-header-write boilerplate in handlers. WriteError produces consistent {"error": "..."} JSON responses. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/respond.go | 29 +++++++++++++++++ server/respond_test.go | 72 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 server/respond.go create mode 100644 server/respond_test.go diff --git a/server/respond.go b/server/respond.go new file mode 100644 index 0000000..628411f --- /dev/null +++ b/server/respond.go @@ -0,0 +1,29 @@ +package server + +import ( + "encoding/json" + "net/http" +) + +// WriteJSON encodes v as JSON and writes it to w with the given status code. +// It sets Content-Type to application/json. +func WriteJSON(w http.ResponseWriter, status int, v any) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, err = w.Write(b) + return err +} + +// WriteError writes a JSON error response with the given status code and +// message. The response body is {"error": ""}. +func WriteError(w http.ResponseWriter, status int, msg string) error { + return WriteJSON(w, status, errorBody{Error: msg}) +} + +type errorBody struct { + Error string `json:"error"` +} diff --git a/server/respond_test.go b/server/respond_test.go new file mode 100644 index 0000000..1602847 --- /dev/null +++ b/server/respond_test.go @@ -0,0 +1,72 @@ +package server_test + +import ( + "encoding/json" + "net/http/httptest" + "testing" + + "git.codelab.vc/pkg/httpx/server" +) + +func TestWriteJSON(t *testing.T) { + t.Run("writes JSON with status and content type", func(t *testing.T) { + w := httptest.NewRecorder() + + type resp struct { + ID int `json:"id"` + Name string `json:"name"` + } + + err := server.WriteJSON(w, 201, resp{ID: 1, Name: "Alice"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if w.Code != 201 { + t.Fatalf("got status %d, want %d", w.Code, 201) + } + if ct := w.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("Content-Type = %q, want %q", ct, "application/json") + } + + var decoded resp + if err := json.Unmarshal(w.Body.Bytes(), &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.ID != 1 || decoded.Name != "Alice" { + t.Fatalf("got %+v, want {ID:1 Name:Alice}", decoded) + } + }) + + t.Run("returns error for unmarshalable input", func(t *testing.T) { + w := httptest.NewRecorder() + + err := server.WriteJSON(w, 200, make(chan int)) + if err == nil { + t.Fatal("expected error for channel type") + } + }) +} + +func TestWriteError(t *testing.T) { + w := httptest.NewRecorder() + + err := server.WriteError(w, 404, "not found") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if w.Code != 404 { + t.Fatalf("got status %d, want %d", w.Code, 404) + } + + var body struct { + Error string `json:"error"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if body.Error != "not found" { + t.Fatalf("error = %q, want %q", body.Error, "not found") + } +}