From 42410d1900d6cbfc0396fc142496aab089af1867 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sat, 13 Jun 2026 13:00:18 -0700 Subject: [PATCH 1/2] fix(binder): serialize BindingError to structured JSON (#2771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- binder.go | 18 ++++++++++++++++++ binder_error_response_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 binder_error_response_test.go diff --git a/binder.go b/binder.go index 405bdf5dd..18fd02cc7 100644 --- a/binder.go +++ b/binder.go @@ -88,6 +88,24 @@ func (be *BindingError) Error() string { return fmt.Sprintf("%s, field=%s", be.HTTPError.Error(), be.Field) } +// MarshalJSON implements json.Marshaler so that binding errors are serialized into +// a structured response (e.g. {"field":"id","message":"..."}) rather than being +// flattened to a generic message. DefaultHTTPErrorHandler routes errors that +// implement json.Marshaler through their own encoding. +func (be *BindingError) MarshalJSON() ([]byte, error) { + message := be.Message + if message == "" { + message = http.StatusText(be.Code) + } + return json.Marshal(struct { + Field string `json:"field"` + Message string `json:"message"` + }{ + Field: be.Field, + Message: message, + }) +} + // ValueBinder provides utility methods for binding query or path parameter to various Go built-in types type ValueBinder struct { // ValueFunc is used to get single parameter (first) value from request diff --git a/binder_error_response_test.go b/binder_error_response_test.go new file mode 100644 index 000000000..b39d2322b --- /dev/null +++ b/binder_error_response_test.go @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors + +package echo + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +// Regression test for #2771: a BindingError returned from a handler must be +// serialized by DefaultHTTPErrorHandler into a structured response that retains +// the field name (and the binder message), not flattened to {"message":"Bad Request"}. +func TestBindingError_serializesToStructuredJSON(t *testing.T) { + e := New() + e.GET("/doc", func(c *Context) error { + var docNum int + return QueryParamsBinder(c).MustInt("docNum", &docNum).BindError() + }) + + req := httptest.NewRequest(http.MethodGet, "/doc?docNum=abc", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + + var body map[string]any + assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Equal(t, "docNum", body["field"], "binding error response must retain the field name") + assert.Equal(t, "failed to bind field value to int", body["message"], "binding error response must retain the binder message") +} From 08240a331d50e154e8f10ef014661134ac34ddb7 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sat, 13 Jun 2026 13:20:32 -0700 Subject: [PATCH 2/2] docs/test(binder): clarify BindingError JSON + cover empty-message fallback (#2771) - Note on the struct that serialization goes through MarshalJSON (the json tags are documentation only now). - Add a test for the empty-message -> status-text fallback branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- binder.go | 3 +++ binder_error_response_test.go | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/binder.go b/binder.go index 18fd02cc7..7ae3709fa 100644 --- a/binder.go +++ b/binder.go @@ -66,6 +66,9 @@ import ( */ // BindingError represents an error that occurred while binding request data. +// +// Note: JSON serialization is handled by the MarshalJSON method below, not by the +// struct tags (which are kept for documentation). MarshalJSON emits {"field","message"}. type BindingError struct { // Field is the field name where value binding failed Field string `json:"field"` diff --git a/binder_error_response_test.go b/binder_error_response_test.go index b39d2322b..0ed077684 100644 --- a/binder_error_response_test.go +++ b/binder_error_response_test.go @@ -33,3 +33,14 @@ func TestBindingError_serializesToStructuredJSON(t *testing.T) { assert.Equal(t, "docNum", body["field"], "binding error response must retain the field name") assert.Equal(t, "failed to bind field value to int", body["message"], "binding error response must retain the binder message") } + +// When the binding error carries no message, MarshalJSON falls back to the +// status text (mirroring DefaultHTTPErrorHandler's *HTTPError branch). +func TestBindingError_marshalJSON_emptyMessageFallsBackToStatusText(t *testing.T) { + be := &BindingError{Field: "name", HTTPError: &HTTPError{Code: http.StatusBadRequest}} + + b, err := be.MarshalJSON() + + assert.NoError(t, err) + assert.JSONEq(t, `{"field":"name","message":"Bad Request"}`, string(b)) +}