Repository: gin-gonic/gin
Pull request #2484 (2020-08-29 01:30:33)

Allow bind with a map[string]string
It's always been possible to bind using an object of type `map[string]string`. For example:

```go
obj := make(map[string]string)
c.Bind(&obj)
```

If the request body was formatted as JSON (or YAML), this worked fine. You'd get a dictionary with key-values. For example, with input:

```json
{
    "foo": "bar",
    "hello": "world"
}
```

You'd get in the object:

- `obj["foo"] = "bar"`
- `obj["hello"] = "world"`

However, this did **not** work if the request's body was form-encoded (`application/x-www-form-urlencoded` or `multipart/form-data`).

This PR adds support for passing a pointer to `map[string]string` to `Bind` that works with: Forms (POST Forms, Multipart forms), JSON, YAML. It also works with query strings. (However, it does NOT work with XML, because the XML parser doesn't allow that).

**Why is this useful?** Sometimes, there are situations in which I'm expecting a key-value dictionary, but I do not know what keys I'll receive.

This PR includes tests.

codecov[bot]: # [Codecov](https://codecov.io/gh/gin-gonic/gin/pull/2484?src=pr&el=h1) Report
> Merging [#2484](https://codecov.io/gh/gin-gonic/gin/pull/2484?src=pr&el=desc) into [master](https://codecov.io/gh/gin-gonic/gin/commit/b94d23d1b48d7b5078035ddbab0c3b5df138b827?el=desc) will **decrease** coverage by `0.32%`.
> The diff coverage is `68.00%`.

[![Impacted file tree graph](https://codecov.io/gh/gin-gonic/gin/pull/2484/graphs/tree.svg?width=650&height=150&src=pr&token=Qpqxhwery8)](https://codecov.io/gh/gin-gonic/gin/pull/2484?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master    #2484      +/-   ##
==========================================
- Coverage   98.72%   98.40%   -0.33%     
==========================================
  Files          41       41              
  Lines        2359     2384      +25     
==========================================
+ Hits         2329     2346      +17     
- Misses         17       22       +5     
- Partials       13       16       +3     
```


| [Impacted Files](https://codecov.io/gh/gin-gonic/gin/pull/2484?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [binding/form\_mapping.go](https://codecov.io/gh/gin-gonic/gin/pull/2484/diff?src=pr&el=tree#diff-YmluZGluZy9mb3JtX21hcHBpbmcuZ28=) | `96.59% <68.00%> (-3.41%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/gin-gonic/gin/pull/2484?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/gin-gonic/gin/pull/2484?src=pr&el=footer). Last update [b94d23d...d723c76](https://codecov.io/gh/gin-gonic/gin/pull/2484?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

ItalyPaleAle: Hi is there any update on this? @thinkerou or someone else?
ItalyPaleAle: Ping on this 😄 @thinkerou 

Patch:
diff --git a/binding/form_mapping.go b/binding/form_mapping.go
index f0913ea..2f4e45b 100644
--- a/binding/form_mapping.go
+++ b/binding/form_mapping.go
@@ -29,6 +29,21 @@ func mapForm(ptr interface{}, form map[string][]string) error {
 var emptyField = reflect.StructField{}
 
 func mapFormByTag(ptr interface{}, form map[string][]string, tag string) error {
+	// Check if ptr is a map
+	ptrVal := reflect.ValueOf(ptr)
+	var pointed interface{}
+	if ptrVal.Kind() == reflect.Ptr {
+		ptrVal = ptrVal.Elem()
+		pointed = ptrVal.Interface()
+	}
+	if ptrVal.Kind() == reflect.Map &&
+		ptrVal.Type().Key().Kind() == reflect.String {
+		if pointed != nil {
+			ptr = pointed
+		}
+		return setFormMap(ptr, form)
+	}
+
 	return mappingByPtr(ptr, formSource(form), tag)
 }
 
@@ -349,3 +364,29 @@ func head(str, sep string) (head string, tail string) {
 	}
 	return str[:idx], str[idx+len(sep):]
 }
+
+func setFormMap(ptr interface{}, form map[string][]string) error {
+	el := reflect.TypeOf(ptr).Elem()
+
+	if el.Kind() == reflect.Slice {
+		ptrMap, ok := ptr.(map[string][]string)
+		if !ok {
+			return errors.New("cannot convert to map slices of strings")
+		}
+		for k, v := range form {
+			ptrMap[k] = v
+		}
+
+		return nil
+	}
+
+	ptrMap, ok := ptr.(map[string]string)
+	if !ok {
+		return errors.New("cannot convert to map of strings")
+	}
+	for k, v := range form {
+		ptrMap[k] = v[len(v)-1] // pick last
+	}
+
+	return nil
+}


Test patch:
diff --git a/binding/binding_test.go b/binding/binding_test.go
index 4424bab..c354be9 100644
--- a/binding/binding_test.go
+++ b/binding/binding_test.go
@@ -13,6 +13,7 @@ import (
 	"mime/multipart"
 	"net/http"
 	"os"
+	"reflect"
 	"strconv"
 	"strings"
 	"testing"
@@ -200,6 +201,12 @@ func TestBindingJSONDisallowUnknownFields(t *testing.T) {
 		`{"foo": "bar"}`, `{"foo": "bar", "what": "this"}`)
 }
 
+func TestBindingJSONStringMap(t *testing.T) {
+	testBodyBindingStringMap(t, JSON,
+		"/", "/",
+		`{"foo": "bar", "hello": "world"}`, `{"num": 2}`)
+}
+
 func TestBindingForm(t *testing.T) {
 	testFormBinding(t, "POST",
 		"/", "/",
@@ -336,6 +343,37 @@ func TestBindingFormForType(t *testing.T) {
 		"", "", "StructPointer")
 }
 
+func TestBindingFormStringMap(t *testing.T) {
+	testBodyBindingStringMap(t, Form,
+		"/", "",
+		`foo=bar&hello=world`, "")
+	// Should pick the last value
+	testBodyBindingStringMap(t, Form,
+		"/", "",
+		`foo=something&foo=bar&hello=world`, "")
+}
+
+func TestBindingFormStringSliceMap(t *testing.T) {
+	obj := make(map[string][]string)
+	req := requestWithBody("POST", "/", "foo=something&foo=bar&hello=world")
+	req.Header.Add("Content-Type", MIMEPOSTForm)
+	err := Form.Bind(req, &obj)
+	assert.NoError(t, err)
+	assert.NotNil(t, obj)
+	assert.Len(t, obj, 2)
+	target := map[string][]string{
+		"foo":   {"something", "bar"},
+		"hello": {"world"},
+	}
+	assert.True(t, reflect.DeepEqual(obj, target))
+
+	objInvalid := make(map[string][]int)
+	req = requestWithBody("POST", "/", "foo=something&foo=bar&hello=world")
+	req.Header.Add("Content-Type", MIMEPOSTForm)
+	err = Form.Bind(req, &objInvalid)
+	assert.Error(t, err)
+}
+
 func TestBindingQuery(t *testing.T) {
 	testQueryBinding(t, "POST",
 		"/?foo=bar&bar=foo", "/",
@@ -366,6 +404,28 @@ func TestBindingQueryBoolFail(t *testing.T) {
 		"bool_foo=unused", "")
 }
 
+func TestBindingQueryStringMap(t *testing.T) {
+	b := Query
+
+	obj := make(map[string]string)
+	req := requestWithBody("GET", "/?foo=bar&hello=world", "")
+	err := b.Bind(req, &obj)
+	assert.NoError(t, err)
+	assert.NotNil(t, obj)
+	assert.Len(t, obj, 2)
+	assert.Equal(t, "bar", obj["foo"])
+	assert.Equal(t, "world", obj["hello"])
+
+	obj = make(map[string]string)
+	req = requestWithBody("GET", "/?foo=bar&foo=2&hello=world", "") // should pick last
+	err = b.Bind(req, &obj)
+	assert.NoError(t, err)
+	assert.NotNil(t, obj)
+	assert.Len(t, obj, 2)
+	assert.Equal(t, "2", obj["foo"])
+	assert.Equal(t, "world", obj["hello"])
+}
+
 func TestBindingXML(t *testing.T) {
 	testBodyBinding(t,
 		XML, "xml",
@@ -387,6 +447,13 @@ func TestBindingYAML(t *testing.T) {
 		`foo: bar`, `bar: foo`)
 }
 
+func TestBindingYAMLStringMap(t *testing.T) {
+	// YAML is a superset of JSON, so the test below is JSON (to avoid newlines)
+	testBodyBindingStringMap(t, YAML,
+		"/", "/",
+		`{"foo": "bar", "hello": "world"}`, `{"nested": {"foo": "bar"}}`)
+}
+
 func TestBindingYAMLFail(t *testing.T) {
 	testBodyBindingFail(t,
 		YAML, "yaml",
@@ -1114,6 +1181,32 @@ func testBodyBinding(t *testing.T, b Binding, name, path, badPath, body, badBody
 	assert.Error(t, err)
 }
 
+func testBodyBindingStringMap(t *testing.T, b Binding, path, badPath, body, badBody string) {
+	obj := make(map[string]string)
+	req := requestWithBody("POST", path, body)
+	if b.Name() == "form" {
+		req.Header.Add("Content-Type", MIMEPOSTForm)
+	}
+	err := b.Bind(req, &obj)
+	assert.NoError(t, err)
+	assert.NotNil(t, obj)
+	assert.Len(t, obj, 2)
+	assert.Equal(t, "bar", obj["foo"])
+	assert.Equal(t, "world", obj["hello"])
+
+	if badPath != "" && badBody != "" {
+		obj = make(map[string]string)
+		req = requestWithBody("POST", badPath, badBody)
+		err = b.Bind(req, &obj)
+		assert.Error(t, err)
+	}
+
+	objInt := make(map[string]int)
+	req = requestWithBody("POST", path, body)
+	err = b.Bind(req, &objInt)
+	assert.Error(t, err)
+}
+
 func testBodyBindingUseNumber(t *testing.T, b Binding, name, path, badPath, body, badBody string) {
 	assert.Equal(t, name, b.Name())
 
diff --git a/binding/json_test.go b/binding/json_test.go
index cae4ccc..fbd5c52 100644
--- a/binding/json_test.go
+++ b/binding/json_test.go
@@ -19,3 +19,12 @@ func TestJSONBindingBindBody(t *testing.T) {
 	require.NoError(t, err)
 	assert.Equal(t, "FOO", s.Foo)
 }
+
+func TestJSONBindingBindBodyMap(t *testing.T) {
+	s := make(map[string]string)
+	err := jsonBinding{}.BindBody([]byte(`{"foo": "FOO","hello":"world"}`), &s)
+	require.NoError(t, err)
+	assert.Len(t, s, 2)
+	assert.Equal(t, "FOO", s["foo"])
+	assert.Equal(t, "world", s["hello"])
+}