From fc2f8f477692e31edb0b67c4a8e6435f015171ae Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Thu, 3 Mar 2022 20:55:28 +0200 Subject: [PATCH] improvements to the x/errors pkg --- _examples/README.md | 1 + .../custom-validation-errors/main.go | 119 ++++++++ configuration.go | 2 +- x/errors/errors.go | 37 ++- x/errors/validation_error.go | 279 ++++++++---------- 5 files changed, 274 insertions(+), 164 deletions(-) create mode 100644 _examples/routing/http-wire-errors/custom-validation-errors/main.go diff --git a/_examples/README.md b/_examples/README.md index a6c6ac4c..ab61519b 100644 --- a/_examples/README.md +++ b/_examples/README.md @@ -49,6 +49,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) * [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 new file mode 100644 index 00000000..55e97238 --- /dev/null +++ b/_examples/routing/http-wire-errors/custom-validation-errors/main.go @@ -0,0 +1,119 @@ +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/configuration.go b/configuration.go index 09cb752d..6d5db5d2 100644 --- a/configuration.go +++ b/configuration.go @@ -912,7 +912,7 @@ type Configuration struct { Other map[string]interface{} `ini:"other" json:"other,omitempty" yaml:"Other" toml:"Other"` } -var _ context.ConfigurationReadOnly = &Configuration{} +var _ context.ConfigurationReadOnly = (*Configuration)(nil) // GetVHost returns the non-exported vhost config field. func (c Configuration) GetVHost() string { diff --git a/x/errors/errors.go b/x/errors/errors.go index 99429e01..5534d3b3 100644 --- a/x/errors/errors.go +++ b/x/errors/errors.go @@ -134,9 +134,13 @@ func (e ErrorCodeName) DataWithDetails(ctx iris.Context, msg, details string, da fail(ctx, e, msg, details, nil, data) } -// Validation sends an error which contains the invalid fields to the client. -func (e ErrorCodeName) Validation(ctx iris.Context, errs ...ValidationError) { - fail(ctx, e, "validation failure", "fields were invalid", errs, nil) +// Validation sends an error which renders the invalid fields to the client. +func (e ErrorCodeName) Validation(ctx iris.Context, validationErrors ...ValidationError) { + e.validation(ctx, validationErrors) +} + +func (e ErrorCodeName) validation(ctx iris.Context, validationErrors interface{}) { + fail(ctx, e, "validation failure", "fields were invalid", validationErrors, nil) } // Err sends the error's text as a message to the client. @@ -148,7 +152,7 @@ func (e ErrorCodeName) Err(ctx iris.Context, err error) { } if validationErrors, ok := AsValidationErrors(err); ok { - e.Validation(ctx, validationErrors...) + e.validation(ctx, validationErrors) return } @@ -231,12 +235,15 @@ var ( ) // Error represents the JSON form of "http wire errors". +// +// Examples can be found at: +// https://github.com/kataras/iris/tree/master/_examples/routing/http-wire-errors. type Error struct { - ErrorCode ErrorCode `json:"http_error_code" yaml:"HTTPErrorCode"` - Message string `json:"message,omitempty" yaml:"Message"` - Details string `json:"details,omitempty" yaml:"Details"` - ValidationErrors ValidationErrors `json:"validation,omitempty" yaml:"Validation,omitempty"` - Data json.RawMessage `json:"data,omitempty" yaml:"Data,omitempty"` // any other custom json data. + ErrorCode ErrorCode `json:"http_error_code" yaml:"HTTPErrorCode"` + Message string `json:"message,omitempty" yaml:"Message"` + Details string `json:"details,omitempty" yaml:"Details"` + Validation interface{} `json:"validation,omitempty" yaml:"Validation,omitempty"` + Data json.RawMessage `json:"data,omitempty" yaml:"Data,omitempty"` // any other custom json data. } // Error method completes the error interface. It just returns the canonical name, status code, message and details. @@ -260,7 +267,7 @@ func (err Error) Error() string { return sprintf("iris http wire error: canonical name: %s, http status code: %d, message: %s, details: %s", err.ErrorCode.CanonicalName, err.ErrorCode.Status, err.Message, err.Details) } -func fail(ctx iris.Context, codeName ErrorCodeName, msg, details string, validationErrors ValidationErrors, dataValue interface{}) { +func fail(ctx iris.Context, codeName ErrorCodeName, msg, details string, validationErrors interface{}, dataValue interface{}) { errorCode, ok := errorCodeMap[codeName] if !ok { // This SHOULD NEVER happen, all ErrorCodeNames MUST be registered. @@ -297,11 +304,11 @@ func fail(ctx iris.Context, codeName ErrorCodeName, msg, details string, validat } err := Error{ - ErrorCode: errorCode, - Message: msg, - Details: details, - Data: data, - ValidationErrors: validationErrors, + ErrorCode: errorCode, + Message: msg, + Details: details, + Data: data, + Validation: validationErrors, } // ctx.SetErr(err) diff --git a/x/errors/validation_error.go b/x/errors/validation_error.go index 49b0b998..e475a77b 100644 --- a/x/errors/validation_error.go +++ b/x/errors/validation_error.go @@ -1,56 +1,32 @@ package errors import ( - "math" - "regexp" + "reflect" "strconv" "strings" ) -// AsValidationErrors reports wheether the given "err" is a type of validation error(s). -func AsValidationErrors(err error) (ValidationErrors, bool) { - if err == nil { - return nil, false - } +// ValidationError is an interface which IF +// it custom error types completes, then +// 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/master/_examples/routing/http-wire-errors/custom-validation-errors +type ValidationError interface { + error - switch e := err.(type) { - case ValidationError: - return ValidationErrors{e}, true - case ValidationErrors: - return e, true - case *ValidationErrors: - return *e, true - default: - return nil, false - } + GetField() string + GetValue() interface{} + GetReason() string } -// ValueValidator is a generic interface which can be used to check if the value is valid for insert (or for comparison inside another validation step). -// Useful for enums. -// Should return a non-empty string on validation error, that string is the failure reason. -type ValueValidator interface { - Validate() string -} - -// ValidationError describes a field validation error. -type ValidationError struct { - Field string `json:"field" yaml:"Field"` - Value interface{} `json:"value" yaml:"Value"` - Reason string `json:"reason" yaml:"Reason"` -} - -// Error completes the standard error interface. -func (e ValidationError) Error() string { - return sprintf("field %q got invalid value of %v: reason: %s", e.Field, e.Value, e.Reason) -} - -// ValidationErrors is just a custom type of ValidationError slice. type ValidationErrors []ValidationError -// Error completes the error interface. -func (e ValidationErrors) Error() string { +func (errs ValidationErrors) Error() string { var buf strings.Builder - for i, err := range e { + for i, err := range errs { buf.WriteByte('[') buf.WriteString(strconv.Itoa(i)) buf.WriteByte(']') @@ -58,7 +34,7 @@ func (e ValidationErrors) Error() string { buf.WriteString(err.Error()) - if i < len(e)-1 { + if i < len(errs)-1 { buf.WriteByte(',') buf.WriteByte(' ') } @@ -67,115 +43,122 @@ func (e ValidationErrors) Error() string { return buf.String() } -// Is reports whether the given "err" is a type of validation error or validation errors. -func (e ValidationErrors) Is(err error) bool { +// 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 false + return nil, false } - switch err.(type) { - case ValidationError: - return true - case *ValidationError: - return true - case ValidationErrors: - return true - case *ValidationErrors: - return true - default: - return 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 + } } -} - -// Add is a helper for appending a validation error. -func (e *ValidationErrors) Add(err ValidationError) *ValidationErrors { - if err.Field == "" || err.Reason == "" { - return e - } - - *e = append(*e, err) - return e -} - -// Join joins an existing Errors to this errors list. -func (e *ValidationErrors) Join(errs ValidationErrors) *ValidationErrors { - *e = append(*e, errs...) - return e -} - -// Validate returns the result of the value's Validate method, if exists otherwise -// it adds the field and value to the error list and reports false (invalidated). -// If reason is empty, means that the field is valid, this method will return true. -func (e *ValidationErrors) Validate(field string, value interface{}) bool { - var reason string - - if v, ok := value.(ValueValidator); ok { - reason = v.Validate() - } - - if reason != "" { - e.Add(ValidationError{ - Field: field, - Value: value, - Reason: reason, - }) - - return false - } - - return true -} - -// MustBeSatisfiedFunc compares the value with the given "isEqualFunc" function and reports -// if it's valid or not. If it's not valid, a new ValidationError is added to the "e" list. -func (e *ValidationErrors) MustBeSatisfiedFunc(field string, value string, isEqualFunc func(string) bool) bool { - if !isEqualFunc(value) { - e.Add(ValidationError{ - Field: field, - Value: value, - Reason: "failed to satisfy constraint", - }) - return false - } - - return true -} - -// MustBeSatisfied compares the value with the given regex and reports -// if it's valid or not. If it's not valid, a new ValidationError is added to the "e" list. -func (e *ValidationErrors) MustBeSatisfied(field string, value string, regex *regexp.Regexp) bool { - return e.MustBeSatisfiedFunc(field, value, regex.MatchString) -} - -// MustBeNotEmptyString reports and fails if the given "value" is empty. -func (e *ValidationErrors) MustBeNotEmptyString(field string, value string) bool { - if strings.TrimSpace(value) == "" { - e.Add(ValidationError{ - Field: field, - Value: value, - Reason: "must be not an empty string", - }) - - return false - } - - return true -} - -// MustBeInRangeString reports whether the "value" is in range of min and max. -func (e *ValidationErrors) MustBeInRangeString(field string, value string, minIncluding, maxIncluding int) bool { - if maxIncluding <= 0 { - maxIncluding = math.MaxInt32 - } - - if len(value) < minIncluding || len(value) > maxIncluding { - e.Add(ValidationError{ - Field: field, - Value: value, - Reason: sprintf("characters length must be between %d and %d", minIncluding, maxIncluding), - }) - return false - } - - return true + + return nil, false }