From 01d86c1727860e7512094c97fc3406c3d1db066f Mon Sep 17 00:00:00 2001 From: Team Humaki Date: Wed, 16 Sep 2026 15:55:24 -0700 Subject: [PATCH] render: skip default serializer after Renderer writes --- render.go | 33 ++++++++++++++++++-- render_test.go | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 render_test.go diff --git a/render.go b/render.go index 75a90e2..3266d5f 100644 --- a/render.go +++ b/render.go @@ -24,22 +24,51 @@ func Bind(r *http.Request, v Binder) error { return binder(r, v) } +// trackingWriter notes whether the Renderer already wrote a response so +// Render/RenderList can skip the default serializer (see #56). +type trackingWriter struct { + http.ResponseWriter + wrote bool +} + +func (w *trackingWriter) Write(b []byte) (int, error) { + w.wrote = true + return w.ResponseWriter.Write(b) +} + +func (w *trackingWriter) WriteHeader(code int) { + w.wrote = true + w.ResponseWriter.WriteHeader(code) +} + +func (w *trackingWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} + // Render renders a single payload and respond to the client request. func Render(w http.ResponseWriter, r *http.Request, v Renderer) error { - if err := renderer(w, r, v); err != nil { + tw := &trackingWriter{ResponseWriter: w} + if err := renderer(tw, r, v); err != nil { return err } + if tw.wrote { + return nil + } Respond(w, r, v) return nil } // RenderList renders a slice of payloads and responds to the client request. func RenderList(w http.ResponseWriter, r *http.Request, l []Renderer) error { + tw := &trackingWriter{ResponseWriter: w} for _, v := range l { - if err := renderer(w, r, v); err != nil { + if err := renderer(tw, r, v); err != nil { return err } } + if tw.wrote { + return nil + } Respond(w, r, l) return nil } diff --git a/render_test.go b/render_test.go new file mode 100644 index 0000000..e802a02 --- /dev/null +++ b/render_test.go @@ -0,0 +1,84 @@ +package render + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type ( + profile struct { + Name string + Age int + } + publicProfile profile + profileJSON struct { + Name string `json:"name"` + } + prepareOnly struct { + Name string `json:"name"` + } +) + +func (p publicProfile) Render(w http.ResponseWriter, r *http.Request) error { + Status(r, http.StatusNoContent) + JSON(w, r, profileJSON{Name: p.Name}) + return nil +} + +func (p prepareOnly) Render(http.ResponseWriter, *http.Request) error { + return nil +} + +func TestRenderSkipsDefaultWhenRendererWrote(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + if err := Render(w, r, publicProfile{Name: "John", Age: 42}); err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(w.Body) + got := strings.TrimSpace(string(body)) + if w.Code != http.StatusNoContent { + t.Fatalf("status=%d want %d", w.Code, http.StatusNoContent) + } + if got != `{"name":"John"}` { + t.Fatalf("body=%q, extra default JSON dump?", got) + } + if strings.Contains(got, `"Age"`) { + t.Fatalf("leaked aliased fields: %s", got) + } +} + +func TestRenderStillSerializesPrepareOnlyRenderer(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + if err := Render(w, r, prepareOnly{Name: "Ada"}); err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(w.Body) + got := strings.TrimSpace(string(body)) + if w.Code != http.StatusOK { + t.Fatalf("status=%d want %d", w.Code, http.StatusOK) + } + if got != `{"name":"Ada"}` { + t.Fatalf("body=%q", got) + } +} + +func TestRenderListSkipsDefaultWhenRendererWrote(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + list := []Renderer{ + publicProfile{Name: "John", Age: 42}, + } + if err := RenderList(w, r, list); err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(w.Body) + got := strings.TrimSpace(string(body)) + if got != `{"name":"John"}` { + t.Fatalf("body=%q", got) + } +}