diff --git a/CHANGELOG.md b/CHANGELOG.md index db38f5a..d5c93df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ How to release a new version: ## [Unreleased] +### Added +- package `http/param`: can parse form data into embedded structs. + ## [0.8.0] - 2024-11-14 ### Added - package `http/param`: can parse into embedded structs. diff --git a/http/param/param.go b/http/param/param.go index 34ceb49..fc8a3a1 100644 --- a/http/param/param.go +++ b/http/param/param.go @@ -2,6 +2,7 @@ package param import ( "encoding" + "errors" "fmt" "net/http" "reflect" @@ -11,8 +12,10 @@ import ( const ( defaultTagName = "param" + defaultMaxMemory = 32 << 20 // 32 MB queryTagValuePrefix = "query" pathTagValuePrefix = "path" + formTagValuePrefix = "form" ) // TagResolver is a function that decides from a field tag what parameter should be searched. @@ -34,6 +37,13 @@ func TagNameResolver(tagName string) TagResolver { // PathParamFunc is a function that returns value of specified http path parameter. type PathParamFunc func(r *http.Request, key string) string +// FormParamFunc is a function that returns value of specified form parameter. +type FormParamFunc func(r *http.Request, key string) string + +func DefaultFormParamFunc(r *http.Request, key string) string { + return r.PostFormValue(key) +} + // Parser can Parse query and path parameters from http.Request into a struct. // Fields struct have to be tagged such that either QueryParamTagResolver or PathParamTagResolver returns // valid parameter name from the provided tag. @@ -43,6 +53,7 @@ type PathParamFunc func(r *http.Request, key string) string type Parser struct { ParamTagResolver TagResolver PathParamFunc PathParamFunc + FormParamFunc FormParamFunc } // DefaultParser returns query and path parameter Parser with intended struct tags @@ -51,6 +62,7 @@ func DefaultParser() Parser { return Parser{ ParamTagResolver: TagNameResolver(defaultTagName), PathParamFunc: nil, // keep nil, as there is no sensible default of how to get value of path parameter + FormParamFunc: DefaultFormParamFunc, } } @@ -61,6 +73,13 @@ func (p Parser) WithPathParamFunc(f PathParamFunc) Parser { return p } +// WithFormParamFunc returns a copy of Parser with set function for getting form parameters from http.Request. +// For more see Parser description. +func (p Parser) WithFormParamFunc(f FormParamFunc) Parser { + p.FormParamFunc = f + return p +} + // Parse accepts the request and a pointer to struct with its fields tagged with appropriate tags set in Parser. // Such tagged fields must be in top level struct, or in exported struct embedded in top-level struct. // All such tagged fields are assigned the respective parameter from the actual request. @@ -113,6 +132,7 @@ type paramType int const ( paramTypeQuery paramType = iota paramTypePath + paramTypeForm ) type taggedFieldIndexPath struct { @@ -139,21 +159,25 @@ func (p Parser) findTaggedIndexPaths(typ reflect.Type, currentNestingIndexPath [ } tag := typeField.Tag pathParamName, okPath := p.resolvePath(tag) + formParamName, okForm := p.resolveForm(tag) queryParamName, okQuery := p.resolveQuery(tag) + + newPath := append(append([]int{}, currentNestingIndexPath...), i) if okPath { - newPath := make([]int, 0, len(currentNestingIndexPath)+1) - newPath = append(newPath, currentNestingIndexPath...) - newPath = append(newPath, i) paths = append(paths, taggedFieldIndexPath{ paramType: paramTypePath, paramName: pathParamName, indexPath: newPath, }) } + if okForm { + paths = append(paths, taggedFieldIndexPath{ + paramType: paramTypeForm, + paramName: formParamName, + indexPath: newPath, + }) + } if okQuery { - newPath := make([]int, 0, len(currentNestingIndexPath)+1) - newPath = append(newPath, currentNestingIndexPath...) - newPath = append(newPath, i) paths = append(paths, taggedFieldIndexPath{ paramType: paramTypeQuery, paramName: queryParamName, @@ -194,6 +218,11 @@ func (p Parser) parseParam(r *http.Request, path taggedFieldIndexPath) error { if err != nil { return err } + case paramTypeForm: + err := p.parseFormParam(r, path.paramName, path.destValue) + if err != nil { + return err + } case paramTypeQuery: err := p.parseQueryParam(r, path.paramName, path.destValue) if err != nil { @@ -217,6 +246,29 @@ func (p Parser) parsePathParam(r *http.Request, paramName string, v reflect.Valu return nil } +func (p Parser) parseFormParam(r *http.Request, paramName string, v reflect.Value) error { + if r.Method != http.MethodPost && r.Method != http.MethodPut && r.Method != http.MethodPatch { + return fmt.Errorf("struct's field was tagged for parsing the form parameter (%s) but request method is not POST, PUT or PATCH", paramName) + } + if err := r.ParseMultipartForm(defaultMaxMemory); err != nil { + if !errors.Is(err, http.ErrNotMultipart) { + return fmt.Errorf("parsing multipart form: %w", err) + } + // Try to parse regular form if not multipart form. + if err := r.ParseForm(); err != nil { + return fmt.Errorf("parsing form: %w", err) + } + } + paramValue := p.FormParamFunc(r, paramName) + if paramValue != "" { + err := unmarshalValue(paramValue, v) + if err != nil { + return fmt.Errorf("unmarshaling form parameter %s: %w", paramName, err) + } + } + return nil +} + func (p Parser) parseQueryParam(r *http.Request, paramName string, v reflect.Value) error { query := r.URL.Query() if values, ok := query[paramName]; ok && len(values) > 0 { @@ -331,6 +383,10 @@ func (p Parser) resolvePath(fieldTag reflect.StructTag) (string, bool) { return p.resolveTagWithModifier(fieldTag, pathTagValuePrefix) } +func (p Parser) resolveForm(fieldTag reflect.StructTag) (string, bool) { + return p.resolveTagWithModifier(fieldTag, formTagValuePrefix) +} + func (p Parser) resolveQuery(fieldTag reflect.StructTag) (string, bool) { return p.resolveTagWithModifier(fieldTag, queryTagValuePrefix) } diff --git a/http/param/param_test.go b/http/param/param_test.go index 4dc9bd5..d6cd61b 100644 --- a/http/param/param_test.go +++ b/http/param/param_test.go @@ -451,6 +451,72 @@ func TestParser_Parse_PathParam_FuncNotDefinedError(t *testing.T) { assert.Error(t, err) } +type structWithFormParams struct { + Subject string `param:"form=subject"` + Amount *int `param:"form=amount"` + Object *maybeShinyObject `param:"form=object"` + Nothing string `param:"form=nothing"` +} + +func TestParser_Parse_FormParam(t *testing.T) { + r := chi.NewRouter() + p := DefaultParser() + result := structWithFormParams{ + Nothing: "should be replaced", + } + expected := structWithFormParams{ + Subject: "world", + Amount: ptr(69), + Object: &maybeShinyObject{ + IsShiny: true, + Object: "apples", + }, + Nothing: "", + } + var parseError error + r.Post("/hello/objects", func(_ http.ResponseWriter, r *http.Request) { + parseError = p.Parse(r, &result) + }) + + urlEncodedBodyContent := "subject=world&amount=69&object=shiny-apples¬hing=" + urlEncodedBody := strings.NewReader(urlEncodedBodyContent) + req := httptest.NewRequest(http.MethodPost, "https://test.com/hello/objects", urlEncodedBody) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.ServeHTTP(httptest.NewRecorder(), req) + + require.NoError(t, parseError) + assert.Equal(t, expected, result) +} + +func TestParser_Parse_FormParam_NoParams(t *testing.T) { + p := DefaultParser() + result := structWithFormParams{ + Subject: "should be replaced", + Amount: ptr(123), // should be zeroed out + Nothing: "should be replaced", + } + expected := structWithFormParams{ + Subject: "", + Amount: nil, + Object: nil, + Nothing: "", + } + var parseError error + + r := chi.NewRouter() + r.Post("/hello/objects", func(_ http.ResponseWriter, r *http.Request) { + parseError = p.Parse(r, &result) + }) + + // Empty form body + req := httptest.NewRequest(http.MethodPost, "https://test.com/hello/objects", strings.NewReader("")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.ServeHTTP(httptest.NewRecorder(), req) + + require.NoError(t, parseError) + assert.Equal(t, expected, result) +} + type otherFieldsStruct struct { Q string `param:"query=q"` Other string `json:"other"`