diff --git a/errors/errors.go b/errors/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..9bd1d17dd4269461f54ec5024a6a8ca0f0edb4ac --- /dev/null +++ b/errors/errors.go @@ -0,0 +1,297 @@ +// Package errors defines structured errors which can +// be used for nesting errors with propagation +// of error identifiers and their messages. +// It also supports JSON serialization, so service to +// service communication can preserve error Kind. +package errors + +import ( + "bytes" + "encoding/json" + "net/http" +) + +var separator = ": " + +type Kind int + +const ( + Unknown Kind = iota // Unknown error. + BadRequest // BadRequest specifies invalid arguments or operation. + Unauthorized // Unauthorized request. + Forbidden // Forbidden operation. + Exist // Exist already. + NotFound // NotFound specifies that a resource does not exist. + Timeout // Timeout of request. + Internal // Internal error or inconsistency. +) + +type Error struct { + // ID is a unique error identifier. + ID string + + // Kind of error returned to the caller. + Kind Kind + + // Message is a description of the error. + Message string + + // The underlying error that triggered this one, if any. + Err error +} + +func (k Kind) String() string { + switch k { + case Unknown: + return "unknown error" + case BadRequest: + return "bad request" + case Unauthorized: + return "not authenticated" + case Forbidden: + return "permission denied" + case Exist: + return "already exist" + case NotFound: + return "not found" + case Timeout: + return "timeout" + case Internal: + return "internal error" + } + + return "unknown error kind" +} + +// New builds an error value from its arguments. +// There must be at least one argument or New panics. +// The type of each argument determines its meaning. +// If more than one argument of a given type is presented, only the last one is +// recorded. +// +// The supported types are: +// errors.Kind: +// The kind of the error. +// *errors.Error +// The underlying error that triggered this one. If the error has +// non-empty ID and Kind fields, they are promoted as values of the +// returned one. +// error: +// The underlying error that triggered this one. +// string: +// Treated as an error message and assigned to the Message field. +func New(args ...interface{}) error { + if len(args) == 0 { + panic("call to errors.New without arguments") + } + + e := &Error{} + var innerKind = Unknown + for _, arg := range args { + switch arg := arg.(type) { + case Kind: + e.Kind = arg + case *Error: + copy := *arg + e.Err = © + e.ID = copy.ID + innerKind = copy.Kind + if e.Message == "" { + e.Message = copy.Message + } + case error: + e.Err = arg + case string: + e.Message = arg + } + } + + if e.ID == "" { + e.ID = NewID() + } + + if e.Kind == Unknown { + e.Kind = innerKind + } + + return e +} + +// Is reports whether err is an *Error of the given Kind. +func Is(kind Kind, err error) bool { + cerr, ok := err.(*Error) + return ok && cerr.Kind == kind +} + +// Error returns description of the error. +func (e *Error) Error() string { + if e == nil { + return "nil" + } + + if e.ID == "" { + e.ID = NewID() + } + + b := new(bytes.Buffer) + b.WriteString(e.Message) + + if e.Kind != 0 { + pad(b, separator) + b.WriteString(e.Kind.String()) + } + + if e.Err != nil { + pad(b, separator) + if cerr, ok := e.Err.(*Error); ok { + b.WriteString(cerr.errorSkipID()) + } else { + b.WriteString(e.Err.Error()) + } + } + b.WriteRune(' ') + b.WriteRune('(') + b.WriteString(e.ID) + b.WriteRune(')') + + return b.String() +} + +func (e *Error) errorSkipID() string { + if e == nil { + return "nil" + } + b := new(bytes.Buffer) + b.WriteString(e.Message) + + if e.Kind != 0 { + pad(b, separator) + b.WriteString(e.Kind.String()) + } + if e.Err != nil { + pad(b, separator) + if cerr, ok := e.Err.(*Error); ok { + b.WriteString(cerr.errorSkipID()) + } else { + b.WriteString(e.Err.Error()) + } + } + return b.String() +} + +// StatusCode returns the HTTP status code corresponding to the error. +func (e *Error) StatusCode() int { + switch e.Kind { + case BadRequest: + return http.StatusBadRequest + case Unauthorized: + return http.StatusUnauthorized + case Forbidden: + return http.StatusForbidden + case Exist: + return http.StatusConflict + case NotFound: + return http.StatusNotFound + case Timeout: + return http.StatusRequestTimeout + case Internal: + return http.StatusInternalServerError + default: + return http.StatusInternalServerError + } +} + +// MarshalJSON returns the JSON representation of an Error. +func (e *Error) MarshalJSON() (data []byte, err error) { + var d = struct { + ID string `json:"id,omitempty"` + Kind Kind `json:"kind"` + Message string `json:"message,omitempty"` + }{ + ID: e.ID, + Kind: e.Kind, + Message: e.Message, + } + return json.Marshal(d) +} + +// UnmarshalJSON decodes a JSON encoded Error. +func (e *Error) UnmarshalJSON(data []byte) error { + var d struct { + ID string `json:"id,omitempty"` + Kind Kind `json:"kind"` + Message string `json:"message,omitempty"` + } + if err := json.Unmarshal(data, &d); err != nil { + return err + } + + *e = Error{ + ID: d.ID, + Kind: d.Kind, + Message: d.Message, + } + return nil +} + +func JSON(w http.ResponseWriter, err error, statusCode ...int) { + var e error + var ok bool + if e, ok = err.(*Error); !ok { + e = New(err) + } + + // check if the error can report its own status code + code := http.StatusInternalServerError + if sc, ok := e.(interface { + StatusCode() int + }); ok { + code = sc.StatusCode() + } + + // overwrite the status code if it's explicitly passed as argument + if len(statusCode) > 0 { + code = statusCode[0] + } + + w.WriteHeader(code) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(e) +} + +// Temporary reports if an Error is temporary and +// whether the request can be retried. +func (e *Error) Temporary() bool { + return e != nil && (e.Kind == Internal || e.Kind == Timeout) +} + +// GetKind returns error kind determined +// by the specified HTTP status code. +func GetKind(statusCode int) Kind { + switch statusCode { + case http.StatusBadRequest: + return BadRequest + case http.StatusUnauthorized: + return Unauthorized + case http.StatusForbidden: + return Forbidden + case http.StatusConflict: + return Exist + case http.StatusNotFound: + return NotFound + case http.StatusRequestTimeout: + return Timeout + case http.StatusInternalServerError: + return Internal + default: + return Unknown + } +} + +// pad appends str to the buffer if the buffer already has some data. +func pad(b *bytes.Buffer, str string) { + if b.Len() == 0 { + return + } + b.WriteString(str) +} diff --git a/errors/errors_test.go b/errors/errors_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3d1927e807003434938a0d2c6d038720d919bdcd --- /dev/null +++ b/errors/errors_test.go @@ -0,0 +1,377 @@ +package errors_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "code.vereign.com/gaiax/tsa/golib/errors" +) + +func TestNew(t *testing.T) { + e := errors.New("something went wrong") + assert.Implements(t, (*error)(nil), e) + + // create error with from a Kind + e = errors.New(errors.Forbidden) + assert.IsType(t, &errors.Error{}, e) + assert.True(t, errors.Is(errors.Forbidden, e)) + assert.Contains(t, e.Error(), "permission denied") + if ec, ok := e.(*errors.Error); ok { + assert.NotEmpty(t, ec.ID) + assert.Empty(t, ec.Message) + assert.Equal(t, errors.Forbidden, ec.Kind) + assert.Nil(t, ec.Err) + } + + // create error with a string message only + e = errors.New("something went wrong") + assert.IsType(t, &errors.Error{}, e) + assert.True(t, errors.Is(errors.Unknown, e)) + assert.Contains(t, e.Error(), "something went wrong") + if ec, ok := e.(*errors.Error); ok { + assert.NotEmpty(t, ec.ID) + assert.Equal(t, "something went wrong", ec.Message) + assert.Equal(t, errors.Unknown, ec.Kind) + assert.Nil(t, ec.Err) + } + + // create error with Kind and Message + e = errors.New(errors.Internal, "something went wrong") + assert.IsType(t, &errors.Error{}, e) + assert.True(t, errors.Is(errors.Internal, e)) + assert.Contains(t, e.Error(), "something went wrong: internal error") + if ec, ok := e.(*errors.Error); ok { + assert.NotEmpty(t, ec.ID) + assert.Equal(t, "something went wrong", ec.Message) + assert.Equal(t, errors.Internal, ec.Kind) + assert.Nil(t, ec.Err) + } + + // create error from a previous error + e = errors.New(fmt.Errorf("oops it did it again")) + assert.IsType(t, &errors.Error{}, e) + assert.True(t, errors.Is(errors.Unknown, e)) + assert.Contains(t, e.Error(), "oops it did it again") + if ec, ok := e.(*errors.Error); ok { + assert.NotEmpty(t, ec.ID) + assert.Equal(t, errors.Unknown, ec.Kind) + assert.NotNil(t, ec.Err) + assert.Equal(t, ec.Err.Error(), "oops it did it again") + } + + // create error from a previous structured error + e = errors.New(errors.New(errors.Unauthorized, "no way out")) + assert.IsType(t, &errors.Error{}, e) + assert.True(t, errors.Is(errors.Unauthorized, e)) + assert.Contains(t, e.Error(), "no way out: not authenticated") + if ec, ok := e.(*errors.Error); ok { + assert.NotEmpty(t, ec.ID) + assert.Equal(t, errors.Unauthorized, ec.Kind) + assert.Equal(t, ec.Message, "no way out") + assert.NotNil(t, ec.Err) + assert.Contains(t, ec.Err.Error(), "no way out: not authenticated") + } + + // create error from a previous structured error + e = errors.New(errors.BadRequest, "bad request", errors.New(errors.Unauthorized, "no way out")) + assert.IsType(t, &errors.Error{}, e) + assert.True(t, errors.Is(errors.BadRequest, e)) + assert.Contains(t, e.Error(), "bad request: no way out: not authenticated") + if ec, ok := e.(*errors.Error); ok { + assert.NotEmpty(t, ec.ID) + assert.Equal(t, errors.BadRequest, ec.Kind) + assert.Equal(t, "bad request", ec.Message) + assert.NotNil(t, ec.Err) + assert.Contains(t, ec.Err.Error(), "no way out: not authenticated") + } + + // create error from a previous structured error and a message + e = errors.New("bad request", errors.New(errors.Unauthorized, "no way out")) + assert.IsType(t, &errors.Error{}, e) + assert.True(t, errors.Is(errors.Unauthorized, e)) + assert.Contains(t, e.Error(), "bad request: not authenticated: no way out: not authenticated") + if ec, ok := e.(*errors.Error); ok { + assert.NotEmpty(t, ec.ID) + assert.Equal(t, errors.Unauthorized, ec.Kind) + assert.Equal(t, "bad request", ec.Message) + assert.NotNil(t, ec.Err) + assert.Contains(t, ec.Err.Error(), "no way out: not authenticated") + } + + // create error from a previous structured error and a message + e = errors.New(errors.BadRequest, errors.New(errors.Unauthorized, "no way out")) + assert.IsType(t, &errors.Error{}, e) + assert.True(t, errors.Is(errors.BadRequest, e)) + assert.Contains(t, e.Error(), "bad request: no way out: not authenticated") + if ec, ok := e.(*errors.Error); ok { + assert.NotEmpty(t, ec.ID) + assert.Equal(t, errors.BadRequest, ec.Kind) + assert.Equal(t, "no way out", ec.Message) + assert.NotNil(t, ec.Err) + assert.Contains(t, ec.Err.Error(), "no way out: not authenticated") + } + + // create three nested errors + e1 := fmt.Errorf("cannot insert record") + e2 := errors.New("account already exists", e1) + e3 := errors.New("failed to create account", e2) + assert.Contains(t, e3.Error(), "failed to create account: account already exists: cannot insert record") + if ec, ok := e3.(*errors.Error); ok { + assert.NotEmpty(t, ec.ID) + assert.Equal(t, errors.Unknown, ec.Kind) + assert.Equal(t, "failed to create account", ec.Message) + assert.NotNil(t, ec.Err) + assert.Contains(t, ec.Err.Error(), "account already exists") + } +} + +func TestIs(t *testing.T) { + e := errors.New(errors.Timeout) + assert.IsType(t, &errors.Error{}, e) + assert.True(t, errors.Is(errors.Timeout, e)) + + // create error from a previous structured error and a message + e = errors.New(errors.Timeout, errors.New(errors.Unauthorized)) + assert.IsType(t, &errors.Error{}, e) + assert.True(t, errors.Is(errors.Timeout, e)) +} + +func TestError_StatusCode(t *testing.T) { + tests := []struct { + name string + kind errors.Kind + code int + }{ + { + name: "unknown error", + kind: errors.Unknown, + code: http.StatusInternalServerError, + }, + { + name: "bad request", + kind: errors.BadRequest, + code: http.StatusBadRequest, + }, + { + name: "unauthorized", + kind: errors.Unauthorized, + code: http.StatusUnauthorized, + }, + { + name: "forbidden", + kind: errors.Forbidden, + code: http.StatusForbidden, + }, + { + name: "exists", + kind: errors.Exist, + code: http.StatusConflict, + }, + { + name: "not found", + kind: errors.NotFound, + code: http.StatusNotFound, + }, + { + name: "timeout", + kind: errors.Timeout, + code: http.StatusRequestTimeout, + }, + { + name: "internal", + kind: errors.Internal, + code: http.StatusInternalServerError, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + e := errors.New(test.kind) + assert.IsType(t, &errors.Error{}, e) + if ec, ok := e.(*errors.Error); ok { + assert.Equal(t, test.code, ec.StatusCode()) + } + }) + } +} + +func TestError_MarshalJSON(t *testing.T) { + e := errors.New(errors.NotFound, "item does not exist") + ec, ok := e.(*errors.Error) + assert.True(t, ok) + id := ec.ID + + data, err := ec.MarshalJSON() + assert.NoError(t, err) + assert.NotEmpty(t, data) + + ee := &errors.Error{} + err = ee.UnmarshalJSON(data) + assert.NoError(t, err) + + assert.Equal(t, id, ee.ID) + assert.Equal(t, ec.Kind, ee.Kind) + assert.Equal(t, ec.Message, ee.Message) + assert.Equal(t, ec.Error(), ee.Error()) + assert.Equal(t, errors.NotFound, ee.Kind) +} + +func TestError_Temporary(t *testing.T) { + e := errors.New(errors.Forbidden) + ec, ok := e.(*errors.Error) + assert.True(t, ok) + assert.False(t, ec.Temporary()) + + e = errors.New(errors.Internal) + ec, ok = e.(*errors.Error) + assert.True(t, ok) + assert.True(t, ec.Temporary()) +} + +func TestGetKind(t *testing.T) { + tests := []struct { + name string + code int + kind errors.Kind + }{ + { + name: "undefined HTTP status code", + code: 9999, + kind: errors.Unknown, + }, + { + name: "bad request", + code: http.StatusBadRequest, + kind: errors.BadRequest, + }, + { + name: "unauthorized", + code: http.StatusUnauthorized, + kind: errors.Unauthorized, + }, + { + name: "forbidden", + code: http.StatusForbidden, + kind: errors.Forbidden, + }, + { + name: "exists", + code: http.StatusConflict, + kind: errors.Exist, + }, + { + name: "not found", + code: http.StatusNotFound, + kind: errors.NotFound, + }, + { + name: "timeout", + code: http.StatusRequestTimeout, + kind: errors.Timeout, + }, + { + name: "internal", + code: http.StatusInternalServerError, + kind: errors.Internal, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + kind := errors.GetKind(test.code) + assert.Equal(t, test.kind, kind) + }) + } +} + +func TestJSON(t *testing.T) { + tests := []struct { + // input + name string + err error + statusCode int + + // output + responseCode int + responseError *errors.Error + }{ + { + name: "error is nil", + err: nil, + responseCode: http.StatusInternalServerError, + responseError: &errors.Error{ + Kind: errors.Unknown, + }, + }, + { + name: "simple text error", + err: fmt.Errorf("simple text error"), + responseCode: http.StatusInternalServerError, + responseError: &errors.Error{ + Kind: errors.Unknown, + Message: "", + }, + }, + { + name: "structured error", + err: errors.New("structured error"), + responseCode: http.StatusInternalServerError, + responseError: &errors.Error{ + Kind: errors.Unknown, + Message: "structured error", + }, + }, + { + name: "structured error with kind", + err: errors.New(errors.Forbidden, "structured error with kind"), + responseCode: http.StatusForbidden, + responseError: &errors.Error{ + Kind: errors.Forbidden, + Message: "structured error with kind", + }, + }, + { + name: "structured error with kind and embedded error", + err: errors.New(errors.NotFound, "structured error with kind and embedded error", fmt.Errorf("embedded error")), + responseCode: http.StatusNotFound, + responseError: &errors.Error{ + Kind: errors.NotFound, + Message: "structured error with kind and embedded error", + Err: fmt.Errorf("embedded error"), + }, + }, + { + name: "structured error with kind only", + err: errors.New(errors.Timeout), + responseCode: http.StatusRequestTimeout, + responseError: &errors.Error{ + Kind: errors.Timeout, + Message: "", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + rr := httptest.NewRecorder() + if test.statusCode > 0 { + errors.JSON(rr, test.err, test.statusCode) + } else { + errors.JSON(rr, test.err) + } + + assert.Equal(t, test.responseCode, rr.Code) + + var responseError *errors.Error + err := json.NewDecoder(rr.Body).Decode(&responseError) + assert.NoError(t, err) + assert.Equal(t, test.responseError.Kind, responseError.Kind) + assert.Equal(t, test.responseError.Message, responseError.Message) + }) + } +} diff --git a/errors/shortid.go b/errors/shortid.go new file mode 100644 index 0000000000000000000000000000000000000000..8fdf9c61a634c7f9bab630640eae576f75be924b --- /dev/null +++ b/errors/shortid.go @@ -0,0 +1,24 @@ +package errors + +import ( + "crypto/rand" + "fmt" +) + +const Alphabet string = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" + +func NewID() string { + length := 16 + + ll := len(Alphabet) + b := make([]byte, length) + _, err := rand.Read(b) // generates len(b) random bytes + if err != nil { + panic(fmt.Errorf("failed to read random bytes: %v", err)) + } + for i := 0; i < length; i++ { + b[i] = Alphabet[int(b[i])%ll] + } + + return string(b) +} diff --git a/errors/shortid_test.go b/errors/shortid_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0f6b3ff8edd11b02856b911808d7dd20c644bfc1 --- /dev/null +++ b/errors/shortid_test.go @@ -0,0 +1,20 @@ +package errors_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "code.vereign.com/gaiax/tsa/golib/errors" +) + +func TestNewID(t *testing.T) { + for i := 0; i < 100; i++ { + id := errors.NewID() + assert.Len(t, id, 16) + + for _, r := range id { + assert.Contains(t, errors.Alphabet, string(r)) + } + } +} diff --git a/go.mod b/go.mod index 0ede429685c6cca95813b406d761f1258baba4af..3b55981cf9c4a0b9067f11ba21173209cd6b8682 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,14 @@ module code.vereign.com/gaiax/tsa/golib go 1.17 + +require github.com/stretchr/testify v1.7.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect + gopkg.in/yaml.v3 v3.0.0-20200601152816-913338de1bd2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000000000000000000000000000000000000..082f086ea049718b042c4a2676260fb675474493 --- /dev/null +++ b/go.sum @@ -0,0 +1,21 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200601152816-913338de1bd2 h1:VEmvx0P+GVTgkNu2EdTN988YCZPcD3lo9AoczZpucwc= +gopkg.in/yaml.v3 v3.0.0-20200601152816-913338de1bd2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=