Repository: gin-gonic/gin
Pull request #4020 (2024-07-30 14:34:24)

docs: fix route group example code
I have identified a couple of issues with the current example code for route groups in the documentation. Below are the details of these issues and the proposed fix.

## Issues

In the current example code, there is a risk of making a typo that can lead to unintended behavior:


```diff
  func main() {
    router := gin.Default()

    // Simple group: v1
    v1 := router.Group("/v1")
    {
      v1.POST("/login", loginEndpoint)
      v1.POST("/submit", submitEndpoint)
      v1.POST("/read", readEndpoint)
    }

    // Simple group: v2
    v2 := router.Group("/v2")
    {
      v2.POST("/login", loginEndpoint)
      v2.POST("/submit", submitEndpoint)
-     v2.POST("/read", readEndpoint)
+     v1.POST("/read", readEndpoint)
    }

    router.Run(":8080")
  }
```

In the code above, the intention is to add endpoints to the v2 group, but due to a typo, the endpoint might accidentally be added to the v1 group instead. This issue is problematic because the code will compile and run without errors, potentially leading to confusion for beginners.

## Proposed Fix

By making the following changes, we can ensure that route groups are clearly separated and reduce the risk of accidentally adding endpoints to the wrong group:


```diff
  func main() {
    router := gin.Default()

    // Simple group: v1
-   v1 := router.Group("/v1")
    {
+     v1 := router.Group("/v1")
      v1.POST("/login", loginEndpoint)
      v1.POST("/submit", submitEndpoint)
      v1.POST("/read", readEndpoint)
    }

    // Simple group: v2
-   v2 := router.Group("/v2")
    {
+     v2 := router.Group("/v2")
      v2.POST("/login", loginEndpoint)
      v2.POST("/submit", submitEndpoint)
      v2.POST("/read", readEndpoint)
    }

    router.Run(":8080")
  }
```

This change will clearly separate the endpoints for each version, helping to prevent the mistake of adding endpoints to the wrong version.

I believe that incorporating this fix into the documentation will help users who are new to Gin avoid this issue. If this pull request is accepted, I also plan to submit a similar update to the gin-gonic/website repository.

Thank you for considering this request.

-----

