Skip to content
Open
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
48 changes: 47 additions & 1 deletion docs/docs/features/request-inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,54 @@ huma.Register(api, huma.Operation{

The files are decoded according to the specified contentType. If no contentType is provided, it defaults to `application/octet-stream`.

Non-file fields in multipart form data can be unmarshalled from JSON and validated, by setting their content-type to `application/json`. Field content in the request must be valid JSON.
Non-file fields in multipart form data can be unmarshalled from JSON and validated, by setting their content-type to `application/json`. Field content in the request **must** be valid JSON.

For JSON fields, Huma also supports the OpenAPI `encoding.explode` setting. This can be configured with the `explode` struct tag.

For example:

```go
huma.Register(api, huma.Operation{
OperationID: "upload-json-fields",
Method: http.MethodPost,
Path: "/upload",
}, func(ctx context.Context, input *struct {
RawBody huma.MultipartFormFiles[struct {
Numbers []int `form:"numbers" contentType:"application/json" explode:"false"`
Tags []string `form:"tags" contentType:"application/json" explode:"true"`
Config MyStruct `form:"config" contentType:"application/json"`
Value string `form:"value" contentType:"application/json"`
}]
}) (*struct{}, error) {
// Process input.RawBody.Data()
return nil, nil
})
```

For multipart form data, explode defaults to true according to the OpenAPI specification. When explode:"true" is used, each multipart part represents one JSON array item:

```
Content-Disposition: form-data; name="tags"
Content-Type: application/json

"tag1"

Content-Disposition: form-data; name="tags"
Content-Type: application/json

"tag2"
```

When `explode:"false"` is used, array values must be sent as a single JSON value:

```
Content-Disposition: form-data; name="numbers"
Content-Type: application/json

[1, 2, 3]
```

Regardless of the `explode` setting, each multipart part with `contentType:"application/json"` must contain valid JSON.

## Request Example

Expand Down
1 change: 1 addition & 0 deletions formdata.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ func multiPartContentEncoding(t reflect.Type) map[string]*Encoding {
}
encoding[name] = &Encoding{
ContentType: contentType,
Explode: maybeBoolTag(f, "explode"),
}
}
Comment thread
wolveix marked this conversation as resolved.
return encoding
Expand Down
61 changes: 52 additions & 9 deletions huma.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"bytes"
"context"
"encoding"
"encoding/json"
"errors"
"fmt"
"io"
Expand Down Expand Up @@ -199,6 +200,7 @@ func parseParamLocation(f reflect.StructField, registry Registry) (*paramLocatio
case f.Tag.Get("form") != "":
pfi.Loc = "form"
pfi.Name = f.Tag.Get("form")
pfi.Explode = boolTag(f, "explode", true)
pfi.Required = !getConfig[registryConfig](registry).FieldsOptionalByDefault
case f.Tag.Get("cookie") != "":
pfi.Loc = "cookie"
Expand Down Expand Up @@ -1059,26 +1061,60 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
return
}

// Validation should fail if multiple values are
// provided but the type of f is not a slice.
if len(value) > 1 && f.Type().Kind() != reflect.Slice {
res.Add(pb, value, "expected at most one value, but received multiple values")
return
}

// JSON fields
if jf := jsonFormFields[p]; jf != nil {
errorsBeforeValidation := len(res.Errors)

var toParse []byte

if jf.schema != nil && jf.schema.Type == "array" {
if !p.Explode {
if len(value) > 1 {
res.Add(pb, value, "expected a single JSON array value (encoding.explode=false), got multiple parts")
return
}

// explode=false means the multipart part already contains the JSON array.
toParse = []byte(value[0])
} else {
// Each multipart part is expected to contain one valid JSON value.
items := make([]json.RawMessage, len(value))

for i, v := range value {
var raw json.RawMessage
if err := jsonUnmarshaler([]byte(v), &raw); err != nil {
pb.PushIndex(i)
res.Add(pb, value, "invalid JSON: "+err.Error())
return
}
items[i] = raw
}

var err error
toParse, err = json.Marshal(items)
if err != nil {
res.Add(pb, value, "invalid JSON: "+err.Error())
return
}
}
} else {
// Non-array JSON fields must be represented by a single multipart part.
if len(value) > 1 {
res.Add(pb, value, "expected a single JSON value, got multiple parts")
return
}
toParse = []byte(value[0])
}

var parsed any
if err := jsonUnmarshaler([]byte(value[0]), &parsed); err != nil {
if err := jsonUnmarshaler(toParse, &parsed); err != nil {
res.Add(pb, value, "invalid JSON: "+err.Error())
} else if !op.SkipValidateParams {
Validate(oapi.Components.Schemas, jf.schema, pb, ModeWriteToServer, parsed, res)
}

if errorsBeforeValidation == len(res.Errors) {
if err := jsonUnmarshaler([]byte(value[0]), f.Addr().Interface()); err != nil {
if err := jsonUnmarshaler(toParse, f.Addr().Interface()); err != nil {
// Should have been caught by the validation above.
res.Add(pb, value, "invalid JSON: "+err.Error())
}
Expand All @@ -1088,6 +1124,13 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I)
return
}

// Validation should fail if multiple values are
// provided but the type of f is not a slice.
if len(value) > 1 && f.Type().Kind() != reflect.Slice {
res.Add(pb, value, "expected at most one value, but received multiple values")
return
}

// Regular fields
pv, err := parseInto(ctx, f, value[0], value, *p)
if err != nil {
Expand Down
194 changes: 185 additions & 9 deletions huma_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1636,6 +1636,50 @@ Content-Disposition: form-data; name="file"; filename="test.txt"
Content-Type: text/plain

Hello World
--SimpleBoundary--`,
},
{
Name: "request-body-multipart-json-explode-false",
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 {
Numbers []int `form:"numbers" explode:"false" contentType:"application/json"`
// Multipart form-data are exploded by default, so we don't need to set explode:"true" here.
Tags []string `form:"tags" contentType:"application/json"`
}]
}) (*struct{}, error) {
return nil, nil
})
explode := api.OpenAPI().Paths["/upload"].Post.RequestBody.Content["multipart/form-data"].Encoding["numbers"].Explode
assert.NotNil(t, explode)
assert.False(t, *explode)
},
Method: http.MethodPost,
URL: "/upload",
Headers: map[string]string{"Content-Type": "multipart/form-data; boundary=SimpleBoundary"},
Body: `--SimpleBoundary
Content-Disposition: form-data; name="numbers"
Content-Type: application/json

[1, 2, 3, 4, 5]
--SimpleBoundary
Content-Disposition: form-data; name="tags"
Content-Type: application/json

"tag1"
--SimpleBoundary
Content-Disposition: form-data; name="tags"
Content-Type: application/json

"tag2"
--SimpleBoundary
Content-Disposition: form-data; name="tags"
Content-Type: application/json

"tag3"
--SimpleBoundary--`,
},
{
Expand All @@ -1646,10 +1690,11 @@ Hello World
Path: "/upload",
}, func(ctx context.Context, input *struct {
RawBody huma.MultipartFormFiles[struct {
Numbers []int `form:"numbers" contentType:"application/json"`
Tags []string `form:"tags" contentType:"application/json"`
Count int `form:"count" contentType:"application/json"`
Active bool `form:"active" contentType:"application/json"`
Numbers []int `form:"numbers" explode:"false" contentType:"application/json"`
// Multipart form-data are exploded by default, so we don't need to set explode:"true" here.
Tags []string `form:"tags" contentType:"application/json"`
Count int `form:"count" contentType:"application/json"`
Active bool `form:"active" contentType:"application/json"`
}]
}) (*struct{}, error) {
data := input.RawBody.Data()
Expand Down Expand Up @@ -1677,7 +1722,17 @@ Content-Type: application/json
Content-Disposition: form-data; name="tags"
Content-Type: application/json

["tag1", "tag2", "tag3"]
"tag1"
--SimpleBoundary
Content-Disposition: form-data; name="tags"
Content-Type: application/json

"tag2"
--SimpleBoundary
Content-Disposition: form-data; name="tags"
Content-Type: application/json

"tag3"
--SimpleBoundary
Content-Disposition: form-data; name="count"
Content-Type: application/json
Expand All @@ -1688,6 +1743,127 @@ Content-Disposition: form-data; name="active"
Content-Type: application/json

true
--SimpleBoundary--`,
},
{
Name: "request-body-multipart-json-array-one-invalid-part",
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 {
Tags []string `form:"tags" contentType:"application/json"`
}]
}) (*struct{}, error) {
return nil, nil
})
},
Method: http.MethodPost,
URL: "/upload",
Headers: map[string]string{"Content-Type": "multipart/form-data; boundary=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, "form.tags[2]", errors.Errors[0].Location)
}
},
Body: `--SimpleBoundary
Content-Disposition: form-data; name="tags"
Content-Type: application/json

"tag1"
--SimpleBoundary
Content-Disposition: form-data; name="tags"
Content-Type: application/json

"tag2"
--SimpleBoundary
Content-Disposition: form-data; name="tags"
Content-Type: application/json

not valid json
--SimpleBoundary--`,
},
{
Name: "request-body-multipart-json-single-value-no-multiple-parts",
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 {
Active bool `form:"active" contentType:"application/json"`
}]
}) (*struct{}, error) {
return nil, nil
})
},
Method: http.MethodPost,
URL: "/upload",
Headers: map[string]string{"Content-Type": "multipart/form-data; boundary=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, "form.active", errors.Errors[0].Location)
}
},
Body: `--SimpleBoundary
Content-Disposition: form-data; name="active"
Content-Type: application/json

false
--SimpleBoundary
Content-Disposition: form-data; name="active"
Content-Type: application/json

true
--SimpleBoundary--`,
},
{
Name: "request-body-multipart-json-array-explode-false-multiple-parts-error",
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 {
Tags []string `form:"tags" contentType:"application/json" explode:"false"`
}]
}) (*struct{}, error) {
return nil, nil
})
},
Method: http.MethodPost,
URL: "/upload",
Headers: map[string]string{"Content-Type": "multipart/form-data; boundary=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, "form.tags", errors.Errors[0].Location)
}
},
Body: `--SimpleBoundary
Content-Disposition: form-data; name="tags"
Content-Type: application/json

"tag1"
--SimpleBoundary
Content-Disposition: form-data; name="tags"
Content-Type: application/json

"tag2"
--SimpleBoundary
Content-Disposition: form-data; name="tags"
Content-Type: application/json

"tag3"
--SimpleBoundary--`,
},
{
Expand Down Expand Up @@ -2919,10 +3095,10 @@ Content-Type: text/plain
},
Method: http.MethodGet,
URL: "/transform",
Assert: func(t *testing.T, resp *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, resp.Code)
assert.JSONEq(t, `null`, resp.Body.String())
},
Assert: func(t *testing.T, resp *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, resp.Code)
assert.JSONEq(t, `null`, resp.Body.String())
},
},
{
Name: "schema-url-from-x-forwarded-host",
Expand Down
Loading
Loading