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
+}