This version provides a clear explanation of the problem and the proposed solution, making it easy for reviewers to understand the importance of the change and its impact.


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/gin-gonic/gin/pull/4020?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 99.06%. Comparing base [(`3dc1cd6`)](https://app.codecov.io/gh/gin-gonic/gin/commit/3dc1cd6572b4e3a0cd170a15debe546c2c72294f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) to head [(`113676a`)](https://app.codecov.io/gh/gin-gonic/gin/commit/113676a4783b7e7ba3ae724df666df3f78cf7f8a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic).
> Report is 67 commits behind head on master.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #4020      +/-   ##
==========================================
- Coverage   99.21%   99.06%   -0.16%     
==========================================
  Files          42       44       +2     
  Lines        3182     2772     -410     
==========================================
- Hits         3157     2746     -411     
+ Misses         17       15       -2     
- Partials        8       11       +3     
```

| [Flag](https://app.codecov.io/gh/gin-gonic/gin/pull/4020/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | Coverage Δ | |
|---|---|---|
| [](https://app.codecov.io/gh/gin-gonic/gin/pull/4020/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `?` | |
| [-tags "sonic avx"](https://app.codecov.io/gh/gin-gonic/gin/pull/4020/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `99.05% <ø> (?)` | |
| [-tags go_json](https://app.codecov.io/gh/gin-gonic/gin/pull/4020/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `99.05% <ø> (?)` | |
| [-tags nomsgpack](https://app.codecov.io/gh/gin-gonic/gin/pull/4020/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `99.04% <ø> (?)` | |
| [go-1.18](https://app.codecov.io/gh/gin-gonic/gin/pull/4020/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `?` | |
| [go-1.19](https://app.codecov.io/gh/gin-gonic/gin/pull/4020/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `?` | |
| [go-1.20](https://app.codecov.io/gh/gin-gonic/gin/pull/4020/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `?` | |
| [go-1.21](https://app.codecov.io/gh/gin-gonic/gin/pull/4020/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `99.06% <ø> (-0.16%)` | :arrow_down: |
| [go-1.22](https://app.codecov.io/gh/gin-gonic/gin/pull/4020/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `99.06% <ø> (?)` | |
| [macos-latest](https://app.codecov.io/gh/gin-gonic/gin/pull/4020/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `99.06% <ø> (-0.16%)` | :arrow_down: |
| [ubuntu-latest](https://app.codecov.io/gh/gin-gonic/gin/pull/4020/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `99.06% <ø> (-0.16%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/gin-gonic/gin/pull/4020?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic).


Patch:
diff --git a/binding/form_mapping.go b/binding/form_mapping.go
index 33389b2..a84536f 100644
--- a/binding/form_mapping.go
+++ b/binding/form_mapping.go
@@ -182,6 +182,38 @@ func trySetCustom(val string, value reflect.Value) (isSet bool, err error) {
 	return false, nil
 }
 
+func trySplit(vs []string, field reflect.StructField) (newVs []string, err error) {
+	cfTag := field.Tag.Get("collection_format")
+	if cfTag == "" || cfTag == "multi" {
+		return vs, nil
+	}
+
+	var sep string
+	switch cfTag {
+	case "csv":
+		sep = ","
+	case "ssv":
+		sep = " "
+	case "tsv":
+		sep = "\t"
+	case "pipes":
+		sep = "|"
+	default:
+		return vs, fmt.Errorf("%s is not supported in the collection_format. (csv, ssv, pipes)", cfTag)
+	}
+
+	totalLength := 0
+	for _, v := range vs {
+		totalLength += strings.Count(v, sep) + 1
+	}
+	newVs = make([]string, 0, totalLength)
+	for _, v := range vs {
+		newVs = append(newVs, strings.Split(v, sep)...)
+	}
+
+	return newVs, nil
+}
+
 func setByForm(value reflect.Value, field reflect.StructField, form map[string][]string, tagValue string, opt setOptions) (isSet bool, err error) {
 	vs, ok := form[tagValue]
 	if !ok && !opt.isDefaultExists {
@@ -198,6 +230,10 @@ func setByForm(value reflect.Value, field reflect.StructField, form map[string][
 			return ok, err
 		}
 
+		if vs, err = trySplit(vs, field); err != nil {
+			return false, err
+		}
+
 		return true, setSlice(vs, value, field)
 	case reflect.Array:
 		if !ok {
@@ -208,6 +244,10 @@ func setByForm(value reflect.Value, field reflect.StructField, form map[string][
 			return ok, err
 		}
 
+		if vs, err = trySplit(vs, field); err != nil {
+			return false, err
+		}
+
 		if len(vs) != value.Len() {
 			return false, fmt.Errorf("%q is not valid value for %s", vs, value.Type().String())
 		}
@@ -221,6 +261,9 @@ func setByForm(value reflect.Value, field reflect.StructField, form map[string][
 
 		if len(vs) > 0 {
 			val = vs[0]
+			if val == "" {
+				val = opt.defaultValue
+			}
 		}
 		if ok, err := trySetCustom(val, value); ok {
 			return ok, err
diff --git a/docs/doc.md b/docs/doc.md
index 5136640..875dd61 100644
--- a/docs/doc.md
+++ b/docs/doc.md
@@ -26,6 +26,7 @@
   - [Custom Validators](#custom-validators)
   - [Only Bind Query String](#only-bind-query-string)
   - [Bind Query String or Post Data](#bind-query-string-or-post-data)
+  - [Collection format for arrays](#collection-format-for-arrays)
   - [Bind Uri](#bind-uri)
   - [Bind custom unmarshaler](#bind-custom-unmarshaler)
   - [Bind Header](#bind-header)
@@ -338,16 +339,16 @@ func main() {
   router := gin.Default()
 
   // Simple group: v1
-  v1 := router.Group("/v1")
   {
+    v1 := router.Group("/v1")
     v1.POST("/login", loginEndpoint)
     v1.POST("/submit", submitEndpoint)
     v1.POST("/read", readEndpoint)
   }
 
   // Simple group: v2
-  v2 := router.Group("/v2")
   {
+    v2 := router.Group("/v2")
     v2.POST("/login", loginEndpoint)
     v2.POST("/submit", submitEndpoint)
     v2.POST("/read", readEndpoint)
@@ -861,6 +862,59 @@ Test it with:
 curl -X GET "localhost:8085/testing?name=appleboy&address=xyz&birthday=1992-03-15&createTime=1562400033000000123&unixTime=1562400033"
 ```
 
+#### Collection format for arrays
+
+| Format          | Description                                               | Example                 |
+| --------------- | --------------------------------------------------------- | ----------------------- |
+| multi (default) | Multiple parameter instances rather than multiple values. | key=foo&key=bar&key=baz |
+| csv             | Comma-separated values.                                   | foo,bar,baz             |
+| ssv             | Space-separated values.                                   | foo bar baz             |
+| tsv             | Tab-separated values.                                     | "foo\tbar\tbaz"         |
+| pipes           | Pipe-separated values.                                    | foo\|bar\|baz           |
+
+```go
+package main
+
+import (
+	"log"
+	"time"
+	"github.com/gin-gonic/gin"
+)
+
+type Person struct {
+	Name       string    `form:"name"`
+	Addresses  []string  `form:"addresses" collection_format:"csv"`
+	Birthday   time.Time `form:"birthday" time_format:"2006-01-02" time_utc:"1"`
+	CreateTime time.Time `form:"createTime" time_format:"unixNano"`
+	UnixTime   time.Time `form:"unixTime" time_format:"unix"`
+}
+
+func main() {
+	route := gin.Default()
+	route.GET("/testing", startPage)
+	route.Run(":8085")
+}
+func startPage(c *gin.Context) {
+	var person Person
+	// If `GET`, only `Form` binding engine (`query`) used.
+	// If `POST`, first checks the `content-type` for `JSON` or `XML`, then uses `Form` (`form-data`).
+	// See more at https://github.com/gin-gonic/gin/blob/master/binding/binding.go#L48
+        if c.ShouldBind(&person) == nil {
+                log.Println(person.Name)
+                log.Println(person.Addresses)
+                log.Println(person.Birthday)
+                log.Println(person.CreateTime)
+                log.Println(person.UnixTime)
+        }
+	c.String(200, "Success")
+}
+```
+
+Test it with:
+```sh
+$ curl -X GET "localhost:8085/testing?name=appleboy&addresses=foo,bar&birthday=1992-03-15&createTime=1562400033000000123&unixTime=1562400033"
+```
+
 ### Bind Uri
 
 See the [detail information](https://github.com/gin-gonic/gin/issues/846).


Test patch:
diff --git a/binding/form_mapping_test.go b/binding/form_mapping_test.go
index 9ea0895..8cf7465 100644
--- a/binding/form_mapping_test.go
+++ b/binding/form_mapping_test.go
@@ -69,6 +69,7 @@ func TestMappingBaseTypes(t *testing.T) {
 
 func TestMappingDefault(t *testing.T) {
 	var s struct {
+		Str   string `form:",default=defaultVal"`
 		Int   int    `form:",default=9"`
 		Slice []int  `form:",default=9"`
 		Array [1]int `form:",default=9"`
@@ -76,6 +77,7 @@ func TestMappingDefault(t *testing.T) {
 	err := mappingByPtr(&s, formSource{}, "form")
 	require.NoError(t, err)
 
+	assert.Equal(t, "defaultVal", s.Str)
 	assert.Equal(t, 9, s.Int)
 	assert.Equal(t, []int{9}, s.Slice)
 	assert.Equal(t, [1]int{9}, s.Array)
@@ -152,6 +154,24 @@ func TestMappingForm(t *testing.T) {
 	assert.Equal(t, 6, s.F)
 }
 
+func TestMappingFormFieldNotSent(t *testing.T) {
+	var s struct {
+		F string `form:"field,default=defVal"`
+	}
+	err := mapForm(&s, map[string][]string{})
+	require.NoError(t, err)
+	assert.Equal(t, "defVal", s.F)
+}
+
+func TestMappingFormWithEmptyToDefault(t *testing.T) {
+	var s struct {
+		F string `form:"field,default=DefVal"`
+	}
+	err := mapForm(&s, map[string][]string{"field": {""}})
+	require.NoError(t, err)
+	assert.Equal(t, "DefVal", s.F)
+}
+
 func TestMapFormWithTag(t *testing.T) {
 	var s struct {
 		F int `externalTag:"field"`
@@ -264,6 +284,45 @@ func TestMappingArray(t *testing.T) {
 	require.Error(t, err)
 }
 
+func TestMappingCollectionFormat(t *testing.T) {
+	var s struct {
+		SliceMulti []int  `form:"slice_multi" collection_format:"multi"`
+		SliceCsv   []int  `form:"slice_csv" collection_format:"csv"`
+		SliceSsv   []int  `form:"slice_ssv" collection_format:"ssv"`
+		SliceTsv   []int  `form:"slice_tsv" collection_format:"tsv"`
+		SlicePipes []int  `form:"slice_pipes" collection_format:"pipes"`
+		ArrayMulti [2]int `form:"array_multi" collection_format:"multi"`
+		ArrayCsv   [2]int `form:"array_csv" collection_format:"csv"`
+		ArraySsv   [2]int `form:"array_ssv" collection_format:"ssv"`
+		ArrayTsv   [2]int `form:"array_tsv" collection_format:"tsv"`
+		ArrayPipes [2]int `form:"array_pipes" collection_format:"pipes"`
+	}
+	err := mappingByPtr(&s, formSource{
+		"slice_multi": {"1", "2"},
+		"slice_csv":   {"1,2"},
+		"slice_ssv":   {"1 2"},
+		"slice_tsv":   {"1	2"},
+		"slice_pipes": {"1|2"},
+		"array_multi": {"1", "2"},
+		"array_csv":   {"1,2"},
+		"array_ssv":   {"1 2"},
+		"array_tsv":   {"1	2"},
+		"array_pipes": {"1|2"},
+	}, "form")
+	require.NoError(t, err)
+
+	assert.Equal(t, []int{1, 2}, s.SliceMulti)
+	assert.Equal(t, []int{1, 2}, s.SliceCsv)
+	assert.Equal(t, []int{1, 2}, s.SliceSsv)
+	assert.Equal(t, []int{1, 2}, s.SliceTsv)
+	assert.Equal(t, []int{1, 2}, s.SlicePipes)
+	assert.Equal(t, [2]int{1, 2}, s.ArrayMulti)
+	assert.Equal(t, [2]int{1, 2}, s.ArrayCsv)
+	assert.Equal(t, [2]int{1, 2}, s.ArraySsv)
+	assert.Equal(t, [2]int{1, 2}, s.ArrayTsv)
+	assert.Equal(t, [2]int{1, 2}, s.ArrayPipes)
+}
+
 func TestMappingStructField(t *testing.T) {
 	var s struct {
 		J struct {