Repository: danielgtaylor/huma
Pull request #324 (2024-03-19 02:33:57)

Multipart Form RawBody Type Support
I'm not entirely sure if this is the direction you want to go in. But this makes adding simple upload endpoints more approachable. 


Patch:
diff --git a/docs/docs/features/request-inputs.md b/docs/docs/features/request-inputs.md
index cb8e0c9..bd4ee72 100644
--- a/docs/docs/features/request-inputs.md
+++ b/docs/docs/features/request-inputs.md
@@ -58,7 +58,7 @@ The special struct field `Body` will be treated as the input request body and ca
 
 ### Other Body Types
 
-Sometimes, you want to bypass the normal body parsing and instead read the raw body conents directly. This is useful for unstructured data, file uploads, or other binary data. You can use `RawBody []byte` **without** a `Body` field to access the raw body bytes without any parsing/validation being applied. For example, to accept some `text/plain` input:
+Sometimes, you want to bypass the normal body parsing and instead read the raw body contents directly. This is useful for unstructured data, file uploads, or other binary data. You can use `RawBody []byte` **without** a `Body` field to access the raw body bytes without any parsing/validation being applied. For example, to accept some `text/plain` input:
 
 ```go title="code.go"
 huma.Register(api, huma.Operation{
@@ -76,6 +76,30 @@ huma.Register(api, huma.Operation{
 
 This enables you to also do your own parsing of the input, if needed.
 
+### Multipart Form Data
+
+Multipart form data is supported by using a `RawBody` with a type of  
+`multipart.Form` type in the input struct. This will parse the request using
+Go standard library multipart processing implementation. 
+
+For example:
+
+```go title="code.go"
+huma.Register(api, huma.Operation{
+	OperationID: "upload-files",
+    Method:      http.MethodPost,
+    Path:        "/upload",
+    Summary:     "Example to upload a file",
+}, func(ctx context.Context, input struct {
+    RawBody multipart.Form
+}) (*struct{}, error) {
+    // Process multipart form here.
+    return nil, nil
+})
+```
+
+This will be useful for supporting file uploads.
+
 ## Request Example
 
 Here is an example request input struct, which has a path param, query param, header param, and a structured body alongside the raw body bytes:
diff --git a/huma.go b/huma.go
index 2635d2e..8643212 100644
--- a/huma.go
+++ b/huma.go
@@ -591,25 +591,58 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
 		}
 	}
 	rawBodyIndex := -1
+	rawBodyMultipart := false
 	if f, ok := inputType.FieldByName("RawBody"); ok {
 		rawBodyIndex = f.Index[0]
 		if op.RequestBody == nil {
 			contentType := "application/octet-stream"
+
+			if f.Type.String() == "multipart.Form" {
+				contentType = "multipart/form-data"
+				rawBodyMultipart = true
+			}
+
 			if c := f.Tag.Get("contentType"); c != "" {
 				contentType = c
 			}
 
-			op.RequestBody = &RequestBody{
-				Required: true,
-				Content: map[string]*MediaType{
-					contentType: {
-						Schema: &Schema{
-							Type:   "string",
-							Format: "binary",
+			switch contentType {
+			case "multipart/form-data":
+				op.RequestBody = &RequestBody{
+					Required: true,
+					Content: map[string]*MediaType{
+						"multipart/form-data": {
+							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",
+									},
+								},
+							},
 						},
 					},
-				},
+				}
+			default:
+				op.RequestBody = &RequestBody{
+					Required: true,
+					Content: map[string]*MediaType{
+						contentType: {
+							Schema: &Schema{
+								Type:   "string",
+								Format: "binary",
+							},
+						},
+					},
+				}
 			}
+
 		}
 	}
 
@@ -1083,110 +1116,120 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
 				ctx.SetReadDeadline(time.Time{})
 			}
 
-			buf := bufPool.Get().(*bytes.Buffer)
-			reader := ctx.BodyReader()
-			if reader == nil {
-				reader = bytes.NewReader(nil)
-			}
-			if closer, ok := reader.(io.Closer); ok {
-				defer closer.Close()
-			}
-			if op.MaxBodyBytes > 0 {
-				reader = io.LimitReader(reader, op.MaxBodyBytes)
-			}
-			count, err := io.Copy(buf, reader)
-			if op.MaxBodyBytes > 0 {
-				if count == op.MaxBodyBytes {
-					buf.Reset()
-					bufPool.Put(buf)
-					WriteErr(api, ctx, http.StatusRequestEntityTooLarge, fmt.Sprintf("request body is too large limit=%d bytes", op.MaxBodyBytes), res.Errors...)
-					return
+			if rawBodyMultipart {
+				form, err := ctx.GetMultipartForm()
+				if err != nil || form == nil {
+					WriteErr(api, ctx, http.StatusUnprocessableEntity, "cannot read multipart form", err)
+				} else {
+					f := v.Field(rawBodyIndex)
+					f.Set(reflect.ValueOf(*form))
 				}
-			}
-			if err != nil {
-				buf.Reset()
-				bufPool.Put(buf)
-
-				if e, ok := err.(net.Error); ok && e.Timeout() {
-					WriteErr(api, ctx, http.StatusRequestTimeout, "request body read timeout", res.Errors...)
-					return
+			} else {
+				buf := bufPool.Get().(*bytes.Buffer)
+				reader := ctx.BodyReader()
+				if reader == nil {
+					reader = bytes.NewReader(nil)
 				}
-
-				WriteErr(api, ctx, http.StatusInternalServerError, "cannot read request body", err)
-				return
-			}
-			body := buf.Bytes()
-
-			if rawBodyIndex != -1 {
-				f := v.Field(rawBodyIndex)
-				f.SetBytes(body)
-			}
-
-			if len(body) == 0 {
-				if op.RequestBody != nil && op.RequestBody.Required {
+				if closer, ok := reader.(io.Closer); ok {
+					defer closer.Close()
+				}
+				if op.MaxBodyBytes > 0 {
+					reader = io.LimitReader(reader, op.MaxBodyBytes)
+				}
+				count, err := io.Copy(buf, reader)
+				if op.MaxBodyBytes > 0 {
+					if count == op.MaxBodyBytes {
+						buf.Reset()
+						bufPool.Put(buf)
+						WriteErr(api, ctx, http.StatusRequestEntityTooLarge, fmt.Sprintf("request body is too large limit=%d bytes", op.MaxBodyBytes), res.Errors...)
+						return
+					}
+				}
+				if err != nil {
 					buf.Reset()
 					bufPool.Put(buf)
-					WriteErr(api, ctx, http.StatusBadRequest, "request body is required", res.Errors...)
+
+					if e, ok := err.(net.Error); ok && e.Timeout() {
+						WriteErr(api, ctx, http.StatusRequestTimeout, "request body read timeout", res.Errors...)
+						return
+					}
+
+					WriteErr(api, ctx, http.StatusInternalServerError, "cannot read request body", err)
 					return
 				}
-			} else {
-				parseErrCount := 0
-				if inputBodyIndex != -1 && !op.SkipValidateBody {
-					// Validate the input. First, parse the body into []any or map[string]any
-					// or equivalent, which can be easily validated. Then, convert to the
-					// expected struct type to call the handler.
-					var parsed any
-					if err := api.Unmarshal(ctx.Header("Content-Type"), body, &parsed); err != nil {
-						errStatus = http.StatusBadRequest
-						if errors.Is(err, ErrUnknownContentType) {
-							errStatus = http.StatusUnsupportedMediaType
-						}
-						res.Errors = append(res.Errors, &ErrorDetail{
-							Location: "body",
-							Message:  err.Error(),
-							Value:    body,
-						})
-						parseErrCount++
-					} else {
-						pb.Reset()
-						pb.Push("body")
-						count := len(res.Errors)
-						Validate(oapi.Components.Schemas, inSchema, pb, ModeWriteToServer, parsed, res)
-						parseErrCount = len(res.Errors) - count
-						if parseErrCount > 0 {
-							errStatus = http.StatusUnprocessableEntity
-						}
-					}
+				body := buf.Bytes()
+
+				if rawBodyIndex != -1 {
+					f := v.Field(rawBodyIndex)
+					f.SetBytes(body)
 				}
 
-				if inputBodyIndex != -1 {
-					// We need to get the body into the correct type now that it has been
-					// validated. Benchmarks on Go 1.20 show that using `json.Unmarshal` a
-					// second time is faster than `mapstructure.Decode` or any of the other
-					// common reflection-based approaches when using real-world medium-sized
-					// JSON payloads with lots of strings.
-					f := v.Field(inputBodyIndex)
-					if err := api.Unmarshal(ctx.Header("Content-Type"), body, f.Addr().Interface()); err != nil {
-						if parseErrCount == 0 {
-							// Hmm, this should have worked... validator missed something?
+				if len(body) == 0 {
+					if op.RequestBody != nil && op.RequestBody.Required {
+						buf.Reset()
+						bufPool.Put(buf)
+						WriteErr(api, ctx, http.StatusBadRequest, "request body is required", res.Errors...)
+						return
+					}
+				} else {
+					parseErrCount := 0
+					if inputBodyIndex != -1 && !op.SkipValidateBody {
+						// Validate the input. First, parse the body into []any or map[string]any
+						// or equivalent, which can be easily validated. Then, convert to the
+						// expected struct type to call the handler.
+						var parsed any
+						if err := api.Unmarshal(ctx.Header("Content-Type"), body, &parsed); err != nil {
+							errStatus = http.StatusBadRequest
+							if errors.Is(err, ErrUnknownContentType) {
+								errStatus = http.StatusUnsupportedMediaType
+							}
 							res.Errors = append(res.Errors, &ErrorDetail{
 								Location: "body",
 								Message:  err.Error(),
-								Value:    string(body),
+								Value:    body,
 							})
+							parseErrCount++
+						} else {
+							pb.Reset()
+							pb.Push("body")
+							count := len(res.Errors)
+							Validate(oapi.Components.Schemas, inSchema, pb, ModeWriteToServer, parsed, res)
+							parseErrCount = len(res.Errors) - count
+							if parseErrCount > 0 {
+								errStatus = http.StatusUnprocessableEntity
+							}
 						}
-					} else {
-						// Set defaults for any fields that were not in the input.
-						defaults.Every(v, func(item reflect.Value, def any) {
-							if item.IsZero() {
-								item.Set(reflect.Indirect(reflect.ValueOf(def)))
+					}
+
+					if inputBodyIndex != -1 {
+						// We need to get the body into the correct type now that it has been
+						// validated. Benchmarks on Go 1.20 show that using `json.Unmarshal` a
+						// second time is faster than `mapstructure.Decode` or any of the other
+						// common reflection-based approaches when using real-world medium-sized
+						// JSON payloads with lots of strings.
+						f := v.Field(inputBodyIndex)
+						if err := api.Unmarshal(ctx.Header("Content-Type"), body, f.Addr().Interface()); err != nil {
+							if parseErrCount == 0 {
+								// Hmm, this should have worked... validator missed something?
+								res.Errors = append(res.Errors, &ErrorDetail{
+									Location: "body",
+									Message:  err.Error(),
+									Value:    string(body),
+								})
 							}
-						})
+						} else {
+							// Set defaults for any fields that were not in the input.
+							defaults.Every(v, func(item reflect.Value, def any) {
+								if item.IsZero() {
+									item.Set(reflect.Indirect(reflect.ValueOf(def)))
+								}
+							})
+						}
 					}
-				}
 
-				buf.Reset()
-				bufPool.Put(buf)
+					buf.Reset()
+					bufPool.Put(buf)
+				}
 			}
 		}
 


Test patch:
diff --git a/huma_test.go b/huma_test.go
index ad9ab6c..dafb4b7 100644
--- a/huma_test.go
+++ b/huma_test.go
@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"errors"
 	"io"
+	"mime/multipart"
 	"net"
 	"net/http"
 	"net/http/httptest"
@@ -494,6 +495,140 @@ func TestFeatures(t *testing.T) {
 			Headers: map[string]string{"Content-Type": "application/foo"},
 			Body:    `some-data`,
 		},
+		{
+			Name: "request-body-multipart-file",
+			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 multipart.Form
+				}) (*struct{}, error) {
+					for name, fh := range input.RawBody.File {
+						for _, f := range fh {
+							r, err := f.Open()
+							require.NoError(t, err)
+
+							b, err := io.ReadAll(r)
+							require.NoError(t, err)
+
+							_ = r.Close()
+
+							assert.Equal(t, "test.txt", f.Filename)
+							assert.Equal(t, "text/plain", f.Header.Get("Content-Type"))
+							assert.Equal(t, "Hello, World!", string(b))
+						}
+						assert.Equal(t, "file", name)
+					}
+					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, "object", mpContent.Schema.Type)
+				assert.Equal(t, "binary", mpContent.Schema.Properties["filename"].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--`,
+		},
+		{
+			Name: "request-body-multipart-files",
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
+					Method: http.MethodPost,
+					Path:   "/upload-files",
+				}, func(ctx context.Context, input *struct {
+					RawBody multipart.Form
+				}) (*struct{}, error) {
+					for name, fh := range input.RawBody.File {
+						for _, f := range fh {
+							r, err := f.Open()
+							require.NoError(t, err)
+
+							b, err := io.ReadAll(r)
+							require.NoError(t, err)
+
+							_ = r.Close()
+
+							// get the last char of name
+							index := name[len(name)-1:]
+							assert.Equal(t, "example"+index+".txt", f.Filename)
+							assert.Equal(t, "text/plain", f.Header.Get("Content-Type"))
+							assert.Equal(t, "Content of example"+index+".txt.", 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-files"].Post.RequestBody.Content["multipart/form-data"]
+				assert.Equal(t, "object", mpContent.Schema.Type)
+				assert.Equal(t, "binary", mpContent.Schema.Properties["filename"].Format)
+			},
+			Method:  http.MethodPost,
+			URL:     "/upload-files",
+			Headers: map[string]string{"Content-Type": "multipart/form-data; boundary=AnotherBoundary"},
+			Body: `--AnotherBoundary
+Content-Disposition: form-data; name="file1"; filename="example1.txt"
+Content-Type: text/plain
+
+Content of example1.txt.
+--AnotherBoundary
+Content-Disposition: form-data; name="file2"; filename="example2.txt"
+Content-Type: text/plain
+
+Content of example2.txt.
+--AnotherBoundary--`,
+		},
+		{
+			Name: "request-body-multipart-wrong-content-type",
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
+					Method: http.MethodPut,
+					Path:   "/upload",
+				}, func(ctx context.Context, input *struct {
+					RawBody multipart.Form
+				}) (*struct{}, error) {
+					return nil, nil
+				})
+			},
+			Method:  http.MethodPut,
+			URL:     "/upload",
+			Headers: map[string]string{"Content-Type": "wrong/type"},
+			Body:    `some-data`,
+			Assert: func(t *testing.T, resp *httptest.ResponseRecorder) {
+				assert.Equal(t, http.StatusUnprocessableEntity, resp.Code)
+			},
+		},
+		{
+			Name: "request-body-multipart-invalid-data",
+			Register: func(t *testing.T, api huma.API) {
+				huma.Register(api, huma.Operation{
+					Method: http.MethodPut,
+					Path:   "/upload",
+				}, func(ctx context.Context, input *struct {
+					RawBody multipart.Form
+				}) (*struct{}, error) {
+					return nil, nil
+				})
+			},
+			Method:  http.MethodPut,
+			URL:     "/upload",
+			Headers: map[string]string{"Content-Type": "multipart/form-data; boundary=SimpleBoundary"},
+			Body:    `invalid`,
+			Assert: func(t *testing.T, resp *httptest.ResponseRecorder) {
+				assert.Equal(t, http.StatusUnprocessableEntity, resp.Code)
+			},
+		},
 		{
 			Name: "handler-error",
 			Register: func(t *testing.T, api huma.API) {