Skip to content

Commit

Permalink
bind: add support for multipart file binding
Browse files Browse the repository at this point in the history
  • Loading branch information
efectn committed Feb 13, 2025
1 parent fb3d374 commit ec7c89a
Show file tree
Hide file tree
Showing 5 changed files with 115 additions and 13 deletions.
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 @@ func (b *FormBinding) bindMultipart(req *fasthttp.Request, out any) error {
}
}

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 {

Check failure on line 101 in binder/form_test.go

View workflow job for this annotation

GitHub Actions / lint

fieldalignment: struct with 88 pointer bytes could be 80 (govet)
Name string `form:"name"`
Names []string `form:"names"`
Posts []Post `form:"posts"`
Age int `form:"age"`
Name string `form:"name"`
Names []string `form:"names"`
Posts []Post `form:"posts"`
Age int `form:"age"`
Avatar *multipart.FileHeader `form:"avatar"`
Avatars []*multipart.FileHeader `form:"avatars"`
}
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
19 changes: 12 additions & 7 deletions binder/mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package binder
import (
"errors"
"fmt"
"mime/multipart"
"reflect"
"strings"
"sync"
Expand Down Expand Up @@ -69,7 +70,7 @@ func init() {
}

// 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 @@ func parse(aliasTag string, out any, data map[string][]string) error {
}

// 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 @@ func FilterFlags(content string) string {
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,14 @@ func formatBindData[T any](out any, data map[string][]string, key string, value

switch v := any(value).(type) {
case string:
assignBindData(out, data, key, v, enableSplitting)
assignBindData(out, any(data).(map[string][]string), key, v, enableSplitting)

Check failure on line 265 in binder/mapping.go

View workflow job for this annotation

GitHub Actions / lint

Error return value is not checked (errcheck)

Check failure on line 265 in binder/mapping.go

View workflow job for this annotation

GitHub Actions / lint

type assertion must be checked (forcetypeassert)
case []string:
for _, val := range v {
assignBindData(out, data, key, val, enableSplitting)
assignBindData(out, any(data).(map[string][]string), key, val, enableSplitting)

Check failure on line 268 in binder/mapping.go

View workflow job for this annotation

GitHub Actions / lint

Error return value is not checked (errcheck)

Check failure on line 268 in binder/mapping.go

View workflow job for this annotation

GitHub Actions / lint

type assertion must be checked (forcetypeassert)
}
case []*multipart.FileHeader:
for _, val := range v {
data[key] = append(data[key], any(val).(T))

Check failure on line 272 in binder/mapping.go

View workflow job for this annotation

GitHub Actions / lint

type assertion must be checked (forcetypeassert)

Check failure on line 272 in binder/mapping.go

View workflow job for this annotation

GitHub Actions / lint

Error return value is not checked (errcheck)
}
default:
return fmt.Errorf("unsupported value type: %T", value)
Expand Down
33 changes: 33 additions & 0 deletions docs/api/bind.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,39 @@ 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
```


Check failure on line 155 in docs/api/bind.md

View workflow job for this annotation

GitHub Actions / markdownlint

Multiple consecutive blank lines

docs/api/bind.md:155 MD012/no-multiple-blanks Multiple consecutive blank lines [Expected: 1; Actual: 2] https://github.com/DavidAnson/markdownlint/blob/v0.37.4/doc/md012.md
### 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

0 comments on commit ec7c89a

Please sign in to comment.