diff --git a/HISTORY.md b/HISTORY.md index ef6a55d7..ba71ae7d 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. +- 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. - Add `x/timex.GetMonthDays` and `x/timex.GetMonthEnd` functions. diff --git a/_examples/README.md b/_examples/README.md index ab14753f..7b8e36b5 100644 --- a/_examples/README.md +++ b/_examples/README.md @@ -55,6 +55,7 @@ * [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** * [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/main.go b/_examples/routing/http-wire-errors/main.go index 912e7f4f..1f5001e5 100644 --- a/_examples/routing/http-wire-errors/main.go +++ b/_examples/routing/http-wire-errors/main.go @@ -29,7 +29,7 @@ import ( // errors.Unavailable // errors.DataLoss var ( - Custom = errors.E("CUSTOM_CANONICAL_ERROR_NAME", iris.StatusBadRequest) + Custom = errors.Register("CUSTOM_CANONICAL_ERROR_NAME", iris.StatusBadRequest) ) func main() { diff --git a/_examples/routing/http-wire-errors/service/main.go b/_examples/routing/http-wire-errors/service/main.go new file mode 100644 index 00000000..8384c938 --- /dev/null +++ b/_examples/routing/http-wire-errors/service/main.go @@ -0,0 +1,127 @@ +package main + +import ( + "context" + "strings" + + "github.com/kataras/iris/v12" + "github.com/kataras/iris/v12/x/errors" +) + +func main() { + app := iris.New() + + service := new(myService) + app.Post("/", createHandler(service)) + app.Get("/", listHandler(service)) + app.Delete("/{id:string}", deleteHandler(service)) + + app.Listen(":8080") +} + +func createHandler(service *myService) iris.Handler { + return func(ctx iris.Context) { + // What it does? + // 1. Reads the request body and binds it to the CreateRequest struct. + // 2. Calls the service.Create function with the given request body. + // 3. If the service.Create returns an error, it sends an appropriate error response to the client. + // 4. If the service.Create returns a response, it sets the status code to 201 (Created) and sends the response as a JSON payload to the client. + // + // Useful for create operations. + errors.Create(ctx, service.Create) + } +} + +func listHandler(service *myService) iris.Handler { + return func(ctx iris.Context) { + // What it does? + // 1. If the 3rd variadic (optional) parameter is empty (not our case here), it reads the request body and binds it to the ListRequest struct, + // otherwise (our case) it calls the service.List function directly with the given input parameter (empty ListRequest struct value in our case). + // 2. Calls the service.List function with the ListRequest value. + // 3. If the service.List returns an error, it sends an appropriate error response to the client. + // 4. If the service.List returns a response, it sets the status code to 200 (OK) and sends the response as a JSON payload to the client. + // + // Useful for get single, fetch multiple and search operations. + errors.OK(ctx, service.List, ListRequest{}) + } +} + +func deleteHandler(service *myService) iris.Handler { + return func(ctx iris.Context) { + id := ctx.Params().Get("id") + // What it does? + // 1. Calls the service.Delete function with the given input parameter. + // 2. If the service.Delete returns an error, it sends an appropriate error response to the client. + // 3.If the service.Delete doesn't return an error then it sets the status code to 204 (No Content) and + // sends the response as a JSON payload to the client. + // errors.NoContent(ctx, service.Delete, id) + // OR: + // 1. Calls the service.DeleteWithFeedback function with the given input parameter. + // 2. If the service.DeleteWithFeedback returns an error, it sends an appropriate error response to the client. + // 3. If the service.DeleteWithFeedback returns true, it sets the status code to 204 (No Content). + // 4. If the service.DeleteWithFeedback returns false, it sets the status code to 304 (Not Modified). + // + // Useful for update and delete operations. + errors.NoContentOrNotModified(ctx, service.DeleteWithFeedback, id) + } +} + +type ( + myService struct{} + + CreateRequest struct { + Fullname string + } + + CreateResponse struct { + ID string + Firstname string + Lastname string + } +) + +func (s *myService) Create(ctx context.Context, in CreateRequest) (CreateResponse, error) { + arr := strings.Split(in.Fullname, " ") + firstname, lastname := arr[0], arr[1] + id := "test_id" + + resp := CreateResponse{ + ID: id, + Firstname: firstname, + Lastname: lastname, + } + return resp, nil // , errors.New("create: test error") +} + +type ListRequest struct { +} + +func (s *myService) List(ctx context.Context, in ListRequest) ([]CreateResponse, error) { + resp := []CreateResponse{ + { + ID: "test-id-1", + Firstname: "test first name 1", + Lastname: "test last name 1", + }, + { + ID: "test-id-2", + Firstname: "test first name 2", + Lastname: "test last name 2", + }, + { + ID: "test-id-3", + Firstname: "test first name 3", + Lastname: "test last name 3", + }, + } + + return resp, nil //, errors.New("list: test error") +} + +func (s *myService) Delete(ctx context.Context, id string) error { + return nil // errors.New("delete: test error") +} + +func (s *myService) DeleteWithFeedback(ctx context.Context, id string) (bool, error) { + return true, nil // false, errors.New("delete: test error") +} diff --git a/go.mod b/go.mod index 4758b8d7..453f9a52 100644 --- a/go.mod +++ b/go.mod @@ -34,7 +34,7 @@ require ( github.com/microcosm-cc/bluemonday v1.0.26 github.com/redis/go-redis/v9 v9.3.1 github.com/schollz/closestmatch v2.1.0+incompatible - github.com/shirou/gopsutil/v3 v3.23.11 + github.com/shirou/gopsutil/v3 v3.23.12 github.com/tdewolff/minify/v2 v2.20.10 github.com/vmihailenco/msgpack/v5 v5.4.1 github.com/yosssi/ace v0.0.5 diff --git a/go.sum b/go.sum index 6bd038a9..52084552 100644 --- a/go.sum +++ b/go.sum @@ -196,8 +196,8 @@ github.com/schollz/closestmatch v2.1.0+incompatible h1:Uel2GXEpJqOWBrlyI+oY9LTiy github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/shirou/gopsutil/v3 v3.23.11 h1:i3jP9NjCPUz7FiZKxlMnODZkdSIp2gnzfrvsu9CuWEQ= -github.com/shirou/gopsutil/v3 v3.23.11/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= +github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= +github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= diff --git a/x/errors/aliases.go b/x/errors/aliases.go index 29e732d4..c0232e81 100644 --- a/x/errors/aliases.go +++ b/x/errors/aliases.go @@ -14,6 +14,8 @@ var ( New = errors.New // Unwrap is an alias of the standard errors.Unwrap function. Unwrap = errors.Unwrap + // Join is an alias of the standard errors.Join function. + Join = errors.Join ) func sprintf(format string, args ...interface{}) string { diff --git a/x/errors/errors.go b/x/errors/errors.go index 5b6aad95..6a6b2ef1 100644 --- a/x/errors/errors.go +++ b/x/errors/errors.go @@ -48,9 +48,12 @@ type ( // 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. +// Deprecated: Use Register instead. +var E = Register + +// Register 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. +// with the standard errors package, therefore the "Register" 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. // @@ -59,14 +62,14 @@ var errorCodeMap = make(map[ErrorCodeName]ErrorCode) // Example: // // var ( -// NotFound = errors.E("NOT_FOUND", http.StatusNotFound) +// NotFound = errors.Register("NOT_FOUND", http.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 { +func Register(httpErrorCanonicalName string, httpStatusCode int) ErrorCodeName { canonicalName := ErrorCodeName(httpErrorCanonicalName) RegisterErrorCode(canonicalName, httpStatusCode) return canonicalName @@ -99,22 +102,22 @@ func RegisterErrorCodeMap(errorMap map[ErrorCodeName]int) { // List of default error codes a server should follow and send back to the client. var ( - Cancelled ErrorCodeName = E("CANCELLED", context.StatusTokenRequired) - Unknown ErrorCodeName = E("UNKNOWN", http.StatusInternalServerError) - InvalidArgument ErrorCodeName = E("INVALID_ARGUMENT", http.StatusBadRequest) - DeadlineExceeded ErrorCodeName = E("DEADLINE_EXCEEDED", http.StatusGatewayTimeout) - NotFound ErrorCodeName = E("NOT_FOUND", http.StatusNotFound) - AlreadyExists ErrorCodeName = E("ALREADY_EXISTS", http.StatusConflict) - PermissionDenied ErrorCodeName = E("PERMISSION_DENIED", http.StatusForbidden) - Unauthenticated ErrorCodeName = E("UNAUTHENTICATED", http.StatusUnauthorized) - ResourceExhausted ErrorCodeName = E("RESOURCE_EXHAUSTED", http.StatusTooManyRequests) - FailedPrecondition ErrorCodeName = E("FAILED_PRECONDITION", http.StatusBadRequest) - Aborted ErrorCodeName = E("ABORTED", http.StatusConflict) - OutOfRange ErrorCodeName = E("OUT_OF_RANGE", http.StatusBadRequest) - Unimplemented ErrorCodeName = E("UNIMPLEMENTED", http.StatusNotImplemented) - Internal ErrorCodeName = E("INTERNAL", http.StatusInternalServerError) - Unavailable ErrorCodeName = E("UNAVAILABLE", http.StatusServiceUnavailable) - DataLoss ErrorCodeName = E("DATA_LOSS", http.StatusInternalServerError) + Cancelled ErrorCodeName = Register("CANCELLED", context.StatusTokenRequired) + Unknown ErrorCodeName = Register("UNKNOWN", http.StatusInternalServerError) + InvalidArgument ErrorCodeName = Register("INVALID_ARGUMENT", http.StatusBadRequest) + DeadlineExceeded ErrorCodeName = Register("DEADLINE_EXCEEDED", http.StatusGatewayTimeout) + NotFound ErrorCodeName = Register("NOT_FOUND", http.StatusNotFound) + AlreadyExists ErrorCodeName = Register("ALREADY_EXISTS", http.StatusConflict) + PermissionDenied ErrorCodeName = Register("PERMISSION_DENIED", http.StatusForbidden) + Unauthenticated ErrorCodeName = Register("UNAUTHENTICATED", http.StatusUnauthorized) + ResourceExhausted ErrorCodeName = Register("RESOURCE_EXHAUSTED", http.StatusTooManyRequests) + FailedPrecondition ErrorCodeName = Register("FAILED_PRECONDITION", http.StatusBadRequest) + Aborted ErrorCodeName = Register("ABORTED", http.StatusConflict) + OutOfRange ErrorCodeName = Register("OUT_OF_RANGE", http.StatusBadRequest) + Unimplemented ErrorCodeName = Register("UNIMPLEMENTED", http.StatusNotImplemented) + Internal ErrorCodeName = Register("INTERNAL", http.StatusInternalServerError) + Unavailable ErrorCodeName = Register("UNAVAILABLE", http.StatusServiceUnavailable) + DataLoss ErrorCodeName = Register("DATA_LOSS", http.StatusInternalServerError) ) // errorFuncCodeMap is a read-only map of error code names and their error functions. @@ -352,7 +355,7 @@ var ( // 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", http.StatusInternalServerError) + ErrUnexpected = Register("UNEXPECTED_ERROR", http.StatusInternalServerError) // ErrUnexpectedErrorCode is the error which logged // when the given error code name is not registered. ErrUnexpectedErrorCode = New("unexpected error code name") diff --git a/x/errors/handlers.go b/x/errors/handlers.go index 98ff3f6c..2a45dcb6 100644 --- a/x/errors/handlers.go +++ b/x/errors/handlers.go @@ -1,12 +1,198 @@ package errors import ( + stdContext "context" "net/http" "github.com/kataras/iris/v12/context" "github.com/kataras/iris/v12/x/pagination" ) +// Handle handles a generic response and error from a service call and sends a JSON response to the context. +// It returns a boolean value indicating whether the handle was successful or not. +// If the error is not nil, it calls HandleError to send an appropriate error response to the client. +func Handle(ctx *context.Context, resp interface{}, err error) bool { + if HandleError(ctx, err) { + return false + } + + ctx.StatusCode(http.StatusOK) + + if resp != nil { + if ctx.JSON(resp) != nil { + return false + } + } + + return true +} + +// IDPayload is a simple struct which describes a json id value. +type IDPayload[T string | int] struct { + ID T `json:"id"` +} + +// HandleCreate handles a create operation and sends a JSON response with the created resource to the client. +// It returns a boolean value indicating whether the handle was successful or not. +// +// If the "respOrID" response is not nil, it sets the status code to 201 (Created) and sends the response as a JSON payload, +// however if the given "respOrID" is a string or an int, it sends the response as a JSON payload of {"id": resp}. +// If the "err" error is not nil, it calls HandleError to send an appropriate error response to the client. +// It sets the status code to 201 (Created) and sends any response as a JSON payload, +func HandleCreate(ctx *context.Context, respOrID any, err error) bool { + if HandleError(ctx, err) { + return false + } + + ctx.StatusCode(http.StatusCreated) + + if respOrID != nil { + switch responseValue := respOrID.(type) { + case string: + if ctx.JSON(IDPayload[string]{ID: responseValue}) != nil { + return false + } + case int: + if ctx.JSON(IDPayload[int]{ID: responseValue}) != nil { + return false + } + default: + if ctx.JSON(responseValue) != nil { + return false + } + } + } + + return true +} + +// HandleUpdate handles an update operation and sends a status code to the client. +// It returns a boolean value indicating whether the handle was successful or not. +// If the error is not nil, it calls HandleError to send an appropriate error response to the client. +// If the updated value is true, it sets the status code to 204 (No Content). +// If the updated value is false, it sets the status code to 304 (Not Modified). +func HandleUpdate(ctx *context.Context, updated bool, err error) bool { + if HandleError(ctx, err) { + return false + } + + if updated { + ctx.StatusCode(http.StatusNoContent) + } else { + ctx.StatusCode(http.StatusNotModified) + } + + return true +} + +// HandleDelete handles a delete operation and sends a status code to the client. +// If the error is not nil, it calls HandleError to send an appropriate error response to the client. +// If the deleted value is true, it sets the status code to 204 (No Content). +// If the deleted value is false, it sets the status code to 304 (Not Modified). +func HandleDelete(ctx *context.Context, deleted bool, err error) bool { + return HandleUpdate(ctx, deleted, err) +} + +// HandleDelete handles a delete operation and sends a status code to the client. +// If the error is not nil, it calls HandleError to send an appropriate error response to the client. +// It sets the status code to 204 (No Content). +func HandleDeleteNoContent(ctx *context.Context, err error) bool { + return HandleUpdate(ctx, true, err) +} + +// ResponseFunc is a function which takes a context and a generic type T and returns a generic type R and an error. +// It is used to bind a request payload to a generic type T and call a service function with it. +type ResponseFunc[T, R any] interface { + func(stdContext.Context, T) (R, error) +} + +// ResponseOnlyErrorFunc is a function which takes a context and a generic type T and returns an error. +// It is used to bind a request payload to a generic type T and call a service function with it. +// It is used for functions which do not return a response. +type ResponseOnlyErrorFunc[T any] interface { + func(stdContext.Context, T) 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) { + case 0: + err := ctx.ReadJSON(&req) + if err != nil { + var resp R + return resp, !HandleError(ctx, err) + } + case 1: + req = fnInput[0] + default: + panic("invalid number of arguments") + } + + resp, err := fn(ctx, req) + return resp, !HandleError(ctx, err) +} + +// OK handles a generic response and error from a service call and sends a JSON response to the context. +// It returns a boolean value indicating whether the handle was successful or not. +// If the error is not nil, it calls HandleError to send an appropriate error response to the client. +// It sets the status code to 200 (OK) and sends any response as a JSON payload. +// +// Useful for Get/List/Fetch operations. +func OK[T, R any, F ResponseFunc[T, R]](ctx *context.Context, fn F, fnInput ...T) bool { // or Fetch. + resp, ok := bindResponse(ctx, fn, fnInput...) + if !ok { + return false + } + + return Handle(ctx, resp, nil) +} + +// Create handles a create operation and sends a JSON response with the created resource to the client. +// It returns a boolean value indicating whether the handle was successful or not. +// If the error is not nil, it calls HandleError to send an appropriate error response to the client. +// It sets the status code to 201 (Created) and sends any response as a JSON payload +// note that if the response is a string, then it sends an {"id": resp} JSON payload). +// +// Useful for Insert operations. +func Create[T, R any, F ResponseFunc[T, R]](ctx *context.Context, fn F, fnInput ...T) bool { + resp, ok := bindResponse(ctx, fn, fnInput...) + if !ok { + return false + } + + return HandleCreate(ctx, resp, nil) +} + +// NoContent handles a generic response and error from a service call and sends a JSON response to the context. +// It returns a boolean value indicating whether the handle was successful or not. +// If the error is not nil, it calls HandleError to send an appropriate error response to the client. +// It sets the status code to 204 (No Content). +// +// Useful for Update and Deletion operations. +func NoContent[T any, F ResponseOnlyErrorFunc[T]](ctx *context.Context, fn F, fnInput ...T) bool { + toFn := func(c stdContext.Context, req T) (bool, error) { + return true, fn(ctx, req) + } + + return NoContentOrNotModified(ctx, toFn, fnInput...) +} + +// NoContent handles a generic response and error from a service call and sends a JSON response to the context. +// It returns a boolean value indicating whether the handle was successful or not. +// If the error is not nil, it calls HandleError to send an appropriate error response to the client. +// If the response is true, it sets the status code to 204 (No Content). +// If the response is false, it sets the status code to 304 (Not Modified). +// +// Useful for Update and Deletion operations. +func NoContentOrNotModified[T any, F ResponseFunc[T, bool]](ctx *context.Context, fn F, fnInput ...T) bool { + resp, ok := bindResponse(ctx, fn, fnInput...) + if !ok { + return false + } + + return HandleUpdate(ctx, bool(resp), nil) +} + // ReadPayload reads a JSON payload from the context and returns it as a generic type T. // It also returns a boolean value indicating whether the read was successful or not. // If the read fails, it sends an appropriate error response to the client. @@ -60,86 +246,3 @@ func ReadPaginationOptions[T /* T is FilterOptions */ any](ctx *context.Context) return list, filter, true } - -// Handle handles a generic response and error from a service call and sends a JSON response to the context. -// It returns a boolean value indicating whether the handle was successful or not. -// If the error is not nil, it calls HandleError to send an appropriate error response to the client. -func Handle(ctx *context.Context, resp interface{}, err error) bool { - if HandleError(ctx, err) { - return false - } - - return ctx.JSON(resp) == nil -} - -// IDPayload is a simple struct which describes a json id value. -type IDPayload struct { - ID string `json:"id"` -} - -// HandleCreate handles a create operation and sends a JSON response with the created id to the client. -// It returns a boolean value indicating whether the handle was successful or not. -// If the error is not nil, it calls HandleError to send an appropriate error response to the client. -// If the id is not empty, it sets the status code to 201 (Created) and sends the id as a JSON payload. -func HandleCreate(ctx *context.Context, id string, err error) bool { - if HandleError(ctx, err) { - return false - } - - ctx.StatusCode(http.StatusCreated) - - if id != "" { - ctx.JSON(IDPayload{ID: id}) - } - - return true -} - -// HandleCreateResponse handles a create operation and sends a JSON response with the created resource to the client. -// It returns a boolean value indicating whether the handle was successful or not. -// If the error is not nil, it calls HandleError to send an appropriate error response to the client. -// If the response is not nil, it sets the status code to 201 (Created) and sends the response as a JSON payload. -func HandleCreateResponse(ctx *context.Context, resp interface{}, err error) bool { - if HandleError(ctx, err) { - return false - } - - ctx.StatusCode(http.StatusCreated) - if resp != nil { - return ctx.JSON(resp) == nil - } - - return true -} - -// HandleUpdate handles an update operation and sends a status code to the client. -// It returns a boolean value indicating whether the handle was successful or not. -// If the error is not nil, it calls HandleError to send an appropriate error response to the client. -// If the updated value is true, it sets the status code to 204 (No Content). -// If the updated value is false, it sets the status code to 304 (Not Modified). -func HandleUpdate(ctx *context.Context, updated bool, err error) bool { - if HandleError(ctx, err) { - return false - } - - if updated { - ctx.StatusCode(http.StatusNoContent) - } else { - ctx.StatusCode(http.StatusNotModified) - } - - return true -} - -// HandleDelete handles a delete operation and sends a status code to the client. -// If the error is not nil, it calls HandleError to send an appropriate error response to the client. -// It sets the status code to 204 (No Content). -func HandleDelete(ctx *context.Context, err error) bool { - if HandleError(ctx, err) { - return false - } - - ctx.StatusCode(http.StatusNoContent) - - return true -}