From 5990e7f432903d38e03678c6b7056e46fbc0ed65 Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Sun, 14 Feb 2021 15:31:27 +0200 Subject: [PATCH] NEW (HOT) FEATURE: Add custom error handlers on path type parameters error --- HISTORY.md | 16 +++++++ _examples/routing/dynamic-path/main.go | 9 ++++ macro/handler/handler.go | 35 ++++++++++++-- macro/macro.go | 28 +++++++---- macro/macro_test.go | 21 +++++++-- macro/macros.go | 64 +++++++++----------------- macro/template.go | 36 ++++++++++----- 7 files changed, 140 insertions(+), 69 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 31beb8fe..118c344e 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -28,6 +28,22 @@ The codebase for Dependency Injection, Internationalization and localization and ## Fixes and Improvements +- **New feature:** add the ability to set custom error handlers on path type parameters errors (existing or custom ones). Example Code: + +```go +app.Macros().Get("uuid").HandleError(func(ctx iris.Context, err error) { + ctx.StatusCode(iris.StatusBadRequest) + ctx.JSON(iris.Map{ + "error": err.Error(), + "message": "invalid path parameter", + }) +}) + +app.Get("/users/{id:uuid}", getUser) +``` + +- Improve the performance and fix `:int, :int8, :int16, :int32, :int64, :uint, :uint8, :uint16, :uint32, :uint64` path type parameters couldn't accept a positive number written with the plus symbol or with a leading zeroes, e.g. `+42` and `021`. + - The `iris.WithEmptyFormError` option is respected on `context.ReadQuery` method too, as requested at [#1727](https://github.com/kataras/iris/issues/1727). [Example comments](https://github.com/kataras/iris/blob/master/_examples/request-body/read-query/main.go) were updated. - New `httptest.Strict` option setter to enable the `httpexpect.RequireReporter` instead of the default `httpexpect.AssetReporter. Use that to enable complete test failure on the first error. As requested at: [#1722](https://github.com/kataras/iris/issues/1722). diff --git a/_examples/routing/dynamic-path/main.go b/_examples/routing/dynamic-path/main.go index 9adab380..16146693 100644 --- a/_examples/routing/dynamic-path/main.go +++ b/_examples/routing/dynamic-path/main.go @@ -152,6 +152,15 @@ func main() { // +------------------------+ // UUIDv4 (and v1) path parameter validation. + // Optionally, set custom handler on path parameter type error: + app.Macros().Get("uuid").HandleError(func(ctx iris.Context, err error) { + ctx.StatusCode(iris.StatusBadRequest) + ctx.JSON(iris.Map{ + "error": err.Error(), + "message": "invalid path parameter", + }) + }) + // http://localhost:8080/user/bb4f33e4-dc08-40d8-9f2b-e8b2bb615c0e -> OK // http://localhost:8080/user/dsadsa-invalid-uuid -> NOT FOUND app.Get("/user/{id:uuid}", func(ctx iris.Context) { diff --git a/macro/handler/handler.go b/macro/handler/handler.go index 42016cad..ce7a36e9 100644 --- a/macro/handler/handler.go +++ b/macro/handler/handler.go @@ -3,11 +3,27 @@ package handler import ( + "fmt" + "github.com/kataras/iris/v12/context" "github.com/kataras/iris/v12/core/memstore" "github.com/kataras/iris/v12/macro" ) +// ParamErrorHandler is a special type of Iris handler which receives +// any error produced by a path type parameter evaluator and let developers +// customize the output instead of the +// provided error code 404 or anyother status code given on the `else` literal. +// +// Note that the builtin macros return error too, but they're handled +// by the `else` literal (error code). To change this behavior +// and send a custom error response you have to register it: +// app.Macros().Get("uuid").HandleError(func(iris.Context, err error)). +// You can also set custom macros by `app.Macros().Register`. +// +// See macro.HandleError to set it. +type ParamErrorHandler = func(*context.Context, error) // alias. + // CanMakeHandler reports whether a macro template needs a special macro's evaluator handler to be validated // before procceed to the next handler(s). // If the template does not contain any dynamic attributes and a special handler is NOT required @@ -24,6 +40,13 @@ func CanMakeHandler(tmpl macro.Template) (needsMacroHandler bool) { if p.CanEval() { // if at least one needs it, then create the handler. needsMacroHandler = true + + if p.HandleError != nil { + // Check for its type. + if _, ok := p.HandleError.(ParamErrorHandler); !ok { + panic(fmt.Sprintf("HandleError must be a type of func(iris.Context, error) but got: %T", p.HandleError)) + } + } break } } @@ -83,9 +106,15 @@ func MakeFilter(tmpl macro.Template) context.Filter { return false } - value := p.Eval(entry.String()) - if value == nil { - ctx.StatusCode(p.ErrCode) + value, passed := p.Eval(entry.String()) + if !passed { + ctx.StatusCode(p.ErrCode) // status code can change from an error handler, set it here. + if value != nil && p.HandleError != nil { + // The "value" is an error here, always (see template.Eval). + // This is always a type of ParamErrorHandler at this state (see CanMakeHandler). + p.HandleError.(ParamErrorHandler)(ctx, value.(error)) + } + return false } diff --git a/macro/macro.go b/macro/macro.go index 25c6df22..54e04260 100644 --- a/macro/macro.go +++ b/macro/macro.go @@ -125,6 +125,12 @@ func convertBuilderFunc(fn interface{}) ParamFuncBuilder { numFields := typFn.NumIn() + panicIfErr := func(i int, err error) { + if err != nil { + panic(fmt.Sprintf("on field index: %d: %v", i, err)) + } + } + return func(args []string) reflect.Value { if len(args) != numFields { // no variadics support, for now. @@ -138,12 +144,6 @@ func convertBuilderFunc(fn interface{}) ParamFuncBuilder { // try to convert the string literal as we get it from the parser. var ( val interface{} - - panicIfErr = func(i int, err error) { - if err != nil { - panic(fmt.Sprintf("on field index: %d: %v", i, err)) - } - } ) // try to get the value based on the expected type. @@ -254,8 +254,9 @@ type ( master bool trailing bool - Evaluator ParamEvaluator - funcs []ParamFunc + Evaluator ParamEvaluator + handleError interface{} + funcs []ParamFunc } // ParamFuncBuilder is a func @@ -312,6 +313,17 @@ func (m *Macro) Trailing() bool { return m.trailing } +// HandleError registers a handler which will be executed +// when a parameter evaluator returns false and a non nil value which is a type of `error`. +// The "fnHandler" value MUST BE a type of `func(iris.Context, err error)`, +// otherwise the program will receive a panic before server startup. +// The status code of the ErrCode (`else` literal) is set +// before the error handler but it can be modified inside the handler itself. +func (m *Macro) HandleError(fnHandler interface{}) *Macro { // See handler.MakeFilter. + m.handleError = fnHandler + return m +} + // func (m *Macro) SetParamResolver(fn func(memstore.Entry) interface{}) *Macro { // m.ParamResolver = fn // return m diff --git a/macro/macro_test.go b/macro/macro_test.go index df2d344d..8ee752b0 100644 --- a/macro/macro_test.go +++ b/macro/macro_test.go @@ -66,6 +66,8 @@ func TestGoodParamFuncName(t *testing.T) { } func testEvaluatorRaw(t *testing.T, macroEvaluator *Macro, input string, expectedType reflect.Kind, pass bool, i int) { + t.Helper() + if macroEvaluator.Evaluator == nil && pass { return // if not evaluator defined then it should allow everything. } @@ -120,7 +122,7 @@ func TestIntEvaluatorRaw(t *testing.T) { {false, "-18446744073709553213213213213213121615"}, // 5 {false, "42 18446744073709551615"}, // 6 {false, "--42"}, // 7 - {false, "+42"}, // 8 + {true, "+42"}, // 8 {false, "main.css"}, // 9 {false, "/assets/main.css"}, // 10 } @@ -130,6 +132,15 @@ func TestIntEvaluatorRaw(t *testing.T) { } } +func BenchmarkIntEvaluatorRaw(b *testing.B) { + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + Int.Evaluator("1234568320") + Int.Evaluator("-12345678999321") + } +} + func TestInt8EvaluatorRaw(t *testing.T) { tests := []struct { pass bool @@ -145,7 +156,7 @@ func TestInt8EvaluatorRaw(t *testing.T) { {false, "-18446744073709553213213213213213121615"}, // 7 {false, "42 18446744073709551615"}, // 8 {false, "--42"}, // 9 - {false, "+42"}, // 10 + {true, "+42"}, // 10 {false, "main.css"}, // 11 {false, "/assets/main.css"}, // 12 } @@ -170,7 +181,7 @@ func TestInt16EvaluatorRaw(t *testing.T) { {false, "-18446744073709553213213213213213121615"}, // 7 {false, "42 18446744073709551615"}, // 8 {false, "--42"}, // 9 - {false, "+42"}, // 10 + {true, "+42"}, // 10 {false, "main.css"}, // 11 {false, "/assets/main.css"}, // 12 } @@ -197,7 +208,7 @@ func TestInt32EvaluatorRaw(t *testing.T) { {false, "-18446744073709553213213213213213121615"}, // 9 {false, "42 18446744073709551615"}, // 10 {false, "--42"}, // 11 - {false, "+42"}, // 12 + {true, "+42"}, // 12 {false, "main.css"}, // 13 {false, "/assets/main.css"}, // 14 } @@ -278,7 +289,7 @@ func TestUint8EvaluatorRaw(t *testing.T) { {false, "+1"}, // 9 {false, "18446744073709551615"}, // 10 {false, "9223372036854775807"}, // 11 - {false, "021"}, // 12 - no leading zeroes are allowed. + {true, "021"}, // 12 - leading zeroes are allowed. {false, "300"}, // 13 {true, "0"}, // 14 {true, "255"}, // 15 diff --git a/macro/macros.go b/macro/macros.go index 2fb3b555..13b2eb57 100644 --- a/macro/macros.go +++ b/macro/macros.go @@ -1,6 +1,8 @@ package macro import ( + "errors" + "fmt" "strconv" "strings" @@ -47,19 +49,14 @@ var ( } }) - simpleNumberEval = MustRegexp("^-?[0-9]+$") // Int or number type // both positive and negative numbers, actual value can be min-max int64 or min-max int32 depends on the arch. // If x64: -9223372036854775808 to 9223372036854775807. // If x32: -2147483648 to 2147483647 and etc.. Int = NewMacro("int", "number", false, false, func(paramValue string) (interface{}, bool) { - if !simpleNumberEval(paramValue) { - return nil, false - } - v, err := strconv.Atoi(paramValue) if err != nil { - return nil, false + return err, false } return v, true @@ -89,13 +86,9 @@ var ( // Int8 type // -128 to 127. Int8 = NewMacro("int8", "", false, false, func(paramValue string) (interface{}, bool) { - if !simpleNumberEval(paramValue) { - return nil, false - } - v, err := strconv.ParseInt(paramValue, 10, 8) if err != nil { - return nil, false + return err, false } return int8(v), true }). @@ -118,13 +111,9 @@ var ( // Int16 type // -32768 to 32767. Int16 = NewMacro("int16", "", false, false, func(paramValue string) (interface{}, bool) { - if !simpleNumberEval(paramValue) { - return nil, false - } - v, err := strconv.ParseInt(paramValue, 10, 16) if err != nil { - return nil, false + return err, false } return int16(v), true }). @@ -147,13 +136,9 @@ var ( // Int32 type // -2147483648 to 2147483647. Int32 = NewMacro("int32", "", false, false, func(paramValue string) (interface{}, bool) { - if !simpleNumberEval(paramValue) { - return nil, false - } - v, err := strconv.ParseInt(paramValue, 10, 32) if err != nil { - return nil, false + return err, false } return int32(v), true }). @@ -176,13 +161,9 @@ var ( // Int64 as int64 type // -9223372036854775808 to 9223372036854775807. Int64 = NewMacro("int64", "long", false, false, func(paramValue string) (interface{}, bool) { - if !simpleNumberEval(paramValue) { - return nil, false - } - v, err := strconv.ParseInt(paramValue, 10, 64) if err != nil { // if err == strconv.ErrRange... - return nil, false + return err, false } return v, true }). @@ -215,7 +196,7 @@ var ( Uint = NewMacro("uint", "", false, false, func(paramValue string) (interface{}, bool) { v, err := strconv.ParseUint(paramValue, 10, strconv.IntSize) // 32,64... if err != nil { - return nil, false + return err, false } return uint(v), true }). @@ -241,17 +222,12 @@ var ( } }) - uint8Eval = MustRegexp("^([0-9]|[1-8][0-9]|9[0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$") // Uint8 as uint8 type // 0 to 255. Uint8 = NewMacro("uint8", "", false, false, func(paramValue string) (interface{}, bool) { - if !uint8Eval(paramValue) { - return nil, false - } - v, err := strconv.ParseUint(paramValue, 10, 8) if err != nil { - return nil, false + return err, false } return uint8(v), true }). @@ -282,7 +258,7 @@ var ( Uint16 = NewMacro("uint16", "", false, false, func(paramValue string) (interface{}, bool) { v, err := strconv.ParseUint(paramValue, 10, 16) if err != nil { - return nil, false + return err, false } return uint16(v), true }). @@ -307,7 +283,7 @@ var ( Uint32 = NewMacro("uint32", "", false, false, func(paramValue string) (interface{}, bool) { v, err := strconv.ParseUint(paramValue, 10, 32) if err != nil { - return nil, false + return err, false } return uint32(v), true }). @@ -332,7 +308,7 @@ var ( Uint64 = NewMacro("uint64", "", false, false, func(paramValue string) (interface{}, bool) { v, err := strconv.ParseUint(paramValue, 10, 64) if err != nil { - return nil, false + return err, false } return v, true }). @@ -366,22 +342,26 @@ var ( // in this case. v, err := strconv.ParseBool(paramValue) if err != nil { - return nil, false + return err, false } return v, true }) - alphabeticalEval = MustRegexp("^[a-zA-Z ]+$") + // ErrParamNotAlphabetical is fired when the parameter value is not an alphabetical text. + ErrParamNotAlphabetical = errors.New("parameter is not alphabetical") + alphabeticalEval = MustRegexp("^[a-zA-Z ]+$") // Alphabetical letter type // letters only (upper or lowercase) Alphabetical = NewMacro("alphabetical", "", false, false, func(paramValue string) (interface{}, bool) { if !alphabeticalEval(paramValue) { - return nil, false + return fmt.Errorf("%s: %w", paramValue, ErrParamNotAlphabetical), false } return paramValue, true }) - fileEval = MustRegexp("^[a-zA-Z0-9_.-]*$") + // ErrParamNotFile is fired when the parameter value is not a form of a file. + ErrParamNotFile = errors.New("parameter is not a file") + fileEval = MustRegexp("^[a-zA-Z0-9_.-]*$") // File type // letters (upper or lowercase) // numbers (0-9) @@ -391,7 +371,7 @@ var ( // no spaces! or other character File = NewMacro("file", "", false, false, func(paramValue string) (interface{}, bool) { if !fileEval(paramValue) { - return nil, false + return fmt.Errorf("%s: %w", paramValue, ErrParamNotFile), false } return paramValue, true }) @@ -409,7 +389,7 @@ var ( UUID = NewMacro("uuid", "uuidv4", false, false, func(paramValue string) (interface{}, bool) { _, err := uuid.Parse(paramValue) // this is x10+ times faster than regexp. if err != nil { - return nil, false + return err, false } return paramValue, true diff --git a/macro/template.go b/macro/template.go index 845646fc..33597ded 100644 --- a/macro/template.go +++ b/macro/template.go @@ -30,10 +30,13 @@ type TemplateParam struct { Src string `json:"src"` // the unparsed param'false source // Type is not useful anywhere here but maybe // it's useful on host to decide how to convert the path template to specific router's syntax - Type ast.ParamType `json:"type"` - Name string `json:"name"` - Index int `json:"index"` - ErrCode int `json:"errCode"` + Type ast.ParamType `json:"type"` + Name string `json:"name"` + Index int `json:"index"` + ErrCode int `json:"errCode"` + // Note that, the value MUST BE a type of `func(iris.Context, err error)`. + HandleError interface{} `json:"-"` /* It's not an typed value because of import-cycle, + // neither a special struct required, see `handler.MakeFilter`. */ TypeEvaluator ParamEvaluator `json:"-"` Funcs []reflect.Value `json:"-"` @@ -53,7 +56,7 @@ func (p TemplateParam) preComputed() TemplateParam { // i.e {myparam} or {myparam:string} or {myparam:path} -> // their type evaluator is nil because they don't do any checks and they don't change // the default parameter value's type (string) so no need for any work). - p.canEval = p.TypeEvaluator != nil || len(p.Funcs) > 0 || p.ErrCode != parser.DefaultParamErrorCode + p.canEval = p.TypeEvaluator != nil || len(p.Funcs) > 0 || p.ErrCode != parser.DefaultParamErrorCode || p.HandleError != nil return p } @@ -64,6 +67,10 @@ func (p *TemplateParam) CanEval() bool { return p.canEval } +type errorInterface interface { + Error() string +} + // Eval is the most critical part of the TemplateParam. // It is responsible to return the type-based value if passed otherwise nil. // If the "paramValue" is the correct type of the registered parameter type @@ -71,21 +78,27 @@ func (p *TemplateParam) CanEval() bool { // // It is called from the converted macro handler (middleware) // from the higher-level component of "kataras/iris/macro/handler#MakeHandler". -func (p *TemplateParam) Eval(paramValue string) interface{} { +func (p *TemplateParam) Eval(paramValue string) (interface{}, bool) { if p.TypeEvaluator == nil { for _, fn := range p.stringInFuncs { if !fn(paramValue) { - return nil + return nil, false } } - return paramValue + return paramValue, true } // fmt.Printf("macro/template.go#L88: Eval for param value: %s and p.Src: %s\n", paramValue, p.Src) newValue, passed := p.TypeEvaluator(paramValue) if !passed { - return nil + if newValue != nil && p.HandleError != nil { // return this error only when a HandleError was registered. + if _, ok := newValue.(errorInterface); ok { + return newValue, false // this is an error, see `HandleError` and `MakeFilter`. + } + } + + return nil, false } if len(p.Funcs) > 0 { @@ -94,14 +107,14 @@ func (p *TemplateParam) Eval(paramValue string) interface{} { // or make it as func(interface{}) bool and pass directly the "newValue" // but that would not be as easy for end-developer, so keep that "slower": if !evalFunc.Call(paramIn)[0].Interface().(bool) { // i.e func(paramValue int) bool - return nil + return nil, false } } } // fmt.Printf("macro/template.go: passed with value: %v and type: %T\n", newValue, newValue) - return newValue + return newValue, true } // Parse takes a full route path and a macro map (macro map contains the macro types with their registered param functions) @@ -130,6 +143,7 @@ func Parse(src string, macros Macros) (Template, error) { Name: p.Name, Index: idx, ErrCode: p.ErrorCode, + HandleError: m.handleError, TypeEvaluator: typEval, }