diff --git a/formdata.go b/formdata.go index f153348c..8ac9efa8 100644 --- a/formdata.go +++ b/formdata.go @@ -150,6 +150,24 @@ func (m *MultipartFormFiles[T]) readMultipleFiles(key string, opMediaType *Media return files, errors } +func (m *MultipartFormFiles[T]) readSingleString(key string, opMediaType *MediaType) (string, *ErrorDetail) { + values := m.Form.Value[key] + if len(values) > 1 { + return "", &ErrorDetail{ + Message: "Multiple strings received but only one was expected", + Location: key, + } + } + if len(values) == 0 { + if opMediaType.Schema.requiredMap[key] { + return "", &ErrorDetail{Message: "String required", Location: key} + } else { + return "", nil + } + } + return values[0], nil +} + func (m *MultipartFormFiles[T]) Data() *T { return m.data } @@ -184,7 +202,13 @@ func (m *MultipartFormFiles[T]) Decode(opMediaType *MediaType) []error { continue } field.Set(reflect.ValueOf(files)) - + case field.Type().Kind() == reflect.String: + s, err := m.readSingleString(key, opMediaType) + if err != nil { + errors = append(errors, err) + continue + } + field.Set(reflect.ValueOf(s)) default: continue } @@ -221,6 +245,8 @@ func multiPartFormFileSchema(t reflect.Type) *Schema { Type: "array", Items: multiPartFileSchema(f), } + case f.Type.Kind() == reflect.String: + schema.Properties[name] = &Schema{Type: TypeString} default: // Should we panic if [T] struct defines fields with unsupported types ? continue @@ -250,9 +276,19 @@ func multiPartContentEncoding(t reflect.Type) map[string]*Encoding { for i := 0; i < nFields; i++ { f := t.Field(i) name := formDataFieldName(f) - contentType := f.Tag.Get("contentType") - if contentType == "" { - contentType = "application/octet-stream" + var contentType string + switch { + case f.Type == reflect.TypeOf(FormFile{}): + fallthrough + case f.Type == reflect.TypeOf([]FormFile{}): + contentType = f.Tag.Get("contentType") + if contentType == "" { + contentType = "application/octet-stream" + } + case f.Type.Kind() == reflect.String: + contentType = "text/plain" + default: + continue } encoding[name] = &Encoding{ ContentType: contentType, diff --git a/huma.go b/huma.go index 37e30b81..99063935 100644 --- a/huma.go +++ b/huma.go @@ -1336,7 +1336,7 @@ func Register[I, O any](api API, op Operation, handler func(context.Context, *I) res.Errors = append(res.Errors, &ErrorDetail{ Location: "body", Message: err.Error(), - Value: string(body), + Value: body, }) parseErrCount++ } else { diff --git a/huma_test.go b/huma_test.go index 400fb040..80348921 100644 --- a/huma_test.go +++ b/huma_test.go @@ -1367,6 +1367,82 @@ Content of example2.txt. assert.Equal(t, http.StatusUnprocessableEntity, resp.Code) }, }, + { + Name: "request-body-multipart-file-decoded-with-strings-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"` + Sender string `form:"sender" required:"true"` + }] + }) (*struct{}, error) { + fileData := input.RawBody.Data() + + assert.Equal(t, "text/plain", fileData.HelloWorld.ContentType) + assert.Equal(t, "test.txt", fileData.HelloWorld.Filename) + assert.Equal(t, len("Hello, World!"), int(fileData.HelloWorld.Size)) + 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, "Your favorite sender", fileData.Sender) + 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["sender"].ContentType) + assert.Equal(t, "string", mpContent.Schema.Properties["sender"].Type) + assert.Contains(t, mpContent.Schema.Required, "sender") + }, + 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="sender" +Content-Type: text/plain + +Your favorite sender +--SimpleBoundary--`, + }, + { + Name: "request-body-multipart-file-decoded-with-strings-required-missing", + 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 { + Sender string `form:"sender" 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--`, + 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, "sender", errors.Errors[0].Location) + } + }, + }, { Name: "handler-error", Register: func(t *testing.T, api huma.API) {