diff --git a/CHANGELOG.md b/CHANGELOG.md index 871c69d..0f05820 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [0.2.1] - 2024-02-07 + +### Added + +- NewUnauthorized for default 401 +- NewForbidden for default 403 + +## [0.2.0] - 2024-01-21 + +### Added + +- ResponsableError.JSON +- ResponsableError extends json.Marshaler +- SettableError.SetField + +## [0.1.1] - 2024-01-17 + +### Changed + +- Just the license + ## [0.1.0] - 2024-01-17 Initial Release! Hope you like it! diff --git a/README.md b/README.md index dc401b7..4df69c0 100644 --- a/README.md +++ b/README.md @@ -40,4 +40,4 @@ In the second example, `err.GetStatus()` returns 404, `err.GetMsg()` returns "Us ## Future plans -I'm thinking about making the errors able to return a default body from `json.Marshal`, possibly with an optional override.` +Any suggestions/requests? diff --git a/errorf.go b/errorf.go index 03bde47..5f371e5 100644 --- a/errorf.go +++ b/errorf.go @@ -1,6 +1,7 @@ package errors import ( + "encoding/json" "fmt" "net/http" ) @@ -14,15 +15,16 @@ import ( // // If you want a different user message, use Msg, such as: // -// err := errors.Errorf(http.StatusNotFound, "%w", sqlError).Msg("user not found %d", userId) +// err := errors.Errorf(http.StatusNotFound, "%w", sqlError).SetMsg("user not found %d", userId) func Errorf(status int, format string, parts ...any) SettableError { + meta := make(map[string]any) if len(parts) == 0 { - return &erf{status, format, ""} + return &erf{status, format, "", meta} } err := fmt.Errorf(format, parts...) msg := err.Error() - er := &erf{status, msg, ""} + er := &erf{status, msg, "", meta} if we, ok := err.(wrappedError); ok { return &wrapErr{er, we.Unwrap()} } @@ -34,14 +36,16 @@ func Errorf(status int, format string, parts ...any) SettableError { var _ ResponsableError = new(erf) var _ SettableError = new(erf) +var _ json.Marshaler = new(erf) type erf struct { stat int err string msg string + meta map[string]any } -func (e *erf) GetStatus() int { +func (e *erf) Status() int { if e.stat < http.StatusContinue || e.stat >= 600 { e.stat = http.StatusInternalServerError } @@ -52,15 +56,15 @@ func (e *erf) Error() string { return e.err } -func (e *erf) GetMsg() string { +func (e *erf) Msg() string { if e.msg == "" { return e.err } return e.msg } -func (e *erf) Status(status int) SettableError { - // GetStatus already handles invalid values, so we'll just ignore this. +func (e *erf) SetStatus(status int) SettableError { + // Status already handles invalid values, so we'll just ignore this. if status < http.StatusContinue || status >= 600 { return e } @@ -68,7 +72,7 @@ func (e *erf) Status(status int) SettableError { return e } -func (e *erf) Msg(msg string, parts ...any) SettableError { +func (e *erf) SetMsg(msg string, parts ...any) SettableError { e.msg = msg if len(parts) > 0 { e.msg = fmt.Sprintf(msg, parts...) @@ -76,6 +80,21 @@ func (e *erf) Msg(msg string, parts ...any) SettableError { return e } +func (e *erf) SetField(field string, value any) SettableError { + e.meta[field] = value + return e +} + +func (e *erf) JSON() any { + m := e.meta + m["error"] = e.Msg() + return m +} + +func (e *erf) MarshalJSON() ([]byte, error) { + return json.Marshal(e.JSON()) +} + var _ UnwrappableError = new(wrapErr) type wrapErr struct { diff --git a/errorf_test.go b/errorf_test.go index 1a83ca0..3847ca2 100644 --- a/errorf_test.go +++ b/errorf_test.go @@ -2,6 +2,7 @@ package errors import ( "errors" + "encoding/json" "net/http" "testing" @@ -28,8 +29,8 @@ func (s *ErrorfTestSuite) TestNoWrap() { exp := "42 is more than 13" s.Assert().Equal(exp, err.Error()) - s.Assert().Equal(exp, err.GetMsg()) - s.Assert().Equal(http.StatusTeapot, err.GetStatus()) + s.Assert().Equal(exp, err.Msg()) + s.Assert().Equal(http.StatusTeapot, err.Status()) } func (s *ErrorfTestSuite) TestWrapOne() { @@ -45,7 +46,7 @@ func (s *ErrorfTestSuite) TestWrapOne() { exp := "I'm a little teapot. Here is my handle." s.Assert().Equal(exp, we.Error()) - s.Assert().Equal(exp, we.GetMsg()) + s.Assert().Equal(exp, we.Msg()) s.Assert().Same(wrapped, we.Unwrap()) } @@ -63,7 +64,7 @@ func (s *ErrorfTestSuite) TestWrapTwo() { exp := "I'm a little teapot: short and stout. Here is my handle." s.Assert().Equal(exp, we.Error()) - s.Assert().Equal(exp, we.GetMsg()) + s.Assert().Equal(exp, we.Msg()) unwrapped := we.Unwrap() s.Assert().Len(unwrapped, 2) s.Assert().Same(wrappedOne, unwrapped[0]) @@ -74,31 +75,52 @@ func (s *ErrorfTestSuite) TestSet() { var err SettableError = Errorf(http.StatusTeapot, "Unable to BREW") s.Assert().NotNil(err) - err.Status(http.StatusTooEarly).Msg("It's only %dAM", 2) - s.Assert().Equal(http.StatusTooEarly, err.GetStatus()) - s.Assert().Equal("It's only 2AM", err.GetMsg()) + err.SetStatus(http.StatusTooEarly).SetMsg("It's only %dAM", 2) + s.Assert().Equal(http.StatusTooEarly, err.Status()) + s.Assert().Equal("It's only 2AM", err.Msg()) s.Assert().Equal("Unable to BREW", err.Error()) - err.Msg("I am so great") - s.Assert().Equal("I am so great", err.GetMsg()) + err.SetMsg("I am so great") + s.Assert().Equal("I am so great", err.Msg()) } func (s *ErrorfTestSuite) TestGetStatusOutsideRange() { var err ResponsableError = Errorf(5, "Hello") s.Assert().NotNil(err) - s.Assert().Equal(http.StatusInternalServerError, err.GetStatus()) + s.Assert().Equal(http.StatusInternalServerError, err.Status()) err = Errorf(5, "Hello") s.Assert().NotNil(err) - s.Assert().Equal(http.StatusInternalServerError, err.GetStatus()) + s.Assert().Equal(http.StatusInternalServerError, err.Status()) } func (s *ErrorfTestSuite) TestSetStatusOutsideRange() { var err SettableError = Errorf(http.StatusPaymentRequired, "Hello") s.Assert().NotNil(err) - err.Status(10) - s.Assert().Equal(http.StatusPaymentRequired, err.GetStatus()) - err.Status(600) - s.Assert().Equal(http.StatusPaymentRequired, err.GetStatus()) + err.SetStatus(10) + s.Assert().Equal(http.StatusPaymentRequired, err.Status()) + err.SetStatus(600) + s.Assert().Equal(http.StatusPaymentRequired, err.Status()) +} + +func (s *ErrorfTestSuite) TestJSON() { + var err SettableError = Errorf(http.StatusPaymentRequired, "Hello") + s.Assert().NotNil(err) + + m := err.JSON() + ma, _ := m.(map[string]any) + s.Assert().Equal("Hello", ma["error"]) + s.Assert().Nil(ma["number"]) + + j, _ := json.Marshal(err) + s.Assert().Equal(`{"error":"Hello"}`, string(j)) + + err.SetField("number",42) + m = err.JSON() + ma, _ = m.(map[string]any) + s.Assert().Equal(42, ma["number"]) + + j, _ = json.Marshal(err) + s.Assert().Equal(`{"error":"Hello","number":42}`, string(j)) } diff --git a/interface.go b/interface.go index da03a48..5701b3b 100644 --- a/interface.go +++ b/interface.go @@ -1,27 +1,42 @@ package errors +import "encoding/json" + // ResponsableError is an error that has information useful in an HTTP response. // The string returned by Error should be suitable for logging useful information // to assist debugging the error. -// GetStatus should return an appropriate HTTP status. -// GetMsg should return a message suitable to display to the end user. If the message +// +// Status should return an appropriate HTTP status. It must not return < 100 || >= 600. +// +// Msg should return a message suitable to display to the end user. If the message // returned by Error is safe for the end user, it may simply call that. -// GetStatus should not return a value outside of the 100 - 599 range. +// +// JSON should return something that can be marshalled to JSON for the response body. +// It can be something as simple as map[string]string{"error":err.Msg()}. +// JSON's return value should be used by MarshalJSON() type ResponsableError interface { + json.Marshaler Error() string - GetStatus() int - GetMsg() string + Status() int + Msg() string + JSON() any } // SettableError is a ResponsableError which can be modified after initial creation. -// The Status method can set the status, while the Msg method can set user message. +// +// The SetStatus method can set the status, while the SetMsg method can set user message. // If any values are passed after the message, it should be passed to fmt.Sprintf for formatting. +// // Methods are chainable. +// // SetStatus should not use a value outside of 100-599. It may either ignore such values, or panic. +// +// SetField should add some metadata to the error, which will be included in the data returned by JSON() type SettableError interface { ResponsableError - Status(int) SettableError - Msg(string, ...any) SettableError + SetStatus(int) SettableError + SetMsg(string, ...any) SettableError + SetField(string, any) SettableError } // UnwrappableError allows a ResponsableError to wrap another error. diff --git a/new.go b/new.go index 73e1cf6..a4c7eef 100644 --- a/new.go +++ b/new.go @@ -20,9 +20,19 @@ func NewBadRequest(format string, parts ...any) SettableError { return Errorf(http.StatusBadRequest, format, parts...) } +// A 401 error with the error message "Unauthorized" +func NewUnauthorized() SettableError { + return Errorf(http.StatusUnauthorized, "Unauthorized") +} + +// A 403 error with the error message "Forbidden" +func NewForbidden() SettableError { + return Errorf(http.StatusForbidden, "Forbidden") +} + // Represents a 500 error. For this error, the user error is preset to "Unknown Error". func NewInternalError(format string, parts ...any) SettableError { status := http.StatusInternalServerError msg := "Unknown Error" - return Errorf(status, format, parts...).Msg(msg) + return Errorf(status, format, parts...).SetMsg(msg) } diff --git a/new_test.go b/new_test.go index fc74651..71ef3c8 100644 --- a/new_test.go +++ b/new_test.go @@ -18,40 +18,54 @@ type NewTestSuite struct { func (s *NewTestSuite) TestNotFound() { msg := "I can't see you" var err ResponsableError = NewNotFound(msg) - s.Assert().NotNil(err) - s.Assert().Equal(http.StatusNotFound, err.GetStatus()) - s.Assert().Equal(msg, err.GetMsg()) + s.Assert().Error(err) + s.Assert().Equal(http.StatusNotFound, err.Status()) + s.Assert().Equal(msg, err.Msg()) s.Assert().Equal(msg, err.Error()) } +func (s *NewTestSuite) TestUnauthorized() { + var err ResponsableError = NewUnauthorized() + s.Assert().Error(err) + s.Assert().Equal(http.StatusUnauthorized, err.Status()) + s.Assert().Equal("Unauthorized", err.Msg()) +} + +func (s *NewTestSuite) TestForbidden() { + var err ResponsableError = NewForbidden() + s.Assert().Error(err) + s.Assert().Equal(http.StatusForbidden, err.Status()) + s.Assert().Equal("Forbidden", err.Msg()) +} + func (s *NewTestSuite) TestNotFoundDefaultMsg() { msg := "Not Found" var err ResponsableError = NewNotFound("") - s.Assert().NotNil(err) - s.Assert().Equal(msg, err.GetMsg()) + s.Assert().Error(err) + s.Assert().Equal(msg, err.Msg()) s.Assert().Equal(msg, err.Error()) } func (s *NewTestSuite) TestBadRequest() { msg := "I can't see you" var err ResponsableError = NewBadRequest(msg) - s.Assert().NotNil(err) - s.Assert().Equal(http.StatusBadRequest, err.GetStatus()) - s.Assert().Equal(msg, err.GetMsg()) + s.Assert().Error(err) + s.Assert().Equal(http.StatusBadRequest, err.Status()) + s.Assert().Equal(msg, err.Msg()) s.Assert().Equal(msg, err.Error()) } func (s *NewTestSuite) TestBadRequestDefaultMsg() { msg := "Bad Request" var err ResponsableError = NewBadRequest("") - s.Assert().NotNil(err) - s.Assert().Equal(msg, err.GetMsg()) + s.Assert().Error(err) + s.Assert().Equal(msg, err.Msg()) s.Assert().Equal(msg, err.Error()) } func (s *NewTestSuite) TestInternal() { var err ResponsableError = NewInternalError("%d > %d", 42, 13) - s.Assert().NotNil(err) + s.Assert().Error(err) s.Assert().Equal("42 > 13", err.Error()) - s.Assert().Equal("Unknown Error", err.GetMsg()) + s.Assert().Equal("Unknown Error", err.Msg()) }