Skip to content

Commit

Permalink
Enhance errors with multiple and custom errors (#797)
Browse files Browse the repository at this point in the history
  • Loading branch information
srijan-27 authored Jul 9, 2024
1 parent c01b302 commit 1fe0a57
Show file tree
Hide file tree
Showing 7 changed files with 173 additions and 44 deletions.
14 changes: 14 additions & 0 deletions docs/advanced-guide/gofr-errors/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,17 @@ func (c customError) StatusCode() int {
return http.StatusMethodNotAllowed
}
```

GoFr also provides a `CustomError` struct to create a custom error type with specific status code, reason, and details (optional)

#### Usage:
```go
customErr := gofrHTTP.CustomError{Code: http.StatusUnauthorized, Reason: "request unauthorized", Details: []string{"invalid credentials"}}
```

GoFr also provides a `MultipleErrors` struct to pass multiple errors with a same status code.

#### Usage:
```go
multipleErr := gofrHTTP.MultipleErrors{Code: http.StatusBadRequest, Errors: []error{gofrHTTP.ErrorInvalidParam{}, gofrHTTP.ErrorMissingParam{}}}
```
16 changes: 7 additions & 9 deletions examples/http-server/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
Expand All @@ -15,6 +16,7 @@ import (
"gofr.dev/pkg/gofr/config"
"gofr.dev/pkg/gofr/container"
"gofr.dev/pkg/gofr/datasource/redis"
gofrHTTP "gofr.dev/pkg/gofr/http"
"gofr.dev/pkg/gofr/logging"
"gofr.dev/pkg/gofr/testutil"
)
Expand Down Expand Up @@ -56,8 +58,6 @@ func TestIntegration_SimpleAPIServer(t *testing.T) {

assert.Equal(t, tc.body, data.Data, "TEST[%d], Failed.\n%s", i, tc.desc)

assert.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc)

assert.Equal(t, http.StatusOK, resp.StatusCode, "TEST[%d], Failed.\n%s", i, tc.desc)

resp.Body.Close()
Expand All @@ -75,19 +75,19 @@ func TestIntegration_SimpleAPIServer_Errors(t *testing.T) {
desc: "error handler called",
path: "/error",
statusCode: http.StatusInternalServerError,
body: map[string]interface{}{"message": "some error occurred"},
body: "some error occurred",
},
{
desc: "empty route",
path: "/",
statusCode: http.StatusNotFound,
body: map[string]interface{}{"message": "route not registered"},
body: gofrHTTP.ErrorInvalidRoute{}.Error(),
},
{
desc: "route not registered with the server",
path: "/route",
statusCode: http.StatusNotFound,
body: map[string]interface{}{"message": "route not registered"},
body: gofrHTTP.ErrorInvalidRoute{}.Error(),
},
}

Expand All @@ -99,7 +99,7 @@ func TestIntegration_SimpleAPIServer_Errors(t *testing.T) {
resp, err := c.Do(req)

var data = struct {
Error interface{} `json:"error"`
Errors interface{} `json:"errors"`
}{}

b, err := io.ReadAll(resp.Body)
Expand All @@ -108,9 +108,7 @@ func TestIntegration_SimpleAPIServer_Errors(t *testing.T) {

_ = json.Unmarshal(b, &data)

assert.Equal(t, tc.body, data.Error, "TEST[%d], Failed.\n%s", i, tc.desc)

assert.NoError(t, err, "TEST[%d], Failed.\n%s", i, tc.desc)
assert.Contains(t, fmt.Sprintf("%v", data.Errors), tc.body, "TEST[%d], Failed.\n%s", i, tc.desc)

assert.Equal(t, tc.statusCode, resp.StatusCode, "TEST[%d], Failed.\n%s", i, tc.desc)

Expand Down
3 changes: 2 additions & 1 deletion pkg/gofr/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func TestHandler_ServeHTTP(t *testing.T) {
{"method is get, data is nil and error is nil", http.MethodGet, nil, nil, http.StatusOK,
`{}`},
{"method is get, data is mil, error is not nil", http.MethodGet, nil, errTest, http.StatusInternalServerError,
`{"error":{"message":"some error"}}`},
`{"errors":[{"reason":"some error"`},
{"method is post, data is nil and error is nil", http.MethodPost, "Created", nil, http.StatusCreated,
`{"data":"Created"}`},
{"method is delete, data is nil and error is nil", http.MethodDelete, nil, nil, http.StatusNoContent,
Expand All @@ -56,6 +56,7 @@ func TestHandler_ServeHTTP(t *testing.T) {
}.ServeHTTP(w, r)

assert.Containsf(t, w.Body.String(), tc.body, "TEST[%d], Failed.\n%s", i, tc.desc)

assert.Equal(t, tc.statusCode, w.Code, "TEST[%d], Failed.\n%s", i, tc.desc)
}
}
Expand Down
33 changes: 33 additions & 0 deletions pkg/gofr/http/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,39 @@ import (
"strings"
)

type MultipleErrors struct {
Code int
Errors []error
}

func (m MultipleErrors) Error() string {
var result string

for _, v := range m.Errors {
result += fmt.Sprintf("%s\n", v)
}

return strings.TrimSuffix(result, "\n")
}

func (m MultipleErrors) StatusCode() int {
return m.Code
}

type CustomError struct {
Code int
Reason string
Details interface{}
}

func (c CustomError) Error() string {
return c.Reason
}

func (c CustomError) StatusCode() int {
return c.Code
}

const alreadyExistsMessage = "entity already exists"

// ErrorEntityNotFound represents an error for when an entity is not found in the system.
Expand Down
16 changes: 16 additions & 0 deletions pkg/gofr/http/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,19 @@ func Test_ErrorErrorPanicRecovery(t *testing.T) {

assert.Equal(t, http.StatusInternalServerError, err.StatusCode(), "TEST Failed.\n")
}

func Test_MultipleErrors(t *testing.T) {
err := MultipleErrors{Code: http.StatusNotFound, Errors: []error{ErrorInvalidRoute{}}}

assert.Equal(t, "route not registered", err.Error(), "TEST Failed.\n")

assert.Equal(t, http.StatusNotFound, err.StatusCode(), "TEST Failed.\n")
}

func Test_CustomError(t *testing.T) {
err := CustomError{Code: http.StatusUnauthorized, Reason: "request unauthorized"}

assert.Equal(t, "request unauthorized", err.Error(), "TEST Failed.\n")

assert.Equal(t, http.StatusUnauthorized, err.StatusCode(), "TEST Failed.\n")
}
93 changes: 70 additions & 23 deletions pkg/gofr/http/responder.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package http

import (
"encoding/json"
"errors"
"net/http"
"time"

resTypes "gofr.dev/pkg/gofr/http/response"
)
Expand All @@ -12,16 +14,34 @@ func NewResponder(w http.ResponseWriter, method string) *Responder {
return &Responder{w: w, method: method}
}

// Responder encapsulates an http.ResponseWriter and is responsible for crafting structured responses.
// Responder encapsulates a http.ResponseWriter and is responsible for crafting structured responses.
type Responder struct {
w http.ResponseWriter
method string
}

// response represents an HTTP response.
type response struct {
Data interface{} `json:"data,omitempty"`
Errors []errResponse `json:"errors,omitempty"`
}

type errResponse struct {
Reason string `json:"reason"`
Details interface{} `json:"details,omitempty"`
DateTime time.Time `json:"datetime"`
}

type statusCodeResponder interface {
StatusCode() int
Error() string
}

// Respond sends a response with the given data and handles potential errors, setting appropriate
// status codes and formatting responses as JSON or raw data as needed.
func (r Responder) Respond(data interface{}, err error) {
statusCode, errorObj := getStatusCode(r.method, data, err)
statusCode := getStatusCode(r.method, data, err)
errObj := getErrResponse(err)

var resp interface{}
switch v := data.(type) {
Expand All @@ -36,8 +56,8 @@ func (r Responder) Respond(data interface{}, err error) {
return
default:
resp = response{
Data: v,
Error: errorObj,
Data: v,
Errors: errObj,
}
}

Expand All @@ -49,40 +69,67 @@ func (r Responder) Respond(data interface{}, err error) {
}

// getStatusCode returns corresponding HTTP status codes.
func getStatusCode(method string, data interface{}, err error) (status int, errObj interface{}) {
func getStatusCode(method string, data interface{}, err error) (status int) {
if err == nil {
switch method {
case http.MethodPost:
if data != nil {
return http.StatusCreated, nil
return http.StatusCreated
}

return http.StatusAccepted, nil
return http.StatusAccepted
case http.MethodDelete:
return http.StatusNoContent, nil
return http.StatusNoContent
default:
return http.StatusOK, nil
return http.StatusOK
}
}

e, ok := err.(statusCodeResponder)
if ok {
return e.StatusCode(), map[string]interface{}{
"message": err.Error(),
var e statusCodeResponder
if errors.As(err, &e) {
if data != nil {
return http.StatusPartialContent
}

status = e.StatusCode()

if e.StatusCode() == 0 {
return http.StatusInternalServerError
}
}

return http.StatusInternalServerError, map[string]interface{}{
"message": err.Error(),
return status
}
}

// response represents an HTTP response.
type response struct {
Error interface{} `json:"error,omitempty"`
Data interface{} `json:"data,omitempty"`
return http.StatusInternalServerError
}

type statusCodeResponder interface {
StatusCode() int
func getErrResponse(err error) []errResponse {
var (
errResp []errResponse
m MultipleErrors
c CustomError
)

if err != nil {
switch {
case errors.As(err, &m):
for _, v := range m.Errors {
resp := errResponse{Reason: v.Error(), DateTime: time.Now()}

if errors.As(v, &c) {
resp.Details = c.Details
}

errResp = append(errResp, resp)
}
case errors.As(err, &c):
errResp = append(errResp, errResponse{Reason: c.Reason, Details: c.Details, DateTime: time.Now()})
default:
errResp = append(errResp, errResponse{Reason: err.Error(), DateTime: time.Now()})
}

return errResp
}

return nil
}
42 changes: 31 additions & 11 deletions pkg/gofr/http/responder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,23 +38,43 @@ func TestResponder_getStatusCode(t *testing.T) {
data interface{}
err error
statusCode int
errObj interface{}
}{
{"success case", http.MethodGet, "success response", nil, http.StatusOK, nil},
{"post with response body", http.MethodPost, "entity created", nil, http.StatusCreated, nil},
{"post with nil response", http.MethodPost, nil, nil, http.StatusAccepted, nil},
{"success delete", http.MethodDelete, nil, nil, http.StatusNoContent, nil},
{"invalid route error", http.MethodGet, nil, ErrorInvalidRoute{}, http.StatusNotFound,
map[string]interface{}{"message": ErrorInvalidRoute{}.Error()}},
{"internal server error", http.MethodGet, nil, http.ErrHandlerTimeout, http.StatusInternalServerError,
map[string]interface{}{"message": http.ErrHandlerTimeout.Error()}},
{"success case", http.MethodGet, "success response", nil, http.StatusOK},
{"post with response body", http.MethodPost, "entity created", nil, http.StatusCreated},
{"post with nil response", http.MethodPost, nil, nil, http.StatusAccepted},
{"success delete", http.MethodDelete, nil, nil, http.StatusNoContent},
{"invalid route error", http.MethodGet, nil, ErrorInvalidRoute{}, http.StatusNotFound},
{"internal server error", http.MethodGet, nil, http.ErrHandlerTimeout, http.StatusInternalServerError},
}

for i, tc := range tests {
statusCode, errObj := getStatusCode(tc.method, tc.data, tc.err)
statusCode := getStatusCode(tc.method, tc.data, tc.err)

assert.Equal(t, tc.statusCode, statusCode, "TEST[%d], Failed.\n%s", i, tc.desc)
}
}

func TestResponder_getErrResponse(t *testing.T) {
tests := []struct {
desc string
err error
reason []string
details interface{}
}{
{"success case", nil, nil, nil},
{"invalid param error", ErrorInvalidParam{}, []string{ErrorInvalidParam{}.Error()}, nil},
{"multiple errors", MultipleErrors{Errors: []error{ErrorMissingParam{}, CustomError{Reason: ErrorEntityAlreadyExist{}.Error()}}},
[]string{ErrorMissingParam{}.Error(), CustomError{Reason: alreadyExistsMessage}.Error()}, nil},
{"custom error", CustomError{Reason: ErrorEntityNotFound{}.Error()}, []string{ErrorEntityNotFound{}.Error()}, nil},
}

for i, tc := range tests {
errObj := getErrResponse(tc.err)

for j, err := range errObj {
assert.Equal(t, tc.reason[j], err.Reason, "TEST[%d], Failed.\n%s", i, tc.desc)

assert.Equal(t, tc.errObj, errObj, "TEST[%d], Failed.\n%s", i, tc.desc)
assert.Equal(t, tc.details, err.Details, "TEST[%d], Failed.\n%s", i, tc.desc)
}
}
}

0 comments on commit 1fe0a57

Please sign in to comment.