From d36e3e801b854f3d17bd2f2593147ac9e432d763 Mon Sep 17 00:00:00 2001 From: Marek Cermak Date: Fri, 15 Aug 2025 10:13:24 +0200 Subject: [PATCH 1/5] feat(http): add form parser The form parser is useful for parsing application/x-www-form-urlencoded data. Signed-off-by: Marek Cermak --- CHANGELOG.md | 3 +++ http/param/param.go | 50 ++++++++++++++++++++++++++++++++++++++++ http/param/param_test.go | 39 +++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+) 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..24b6ac0 100644 --- a/http/param/param.go +++ b/http/param/param.go @@ -13,6 +13,7 @@ const ( defaultTagName = "param" queryTagValuePrefix = "query" pathTagValuePrefix = "path" + formTagValuePrefix = "form" ) // TagResolver is a function that decides from a field tag what parameter should be searched. @@ -34,6 +35,9 @@ 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 + // 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 +47,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 +56,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: nil, // keep nil, as there is no sensible default of how to get value of form parameter } } @@ -61,6 +67,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 +126,7 @@ type paramType int const ( paramTypeQuery paramType = iota paramTypePath + paramTypeForm ) type taggedFieldIndexPath struct { @@ -139,6 +153,7 @@ 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) if okPath { newPath := make([]int, 0, len(currentNestingIndexPath)+1) @@ -150,6 +165,16 @@ func (p Parser) findTaggedIndexPaths(typ reflect.Type, currentNestingIndexPath [ indexPath: newPath, }) } + if okForm { + newPath := make([]int, 0, len(currentNestingIndexPath)+1) + newPath = append(newPath, currentNestingIndexPath...) + newPath = append(newPath, i) + paths = append(paths, taggedFieldIndexPath{ + paramType: paramTypeForm, + paramName: formParamName, + indexPath: newPath, + }) + } if okQuery { newPath := make([]int, 0, len(currentNestingIndexPath)+1) newPath = append(newPath, currentNestingIndexPath...) @@ -194,6 +219,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 +247,22 @@ 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.ParseForm(); err != nil { + return fmt.Errorf("parsing form data: %w", err) + } + if values, ok := r.Form[paramName]; ok && len(values) > 0 { + err := unmarshalValueOrSlice(values, 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 +377,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..90863d8 100644 --- a/http/param/param_test.go +++ b/http/param/param_test.go @@ -451,6 +451,45 @@ 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().WithFormParamFunc(func(r *http.Request, key string) string { + return r.FormValue(key) + }) + 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(w 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) + + assert.NoError(t, parseError) + assert.Equal(t, expected, result) +} + type otherFieldsStruct struct { Q string `param:"query=q"` Other string `json:"other"` From 4212783f4c2b24df842752e824eeba537c8855fa Mon Sep 17 00:00:00 2001 From: Marek Cermak Date: Fri, 15 Aug 2025 15:44:11 +0200 Subject: [PATCH 2/5] chore: address review comments - use FormParamFunc - use PostForm instead of Form - simplify newPath Signed-off-by: Marek Cermak --- http/param/param.go | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/http/param/param.go b/http/param/param.go index 24b6ac0..38502b0 100644 --- a/http/param/param.go +++ b/http/param/param.go @@ -36,7 +36,15 @@ func TagNameResolver(tagName string) TagResolver { 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 +type FormParamFunc func(r *http.Request, key string) []string + +func DefaultFormParamFunc(r *http.Request, key string) []string { + // ParseForm is called in Parser.Parse, so we can safely assume that r.Form is already populated. + if values, ok := r.PostForm[key]; ok && len(values) > 0 { + return values + } + return nil +} // 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 @@ -56,7 +64,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: nil, // keep nil, as there is no sensible default of how to get value of form parameter + FormParamFunc: DefaultFormParamFunc, } } @@ -155,10 +163,9 @@ func (p Parser) findTaggedIndexPaths(typ reflect.Type, currentNestingIndexPath [ 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, @@ -166,9 +173,6 @@ func (p Parser) findTaggedIndexPaths(typ reflect.Type, currentNestingIndexPath [ }) } if okForm { - newPath := make([]int, 0, len(currentNestingIndexPath)+1) - newPath = append(newPath, currentNestingIndexPath...) - newPath = append(newPath, i) paths = append(paths, taggedFieldIndexPath{ paramType: paramTypeForm, paramName: formParamName, @@ -176,9 +180,6 @@ func (p Parser) findTaggedIndexPaths(typ reflect.Type, currentNestingIndexPath [ }) } 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, @@ -254,8 +255,9 @@ func (p Parser) parseFormParam(r *http.Request, paramName string, v reflect.Valu if err := r.ParseForm(); err != nil { return fmt.Errorf("parsing form data: %w", err) } - if values, ok := r.Form[paramName]; ok && len(values) > 0 { - err := unmarshalValueOrSlice(values, v) + paramValues := p.FormParamFunc(r, paramName) + if len(paramValues) > 0 { + err := unmarshalValueOrSlice(paramValues, v) if err != nil { return fmt.Errorf("unmarshaling form parameter %s: %w", paramName, err) } From efa90be01758084b02d0a29ed7780e2b5ccdd524 Mon Sep 17 00:00:00 2001 From: Marek Cermak Date: Fri, 15 Aug 2025 16:37:58 +0200 Subject: [PATCH 3/5] chore: use r.PostFormValue as the default form param func Signed-off-by: Marek Cermak --- http/param/param.go | 16 ++++++---------- http/param/param_test.go | 4 +--- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/http/param/param.go b/http/param/param.go index 38502b0..8938a57 100644 --- a/http/param/param.go +++ b/http/param/param.go @@ -36,14 +36,10 @@ func TagNameResolver(tagName string) TagResolver { 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 +type FormParamFunc func(r *http.Request, key string) string -func DefaultFormParamFunc(r *http.Request, key string) []string { - // ParseForm is called in Parser.Parse, so we can safely assume that r.Form is already populated. - if values, ok := r.PostForm[key]; ok && len(values) > 0 { - return values - } - return nil +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. @@ -255,9 +251,9 @@ func (p Parser) parseFormParam(r *http.Request, paramName string, v reflect.Valu if err := r.ParseForm(); err != nil { return fmt.Errorf("parsing form data: %w", err) } - paramValues := p.FormParamFunc(r, paramName) - if len(paramValues) > 0 { - err := unmarshalValueOrSlice(paramValues, v) + paramValue := p.FormParamFunc(r, paramName) + if paramValue != "" { + err := unmarshalValue(paramValue, v) if err != nil { return fmt.Errorf("unmarshaling form parameter %s: %w", paramName, err) } diff --git a/http/param/param_test.go b/http/param/param_test.go index 90863d8..1118432 100644 --- a/http/param/param_test.go +++ b/http/param/param_test.go @@ -460,9 +460,7 @@ type structWithFormParams struct { func TestParser_Parse_FormParam(t *testing.T) { r := chi.NewRouter() - p := DefaultParser().WithFormParamFunc(func(r *http.Request, key string) string { - return r.FormValue(key) - }) + p := DefaultParser() result := structWithFormParams{ Nothing: "should be replaced", } From 3618505ee9ebf2b0799603bfd51947c6b3a9d019 Mon Sep 17 00:00:00 2001 From: Marek Cermak Date: Wed, 27 Aug 2025 09:37:17 +0200 Subject: [PATCH 4/5] chore: address review comments Signed-off-by: Marek Cermak --- http/param/param.go | 12 ++++++++++-- http/param/param_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/http/param/param.go b/http/param/param.go index 8938a57..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,6 +12,7 @@ import ( const ( defaultTagName = "param" + defaultMaxMemory = 32 << 20 // 32 MB queryTagValuePrefix = "query" pathTagValuePrefix = "path" formTagValuePrefix = "form" @@ -248,8 +250,14 @@ func (p Parser) parseFormParam(r *http.Request, paramName string, v reflect.Valu 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.ParseForm(); err != nil { - return fmt.Errorf("parsing form data: %w", err) + 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 != "" { diff --git a/http/param/param_test.go b/http/param/param_test.go index 1118432..9cbdccf 100644 --- a/http/param/param_test.go +++ b/http/param/param_test.go @@ -488,6 +488,35 @@ func TestParser_Parse_FormParam(t *testing.T) { 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(w 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) + + assert.NoError(t, parseError) + assert.Equal(t, expected, result) +} + type otherFieldsStruct struct { Q string `param:"query=q"` Other string `json:"other"` From 7c4ba131719808addcc05c6942876924d9946623 Mon Sep 17 00:00:00 2001 From: Marek Cermak Date: Wed, 27 Aug 2025 14:09:46 +0200 Subject: [PATCH 5/5] chore: fix linter issues Signed-off-by: Marek Cermak --- http/param/param_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/http/param/param_test.go b/http/param/param_test.go index 9cbdccf..d6cd61b 100644 --- a/http/param/param_test.go +++ b/http/param/param_test.go @@ -474,7 +474,7 @@ func TestParser_Parse_FormParam(t *testing.T) { Nothing: "", } var parseError error - r.Post("/hello/objects", func(w http.ResponseWriter, r *http.Request) { + r.Post("/hello/objects", func(_ http.ResponseWriter, r *http.Request) { parseError = p.Parse(r, &result) }) @@ -484,7 +484,7 @@ func TestParser_Parse_FormParam(t *testing.T) { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.ServeHTTP(httptest.NewRecorder(), req) - assert.NoError(t, parseError) + require.NoError(t, parseError) assert.Equal(t, expected, result) } @@ -504,7 +504,7 @@ func TestParser_Parse_FormParam_NoParams(t *testing.T) { var parseError error r := chi.NewRouter() - r.Post("/hello/objects", func(w http.ResponseWriter, r *http.Request) { + r.Post("/hello/objects", func(_ http.ResponseWriter, r *http.Request) { parseError = p.Parse(r, &result) }) @@ -513,7 +513,7 @@ func TestParser_Parse_FormParam_NoParams(t *testing.T) { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.ServeHTTP(httptest.NewRecorder(), req) - assert.NoError(t, parseError) + require.NoError(t, parseError) assert.Equal(t, expected, result) }