[#2763] fixed multipart/form-data array value bind

This commit is contained in:
Gani Georgiev 2023-06-26 12:30:51 +03:00
parent bd94940eef
commit 9cba6ac386
2 changed files with 101 additions and 24 deletions

View File

@ -5,9 +5,11 @@ import (
"encoding/json" "encoding/json"
"io" "io"
"net/http" "net/http"
"reflect"
"strings" "strings"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/spf13/cast"
) )
// BindBody binds request body content to i. // BindBody binds request body content to i.
@ -28,10 +30,12 @@ func BindBody(c echo.Context, i interface{}) error {
return echo.NewHTTPErrorWithInternal(http.StatusBadRequest, err, err.Error()) return echo.NewHTTPErrorWithInternal(http.StatusBadRequest, err, err.Error())
} }
return nil return nil
default: case strings.HasPrefix(ctype, echo.MIMEApplicationForm), strings.HasPrefix(ctype, echo.MIMEMultipartForm):
// fallback to the default binder return bindFormData(c, i)
return echo.BindBody(c, i)
} }
// fallback to the default binder
return echo.BindBody(c, i)
} }
// CopyJsonBody reads the request body into i by // CopyJsonBody reads the request body into i by
@ -57,3 +61,69 @@ func CopyJsonBody(r *http.Request, i interface{}) error {
return err return err
} }
// This is temp hotfix for properly binding multipart/form-data array values
// when a map destination is used.
//
// It should be replaced with echo.BindBody(c, i) once the issue is fixed in echo.
func bindFormData(c echo.Context, i interface{}) error {
if i == nil {
return nil
}
values, err := c.FormValues()
if err != nil {
return echo.NewHTTPErrorWithInternal(http.StatusBadRequest, err, err.Error())
}
if len(values) == 0 {
return nil
}
rt := reflect.TypeOf(i).Elem()
// map
if rt.Kind() == reflect.Map {
rv := reflect.ValueOf(i).Elem()
for k, v := range values {
if total := len(v); total == 1 {
rv.SetMapIndex(reflect.ValueOf(k), reflect.ValueOf(normalizeMultipartValue(v[0])))
} else {
normalized := make([]any, total)
for i, vItem := range v {
normalized[i] = vItem
}
rv.SetMapIndex(reflect.ValueOf(k), reflect.ValueOf(normalized))
}
}
return nil
}
// anything else
return echo.BindBody(c, i)
}
// In order to support more seamlessly both json and multipart/form-data requests,
// the following normalization rules are applied for plain multipart string values:
// - "true" is converted to the json `true`
// - "false" is converted to the json `false`
// - numeric (non-scientific) strings are converted to json number
// - any other string (empty string too) is left as it is
func normalizeMultipartValue(raw string) any {
switch raw {
case "true":
return true
case "false":
return false
default:
if raw[0] >= '0' && raw[0] <= '9' {
if v, err := cast.ToFloat64E(raw); err == nil {
return v
}
}
return raw
}
}

View File

@ -1,6 +1,7 @@
package rest_test package rest_test
import ( import (
"encoding/json"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@ -16,31 +17,40 @@ func TestBindBody(t *testing.T) {
scenarios := []struct { scenarios := []struct {
body io.Reader body io.Reader
contentType string contentType string
result map[string]string expectBody string
expectError bool expectError bool
}{ }{
{ {
strings.NewReader(""), strings.NewReader(""),
echo.MIMEApplicationJSON, echo.MIMEApplicationJSON,
map[string]string{}, `{}`,
false, false,
}, },
{ {
strings.NewReader(`{"test":"invalid`), strings.NewReader(`{"test":"invalid`),
echo.MIMEApplicationJSON, echo.MIMEApplicationJSON,
map[string]string{}, `{}`,
true, true,
}, },
{ {
strings.NewReader(`{"test":"test123"}`), strings.NewReader(`{"test":123}`),
echo.MIMEApplicationJSON, echo.MIMEApplicationJSON,
map[string]string{"test": "test123"}, `{"test":123}`,
false, false,
}, },
{ {
strings.NewReader(url.Values{"test": []string{"test123"}}.Encode()), strings.NewReader(
url.Values{
"string": []string{"str"},
"stings": []string{"str1", "str2"},
"number": []string{"123"},
"numbers": []string{"123", "456"},
"bool": []string{"true"},
"bools": []string{"true", "false"},
}.Encode(),
),
echo.MIMEApplicationForm, echo.MIMEApplicationForm,
map[string]string{"test": "test123"}, `{"bool":true,"bools":["true","false"],"number":123,"numbers":["123","456"],"stings":["str1","str2"],"string":"str"}`,
false, false,
}, },
} }
@ -52,25 +62,22 @@ func TestBindBody(t *testing.T) {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
c := e.NewContext(req, rec) c := e.NewContext(req, rec)
result := map[string]string{} data := map[string]any{}
err := rest.BindBody(c, &result) err := rest.BindBody(c, &data)
if err == nil && scenario.expectError { hasErr := err != nil
t.Errorf("(%d) Expected error, got nil", i) if hasErr != scenario.expectError {
t.Errorf("[%d] Expected hasErr %v, got %v", i, scenario.expectError, hasErr)
} }
if err != nil && !scenario.expectError { rawBody, err := json.Marshal(data)
t.Errorf("(%d) Expected nil, got error %v", i, err) if err != nil {
t.Errorf("[%d] Failed to marshal binded body: %v", i, err)
} }
if len(result) != len(scenario.result) { if scenario.expectBody != string(rawBody) {
t.Errorf("(%d) Expected %v, got %v", i, scenario.result, result) t.Errorf("[%d] Expected body \n%s, \ngot \n%s", i, scenario.expectBody, rawBody)
}
for k, v := range result {
if sv, ok := scenario.result[k]; !ok || v != sv {
t.Errorf("(%d) Expected value %v for key %s, got %v", i, sv, k, v)
}
} }
} }
} }