Repository: danielgtaylor/huma
Pull request #415 (2024-04-26 14:17:01)

Feature: handling files from `multipart/form-data` request
This PR aims to improve the handling of `multipart/form-data` requests by: 

- 3570bf1 allowing users to document the request body at the level of operations, which is currently not possible since it is overwritten by huma when `RawBody` is `multipart.FormData`. The [generated schema](https://github.com/danielgtaylor/huma/blob/ed2c3cd81698cb5728a49c7cfa267f23c392f644/huma.go#L633) is more of an example and does not reflect the actual form data structure, which should be specified by the user.
- d6eb219 implementing `MultipartFormFiles`, which can be used to type `RawBody` in request inputs and provides logic for decoding and validating files from a `multipart/form-data` request as `multipart.File` values, as well as generating the associated schema in the spec. Although this could be extended to support data types other than files, it would require significant refactoring to reuse some of the logic from body/query/path/header parameters decoding, and I was not confident enough to engage into it.
- 01ca953 implementing mimetype validation for submitted files

There is currently no automated tests for this PR, I would like to first get some feedback before spending more time on this (and still have to get familiar with `humatest`).

Example usage: 
```go
type FileData struct {
        // This is an example, any number of `multipart.File` fields can be defined.
        // Nested structs are not supported.
	SomeFile multipart.File   `form-data:"some_file" content-type:"image/png" required:"true"`
	SeveralFiles []multipart.File `form-data:"several_files" content-type:"image/png,image/jpeg" required:"true"`
}

type FileHandlerInput struct {
	RawBody huma.MultipartFormFiles[FileData]
}

func FileHandler(ctx context.Context, input *FileHandlerInput) (*struct{}, error) {
  fileData := input.RawBody.Data()
  DoSomeThingWith(fileData.SomeFile)
  OrSomethingElseWith(fileData.SeveralFiles)
}

huma.Register(api,
  huma.Operation{
	  Path:             "/handle-files",
	  Method:        http.MethodPost,
	  OperationID:     "Handle files",
  }, FileHandler)
```

When using `MultipartFormFiles[T]`, only the fields in `T` that implement `multipart.File` or `[]multipart.File` are considered when generating the spec or resolving a request. The raw form data can be accessed at `MultipartFormFiles.Form` which is a `multipart.Form`.

`form-data` tag specifies the lookup key to retrieve the encoded file in the `multipart.Form`. In its absence, the name of the field is used instead. 
`content-type` specifies the acceptable mime types for a file. It is used to document the endpoint in the spec, and validate file format using [`http.DetectContentType`](https://pkg.go.dev/net/http#DetectContentType) 
`required` makes the field required. In the case of `[]multipart.File`, validation checks that at least one file was submitted.

Generated operation spec : 
```json
{
  "/handle-files": {
    "post": {
      "operationId": "HandleFiles",
      "requestBody": {
        "content": {
          "multipart/form-data": {
            "encoding": {
              "some_file": {
                "contentType": "image/png"
              },
              "several_files": {
                "contentType": "image/png,image/jpeg"
              }
            },
            "schema": {
              "properties": {
                "some_file": {
                  "format": "binary",
                  "type": "string"
                },
                "several_files": {
                  "items": {
                    "format": "binary",
                    "type": "string"
                  },
                  "type": "array"
                }
              },
              "required": [
                "some_file",
                "several_files"
              ],
              "type": "object"
            }
          }
        }
      }
    }
  }
}
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **New Features**
  - Introduced functionality for handling multipart form data in HTTP requests.
  - Added support for validating MIME types in uploaded files.
  - Enhanced the ability to read and decode form data, including single and multiple file uploads.

- **Tests**
  - Added multiple test cases to ensure robust handling of multipart form data, including validation, error handling, and content type detection.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/danielgtaylor/huma/pull/415?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor) Report
Attention: Patch coverage is `9.94475%` with `163 lines` in your changes are missing coverage. Please review.
> Project coverage is 88.58%. Comparing base [(`ed2c3cd`)](https://app.codecov.io/gh/danielgtaylor/huma/commit/ed2c3cd81698cb5728a49c7cfa267f23c392f644?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor) to head [(`c68f1ce`)](https://app.codecov.io/gh/danielgtaylor/huma/pull/415?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor).

| [Files](https://app.codecov.io/gh/danielgtaylor/huma/pull/415?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor) | Patch % | Lines |
|---|---|---|
| [formdata.go](https://app.codecov.io/gh/danielgtaylor/huma/pull/415?src=pr&el=tree&filepath=formdata.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor#diff-Zm9ybWRhdGEuZ28=) | 0.00% | [139 Missing :warning: ](https://app.codecov.io/gh/danielgtaylor/huma/pull/415?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor) |
| [huma.go](https://app.codecov.io/gh/danielgtaylor/huma/pull/415?src=pr&el=tree&filepath=huma.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor#diff-aHVtYS5nbw==) | 42.85% | [20 Missing and 4 partials :warning: ](https://app.codecov.io/gh/danielgtaylor/huma/pull/415?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor) |

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


```diff
@@            Coverage Diff             @@
##             main     #415      +/-   ##
==========================================
- Coverage   92.65%   88.58%   -4.08%     
==========================================
  Files          21       22       +1     
  Lines        3539     3705     +166     
==========================================
+ Hits         3279     3282       +3     
- Misses        221      380     +159     
- Partials       39       43       +4     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/danielgtaylor/huma/pull/415?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Daniel+G.+Taylor).   
: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=Daniel+G.+Taylor).

