diff --git a/HISTORY.md b/HISTORY.md index ba71ae7d..1a67e8bf 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -23,6 +23,7 @@ Developers are not forced to upgrade if they don't really need it. Upgrade whene Changes apply to `main` branch. +- Simplify how validation errors on `/x/errors` package works. A new `x/errors/validation` sub-package added to make your life easier (using the powerful Generics feature). - Add `x/errors.OK`, `Create`, `NoContent` and `NoContentOrNotModified` package-level generic functions as custom service method caller helpers. Example can be found [here](_examples/routing/http-wire-errors/service/main.go). - Add `x/errors.ReadPayload`, `ReadQuery`, `ReadPaginationOptions`, `Handle`, `HandleCreate`, `HandleCreateResponse`, `HandleUpdate` and `HandleDelete` package-level functions as helpers for common actions. - Add `x/jsonx.GetSimpleDateRange(date, jsonx.WeekRange, time.Monday, time.Sunday)` which returns all dates between the given range and start/end weekday values for WeekRange. diff --git a/_examples/README.md b/_examples/README.md index 7b8e36b5..57593a57 100644 --- a/_examples/README.md +++ b/_examples/README.md @@ -54,8 +54,7 @@ * [Basic](routing/basic/main.go) * [Custom HTTP Errors](routing/http-errors/main.go) * [HTTP Wire Errors](routing/http-wire-errors/main.go) **NEW** - * [Custom Validation Errors](routing/http-wire-errors/custom-validation-errors/main.go) - * [Service](routing/http-wire-errors/service/main.go) **NEW** + * [Service and Validation](routing/http-wire-errors/service/main.go) **NEW** * [Not Found - Intelligence](routing/intelligence/main.go) * [Not Found - Suggest Closest Paths](routing/intelligence/manual/main.go) * [Dynamic Path](routing/dynamic-path/main.go) diff --git a/_examples/README_ZH_HANT.md b/_examples/README_ZH_HANT.md index 6ce9ffc3..db1bc144 100644 --- a/_examples/README_ZH_HANT.md +++ b/_examples/README_ZH_HANT.md @@ -51,7 +51,6 @@ - [Basic](routing/basic/main.go) - [Custom HTTP Errors](routing/http-errors/main.go) - [HTTP Wire Errors](routing/http-wire-errors/main.go) **新範例** - - [Custom Validation Errors](routing/http-wire-errors/custom-validation-errors/main.go) - [Not Found - Intelligence](routing/intelligence/main.go) - [Not Found - Suggest Closest Paths](routing/intelligence/manual/main.go) - [Dynamic Path](routing/dynamic-path/main.go) diff --git a/_examples/routing/http-wire-errors/custom-validation-errors/main.go b/_examples/routing/http-wire-errors/custom-validation-errors/main.go deleted file mode 100644 index 55e97238..00000000 --- a/_examples/routing/http-wire-errors/custom-validation-errors/main.go +++ /dev/null @@ -1,119 +0,0 @@ -package main - -import ( - "fmt" - "time" - - "github.com/kataras/iris/v12" - "github.com/kataras/iris/v12/x/errors" -) - -func main() { - app := newApp() - app.Listen(":8080") -} - -func newApp() *iris.Application { - app := iris.New() - app.Get("/", fireCustomValidationError) - app.Get("/multi", fireCustomValidationErrors) - app.Get("/invalid", fireInvalidError) - return app -} - -type MyValidationError struct { - Field string `json:"field"` - Value interface{} `json:"value"` - Reason string `json:"reason"` - Timestamp int64 `json:"timestamp"` -} - -func (err MyValidationError) Error() string { - return fmt.Sprintf("field %q got invalid value of %v: reason: %s", err.Field, err.Value, err.Reason) -} - -// Error, GetField, GetValue and GetReason completes -// the x/errors.ValidationError interface which can be used -// for faster rendering without the necessity of registering a custom -// type (see at the end of the example). -// -// func (err MyValidationError) GetField() string { -// return err.Field -// } -// -// func (err MyValidationError) GetValue() interface{} { -// return err.Value -// } -// -// func (err MyValidationError) GetReason() string { -// return err.Reason -// } - -const shouldFail = true - -func fireCustomValidationError(ctx iris.Context) { - if shouldFail { - err := MyValidationError{ - Field: "username", - Value: "", - Reason: "empty string", - Timestamp: time.Now().Unix(), - } - - // The "validation" field, when used, is always rendering as - // a JSON array, NOT a single object. - errors.InvalidArgument.Err(ctx, err) - return - } - - ctx.WriteString("OK") -} - -// Optionally register custom types that you may need -// to be rendered as validation errors if the given "ErrorCodeName.Err.err" -// input parameter is matched with one of these. Register once, at initialiation. -func init() { - mapper := errors.NewValidationErrorTypeMapper(MyValidationError{} /*, OtherCustomType{} */) - errors.RegisterValidationErrorMapper(mapper) -} - -// A custom type of the example validation error type -// in order to complete the error interface, so it can be -// pass through the errors.InvalidArgument.Err method. -type MyValidationErrors []MyValidationError - -func (m MyValidationErrors) Error() string { - return "to be an error" -} - -func fireCustomValidationErrors(ctx iris.Context) { - if shouldFail { - errs := MyValidationErrors{ - { - Field: "username", - Value: "", - Reason: "empty string", - Timestamp: time.Now().Unix(), - }, - { - Field: "birth_date", - Value: "2022-01-01", - Reason: "too young", - Timestamp: time.Now().Unix(), - }, - } - errors.InvalidArgument.Err(ctx, errs) - return - } - - ctx.WriteString("OK") -} - -func fireInvalidError(ctx iris.Context) { - if shouldFail { - errors.InvalidArgument.Err(ctx, fmt.Errorf("just a custom error text")) - return - } - - ctx.WriteString("OK") -} diff --git a/_examples/routing/http-wire-errors/service/main.go b/_examples/routing/http-wire-errors/service/main.go index 8384c938..db81dce9 100644 --- a/_examples/routing/http-wire-errors/service/main.go +++ b/_examples/routing/http-wire-errors/service/main.go @@ -6,12 +6,13 @@ import ( "github.com/kataras/iris/v12" "github.com/kataras/iris/v12/x/errors" + "github.com/kataras/iris/v12/x/errors/validation" ) func main() { app := iris.New() - service := new(myService) + app.Post("/", createHandler(service)) app.Get("/", listHandler(service)) app.Delete("/{id:string}", deleteHandler(service)) @@ -70,16 +71,65 @@ type ( myService struct{} CreateRequest struct { - Fullname string + Fullname string `json:"fullname"` + Age int `json:"age"` + Hobbies []string `json:"hobbies"` } CreateResponse struct { - ID string - Firstname string - Lastname string + ID string `json:"id"` + Firstname string `json:"firstname"` + Lastname string `json:"lastname"` + Age int `json:"age"` + Hobbies []string `json:"hobbies"` } ) +// ValidateContext implements the errors.ContextValidator interface. +// It validates the request body and returns an error if the request body is invalid. +// You can also alter the "r" CreateRequest before calling the service method, +// e.g. give a default value to a field if it's empty or set an ID based on a path parameter. +func (r *CreateRequest) ValidateContext(ctx iris.Context) error { + // To pass custom validation functions: + // return validation.Join( + // validation.String("fullname", r.Fullname).Func(customStringFuncHere), + // OR + // validation.Field("any_field", r.AnyFieldValue).Func(customAnyFuncHere)) + return validation.Join( + validation.String("fullname", r.Fullname).NotEmpty().Fullname().Length(3, 50), + validation.Number("age", r.Age).InRange(18, 130), + validation.Slice("hobbies", r.Hobbies).Length(1, 10), + ) + + /* Example Output: + { + "http_error_code": { + "canonical_name": "INVALID_ARGUMENT", + "status": 400 + }, + "message": "validation failure", + "details": "fields were invalid", + "validation": [ + { + "field": "fullname", + "value": "", + "reason": "must not be empty, must contain first and last name, must be between 3 and 50 characters" + }, + { + "field": "age", + "value": 0, + "reason": "must be in range of [18, 130]" + }, + { + "field": "hobbies", + "value": null, + "reason": "must be between 1 and 10 elements" + } + ] + } + */ +} + func (s *myService) Create(ctx context.Context, in CreateRequest) (CreateResponse, error) { arr := strings.Split(in.Fullname, " ") firstname, lastname := arr[0], arr[1] @@ -89,6 +139,8 @@ func (s *myService) Create(ctx context.Context, in CreateRequest) (CreateRespons ID: id, Firstname: firstname, Lastname: lastname, + Age: in.Age, + Hobbies: in.Hobbies, } return resp, nil // , errors.New("create: test error") } diff --git a/go.mod b/go.mod index 453f9a52..b59f98eb 100644 --- a/go.mod +++ b/go.mod @@ -32,16 +32,17 @@ require ( github.com/mailgun/raymond/v2 v2.0.48 github.com/mailru/easyjson v0.7.7 github.com/microcosm-cc/bluemonday v1.0.26 - github.com/redis/go-redis/v9 v9.3.1 + github.com/redis/go-redis/v9 v9.4.0 github.com/schollz/closestmatch v2.1.0+incompatible github.com/shirou/gopsutil/v3 v3.23.12 - github.com/tdewolff/minify/v2 v2.20.10 + github.com/tdewolff/minify/v2 v2.20.12 github.com/vmihailenco/msgpack/v5 v5.4.1 github.com/yosssi/ace v0.0.5 go.etcd.io/bbolt v1.3.8 golang.org/x/crypto v0.17.0 + golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc golang.org/x/net v0.19.0 - golang.org/x/sys v0.15.0 + golang.org/x/sys v0.16.0 golang.org/x/text v0.14.0 golang.org/x/time v0.5.0 google.golang.org/protobuf v1.32.0 @@ -107,7 +108,6 @@ require ( github.com/yudai/gojsondiff v1.0.0 // indirect github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect - golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect moul.io/http2curl/v2 v2.3.0 // indirect ) diff --git a/go.sum b/go.sum index 52084552..3f027740 100644 --- a/go.sum +++ b/go.sum @@ -185,8 +185,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/redis/go-redis/v9 v9.3.1 h1:KqdY8U+3X6z+iACvumCNxnoluToB+9Me+TvyFa21Mds= -github.com/redis/go-redis/v9 v9.3.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +github.com/redis/go-redis/v9 v9.4.0 h1:Yzoz33UZw9I/mFhx4MNrB6Fk+XHO1VukNcCa1+lwyKk= +github.com/redis/go-redis/v9 v9.4.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -226,12 +226,13 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8= -github.com/tdewolff/minify/v2 v2.20.10 h1:iz9IkdRqD2pyneib/AvTas23RRG5TnuUFNcNVKmL/jU= -github.com/tdewolff/minify/v2 v2.20.10/go.mod h1:xSJ9fXIfyuEMex88JT4jl8GvXnl/RzWNdqD96AqKlX0= +github.com/tdewolff/minify/v2 v2.20.12 h1:ie5+91QGUUeEDbLkexhx2tlI9BQgwwnfY+/Qdj4BlQ4= +github.com/tdewolff/minify/v2 v2.20.12/go.mod h1:8ktdncc9Rh41MkTX2KYaicHT9+VnpvIDjCyIVsr/nN8= github.com/tdewolff/parse/v2 v2.7.7 h1:V+50eFDH7Piw4IBwH8D8FtYeYbZp3T4SCtIvmBSIMyc= github.com/tdewolff/parse/v2 v2.7.7/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA= -github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52 h1:gAQliwn+zJrkjAHVcBEYW/RFvd2St4yYimisvozAYlA= github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= +github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo= +github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= @@ -272,8 +273,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc h1:ao2WRsKSzW6KuUY9IWPwWahcHCgR0s52IfwutMfEbdM= +golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= @@ -306,8 +307,9 @@ golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/x/errors/errors.go b/x/errors/errors.go index 35aa895d..77df1773 100644 --- a/x/errors/errors.go +++ b/x/errors/errors.go @@ -174,8 +174,21 @@ func HandleError(ctx *context.Context, err error) bool { return true } - if vErrs, ok := AsValidationErrors(err); ok { - InvalidArgument.Data(ctx, "validation failure", vErrs) + if vErr, ok := err.(ValidationError); ok { + if vErr == nil { + return false // consider as not error for any case, this should never happen. + } + + InvalidArgument.Validation(ctx, vErr) + return true + } + + if vErrs, ok := err.(ValidationErrors); ok { + if len(vErrs) == 0 { + return false // consider as not error for any case, this should never happen. + } + + InvalidArgument.Validation(ctx, vErrs...) return true } @@ -283,9 +296,20 @@ func (e ErrorCodeName) Err(ctx *context.Context, err error) { return } - if validationErrors, ok := AsValidationErrors(err); ok { - e.validation(ctx, validationErrors) - return + if vErr, ok := err.(ValidationError); ok { + if vErr == nil { + return // consider as not error for any case, this should never happen. + } + + e.Validation(ctx, vErr) + } + + if vErrs, ok := err.(ValidationErrors); ok { + if len(vErrs) == 0 { + return // consider as not error for any case, this should never happen. + } + + e.Validation(ctx, vErrs...) } // If it's already an Error type then send it directly. diff --git a/x/errors/handlers.go b/x/errors/handlers.go index d8dafb20..819e7efd 100644 --- a/x/errors/handlers.go +++ b/x/errors/handlers.go @@ -115,6 +115,12 @@ type ResponseOnlyErrorFunc[T any] interface { func(stdContext.Context, T) error } +// ContextValidator is an interface which can be implemented by a request payload struct +// in order to validate the context before calling a service function. +type ContextValidator interface { + ValidateContext(*context.Context) error +} + func bindResponse[T, R any, F ResponseFunc[T, R]](ctx *context.Context, fn F, fnInput ...T) (R, bool) { var req T switch len(fnInput) { @@ -131,6 +137,16 @@ func bindResponse[T, R any, F ResponseFunc[T, R]](ctx *context.Context, fn F, fn panic("invalid number of arguments") } + if contextValidator, ok := any(&req).(ContextValidator); ok { + err := contextValidator.ValidateContext(ctx) + if err != nil { + if HandleError(ctx, err) { + var resp R + return resp, false + } + } + } + resp, err := fn(ctx, req) return resp, !HandleError(ctx, err) } @@ -208,14 +224,7 @@ func ReadPayload[T any](ctx *context.Context) (T, bool) { return payload, false } - if !handleJSONError(ctx, err) { - if vErrs, ok := AsValidationErrors(err); ok { - InvalidArgument.Data(ctx, "validation failure", vErrs) - } else { - InvalidArgument.Details(ctx, "unable to parse body", err.Error()) - } - } - + HandleError(ctx, err) return payload, false } @@ -229,11 +238,7 @@ func ReadQuery[T any](ctx *context.Context) (T, bool) { var payload T err := ctx.ReadQuery(&payload) if err != nil { - if vErrs, ok := AsValidationErrors(err); ok { - InvalidArgument.Data(ctx, "validation failure", vErrs) - } else { - InvalidArgument.Details(ctx, "unable to parse query", err.Error()) - } + HandleError(ctx, err) return payload, false } diff --git a/x/errors/validation/error.go b/x/errors/validation/error.go new file mode 100644 index 00000000..f31cadc6 --- /dev/null +++ b/x/errors/validation/error.go @@ -0,0 +1,92 @@ +package validation + +import ( + "fmt" + + "github.com/kataras/iris/v12/x/errors" +) + +// FieldError describes a field validation error. +// It completes the errors.ValidationError interface. +type FieldError[T any] struct { + Field string `json:"field"` + Value T `json:"value"` + Reason string `json:"reason"` +} + +// Field returns a new validation error. +// +// Use its Func method to add validations over this field. +func Field[T any](field string, value T) *FieldError[T] { + return &FieldError[T]{Field: field, Value: value} +} + +// Error completes the standard error interface. +func (e *FieldError[T]) Error() string { + return fmt.Sprintf("field %q got invalid value of %v: reason: %s", e.Field, e.Value, e.Reason) +} + +// GetField returns the field name. +func (e *FieldError[T]) GetField() string { + return e.Field +} + +// GetValue returns the value of the field. +func (e *FieldError[T]) GetValue() any { + return e.Value +} + +// GetReason returns the reason of the validation error. +func (e *FieldError[T]) GetReason() string { + return e.Reason +} + +// IsZero reports whether the error is nil or has an empty reason. +func (e *FieldError[T]) IsZero() bool { + return e == nil || e.Reason == "" +} + +func (e *FieldError[T]) joinReason(reason string) { + if reason == "" { + return + } + + if e.Reason == "" { + e.Reason = reason + } else { + e.Reason += ", " + reason + } +} + +// Func accepts a variadic number of functions which accept the value of the field +// and return a string message if the value is invalid. +// It joins the reasons into one. +func (e *FieldError[T]) Func(fns ...func(value T) string) *FieldError[T] { + for _, fn := range fns { + e.joinReason(fn(e.Value)) + } + + return e +} + +// Join joins the given validation errors into one. +func Join(errs ...errors.ValidationError) error { // note that here we return the standard error type instead of the errors.ValidationError in order to make the error nil instead of ValidationErrors(nil) on empty slice. + if len(errs) == 0 { + return nil + } + + joinedErrs := make(errors.ValidationErrors, 0, len(errs)) + for _, err := range errs { + if err == nil || err.GetReason() == "" { + continue + } + + joinedErrs = append(joinedErrs, err) + } + + if len(joinedErrs) == 0 { + return nil + } + + return joinedErrs +} diff --git a/x/errors/validation/number.go b/x/errors/validation/number.go new file mode 100644 index 00000000..b8688a2e --- /dev/null +++ b/x/errors/validation/number.go @@ -0,0 +1,105 @@ +package validation + +import ( + "fmt" + + "golang.org/x/exp/constraints" +) + +// NumberValue is a type constraint that accepts any numeric type. +type NumberValue interface { + constraints.Integer | constraints.Float +} + +// NumberError describes a number field validation error. +type NumberError[T NumberValue] struct{ *FieldError[T] } + +// Number returns a new number validation error. +func Number[T NumberValue](field string, value T) *NumberError[T] { + return &NumberError[T]{Field(field, value)} +} + +// Positive adds an error if the value is not positive. +func (e *NumberError[T]) Positive() *NumberError[T] { + e.Func(Positive) + return e +} + +// Negative adds an error if the value is not negative. +func (e *NumberError[T]) Negative() *NumberError[T] { + e.Func(Negative) + return e +} + +// Zero reports whether the value is zero. +func (e *NumberError[T]) Zero() *NumberError[T] { + e.Func(Zero) + return e +} + +// NonZero adds an error if the value is zero. +func (e *NumberError[T]) NonZero() *NumberError[T] { + e.Func(NonZero) + return e +} + +// InRange adds an error if the value is not in the range. +func (e *NumberError[T]) InRange(min, max T) *NumberError[T] { + e.Func(InRange(min, max)) + return e +} + +// Positive accepts any numeric type and +// returns a message if the value is not positive. +func Positive[T NumberValue](n T) string { + if n <= 0 { + return "must be positive" + } + + return "" +} + +// Negative accepts any numeric type and returns a message if the value is not negative. +func Negative[T NumberValue](n T) string { + if n >= 0 { + return "must be negative" + } + + return "" +} + +// Zero accepts any numeric type and returns a message if the value is not zero. +func Zero[T NumberValue](n T) string { + if n != 0 { + return "must be zero" + } + + return "" +} + +// NonZero accepts any numeric type and returns a message if the value is not zero. +func NonZero[T NumberValue](n T) string { + if n == 0 { + return "must not be zero" + } + + return "" +} + +// InRange accepts any numeric type and returns a message if the value is not in the range. +func InRange[T NumberValue](min, max T) func(T) string { + return func(n T) string { + if n < min || n > max { + return "must be in range of " + FormatRange(min, max) + } + + return "" + } +} + +// FormatRange returns a string representation of a range of values, such as "[1, 10]". +// It uses a type constraint NumberValue, which means that the parameters must be numeric types +// that support comparison and formatting operations. +func FormatRange[T NumberValue](min, max T) string { + return fmt.Sprintf("[%v, %v]", min, max) +} diff --git a/x/errors/validation/slice.go b/x/errors/validation/slice.go new file mode 100644 index 00000000..8386c243 --- /dev/null +++ b/x/errors/validation/slice.go @@ -0,0 +1,57 @@ +package validation + +import "fmt" + +type SliceValue[T any] interface { + ~[]T +} + +// SliceError describes a slice field validation error. +type SliceError[T any, V SliceValue[T]] struct{ *FieldError[V] } + +// Slice returns a new slice validation error. +func Slice[T any, V SliceValue[T]](field string, value V) *SliceError[T, V] { + return &SliceError[T, V]{Field(field, value)} +} + +// NotEmpty adds an error if the slice is empty. +func (e *SliceError[T, V]) NotEmpty() *SliceError[T, V] { + e.Func(NotEmptySlice) + return e +} + +// Length adds an error if the slice length is not in the given range. +func (e *SliceError[T, V]) Length(min, max int) *SliceError[T, V] { + e.Func(SliceLength[T, V](min, max)) + return e +} + +// NotEmptySlice accepts any slice and returns a message if the value is empty. +func NotEmptySlice[T any, V SliceValue[T]](s V) string { + if len(s) == 0 { + return "must not be empty" + } + + return "" +} + +// SliceLength accepts any slice and returns a message if the length is not in the given range. +func SliceLength[T any, V SliceValue[T]](min, max int) func(s V) string { + return func(s V) string { + n := len(s) + + if min == max { + if n != min { + return fmt.Sprintf("must be %d elements", min) + } + + return "" + } + + if n < min || n > max { + return fmt.Sprintf("must be between %d and %d elements", min, max) + } + + return "" + } +} diff --git a/x/errors/validation/string.go b/x/errors/validation/string.go new file mode 100644 index 00000000..f7efdb62 --- /dev/null +++ b/x/errors/validation/string.go @@ -0,0 +1,69 @@ +package validation + +import ( + "fmt" + "strings" +) + +// StringError describes a string field validation error. +type StringError struct{ *FieldError[string] } + +// String returns a new string validation error. +func String(field string, value string) *StringError { + return &StringError{Field(field, value)} +} + +// NotEmpty adds an error if the string is empty. +func (e *StringError) NotEmpty() *StringError { + e.Func(NotEmpty) + return e +} + +// Fullname adds an error if the string is not a full name. +func (e *StringError) Fullname() *StringError { + e.Func(Fullname) + return e +} + +// Length adds an error if the string length is not in the given range. +func (e *StringError) Length(min, max int) *StringError { + e.Func(StringLength(min, max)) + return e +} + +// NotEmpty accepts any string and returns a message if the value is empty. +func NotEmpty(s string) string { + if s == "" { + return "must not be empty" + } + + return "" +} + +// Fullname accepts any string and returns a message if the value is not a full name. +func Fullname(s string) string { + if len(strings.Split(s, " ")) < 2 { + return "must contain first and last name" + } + + return "" +} + +// StringLength accepts any string and returns a message if the length is not in the given range. +func StringLength(min, max int) func(s string) string { + return func(s string) string { + n := len(s) + + if min == max { + if n != min { + return fmt.Sprintf("must be %d characters", min) + } + } + + if n < min || n > max { + return fmt.Sprintf("must be between %d and %d characters", min, max) + } + + return "" + } +} diff --git a/x/errors/validation_error.go b/x/errors/validation_error.go index 7ab7b7eb..94103e50 100644 --- a/x/errors/validation_error.go +++ b/x/errors/validation_error.go @@ -1,7 +1,6 @@ package errors import ( - "reflect" "strconv" "strings" ) @@ -11,10 +10,6 @@ import ( // it can by mapped to a validation error. // // A validation error(s) can be given by ErrorCodeName's Validation or Err methods. -// -// Example can be found at: -// -// https://github.com/kataras/iris/tree/main/_examples/routing/http-wire-errors/custom-validation-errors type ValidationError interface { error @@ -43,123 +38,3 @@ func (errs ValidationErrors) Error() string { return buf.String() } - -// ValidationErrorMapper is the interface which -// custom validation error mappers should complete. -type ValidationErrorMapper interface { - // The implementation must check the given "err" - // and make decision if it's an error of validation - // and if so it should return the value (err or another one) - // and true as the last output argument. - // - // Outputs: - // 1. the validation error(s) value - // 2. true if the interface{} is an array, otherise false - // 3. true if it's a validation error or false if not. - MapValidationErrors(err error) (interface{}, bool, bool) -} - -// ValidationErrorMapperFunc is an "ValidationErrorMapper" but in type of a function. -type ValidationErrorMapperFunc func(err error) (interface{}, bool, bool) - -// MapValidationErrors completes the "ValidationErrorMapper" interface. -func (v ValidationErrorMapperFunc) MapValidationErrors(err error) (interface{}, bool, bool) { - return v(err) -} - -// read-only at serve time, holds the validation error mappers. -var validationErrorMappers []ValidationErrorMapper = []ValidationErrorMapper{ - ValidationErrorMapperFunc(func(err error) (interface{}, bool, bool) { - switch e := err.(type) { - case ValidationError: - return e, false, true - case ValidationErrors: - return e, true, true - default: - return nil, false, false - } - }), -} - -// RegisterValidationErrorMapper registers a custom -// implementation of validation error mapping. -// Call it on program initilization, main() or init() functions. -func RegisterValidationErrorMapper(m ValidationErrorMapper) { - validationErrorMappers = append(validationErrorMappers, m) -} - -// RegisterValidationErrorMapperFunc registers a custom -// function implementation of validation error mapping. -// Call it on program initilization, main() or init() functions. -func RegisterValidationErrorMapperFunc(fn func(err error) (interface{}, bool, bool)) { - validationErrorMappers = append(validationErrorMappers, ValidationErrorMapperFunc(fn)) -} - -type validationErrorTypeMapper struct { - types []reflect.Type -} - -var _ ValidationErrorMapper = (*validationErrorTypeMapper)(nil) - -func (v *validationErrorTypeMapper) MapValidationErrors(err error) (interface{}, bool, bool) { - errType := reflect.TypeOf(err) - for _, typ := range v.types { - if equalTypes(errType, typ) { - return err, false, true - } - - // a slice is given but the underline type is registered. - if errType.Kind() == reflect.Slice { - if equalTypes(errType.Elem(), typ) { - return err, true, true - } - } - } - - return nil, false, false -} - -func equalTypes(err reflect.Type, binding reflect.Type) bool { - return err == binding - // return binding.AssignableTo(err) -} - -// NewValidationErrorTypeMapper returns a validation error mapper -// which compares the error with one or more of the given "types", -// through reflection. Each of the given types MUST complete the -// standard error type, so it can be passed through the error code. -func NewValidationErrorTypeMapper(types ...error) ValidationErrorMapper { - typs := make([]reflect.Type, 0, len(types)) - for _, typ := range types { - v, ok := typ.(reflect.Type) - if !ok { - v = reflect.TypeOf(typ) - } - - typs = append(typs, v) - } - - return &validationErrorTypeMapper{ - types: typs, - } -} - -// AsValidationErrors reports wheether the given "err" is a type of validation error(s). -// Its behavior can be modified before serve-time -// through the "RegisterValidationErrorMapper" function. -func AsValidationErrors(err error) (interface{}, bool) { - if err == nil { - return nil, false - } - - for _, m := range validationErrorMappers { - if errs, isArray, ok := m.MapValidationErrors(err); ok { - if !isArray { // ensure always-array on Validation field of the http error. - return []interface{}{errs}, true - } - return errs, true - } - } - - return nil, false -}