diff --git a/HISTORY.md b/HISTORY.md index 4c9b55d8..d8ca2a57 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -28,6 +28,8 @@ The codebase for Dependency Injection, Internationalization and localization and ## Fixes and Improvements +- New [x/errors](x/errors) sub-package, helps with HTTP Wire Errors. Example can be found [here](_examples/routing/http-wire-errors/main.go). + - New [x/timex](x/timex) sub-package, helps working with weekdays. - Minor improvements to the [JSON Kitchen Time](x/jsonx/kitchen_time.go). diff --git a/_examples/README.md b/_examples/README.md index 5a563f69..0db92a9d 100644 --- a/_examples/README.md +++ b/_examples/README.md @@ -44,6 +44,7 @@ * [Overview](routing/overview/main.go) * [Basic](routing/basic/main.go) * [Custom HTTP Errors](routing/http-errors/main.go) + * [HTTP Wire Errors](routing/http-wire-errors/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/http-server/listen-addr-public/main.go b/_examples/http-server/listen-addr-public/main.go index 1e17cd0e..ceb823c9 100644 --- a/_examples/http-server/listen-addr-public/main.go +++ b/_examples/http-server/listen-addr-public/main.go @@ -28,6 +28,7 @@ func main() { { Name: "MyApp", Addr: ":8080", + Hostname: "your-custom-sub-domain.ngrok.io", // optionally }, }, }, diff --git a/_examples/routing/http-errors/main.go b/_examples/routing/http-errors/main.go index e3fdca01..f5af0147 100644 --- a/_examples/routing/http-errors/main.go +++ b/_examples/routing/http-errors/main.go @@ -4,6 +4,7 @@ import ( "github.com/kataras/iris/v12" ) +// See _examples/routing/http-wire-errors as well. func main() { app := iris.New() diff --git a/_examples/routing/http-wire-errors/main.go b/_examples/routing/http-wire-errors/main.go new file mode 100644 index 00000000..912e7f4f --- /dev/null +++ b/_examples/routing/http-wire-errors/main.go @@ -0,0 +1,110 @@ +package main + +import ( + "github.com/kataras/iris/v12" + // IMPORTANT, import this sub-package. + // Note tht it does NOT break compatibility with the + // standard "errors" package as the New, + // Is, As, Unwrap functions are aliases to the standard package. + "github.com/kataras/iris/v12/x/errors" +) + +// Optionally, register custom error codes. +// +// The default list of error code names: +// errors.Cancelled +// errors.Unknown +// errors.InvalidArgument +// errors.DeadlineExceeded +// errors.NotFound +// errors.AlreadyExists +// errors.PermissionDenied +// errors.Unauthenticated +// errors.ResourceExhausted +// errors.FailedPrecondition +// errors.Aborted +// errors.OutOfRange +// errors.Unimplemented +// errors.Internal +// errors.Unavailable +// errors.DataLoss +var ( + Custom = errors.E("CUSTOM_CANONICAL_ERROR_NAME", iris.StatusBadRequest) +) + +func main() { + app := iris.New() + + // Custom error code name. + app.Get("/custom", fireCustomErrorCodeName) + + // Send a simple 400 request with message and an error + // or with more details and data. + app.Post("/invalid_argument", fireInvalidArgument) + + // Compatibility with the iris.Problem type (and any other custom type). + app.Get("/problem", fireErrorWithProblem) + + app.Listen(":8080") +} + +func fireCustomErrorCodeName(ctx iris.Context) { + Custom.Details(ctx, "message", "details with arguments: %s", "an argument") +} + +func fireInvalidArgument(ctx iris.Context) { + var req = struct { + Username string `json:"username"` + }{} + if err := ctx.ReadJSON(&req); err != nil { + errors.InvalidArgument.Err(ctx, err) + return + } + + ctx.WriteString(req.Username) + + // Other examples: errors.InvalidArgument/NotFound/Internal and e.t.c. + // .Message(ctx, "message %s", "optional argument") + // .Details(ctx, "message", "details %s", "optional details argument") + // .Data(ctx, "message", anyTypeOfValue) + // .DataWithDetails(ctx, "unable to read the body", "malformed json", iris.Map{"custom": "data of any type"}) + // .Log(ctx, "message %s", "optional argument") + // .LogErr(ctx, err) +} + +func fireErrorWithProblem(ctx iris.Context) { + myCondition := true + if myCondition { + problem := iris.NewProblem(). + // The type URI, if relative it automatically convert to absolute. + Type("/product-error"). + // The title, if empty then it gets it from the status code. + Title("Product validation problem"). + // Any optional details. + Detail("details about the product error"). + // The status error code of the problem, can be optional here. + // Status(iris.StatusBadRequest). + // Any custom key-value pair. + Key("product_name", "the product name") + + errors.InvalidArgument.Data(ctx, "unable to process the request", problem) + return + + /* Prints to the client: + { + "http_error_code": { + "canonical_name": "INVALID_ARGUMENT", + "status": 400 + }, + "message": "unable to process the request", + "data": { + "detail": "details about the product error", + "product_name": "the product name", + "title": "Product validation problem", + "type": "/product-error" + } + } + */ + } + +} diff --git a/x/client/client.go b/x/client/client.go index 562d8871..299b9b3c 100644 --- a/x/client/client.go +++ b/x/client/client.go @@ -33,6 +33,9 @@ type Client struct { // Optional handlers that are being fired before and after each new request. requestHandlers []RequestHandler + + // store it here for future use. + keepAlive bool } // New returns a new Iris HTTP Client. @@ -57,6 +60,10 @@ func New(opts ...Option) *Client { opt(c) } + if transport, ok := c.HTTPClient.Transport.(*http.Transport); ok { + c.keepAlive = !transport.DisableKeepAlives + } + return c } @@ -271,6 +278,13 @@ func (c *Client) Do(ctx context.Context, method, urlpath string, payload interfa return resp, respErr } +// DrainResponseBody drains response body and close it, allowing the transport to reuse TCP connections. +// It's automatically called on Client.ReadXXX methods on the end. +func (c *Client) DrainResponseBody(resp *http.Response) { + _, _ = io.Copy(ioutil.Discard, resp.Body) + resp.Body.Close() +} + const ( acceptKey = "Accept" contentTypeKey = "Content-Type" @@ -377,7 +391,7 @@ func (c *Client) ReadJSON(ctx context.Context, dest interface{}, method, urlpath if err != nil { return err } - defer resp.Body.Close() + defer c.DrainResponseBody(resp) if resp.StatusCode >= http.StatusBadRequest { return ExtractError(resp) @@ -398,7 +412,7 @@ func (c *Client) ReadPlain(ctx context.Context, dest interface{}, method, urlpat if err != nil { return err } - defer resp.Body.Close() + defer c.DrainResponseBody(resp) if resp.StatusCode >= http.StatusBadRequest { return ExtractError(resp) diff --git a/x/errors/aliases.go b/x/errors/aliases.go new file mode 100644 index 00000000..29e732d4 --- /dev/null +++ b/x/errors/aliases.go @@ -0,0 +1,25 @@ +package errors + +import ( + "errors" + "fmt" +) + +var ( + // Is is an alias of the standard errors.Is function. + Is = errors.Is + // As is an alias of the standard errors.As function. + As = errors.As + // New is an alias of the standard errors.New function. + New = errors.New + // Unwrap is an alias of the standard errors.Unwrap function. + Unwrap = errors.Unwrap +) + +func sprintf(format string, args ...interface{}) string { + if len(args) > 0 { + return fmt.Sprintf(format, args...) + } + + return format +} diff --git a/x/errors/errors.go b/x/errors/errors.go new file mode 100644 index 00000000..99429e01 --- /dev/null +++ b/x/errors/errors.go @@ -0,0 +1,309 @@ +package errors + +import ( + "encoding/json" + "fmt" + + "github.com/kataras/iris/v12" + "github.com/kataras/iris/v12/x/client" +) + +// LogErrorFunc is an alias of a function type which accepts the Iris request context and an error +// and it's fired whenever an error should be logged. +// +// See "OnErrorLog" variable to change the way an error is logged, +// by default the error is logged using the Application's Logger's Error method. +type LogErrorFunc = func(ctx iris.Context, err error) + +// LogError can be modified to customize the way an error is logged to the server (most common: internal server errors, database errors et.c.). +// Can be used to customize the error logging, e.g. using Sentry (cloud-based error console). +var LogError LogErrorFunc = func(ctx iris.Context, err error) { + ctx.Application().Logger().Error(err) +} + +// SkipCanceled is a package-level setting which by default +// skips the logging of a canceled response or operation. +// See the "Context.IsCanceled()" method and "iris.IsCanceled()" function +// that decide if the error is caused by a canceled operation. +// +// Change of this setting MUST be done on initialization of the program. +var SkipCanceled = true + +type ( + // ErrorCodeName is a custom string type represents canonical error names. + // + // It contains functionality for safe and easy error populating. + // See its "Message", "Details", "Data" and "Log" methods. + ErrorCodeName string + + // ErrorCode represents the JSON form ErrorCode of the Error. + ErrorCode struct { + CanonicalName ErrorCodeName `json:"canonical_name" yaml:"CanonicalName"` + Status int `json:"status" yaml:"Status"` + } +) + +// A read-only map of valid http error codes. +var errorCodeMap = make(map[ErrorCodeName]ErrorCode) + +// E registers a custom HTTP Error and returns its canonical name for future use. +// The method "New" is reserved and was kept as it is for compatibility +// with the standard errors package, therefore the "E" name was chosen instead. +// The key stroke "e" is near and accessible while typing the "errors" word +// so developers may find it easy to use. +// +// See "RegisterErrorCode" and "RegisterErrorCodeMap" for alternatives. +// +// Example: +// var ( +// NotFound = errors.E("NOT_FOUND", iris.StatusNotFound) +// ) +// ... +// NotFound.Details(ctx, "resource not found", "user with id: %q was not found", userID) +// +// This method MUST be called on initialization, before HTTP server starts as +// the internal map is not protected by mutex. +func E(httpErrorCanonicalName string, httpStatusCode int) ErrorCodeName { + canonicalName := ErrorCodeName(httpErrorCanonicalName) + RegisterErrorCode(canonicalName, httpStatusCode) + return canonicalName +} + +// RegisterErrorCode registers a custom HTTP Error. +// +// This method MUST be called on initialization, before HTTP server starts as +// the internal map is not protected by mutex. +func RegisterErrorCode(canonicalName ErrorCodeName, httpStatusCode int) { + errorCodeMap[canonicalName] = ErrorCode{ + CanonicalName: canonicalName, + Status: httpStatusCode, + } +} + +// RegisterErrorCodeMap registers one or more custom HTTP Errors. +// +// This method MUST be called on initialization, before HTTP server starts as +// the internal map is not protected by mutex. +func RegisterErrorCodeMap(errorMap map[ErrorCodeName]int) { + if len(errorMap) == 0 { + return + } + + for canonicalName, httpStatusCode := range errorMap { + RegisterErrorCode(canonicalName, httpStatusCode) + } +} + +// List of default error codes a server should follow and send back to the client. +var ( + Cancelled ErrorCodeName = E("CANCELLED", iris.StatusTokenRequired) + Unknown ErrorCodeName = E("UNKNOWN", iris.StatusInternalServerError) + InvalidArgument ErrorCodeName = E("INVALID_ARGUMENT", iris.StatusBadRequest) + DeadlineExceeded ErrorCodeName = E("DEADLINE_EXCEEDED", iris.StatusGatewayTimeout) + NotFound ErrorCodeName = E("NOT_FOUND", iris.StatusNotFound) + AlreadyExists ErrorCodeName = E("ALREADY_EXISTS", iris.StatusConflict) + PermissionDenied ErrorCodeName = E("PERMISSION_DENIED", iris.StatusForbidden) + Unauthenticated ErrorCodeName = E("UNAUTHENTICATED", iris.StatusUnauthorized) + ResourceExhausted ErrorCodeName = E("RESOURCE_EXHAUSTED", iris.StatusTooManyRequests) + FailedPrecondition ErrorCodeName = E("FAILED_PRECONDITION", iris.StatusBadRequest) + Aborted ErrorCodeName = E("ABORTED", iris.StatusConflict) + OutOfRange ErrorCodeName = E("OUT_OF_RANGE", iris.StatusBadRequest) + Unimplemented ErrorCodeName = E("UNIMPLEMENTED", iris.StatusNotImplemented) + Internal ErrorCodeName = E("INTERNAL", iris.StatusInternalServerError) + Unavailable ErrorCodeName = E("UNAVAILABLE", iris.StatusServiceUnavailable) + DataLoss ErrorCodeName = E("DATA_LOSS", iris.StatusInternalServerError) +) + +// Message sends an error with a simple message to the client. +func (e ErrorCodeName) Message(ctx iris.Context, format string, args ...interface{}) { + fail(ctx, e, sprintf(format, args...), "", nil, nil) +} + +// Details sends an error with a message and details to the client. +func (e ErrorCodeName) Details(ctx iris.Context, msg, details string, detailsArgs ...interface{}) { + fail(ctx, e, msg, sprintf(details, detailsArgs...), nil, nil) +} + +// Data sends an error with a message and json data to the client. +func (e ErrorCodeName) Data(ctx iris.Context, msg string, data interface{}) { + fail(ctx, e, msg, "", nil, data) +} + +// DataWithDetails sends an error with a message, details and json data to the client. +func (e ErrorCodeName) DataWithDetails(ctx iris.Context, msg, details string, data interface{}) { + 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) +} + +// Err sends the error's text as a message to the client. +// In exception, if the given "err" is a type of validation error +// then the Validation method is called instead. +func (e ErrorCodeName) Err(ctx iris.Context, err error) { + if err == nil { + return + } + + if validationErrors, ok := AsValidationErrors(err); ok { + e.Validation(ctx, validationErrors...) + return + } + + e.Message(ctx, err.Error()) +} + +// Log sends an error of "format" and optional "args" to the client and prints that +// error using the "LogError" package-level function, which can be customized. +// +// See "LogErr" too. +func (e ErrorCodeName) Log(ctx iris.Context, format string, args ...interface{}) { + if SkipCanceled { + if ctx.IsCanceled() { + return + } + + for _, arg := range args { + if err, ok := arg.(error); ok { + if iris.IsErrCanceled(err) { + return + } + } + } + } + + err := fmt.Errorf(format, args...) + e.LogErr(ctx, err) +} + +// LogErr sends the given "err" as message to the client and prints that +// error to using the "LogError" package-level function, which can be customized. +func (e ErrorCodeName) LogErr(ctx iris.Context, err error) { + if SkipCanceled && (ctx.IsCanceled() || iris.IsErrCanceled(err)) { + return + } + + LogError(ctx, err) + + e.Message(ctx, "server error") +} + +// HandleAPIError handles remote server errors. +// Optionally, use it when you write your server's HTTP clients using the the /x/client package. +// When the HTTP Client sends data to a remote server but that remote server +// failed to accept the request as expected, then the error will be proxied +// to this server's end-client. +// +// When the given "err" is not a type of client.APIError then +// the error will be sent using the "Internal.LogErr" method which sends +// HTTP internal server error to the end-client and +// prints the "err" using the "LogError" package-level function. +func HandleAPIError(ctx iris.Context, err error) { + // Error expected and came from the external server, + // save its body so we can forward it to the end-client. + if apiErr, ok := client.GetError(err); ok { + statusCode := apiErr.Response.StatusCode + if statusCode >= 400 && statusCode < 500 { + InvalidArgument.DataWithDetails(ctx, "remote server error", "invalid client request", apiErr.Body) + } else { + Internal.Data(ctx, "remote server error", apiErr.Body) + } + + // Unavailable.DataWithDetails(ctx, "remote server error", "unavailable", apiErr.Body) + return + } + + Internal.LogErr(ctx, err) +} + +var ( + // ErrUnexpected is the HTTP error which sent to the client + // when server fails to send an error, it's a fallback error. + // The server fails to send an error on two cases: + // 1. when the provided error code name is not registered (the error value is the ErrUnexpectedErrorCode) + // 2. when the error contains data but cannot be encoded to json (the value of the error is the result error of json.Marshal). + ErrUnexpected = E("UNEXPECTED_ERROR", iris.StatusInternalServerError) + // ErrUnexpectedErrorCode is the error which logged + // when the given error code name is not registered. + ErrUnexpectedErrorCode = New("unexpected error code name") +) + +// Error represents the JSON form of "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. +} + +// Error method completes the error interface. It just returns the canonical name, status code, message and details. +func (err Error) Error() string { + if err.Message == "" { + err.Message = "" + } + + if err.Details == "" { + err.Details = "" + } + + if err.ErrorCode.CanonicalName == "" { + err.ErrorCode.CanonicalName = ErrUnexpected + } + + if err.ErrorCode.Status <= 0 { + err.ErrorCode.Status = iris.StatusInternalServerError + } + + 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{}) { + errorCode, ok := errorCodeMap[codeName] + if !ok { + // This SHOULD NEVER happen, all ErrorCodeNames MUST be registered. + LogError(ctx, ErrUnexpectedErrorCode) + fail(ctx, ErrUnexpected, msg, details, validationErrors, dataValue) + return + } + + var data json.RawMessage + if dataValue != nil { + switch v := dataValue.(type) { + case json.RawMessage: + data = v + case []byte: + data = v + case error: + if msg == "" { + msg = v.Error() + } else if details == "" { + details = v.Error() + } else { + data = json.RawMessage(v.Error()) + } + default: + b, err := json.Marshal(v) + if err != nil { + LogError(ctx, err) + fail(ctx, ErrUnexpected, err.Error(), "", nil, nil) + return + } + data = b + + } + } + + err := Error{ + ErrorCode: errorCode, + Message: msg, + Details: details, + Data: data, + ValidationErrors: validationErrors, + } + + // ctx.SetErr(err) + ctx.StopWithJSON(errorCode.Status, err) +} diff --git a/x/errors/validation_error.go b/x/errors/validation_error.go new file mode 100644 index 00000000..49b0b998 --- /dev/null +++ b/x/errors/validation_error.go @@ -0,0 +1,181 @@ +package errors + +import ( + "math" + "regexp" + "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 + } + + switch e := err.(type) { + case ValidationError: + return ValidationErrors{e}, true + case ValidationErrors: + return e, true + case *ValidationErrors: + return *e, true + default: + return nil, false + } +} + +// 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 { + var buf strings.Builder + for i, err := range e { + buf.WriteByte('[') + buf.WriteString(strconv.Itoa(i)) + buf.WriteByte(']') + buf.WriteByte(' ') + + buf.WriteString(err.Error()) + + if i < len(e)-1 { + buf.WriteByte(',') + buf.WriteByte(' ') + } + } + + 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 { + if err == nil { + return false + } + + switch err.(type) { + case ValidationError: + return true + case *ValidationError: + return true + case ValidationErrors: + return true + case *ValidationErrors: + return true + default: + return false + } +} + +// 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 +}