danielgtaylor: @lsdch this looks amazing! I will dig in and give it a thorough review soon, just wanted to say thanks for all the work on this and I like the idea of better file handling & documentation.

Patch:
diff --git a/formdata.go b/formdata.go
new file mode 100644
index 0000000..1e06d08
--- /dev/null
+++ b/formdata.go
@@ -0,0 +1,252 @@
+package huma
+
+import (
+	"fmt"
+	"io"
+	"mime/multipart"
+	"net/http"
+	"reflect"
+	"slices"
+	"strings"
+)
+
+type FormFile struct {
+	multipart.File
+	ContentType string // Content-Type as declared in the multipart form field, or detected when parsing request as fallback
+	IsSet       bool   // Indicates whether content was received when working with optional files
+}
+
+type MultipartFormFiles[T any] struct {
+	Form *multipart.Form
+	data *T
+}
+
+type MimeTypeValidator struct {
+	accept []string
+}
+
+func NewMimeTypeValidator(encoding *Encoding) MimeTypeValidator {
+	var mimeTypes = strings.Split(encoding.ContentType, ",")
+	for i := range mimeTypes {
+		mimeTypes[i] = strings.Trim(mimeTypes[i], " ")
+	}
+	if len(mimeTypes) == 0 {
+		mimeTypes = []string{"application/octet-stream"}
+	}
+	return MimeTypeValidator{accept: mimeTypes}
+}
+
+// Validate checks the mime type of the provided file against the expected content type.
+// In the absence of a Content-Type file header, the mime type is detected using [http.DetectContentType].
+func (v MimeTypeValidator) Validate(fh *multipart.FileHeader, location string) (string, *ErrorDetail) {
+	file, err := fh.Open()
+	if err != nil {
+		return "", &ErrorDetail{Message: "Failed to open file", Location: location}
+	}
+
+	mimeType := fh.Header.Get("Content-Type")
+	if mimeType == "" {
+		var buffer = make([]byte, 1000)
+		if _, err := file.Read(buffer); err != nil {
+			return "", &ErrorDetail{Message: "Failed to infer file media type", Location: location}
+		}
+		file.Seek(int64(0), io.SeekStart)
+		mimeType = http.DetectContentType(buffer)
+	}
+	accept := slices.ContainsFunc(v.accept, func(m string) bool {
+		if m == "text/plain" || m == "application/octet-stream" {
+			return true
+		}
+		if strings.HasSuffix(m, "/*") &&
+			strings.HasPrefix(mimeType, strings.TrimRight(m, "*")) {
+			return true
+		}
+		if mimeType == m {
+			return true
+		}
+		return false
+	})
+
+	if accept {
+		return mimeType, nil
+	} else {
+		return mimeType, &ErrorDetail{
+			Message: fmt.Sprintf(
+				"Invalid mime type: got %v, expected %v",
+				mimeType, strings.Join(v.accept, ","),
+			),
+			Location: location,
+			Value:    mimeType,
+		}
+	}
+}
+
+func (m *MultipartFormFiles[T]) readFile(
+	fh *multipart.FileHeader,
+	location string,
+	validator MimeTypeValidator,
+) (FormFile, *ErrorDetail) {
+	f, err := fh.Open()
+	if err != nil {
+		return FormFile{}, &ErrorDetail{Message: "Failed to open file", Location: location}
+	}
+	contentType, validationErr := validator.Validate(fh, location)
+	if validationErr != nil {
+		return FormFile{}, validationErr
+	}
+	return FormFile{File: f, ContentType: contentType, IsSet: true}, nil
+}
+
+func (m *MultipartFormFiles[T]) readSingleFile(key string, opMediaType *MediaType) (FormFile, *ErrorDetail) {
+	fileHeaders := m.Form.File[key]
+	if len(fileHeaders) == 0 {
+		if opMediaType.Schema.requiredMap[key] {
+			return FormFile{}, &ErrorDetail{Message: "File required", Location: key}
+		} else {
+			return FormFile{}, nil
+		}
+	} else if len(fileHeaders) == 1 {
+		validator := NewMimeTypeValidator(opMediaType.Encoding[key])
+		return m.readFile(fileHeaders[0], key, validator)
+	}
+	return FormFile{}, &ErrorDetail{
+		Message:  "Multiple files received but only one was expected",
+		Location: key,
+	}
+}
+
+func (m *MultipartFormFiles[T]) readMultipleFiles(key string, opMediaType *MediaType) ([]FormFile, []error) {
+	fileHeaders := m.Form.File[key]
+	var (
+		files  = make([]FormFile, len(fileHeaders))
+		errors []error
+	)
+	if opMediaType.Schema.requiredMap[key] && len(fileHeaders) == 0 {
+		return nil, []error{&ErrorDetail{Message: "At least one file is required", Location: key}}
+	}
+	validator := NewMimeTypeValidator(opMediaType.Encoding[key])
+	for i, fh := range fileHeaders {
+		file, err := m.readFile(
+			fh,
+			fmt.Sprintf("%s[%d]", key, i),
+			validator,
+		)
+		if err != nil {
+			errors = append(errors, err)
+			continue
+		}
+		files[i] = file
+	}
+	return files, errors
+}
+
+func (m *MultipartFormFiles[T]) Data() *T {
+	return m.data
+}
+
+// Decodes multipart.Form data into *T, returning []*ErrorDetail if any
+// Schema is used to check for validation constraints
+func (m *MultipartFormFiles[T]) Decode(opMediaType *MediaType) []error {
+	var (
+		dataType = reflect.TypeOf(m.data).Elem()
+		value    = reflect.New(dataType)
+		errors   []error
+	)
+	for i := 0; i < dataType.NumField(); i++ {
+		field := value.Elem().Field(i)
+		structField := dataType.Field(i)
+		key := structField.Tag.Get("form")
+		if key == "" {
+			key = structField.Name
+		}
+		switch {
+		case field.Type() == reflect.TypeOf(FormFile{}):
+			file, err := m.readSingleFile(key, opMediaType)
+			if err != nil {
+				errors = append(errors, err)
+				continue
+			}
+			field.Set(reflect.ValueOf(file))
+		case field.Type() == reflect.TypeOf([]FormFile{}):
+			files, errs := m.readMultipleFiles(key, opMediaType)
+			if errs != nil {
+				errors = slices.Concat(errors, errs)
+				continue
+			}
+			field.Set(reflect.ValueOf(files))
+
+		default:
+			continue
+		}
+	}
+	m.data = value.Interface().(*T)
+	return errors
+}
+
+func formDataFieldName(f reflect.StructField) string {
+	name := f.Name
+	if formDataKey := f.Tag.Get("form"); formDataKey != "" {
+		name = formDataKey
+	}
+	return name
+}
+
+func multiPartFormFileSchema(t reflect.Type) *Schema {
+	nFields := t.NumField()
+	schema := &Schema{
+		Type:        "object",
+		Properties:  make(map[string]*Schema, nFields),
+		requiredMap: make(map[string]bool, nFields),
+	}
+	requiredFields := make([]string, nFields)
+	for i := 0; i < nFields; i++ {
+		f := t.Field(i)
+		name := formDataFieldName(f)
+
+		switch {
+		case f.Type == reflect.TypeOf(FormFile{}):
+			schema.Properties[name] = multiPartFileSchema(f)
+		case f.Type == reflect.TypeOf([]FormFile{}):
+			schema.Properties[name] = &Schema{
+				Type:  "array",
+				Items: multiPartFileSchema(f),
+			}
+		default:
+			// Should we panic if [T] struct defines fields with unsupported types ?
+			continue
+		}
+
+		if _, ok := f.Tag.Lookup("required"); ok && boolTag(f, "required") {
+			requiredFields[i] = name
+			schema.requiredMap[name] = true
+		}
+	}
+	schema.Required = requiredFields
+	return schema
+}
+
+func multiPartFileSchema(f reflect.StructField) *Schema {
+	return &Schema{
+		Type:            "string",
+		Format:          "binary",
+		Description:     f.Tag.Get("doc"),
+		ContentEncoding: "binary",
+	}
+}
+
+func multiPartContentEncoding(t reflect.Type) map[string]*Encoding {
+	nFields := t.NumField()
+	encoding := make(map[string]*Encoding, nFields)
+	for i := 0; i < nFields; i++ {
+		f := t.Field(i)
+		name := formDataFieldName(f)
+		contentType := f.Tag.Get("contentType")
+		if contentType == "" {
+			contentType = "application/octet-stream"
+		}
+		encoding[name] = &Encoding{
+			ContentType: contentType,
+		}
+	}
+	return encoding
+}
diff --git a/huma.go b/huma.go
index 8d0a0a9..c445efd 100644
--- a/huma.go
+++ b/huma.go
@@ -610,6 +610,7 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
 	}
 	rawBodyIndex := -1
 	rawBodyMultipart := false
