diff --git a/binder.go b/binder.go index 405bdf5dd..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"` @@ -88,6 +91,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..0ed077684 --- /dev/null +++ b/binder_error_response_test.go @@ -0,0 +1,46 @@ +// 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") +} + +// 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)) +}