Skip to content

Commit 42410d1

Browse files
vishrclaude
andcommitted
fix(binder): serialize BindingError to structured JSON (#2771)
BindingError embeds *HTTPError but did not implement json.Marshaler, so DefaultHTTPErrorHandler's type switch fell through to its default branch (the value is a *BindingError, not a *HTTPError), flattening responses to {"message":"Bad Request"} and dropping both the field name and the binder message — a regression from fbfe216. Implement MarshalJSON on *BindingError so it takes the handler's json.Marshaler branch, restoring the structured {"field":...,"message":...} response (the v4.10.2 behavior). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4f5ac60 commit 42410d1

2 files changed

Lines changed: 53 additions & 0 deletions

File tree

binder.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,24 @@ func (be *BindingError) Error() string {
8888
return fmt.Sprintf("%s, field=%s", be.HTTPError.Error(), be.Field)
8989
}
9090

91+
// MarshalJSON implements json.Marshaler so that binding errors are serialized into
92+
// a structured response (e.g. {"field":"id","message":"..."}) rather than being
93+
// flattened to a generic message. DefaultHTTPErrorHandler routes errors that
94+
// implement json.Marshaler through their own encoding.
95+
func (be *BindingError) MarshalJSON() ([]byte, error) {
96+
message := be.Message
97+
if message == "" {
98+
message = http.StatusText(be.Code)
99+
}
100+
return json.Marshal(struct {
101+
Field string `json:"field"`
102+
Message string `json:"message"`
103+
}{
104+
Field: be.Field,
105+
Message: message,
106+
})
107+
}
108+
91109
// ValueBinder provides utility methods for binding query or path parameter to various Go built-in types
92110
type ValueBinder struct {
93111
// ValueFunc is used to get single parameter (first) value from request

binder_error_response_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// SPDX-License-Identifier: MIT
2+
// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors
3+
4+
package echo
5+
6+
import (
7+
"encoding/json"
8+
"net/http"
9+
"net/http/httptest"
10+
"testing"
11+
12+
"github.com/stretchr/testify/assert"
13+
)
14+
15+
// Regression test for #2771: a BindingError returned from a handler must be
16+
// serialized by DefaultHTTPErrorHandler into a structured response that retains
17+
// the field name (and the binder message), not flattened to {"message":"Bad Request"}.
18+
func TestBindingError_serializesToStructuredJSON(t *testing.T) {
19+
e := New()
20+
e.GET("/doc", func(c *Context) error {
21+
var docNum int
22+
return QueryParamsBinder(c).MustInt("docNum", &docNum).BindError()
23+
})
24+
25+
req := httptest.NewRequest(http.MethodGet, "/doc?docNum=abc", nil)
26+
rec := httptest.NewRecorder()
27+
e.ServeHTTP(rec, req)
28+
29+
assert.Equal(t, http.StatusBadRequest, rec.Code)
30+
31+
var body map[string]any
32+
assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
33+
assert.Equal(t, "docNum", body["field"], "binding error response must retain the field name")
34+
assert.Equal(t, "failed to bind field value to int", body["message"], "binding error response must retain the binder message")
35+
}

0 commit comments

Comments
 (0)