Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

bind: add support for multipart file binding #3309

Merged
merged 7 commits into from
Feb 25, 2025
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion binder/form.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package binder

import (
"mime/multipart"

"github.com/gofiber/utils/v2"
"github.com/valyala/fasthttp"
)
Expand Down Expand Up @@ -59,7 +61,15 @@
}
}

return parse(b.Name(), out, data)
files := make(map[string][]*multipart.FileHeader)
for key, values := range multipartForm.File {
err = formatBindData(out, files, key, values, b.EnableSplitting, true)
if err != nil {
return err
}

Check warning on line 69 in binder/form.go

View check run for this annotation

Codecov / codecov/patch

binder/form.go#L68-L69

Added lines #L68 - L69 were not covered by tests
}

return parse(b.Name(), out, data, files)
}

// Reset resets the FormBinding binder.
Expand Down
63 changes: 58 additions & 5 deletions binder/form_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package binder

import (
"bytes"
"io"
"mime/multipart"
"testing"

Expand Down Expand Up @@ -98,10 +99,12 @@ func Test_FormBinder_BindMultipart(t *testing.T) {
}

type User struct {
Name string `form:"name"`
Names []string `form:"names"`
Posts []Post `form:"posts"`
Age int `form:"age"`
Avatar *multipart.FileHeader `form:"avatar"`
Name string `form:"name"`
Names []string `form:"names"`
Posts []Post `form:"posts"`
Avatars []*multipart.FileHeader `form:"avatars"`
Age int `form:"age"`
}
var user User

Expand All @@ -118,6 +121,24 @@ func Test_FormBinder_BindMultipart(t *testing.T) {
require.NoError(t, mw.WriteField("posts[1][title]", "post2"))
require.NoError(t, mw.WriteField("posts[2][title]", "post3"))

writer, err := mw.CreateFormFile("avatar", "avatar.txt")
require.NoError(t, err)

_, err = writer.Write([]byte("avatar"))
require.NoError(t, err)

writer, err = mw.CreateFormFile("avatars", "avatar1.txt")
require.NoError(t, err)

_, err = writer.Write([]byte("avatar1"))
require.NoError(t, err)

writer, err = mw.CreateFormFile("avatars", "avatar2.txt")
require.NoError(t, err)

_, err = writer.Write([]byte("avatar2"))
require.NoError(t, err)

require.NoError(t, mw.Close())

req.Header.SetContentType(mw.FormDataContentType())
Expand All @@ -127,7 +148,7 @@ func Test_FormBinder_BindMultipart(t *testing.T) {
fasthttp.ReleaseRequest(req)
})

err := b.Bind(req, &user)
err = b.Bind(req, &user)

require.NoError(t, err)
require.Equal(t, "john", user.Name)
Expand All @@ -139,6 +160,38 @@ func Test_FormBinder_BindMultipart(t *testing.T) {
require.Equal(t, "post1", user.Posts[0].Title)
require.Equal(t, "post2", user.Posts[1].Title)
require.Equal(t, "post3", user.Posts[2].Title)

require.NotNil(t, user.Avatar)
require.Equal(t, "avatar.txt", user.Avatar.Filename)
require.Equal(t, "application/octet-stream", user.Avatar.Header.Get("Content-Type"))

file, err := user.Avatar.Open()
require.NoError(t, err)

content, err := io.ReadAll(file)
require.NoError(t, err)
require.Equal(t, "avatar", string(content))

require.Len(t, user.Avatars, 2)
require.Equal(t, "avatar1.txt", user.Avatars[0].Filename)
require.Equal(t, "application/octet-stream", user.Avatars[0].Header.Get("Content-Type"))

file, err = user.Avatars[0].Open()
require.NoError(t, err)

content, err = io.ReadAll(file)
require.NoError(t, err)
require.Equal(t, "avatar1", string(content))

require.Equal(t, "avatar2.txt", user.Avatars[1].Filename)
require.Equal(t, "application/octet-stream", user.Avatars[1].Header.Get("Content-Type"))

file, err = user.Avatars[1].Open()
require.NoError(t, err)

content, err = io.ReadAll(file)
require.NoError(t, err)
require.Equal(t, "avatar2", string(content))
}

func Benchmark_FormBinder_BindMultipart(b *testing.B) {
Expand Down
33 changes: 26 additions & 7 deletions binder/mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import (
"errors"
"fmt"
"mime/multipart"
"reflect"
"strings"
"sync"
Expand Down Expand Up @@ -69,7 +70,7 @@
}

// parse data into the map or struct
func parse(aliasTag string, out any, data map[string][]string) error {
func parse(aliasTag string, out any, data map[string][]string, files ...map[string][]*multipart.FileHeader) error {
ptrVal := reflect.ValueOf(out)

// Get pointer value
Expand All @@ -83,19 +84,19 @@
}

// Parse into the struct
return parseToStruct(aliasTag, out, data)
return parseToStruct(aliasTag, out, data, files...)
}

// Parse data into the struct with gorilla/schema
func parseToStruct(aliasTag string, out any, data map[string][]string) error {
func parseToStruct(aliasTag string, out any, data map[string][]string, files ...map[string][]*multipart.FileHeader) error {
// Get decoder from pool
schemaDecoder := decoderPoolMap[aliasTag].Get().(*schema.Decoder) //nolint:errcheck,forcetypeassert // not needed
defer decoderPoolMap[aliasTag].Put(schemaDecoder)

// Set alias tag
schemaDecoder.SetAliasTag(aliasTag)

if err := schemaDecoder.Decode(out, data); err != nil {
if err := schemaDecoder.Decode(out, data, files...); err != nil {
return fmt.Errorf("bind: %w", err)
}

Expand Down Expand Up @@ -250,7 +251,7 @@
return content
}

func formatBindData[T any](out any, data map[string][]string, key string, value T, enableSplitting, supportBracketNotation bool) error { //nolint:revive // it's okay
func formatBindData[T, K any](out any, data map[string][]T, key string, value K, enableSplitting, supportBracketNotation bool) error { //nolint:revive // it's okay
var err error
if supportBracketNotation && strings.Contains(key, "[") {
key, err = parseParamSquareBrackets(key)
Expand All @@ -261,10 +262,28 @@

switch v := any(value).(type) {
case string:
assignBindData(out, data, key, v, enableSplitting)
dataMap, ok := any(data).(map[string][]string)
if !ok {
return fmt.Errorf("unsupported value type: %T", value)
}

Check warning on line 268 in binder/mapping.go

View check run for this annotation

Codecov / codecov/patch

binder/mapping.go#L267-L268

Added lines #L267 - L268 were not covered by tests

assignBindData(out, dataMap, key, v, enableSplitting)
case []string:
dataMap, ok := any(data).(map[string][]string)
if !ok {
return fmt.Errorf("unsupported value type: %T", value)
}

Check warning on line 275 in binder/mapping.go

View check run for this annotation

Codecov / codecov/patch

binder/mapping.go#L274-L275

Added lines #L274 - L275 were not covered by tests

for _, val := range v {
assignBindData(out, dataMap, key, val, enableSplitting)
}
case []*multipart.FileHeader:
for _, val := range v {
assignBindData(out, data, key, val, enableSplitting)
valT, ok := any(val).(T)
if !ok {
return fmt.Errorf("unsupported value type: %T", value)
}

Check warning on line 285 in binder/mapping.go

View check run for this annotation

Codecov / codecov/patch

binder/mapping.go#L284-L285

Added lines #L284 - L285 were not covered by tests
data[key] = append(data[key], valT)
}
default:
return fmt.Errorf("unsupported value type: %T", value)
Expand Down
32 changes: 32 additions & 0 deletions docs/api/bind.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,38 @@ curl -X POST -H "Content-Type: application/x-www-form-urlencoded" --data "name=j
curl -X POST -H "Content-Type: multipart/form-data" -F "name=john" -F "pass=doe" localhost:3000
```

:::info
If you need to bind multipart file, you can use `*multipart.FileHeader`, `*[]*multipart.FileHeader` or `[]*multipart.FileHeader` as a field type.
:::

```go title="Example"
type Person struct {
Name string `form:"name"`
Pass string `form:"pass"`
Avatar *multipart.FileHeader `form:"avatar"`
}

app.Post("/", func(c fiber.Ctx) error {
p := new(Person)

if err := c.Bind().Form(p); err != nil {
return err
}

log.Println(p.Name) // john
log.Println(p.Pass) // doe
log.Println(p.Avatar.Filename) // file.txt

// ...
})
```

Run tests with the following `curl` command:

```bash
curl -X POST -H "Content-Type: multipart/form-data" -F "name=john" -F "pass=doe" -F 'avatar=@filename' localhost:3000
```

### JSON

Binds the request JSON body to a struct.
Expand Down
1 change: 1 addition & 0 deletions docs/whats_new.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,7 @@ Fiber v3 introduces a new binding mechanism that simplifies the process of bindi
- Unified binding from URL parameters, query parameters, headers, and request bodies.
- Support for custom binders and constraints.
- Improved error handling and validation.
- Support multipart file binding for `*multipart.FileHeader`, `*[]*multipart.FileHeader`, and `[]*multipart.FileHeader` field types.

<details>
<summary>Example</summary>
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/gofiber/fiber/v3
go 1.23

require (
github.com/gofiber/schema v1.2.0
github.com/gofiber/schema v1.3.0
github.com/gofiber/utils/v2 v2.0.0-beta.7
github.com/google/uuid v1.6.0
github.com/mattn/go-colorable v0.1.14
Expand All @@ -24,7 +24,7 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gofiber/schema v1.2.0 h1:j+ZRrNnUa/0ZuWrn/6kAtAufEr4jCJ+JuTURAMxNSZg=
github.com/gofiber/schema v1.2.0/go.mod h1:YYwj01w3hVfaNjhtJzaqetymL56VW642YS3qZPhuE6c=
github.com/gofiber/schema v1.3.0 h1:K3F3wYzAY+aivfCCEHPufCthu5/13r/lzp1nuk6mr3Q=
github.com/gofiber/schema v1.3.0/go.mod h1:YYwj01w3hVfaNjhtJzaqetymL56VW642YS3qZPhuE6c=
github.com/gofiber/utils/v2 v2.0.0-beta.7 h1:NnHFrRHvhrufPABdWajcKZejz9HnCWmT/asoxRsiEbQ=
github.com/gofiber/utils/v2 v2.0.0-beta.7/go.mod h1:J/M03s+HMdZdvhAeyh76xT72IfVqBzuz/OJkrMa7cwU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
Expand Down Expand Up @@ -38,6 +40,8 @@ golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
Expand Down
Loading