+	rawBodyDecodedMultipart := false
 	if f, ok := inputType.FieldByName("RawBody"); ok {
 		rawBodyIndex = f.Index[0]
 		if op.RequestBody == nil {
@@ -625,6 +626,10 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
 			contentType = "multipart/form-data"
 			rawBodyMultipart = true
 		}
+		if strings.HasPrefix(f.Type.Name(), "MultipartFormFiles") {
+			contentType = "multipart/form-data"
+			rawBodyDecodedMultipart = true
+		}
 
 		if c := f.Tag.Get("contentType"); c != "" {
 			contentType = c
@@ -632,21 +637,37 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
 
 		switch contentType {
 		case "multipart/form-data":
-			op.RequestBody.Content["multipart/form-data"] = &MediaType{
-				Schema: &Schema{
-					Type: "object",
-					Properties: map[string]*Schema{
-						"name": {
-							Type:        "string",
-							Description: "general purpose name for multipart form value",
-						},
-						"filename": {
-							Type:        "string",
-							Format:      "binary",
-							Description: "filename of the file being uploaded",
+			if op.RequestBody.Content["multipart/form-data"] != nil {
+				break
+			}
+			if rawBodyMultipart {
+				op.RequestBody.Content["multipart/form-data"] = &MediaType{
+					Schema: &Schema{
+						Type: "object",
+						Properties: map[string]*Schema{
+							"name": {
+								Type:        "string",
+								Description: "general purpose name for multipart form value",
+							},
+							"filename": {
+								Type:        "string",
+								Format:      "binary",
+								Description: "filename of the file being uploaded",
+							},
 						},
 					},
-				},
+				}
+			}
+			if rawBodyDecodedMultipart {
+				dataField, ok := f.Type.FieldByName("data")
+				if !ok {
+					panic("Expected type MultipartFormFiles[T] to have a 'data *T' generic pointer field")
+				}
+				op.RequestBody.Content["multipart/form-data"] = &MediaType{
+					Schema:   multiPartFormFileSchema(dataField.Type.Elem()),
+					Encoding: multiPartContentEncoding(dataField.Type.Elem()),
+				}
+				op.RequestBody.Required = false
 			}
 		default:
 			op.RequestBody.Content[contentType] = &MediaType{
@@ -1132,7 +1153,7 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
 				ctx.SetReadDeadline(time.Time{})
 			}
 
-			if rawBodyMultipart {
+			if rawBodyMultipart || rawBodyDecodedMultipart {
 				form, err := ctx.GetMultipartForm()
 				if err != nil || form == nil {
 					res.Errors = append(res.Errors, &ErrorDetail{
@@ -1141,7 +1162,21 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
 					})
 				} else {
 					f := v.Field(rawBodyIndex)
-					f.Set(reflect.ValueOf(*form))
+					if rawBodyMultipart {
+						f.Set(reflect.ValueOf(*form))
+					} else {
+						f.FieldByName("Form").Set(reflect.ValueOf(form))
+						r := f.Addr().
+							MethodByName("Decode").
+							Call([]reflect.Value{
+								reflect.ValueOf(op.RequestBody.Content["multipart/form-data"]),
+							})
+						errs := r[0].Interface().([]error)
+						if errs != nil {
+							WriteErr(api, ctx, http.StatusUnprocessableEntity, "validation failed", errs...)
+							return
+						}
+					}
 				}
 			} else {
 				buf := bufPool.Get().(*bytes.Buffer)


Test patch:
diff --git a/huma_test.go b/huma_test.go
index 15333c7..90efd85 100644
--- a/huma_test.go
+++ b/huma_test.go
@@ -11,6 +11,7 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"net/http/httputil"
+	"os"
 	"strings"
 	"testing"
 	"time"
@@ -734,6 +735,333 @@ func TestFeatures(t *testing.T) {
 			Headers: map[string]string{"Content-Type": "application/foo"},
 			Body:    `some-data`,
 		},
+		{
+			Name: "request-body-multipart-file-decoded",
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
+					Method: http.MethodPost,
+					Path:   "/upload",
+				}, func(ctx context.Context, input *struct {
+					RawBody huma.MultipartFormFiles[struct {
+						HelloWorld   huma.FormFile   `form:"file" contentType:"text/plain" required:"true"`
+						Greetings    []huma.FormFile `form:"greetings" contentType:"text/plain" required:"true"`
+						NoTagBinding huma.FormFile   `contentType:"text/plain"`
+						UnusedField  string          // Ignored altogether
+					}]
+				}) (*struct{}, error) {
+					fileData := input.RawBody.Data()
+
+					assert.Equal(t, "text/plain", fileData.HelloWorld.ContentType)
+					assert.True(t, fileData.HelloWorld.IsSet)
+					b, err := io.ReadAll(fileData.HelloWorld)
+					require.NoError(t, err)
+					assert.Equal(t, "Hello, World!", string(b))
+
+					assert.Equal(t, "text/plain", fileData.NoTagBinding.ContentType)
+					assert.True(t, fileData.NoTagBinding.IsSet)
+					b, err = io.ReadAll(fileData.NoTagBinding)
+					require.NoError(t, err)
+					assert.Equal(t, `Use struct field name as fallback when no "form" tag is provided.`, string(b))
+
+					expected := []string{"Hello", "World"}
+					for i, e := range expected {
+						assert.Equal(t, "text/plain", fileData.Greetings[i].ContentType)
+						assert.True(t, fileData.Greetings[i].IsSet)
+
+						b, err := io.ReadAll(fileData.Greetings[i])
+						require.NoError(t, err)
+						assert.Equal(t, e, string(b))
+					}
+
+					return nil, nil
+				})
+
+				// Ensure OpenAPI spec is listed as a multipart/form-data upload with
+				// the appropriate schema.
+				mpContent := api.OpenAPI().Paths["/upload"].Post.RequestBody.Content["multipart/form-data"]
+				assert.Equal(t, "text/plain", mpContent.Encoding["file"].ContentType)
+				assert.Equal(t, "text/plain", mpContent.Encoding["greetings"].ContentType)
+				assert.Equal(t, "object", mpContent.Schema.Type)
+				assert.Equal(t, "binary", mpContent.Schema.Properties["file"].Format)
+				assert.Equal(t, "binary", mpContent.Schema.Properties["greetings"].Items.Format)
+			},
+			Method:  http.MethodPost,
+			URL:     "/upload",
+			Headers: map[string]string{"Content-Type": "multipart/form-data; boundary=SimpleBoundary"},
+			Body: `--SimpleBoundary
+Content-Disposition: form-data; name="file"; filename="test.txt"
+Content-Type: text/plain
+
+Hello, World!
+--SimpleBoundary
+Content-Disposition: form-data; name="greetings"; filename="greetings_1.txt"
+Content-Type: text/plain
+
+Hello
+--SimpleBoundary
+Content-Disposition: form-data; name="greetings"; filename="greetings_2.txt"
+Content-Type: text/plain
+
+World
+--SimpleBoundary
+Content-Disposition: form-data; name="NoTagBinding"; filename="notag.txt"
+Content-Type: text/plain
+
+Use struct field name as fallback when no "form" tag is provided.
+--SimpleBoundary--`,
+		},
+		{
+			Name: "request-body-multipart-file-decoded-required",
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
+					Method: http.MethodPost,
+					Path:   "/upload",
+				}, func(ctx context.Context, input *struct {
+					RawBody huma.MultipartFormFiles[struct {
+						HelloWorld huma.FormFile   `form:"file" contentType:"text/plain" required:"true"`
+						Sentences  []huma.FormFile `form:"greetings" contentType:"text/plain" required:"true"`
+					}]
+				}) (*struct{}, error) {
+					return nil, nil
+				})
+
+				// Ensure OpenAPI spec is listed as a multipart/form-data upload with
+				// the appropriate schema.
+				mpContent := api.OpenAPI().Paths["/upload"].Post.RequestBody.Content["multipart/form-data"]
+				assert.Equal(t, "text/plain", mpContent.Encoding["file"].ContentType)
+				assert.Equal(t, "object", mpContent.Schema.Type)
+				assert.Equal(t, "binary", mpContent.Schema.Properties["file"].Format)
+			},
+			Method:  http.MethodPost,
+			URL:     "/upload",
+			Headers: map[string]string{"Content-Type": "multipart/form-data; boundary=SimpleBoundary"},
+			Body: `--SimpleBoundary
+Content-Disposition: form-data; name="bad_key_name"; filename="test.txt"
+Content-Type: text/plain
+
+Hello, World!
+--SimpleBoundary--`,
+			Assert: func(t *testing.T, resp *httptest.ResponseRecorder) {
+				if ok := assert.Equal(t, http.StatusUnprocessableEntity, resp.Code); ok {
+					var errors huma.ErrorModel
+					err := json.Unmarshal(resp.Body.Bytes(), &errors)
+					require.NoError(t, err)
+					assert.Equal(t, "file", errors.Errors[0].Location)
+					assert.Equal(t, "greetings", errors.Errors[1].Location)
+				}
+			},
+		},
+		{
+			Name: "request-body-multipart-file-decoded-optional",
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
+					Method: http.MethodPost,
+					Path:   "/upload",
+				}, func(ctx context.Context, input *struct {
+					RawBody huma.MultipartFormFiles[struct {
+						HelloWorld huma.FormFile `form:"file" contentType:"text/plain"`
+					}]
+				}) (*struct{}, error) {
+					assert.False(t, input.RawBody.Data().HelloWorld.IsSet)
+					return nil, nil
+				})
+			},
+			Method:  http.MethodPost,
+			URL:     "/upload",
+			Headers: map[string]string{"Content-Type": "multipart/form-data; boundary=SimpleBoundary"},
+			Body:    `--SimpleBoundary--`,
+		},
+		{
+			Name: "request-body-multipart-file-decoded-bad-cardinality",
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
+					Method: http.MethodPost,
+					Path:   "/upload",
+				}, func(ctx context.Context, input *struct {
+					RawBody huma.MultipartFormFiles[struct {
+						HelloWorld huma.FormFile `form:"file" contentType:"text/plain" required:"true"`
+					}]
+				}) (*struct{}, error) {
+					return nil, nil
+				})
+			},
+			Method:  http.MethodPost,
+			URL:     "/upload",
+			Headers: map[string]string{"Content-Type": "multipart/form-data; boundary=SimpleBoundary"},
+			Body: `--SimpleBoundary
+Content-Disposition: form-data; name="file"; filename="test.txt"
+Content-Type: text/plain
+
+Hello, World!
+--SimpleBoundary
+Content-Disposition: form-data; name="file"; filename="text.txt"
+Content-Type: text/plain
+
+What are you doing here ?
+--SimpleBoundary--`,
+			Assert: func(t *testing.T, resp *httptest.ResponseRecorder) {
+				if ok := assert.Equal(t, http.StatusUnprocessableEntity, resp.Code); ok {
+					var errors huma.ErrorModel
+					err := json.Unmarshal(resp.Body.Bytes(), &errors)
+					require.NoError(t, err)
+					assert.Equal(t, "file", errors.Errors[0].Location)
+				}
+			},
+		},
+		{
+			Name: "request-body-multipart-file-decoded-invalid-content-type",
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
+					Method: http.MethodPost,
+					Path:   "/upload",
+				}, func(ctx context.Context, input *struct {
+					RawBody huma.MultipartFormFiles[struct {
+						// Expecting 'image/png', will receive 'text/plain'
+						Image  huma.FormFile   `form:"file" contentType:"image/png"`
+						Images []huma.FormFile `form:"file" contentType:"image/png"`
+					}]
+				}) (*struct{}, error) {
+					return nil, nil
+				})
+			},
+			Method:  http.MethodPost,
+			URL:     "/upload",
+			Headers: map[string]string{"Content-Type": "multipart/form-data; boundary=SimpleBoundary"},
+			Body: `--SimpleBoundary
+Content-Disposition: form-data; name="file"; filename="test.txt"
+Content-Type: text/plain
+
+Hello, World!
+--SimpleBoundary--`,
+			Assert: func(t *testing.T, resp *httptest.ResponseRecorder) {
+				var errors huma.ErrorModel
+				err := json.Unmarshal(resp.Body.Bytes(), &errors)
+				require.NoError(t, err)
+				assert.Len(t, errors.Errors, 2) // Both single and multiple file receiver should fail
+				assert.Equal(t, "file", errors.Errors[0].Location)
+				assert.Equal(t, http.StatusUnprocessableEntity, resp.Code)
+			},
+		},
+		{
+			Name: "request-body-multipart-file-decoded-content-type-default",
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
+					Method: http.MethodPost,
+					Path:   "/upload",
+				}, func(ctx context.Context, input *struct {
+					RawBody huma.MultipartFormFiles[struct {
+						// No contentType tag: default to "application/octet-stream"
+						Image huma.FormFile `form:"file" required:"true"`
+					}]
+				}) (*struct{}, error) {
+					fileData := input.RawBody.Data()
+					b, err := io.ReadAll(fileData.Image.File)
+					require.NoError(t, err)
+					assert.Equal(t, "console.log('Hello, World!')", string(b))
+					return nil, nil
+				})
+			},
+			Method:  http.MethodPost,
+			URL:     "/upload",
+			Headers: map[string]string{"Content-Type": "multipart/form-data; boundary=SimpleBoundary"},
+			Body: `--SimpleBoundary
+Content-Disposition: form-data; name="file"; filename="test.js"
+Content-Type: text/javascript
+
+console.log('Hello, World!')
+--SimpleBoundary--`,
+		},
+		{
+			Name: "request-body-multipart-file-decoded-content-type-wildcard",
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
+					Method: http.MethodPost,
+					Path:   "/upload",
+				}, func(ctx context.Context, input *struct {
+					RawBody huma.MultipartFormFiles[struct {
+						File huma.FormFile `form:"file" contentType:"text/*" required:"true"`
+					}]
+				}) (*struct{}, error) {
+					fileData := input.RawBody.Data()
+					b, err := io.ReadAll(fileData.File)
+					require.NoError(t, err)
+					assert.Equal(t, "console.log('Hello, World!')", string(b))
+					return nil, nil
+				})
+			},
+			Method:  http.MethodPost,
+			URL:     "/upload",
+			Headers: map[string]string{"Content-Type": "multipart/form-data; boundary=SimpleBoundary"},
+			Body: `--SimpleBoundary
+Content-Disposition: form-data; name="file"; filename="test.js"
+Content-Type: text/javascript
+
+console.log('Hello, World!')
+--SimpleBoundary--`,
+		},
+		{
+			Name: "request-body-multipart-file-decoded-image",
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
+					Method: http.MethodPost,
+					Path:   "/upload",
+				}, func(ctx context.Context, input *struct {
+					RawBody huma.MultipartFormFiles[struct {
+						Image huma.FormFile `form:"file" contentType:"image/jpeg,image/png" required:"true"`
+					}]
+				}) (*struct{}, error) {
+					fileData := input.RawBody.Data()
+					assert.Equal(t, "image/png", fileData.Image.ContentType)
+					return nil, nil
+				})
+			},
+			Method:  http.MethodPost,
+			URL:     "/upload",
+			Headers: map[string]string{"Content-Type": "multipart/form-data; boundary=SimpleBoundary"},
+			Body: func() string {
+				file, err := os.Open("docs/docs/huma.png")
+				require.NoError(t, err)
+				b, err := io.ReadAll(file)
+				require.NoError(t, err)
+				return fmt.Sprintf(`--SimpleBoundary
+Content-Disposition: form-data; name="file"; filename="test.js"
+Content-Type: image/png
+
+%s
+--SimpleBoundary--`, string(b))
+			}(),
+		},
+		{
+			Name: "request-body-multipart-file-decoded-image-detect-type",
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
+					Method: http.MethodPost,
+					Path:   "/upload",
+				}, func(ctx context.Context, input *struct {
+					RawBody huma.MultipartFormFiles[struct {
+						Image huma.FormFile `form:"file" contentType:"image/jpeg,image/png" required:"true"`
+					}]
+				}) (*struct{}, error) {
+					fileData := input.RawBody.Data()
+					assert.Equal(t, "image/png", fileData.Image.ContentType)
+					return nil, nil
+				})
+			},
+			Method:  http.MethodPost,
+			URL:     "/upload",
+			Headers: map[string]string{"Content-Type": "multipart/form-data; boundary=SimpleBoundary"},
+			Body: func() string {
+				file, err := os.Open("docs/docs/huma.png")
+				require.NoError(t, err)
+				b, err := io.ReadAll(file)
+				require.NoError(t, err)
+				return fmt.Sprintf(`--SimpleBoundary
+Content-Disposition: form-data; name="file"; filename="test.js"
+
+%s
+--SimpleBoundary--`, string(b))
+			}(),
+		},
 		{
 			Name: "request-body-multipart-file",
 			Register: func(t *testing.T, api huma.API) {