Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion binding/binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const (
MIMEPlain = "text/plain"
MIMEPOSTForm = "application/x-www-form-urlencoded"
MIMEMultipartPOSTForm = "multipart/form-data"
MIMEMultipartMixed = "multipart/mixed"
MIMEPROTOBUF = "application/x-protobuf"
MIMEMSGPACK = "application/x-msgpack"
MIMEMSGPACK2 = "application/msgpack"
Expand Down Expand Up @@ -110,7 +111,7 @@ func Default(method, contentType string) Binding {
return YAML
case MIMETOML:
return TOML
case MIMEMultipartPOSTForm:
case MIMEMultipartPOSTForm, MIMEMultipartMixed:
return FormMultipart
case MIMEBSON:
return BSON
Expand Down
3 changes: 2 additions & 1 deletion binding/binding_nomsgpack.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const (
MIMEPlain = "text/plain"
MIMEPOSTForm = "application/x-www-form-urlencoded"
MIMEMultipartPOSTForm = "multipart/form-data"
MIMEMultipartMixed = "multipart/mixed"
MIMEPROTOBUF = "application/x-protobuf"
MIMEYAML = "application/x-yaml"
MIMEYAML2 = "application/yaml"
Expand Down Expand Up @@ -102,7 +103,7 @@ func Default(method, contentType string) Binding {
return ProtoBuf
case MIMEYAML, MIMEYAML2:
return YAML
case MIMEMultipartPOSTForm:
case MIMEMultipartPOSTForm, MIMEMultipartMixed:
return FormMultipart
case MIMETOML:
return TOML
Expand Down
36 changes: 36 additions & 0 deletions binding/binding_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ func TestBindingDefault(t *testing.T) {

assert.Equal(t, FormMultipart, Default(http.MethodPost, MIMEMultipartPOSTForm))
assert.Equal(t, FormMultipart, Default(http.MethodPut, MIMEMultipartPOSTForm))
assert.Equal(t, FormMultipart, Default(http.MethodPost, MIMEMultipartMixed))
assert.Equal(t, FormMultipart, Default(http.MethodPut, MIMEMultipartMixed))

assert.Equal(t, ProtoBuf, Default(http.MethodPost, MIMEPROTOBUF))
assert.Equal(t, ProtoBuf, Default(http.MethodPut, MIMEPROTOBUF))
Expand Down Expand Up @@ -593,6 +595,21 @@ func createFormMultipartRequest(t *testing.T) *http.Request {
return req
}

func createFormMultipartMixedRequest(t *testing.T) *http.Request {
boundary := "--testboundary"
body := new(bytes.Buffer)
mw := multipart.NewWriter(body)
defer mw.Close()

require.NoError(t, mw.SetBoundary(boundary))
require.NoError(t, mw.WriteField("foo", "bar"))
require.NoError(t, mw.WriteField("bar", "foo"))
req, err := http.NewRequest(http.MethodPost, "/?foo=getfoo&bar=getbar", body)
require.NoError(t, err)
req.Header.Set("Content-Type", MIMEMultipartMixed+"; boundary="+boundary)
return req
}

func createFormMultipartRequestForMap(t *testing.T) *http.Request {
boundary := "--testboundary"
body := new(bytes.Buffer)
Expand Down Expand Up @@ -631,6 +648,16 @@ func TestBindingFormPost(t *testing.T) {
assert.Equal(t, "foo", obj.Bar)
}

func TestBindingFormMultipartMixed(t *testing.T) {
req := createFormMultipartMixedRequest(t)
var obj FooBarStruct
require.NoError(t, FormMultipart.Bind(req, &obj))

assert.Equal(t, "multipart/form-data", FormMultipart.Name())
assert.Equal(t, "bar", obj.Foo)
assert.Equal(t, "foo", obj.Bar)
}

func TestBindingDefaultValueFormPost(t *testing.T) {
req := createDefaultFormPostRequest(t)
var obj FooDefaultBarStruct
Expand Down Expand Up @@ -932,6 +959,15 @@ func TestFormBindingMultipartFail(t *testing.T) {
require.Error(t, err)
}

func TestFormBindingMultipartMixed(t *testing.T) {
obj := FooBarStruct{}
req := createFormMultipartMixedRequest(t)
err := Form.Bind(req, &obj)
require.NoError(t, err)
assert.Equal(t, "getfoo", obj.Foo)
assert.Equal(t, "getbar", obj.Bar)
}

func TestFormPostBindingFail(t *testing.T) {
b := FormPost
assert.Equal(t, "form-urlencoded", b.Name())
Expand Down
4 changes: 2 additions & 2 deletions binding/form.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func (formBinding) Bind(req *http.Request, obj any) error {
if err := req.ParseForm(); err != nil {
return err
}
if err := req.ParseMultipartForm(defaultMemory); err != nil && !errors.Is(err, http.ErrNotMultipart) {
if err := parseMultipartForm(req, defaultMemory); err != nil && !errors.Is(err, http.ErrNotMultipart) {
return err
}
if err := mapForm(obj, req.Form); err != nil {
Expand Down Expand Up @@ -53,7 +53,7 @@ func (formMultipartBinding) Name() string {
}

func (formMultipartBinding) Bind(req *http.Request, obj any) error {
if err := req.ParseMultipartForm(defaultMemory); err != nil {
if err := parseMultipartForm(req, defaultMemory); err != nil {
return err
}
if err := mappingByPtr(obj, (*multipartRequest)(req), "form"); err != nil {
Expand Down
56 changes: 56 additions & 0 deletions binding/multipart_form.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright 2026 Gin Core Team. All rights reserved.
// Use of this source code is governed by a MIT style
// license that can be found in the LICENSE file.

package binding

import (
"errors"
"mime"
"net/http"
"net/url"
)

func parseMultipartForm(req *http.Request, maxMemory int64) error {
err := req.ParseMultipartForm(maxMemory)
if err == nil || !errors.Is(err, http.ErrNotMultipart) {
return err
}

mediaType, _, parseErr := mime.ParseMediaType(req.Header.Get("Content-Type"))
if parseErr != nil || mediaType != MIMEMultipartMixed {
return err
}

reader, readerErr := req.MultipartReader()
if readerErr != nil {
return readerErr
}

form, readErr := reader.ReadForm(maxMemory)
if readErr != nil {
return readErr
}
req.MultipartForm = form

if req.PostForm == nil {
req.PostForm = make(url.Values)
}
for key, values := range form.Value {
req.PostForm[key] = append(req.PostForm[key], values...)
}

if req.Form == nil {
req.Form = make(url.Values, len(req.PostForm))
for key, values := range req.PostForm {
req.Form[key] = append(req.Form[key], values...)
}
}
if req.URL != nil {
for key, values := range req.URL.Query() {
req.Form[key] = append(req.Form[key], values...)
}
}

return nil
}
7 changes: 4 additions & 3 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const (
MIMEPlain = binding.MIMEPlain
MIMEPOSTForm = binding.MIMEPOSTForm
MIMEMultipartPOSTForm = binding.MIMEMultipartPOSTForm
MIMEMultipartMixed = binding.MIMEMultipartMixed
MIMEYAML = binding.MIMEYAML
MIMEYAML2 = binding.MIMEYAML2
MIMETOML = binding.MIMETOML
Expand Down Expand Up @@ -639,7 +640,7 @@ func (c *Context) initFormCache() {
if c.formCache == nil {
c.formCache = make(url.Values)
req := c.Request
if err := req.ParseMultipartForm(c.engine.MaxMultipartMemory); err != nil {
if err := parseMultipartForm(req, c.engine.MaxMultipartMemory); err != nil {
if !errors.Is(err, http.ErrNotMultipart) {
debugPrint("error on parse multipart form array: %v", err)
}
Expand Down Expand Up @@ -697,7 +698,7 @@ func getMapFromFormData(m map[string][]string, key string) (map[string]string, b
// FormFile returns the first file for the provided form key.
func (c *Context) FormFile(name string) (*multipart.FileHeader, error) {
if c.Request.MultipartForm == nil {
if err := c.Request.ParseMultipartForm(c.engine.MaxMultipartMemory); err != nil {
if err := parseMultipartForm(c.Request, c.engine.MaxMultipartMemory); err != nil {
return nil, err
}
}
Expand All @@ -711,7 +712,7 @@ func (c *Context) FormFile(name string) (*multipart.FileHeader, error) {

// MultipartForm is the parsed multipart form, including file uploads.
func (c *Context) MultipartForm() (*multipart.Form, error) {
err := c.Request.ParseMultipartForm(c.engine.MaxMultipartMemory)
err := parseMultipartForm(c.Request, c.engine.MaxMultipartMemory)
return c.Request.MultipartForm, err
}

Expand Down
52 changes: 52 additions & 0 deletions context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,58 @@ func TestContextMultipartForm(t *testing.T) {
require.NoError(t, c.SaveUploadedFile(f.File["file"][0], "test"))
}

func TestContextFormFileMixed(t *testing.T) {
buf := new(bytes.Buffer)
mw := multipart.NewWriter(buf)
w, err := mw.CreateFormFile("file", "mixed-test.txt")
require.NoError(t, err)
_, err = w.Write([]byte("mixed-content"))
require.NoError(t, err)
require.NoError(t, mw.Close())

c, _ := CreateTestContext(httptest.NewRecorder())
c.Request, _ = http.NewRequest(http.MethodPost, "/", buf)
c.Request.Header.Set("Content-Type", MIMEMultipartMixed+"; boundary="+mw.Boundary())

f, err := c.FormFile("file")
require.NoError(t, err)
assert.Equal(t, "mixed-test.txt", f.Filename)
}

func TestContextMultipartFormMixed(t *testing.T) {
buf := new(bytes.Buffer)
mw := multipart.NewWriter(buf)
require.NoError(t, mw.WriteField("foo", "bar"))
w, err := mw.CreateFormFile("file", "mixed-test.txt")
require.NoError(t, err)
_, err = w.Write([]byte("mixed-content"))
require.NoError(t, err)
require.NoError(t, mw.Close())

c, _ := CreateTestContext(httptest.NewRecorder())
c.Request, _ = http.NewRequest(http.MethodPost, "/", buf)
c.Request.Header.Set("Content-Type", MIMEMultipartMixed+"; boundary="+mw.Boundary())

f, err := c.MultipartForm()
require.NoError(t, err)
assert.NotNil(t, f)
assert.Equal(t, []string{"bar"}, f.Value["foo"])
assert.Len(t, f.File["file"], 1)
}

func TestContextPostFormMixed(t *testing.T) {
buf := new(bytes.Buffer)
mw := multipart.NewWriter(buf)
require.NoError(t, mw.WriteField("type", "image"))
require.NoError(t, mw.Close())

c, _ := CreateTestContext(httptest.NewRecorder())
c.Request, _ = http.NewRequest(http.MethodPost, "/", buf)
c.Request.Header.Set("Content-Type", MIMEMultipartMixed+"; boundary="+mw.Boundary())

assert.Equal(t, "image", c.PostForm("type"))
}

func TestSaveUploadedOpenFailed(t *testing.T) {
buf := new(bytes.Buffer)
mw := multipart.NewWriter(buf)
Expand Down
56 changes: 56 additions & 0 deletions multipart_form.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright 2026 Gin Core Team. All rights reserved.
// Use of this source code is governed by a MIT style
// license that can be found in the LICENSE file.

package gin

import (
"errors"
"mime"
"net/http"
"net/url"
)

func parseMultipartForm(req *http.Request, maxMemory int64) error {
err := req.ParseMultipartForm(maxMemory)
if err == nil || !errors.Is(err, http.ErrNotMultipart) {
return err
}

mediaType, _, parseErr := mime.ParseMediaType(req.Header.Get("Content-Type"))
if parseErr != nil || mediaType != MIMEMultipartMixed {
return err
}

reader, readerErr := req.MultipartReader()
if readerErr != nil {
return readerErr
}

form, readErr := reader.ReadForm(maxMemory)
if readErr != nil {
return readErr
}
req.MultipartForm = form

if req.PostForm == nil {
req.PostForm = make(url.Values)
}
for key, values := range form.Value {
req.PostForm[key] = append(req.PostForm[key], values...)
}

if req.Form == nil {
req.Form = make(url.Values, len(req.PostForm))
for key, values := range req.PostForm {
req.Form[key] = append(req.Form[key], values...)
}
}
if req.URL != nil {
for key, values := range req.URL.Query() {
req.Form[key] = append(req.Form[key], values...)
}
}

return nil
}