refactor(utils): centralize JSON body decoding

cognitive committed Apr 30, 2026 at 00:07 UTC 8ab20b3e88b4a37ed85659576fe77a0c1695c37e
2 files changed +62 -7
utils/api.go
+13 -7
@@ -180,10 +180,8 @@ func DecodeAPIRequestError(resp *http.Response) error {
180 }
181
182 func DecodeJSONRequest[T any](w http.ResponseWriter, r *http.Request, maxBytes int64) (T, bool) {
183 - var dst T
184 - r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
185 - defer r.Body.Close()
186 - if err := json.NewDecoder(r.Body).Decode(&dst); err != nil {
183 + dst, err := decodeJSONRequestBody[T](w, r, maxBytes)
184 + if err != nil {
185 WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidJSON, err.Error())
186 return dst, false
187 }
@@ -191,14 +189,22 @@ func DecodeJSONRequest[T any](w http.ResponseWriter, r *http.Request, maxBytes i
189 }
190
191 func DecodeJSONRequestAs[T any](w http.ResponseWriter, r *http.Request, maxBytes int64, invalid APIErrorResponse) (T, bool) {
192 + dst, err := decodeJSONRequestBody[T](w, r, maxBytes)
193 + if err != nil {
194 + invalid.Write(w)
195 + return dst, false
196 + }
197 + return dst, true
198 +}
199 +
200 +func decodeJSONRequestBody[T any](w http.ResponseWriter, r *http.Request, maxBytes int64) (T, error) {
201 var dst T
202 r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
203 defer r.Body.Close()
204 if err := json.NewDecoder(r.Body).Decode(&dst); err != nil {
198 - invalid.Write(w)
199 - return dst, false
205 + return dst, err
206 }
201 - return dst, true
207 + return dst, nil
208 }
209
210 func httpJSONRequest(payload any, headers http.Header) (io.Reader, http.Header, error) {
utils/api_test.go
+49
@@ -1,6 +1,7 @@
1 package utils
2
3 import (
4 + "bytes"
5 "encoding/json"
6 "errors"
7 "io"
@@ -48,3 +49,51 @@ func TestDecodeAPIRequestError(t *testing.T) {
49 t.Fatalf("DecodeAPIRequestError() = %+v, want status/code/message populated", apiErr)
50 }
51 }
52 +
53 +func TestDecodeJSONRequestWritesInvalidJSONError(t *testing.T) {
54 + t.Parallel()
55 +
56 + req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader("{"))
57 + rec := httptest.NewRecorder()
58 +
59 + if _, ok := DecodeJSONRequest[map[string]string](rec, req, 1024); ok {
60 + t.Fatal("DecodeJSONRequest() ok = true, want false")
61 + }
62 +
63 + if rec.Code != http.StatusBadRequest {
64 + t.Fatalf("DecodeJSONRequest() status = %d, want %d", rec.Code, http.StatusBadRequest)
65 + }
66 +
67 + var envelope types.APIEnvelope[json.RawMessage]
68 + if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
69 + t.Fatalf("json.Unmarshal() error = %v", err)
70 + }
71 + if envelope.OK || envelope.Error == nil || envelope.Error.Code != types.APIErrorCodeInvalidJSON {
72 + t.Fatalf("decoded envelope = %+v, want invalid_json error", envelope)
73 + }
74 +}
75 +
76 +func TestDecodeJSONRequestAsWritesCustomInvalidError(t *testing.T) {
77 + t.Parallel()
78 +
79 + req := httptest.NewRequest(http.MethodPost, "/api", bytes.NewBufferString("{"))
80 + rec := httptest.NewRecorder()
81 + invalid := APIErrorResponse{
82 + Status: http.StatusTeapot,
83 + Code: "custom_invalid",
84 + Message: "custom invalid request",
85 + }
86 +
87 + if _, ok := DecodeJSONRequestAs[map[string]string](rec, req, 1024, invalid); ok {
88 + t.Fatal("DecodeJSONRequestAs() ok = true, want false")
89 + }
90 +
91 + var envelope types.APIEnvelope[json.RawMessage]
92 + if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
93 + t.Fatalf("json.Unmarshal() error = %v", err)
94 + }
95 + if rec.Code != http.StatusTeapot || envelope.OK || envelope.Error == nil ||
96 + envelope.Error.Code != "custom_invalid" || envelope.Error.Message != "custom invalid request" {
97 + t.Fatalf("DecodeJSONRequestAs() status/envelope = %d/%+v, want custom invalid error", rec.Code, envelope)
98 + }
99 +}