Skip to content

Commit

Permalink
Adding Error wrapper for fields and messages
Browse files Browse the repository at this point in the history
  • Loading branch information
samlown committed Feb 13, 2024
1 parent 30449fd commit 0d14c78
Show file tree
Hide file tree
Showing 3 changed files with 204 additions and 34 deletions.
2 changes: 1 addition & 1 deletion envelope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ func TestDocumentValidationOutput(t *testing.T) {
err = env.Validate()
data, err = json.Marshal(err)
require.NoError(t, err)
assert.Equal(t, `{"key":"validation","cause":{"doc":{"content":"cannot be blank"}}}`, string(data))
assert.Equal(t, `{"key":"validation","fields":{"doc":{"content":"cannot be blank"}}}`, string(data))
}

func TestEnvelopeVerify(t *testing.T) {
Expand Down
142 changes: 109 additions & 33 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,26 @@ import (
"encoding/json"
"errors"
"fmt"
"reflect"
"sort"
"strings"

"github.com/invopop/gobl/cbc"
"github.com/invopop/gobl/schema"
"github.com/invopop/validation"
)

// Error provides a structure to better be able to make error comparisons.
// An Error provides a structure to better be able to make error comparisons.
// The contents can also be serialised as JSON ready to send to a client
// if needed, see MarshalJSON method.
// if needed, see [MarshalJSON] method.
type Error struct {
// Key describes the area of concern for the error
Key cbc.Key
// What was the cause of the error when GOBL is used as a library.
Cause error
key cbc.Key
cause error // the underlying error
}

// FieldErrors is a map of field names to errors, which is provided when it
// was possible to determine that the error was related to a specific field.
type FieldErrors map[string]error

var (
// ErrNoDocument is provided when the envelope does not contain a
// document payload.
Expand Down Expand Up @@ -58,7 +61,7 @@ var (
// NewError provides a new error with a code that is meant to provide
// a context.
func NewError(key cbc.Key) *Error {
return &Error{Key: key}
return &Error{key: key}
}

// wrapError is used to ensure that errors are wrapped around the GOBL standard
Expand All @@ -73,43 +76,70 @@ func wrapError(err error) error {
if errors.Is(err, schema.ErrUnknownSchema) {
return ErrUnknownSchema
}
if _, ok := err.(validation.Errors); ok {
switch err.(type) {
case validation.Errors:
return ErrValidation.WithCause(err)
}
return ErrInternal.WithCause(err)
}

// Error provides a string representation of the error.
func (e *Error) Error() string {
if e.Cause != nil {
msg := e.Cause.Error()
if reflect.TypeOf(e.Cause).Kind() == reflect.Map {
if e.cause != nil {
msg := e.cause.Error()
if e.Fields() != nil {
msg = fmt.Sprintf("(%s).", msg)
}
return fmt.Sprintf("%s: %s", e.Key, msg)
return fmt.Sprintf("%s: %s", e.key, msg)
}
return e.Key.String()
return e.key.String()
}

// WithCause is used to copy and add an underlying error to this one,
// unless the errors is already of type *Error, in which case it will
// unless the errors is already of type [*Error], in which case it will
// be returned as is.
func (e *Error) WithCause(err error) *Error {
ne := e.copy()
if eo, ok := err.(*Error); ok {
return eo
switch te := err.(type) {
case *Error:
return te
case validation.Errors:
ne.cause = fieldErrorsFromValidation(te)
default:
ne.cause = err
}
ne.Cause = err
return ne
}

// WithReason returns the error with a specific reason.
func (e *Error) WithReason(msg string, a ...interface{}) *Error {
ne := e.copy()
ne.Cause = fmt.Errorf(msg, a...)
ne.cause = fmt.Errorf(msg, a...)
return ne
}

// Fields returns the errors that are associated with specific fields
// or nil if there are no field errors available.
func (e *Error) Fields() FieldErrors {
if fe, ok := e.cause.(FieldErrors); ok {
return fe
}
return nil
}

// Message returns a string representation of the error if it cannot
// be serialised as a map of field names to sub-errors, see also the
// [Fields] method.
func (e *Error) Message() string {
if e.cause == nil {
return ""
}
if e.Fields() != nil {
return ""
}
return e.cause.Error()
}

func (e *Error) copy() *Error {
ne := new(Error)
*ne = *e
Expand All @@ -121,30 +151,76 @@ func (e *Error) copy() *Error {
func (e *Error) Is(target error) bool {
t, ok := target.(*Error)
if !ok {
return errors.Is(e.Cause, target)
return errors.Is(e.cause, target)
}
return e.Key == t.Key
return e.key == t.key
}

// MarshalJSON converts the Error into a valid JSON, correctly
// handling mashalling for cause objects that might not have a
// handling mashaling of cause objects that might not have a
// valid MarhsalJSON method.
func (e *Error) MarshalJSON() ([]byte, error) {
if e == nil {
return nil, nil
}
err := struct {
Key cbc.Key `json:"key"`
Cause any `json:"cause,omitempty"`
Key cbc.Key `json:"key"`
Fields FieldErrors `json:"fields,omitempty"`
Message string `json:"message,omitempty"`
}{
Key: e.Key,
Key: e.key,
Fields: e.Fields(),
Message: e.Message(),
}
return json.Marshal(err)
}

// Error returns the error string of FieldErrors. This is based on the
// implementation of [validation.Errors].
func (fe FieldErrors) Error() string {
if len(fe) == 0 {
return ""
}
if e.Cause != nil {
if ms, ok := e.Cause.(json.Marshaler); ok {
err.Cause = ms

keys := make([]string, len(fe))
i := 0
for key := range fe {
keys[i] = key
i++
}
sort.Strings(keys)

var s strings.Builder
for i, key := range keys {
if i > 0 {
s.WriteString("; ")
}
switch errs := fe[key].(type) {
case FieldErrors, validation.Errors:
_, _ = fmt.Fprintf(&s, "%v: (%v)", key, errs)
default:
_, _ = fmt.Fprintf(&s, "%v: %v", key, fe[key].Error())
}
}
s.WriteString(".")
return s.String()
}

// MarshalJSON converts the FieldErrors into a valid JSON. Based on the
// implementation of [validation.Errors].
func (fe FieldErrors) MarshalJSON() ([]byte, error) {
errs := map[string]any{}
for key, err := range fe {
if ms, ok := err.(json.Marshaler); ok {
errs[key] = ms
} else {
err.Cause = e.Cause.Error()
errs[key] = err.Error()
}
}
return json.Marshal(err)
return json.Marshal(errs)
}

func fieldErrorsFromValidation(errs validation.Errors) FieldErrors {
fe := make(FieldErrors)
for key, err := range errs {
fe[key] = err
}
return fe
}
94 changes: 94 additions & 0 deletions errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package gobl_test

import (
"encoding/json"
"errors"
"testing"

"github.com/invopop/gobl"
"github.com/invopop/gobl/schema"
"github.com/invopop/validation"
"github.com/stretchr/testify/assert"
)

func TestError(t *testing.T) {
// basic error
err := gobl.ErrNoDocument

assert.Equal(t, "no-document", err.Error())
assert.Equal(t, "", err.Message())
assert.Nil(t, err.Fields())
data, _ := json.Marshal(err)
assert.JSONEq(t, `{"key":"no-document"}`, string(data))
assert.True(t, err.Is(gobl.ErrNoDocument))

se := errors.New("simple error message")
err = gobl.ErrValidation.WithCause(se)
assert.Equal(t, "validation: simple error message", err.Error())
assert.Equal(t, "simple error message", err.Message())
data, _ = json.Marshal(err)
assert.JSONEq(t, `{"key":"validation","message":"simple error message"}`, string(data))

err2 := err.WithReason("overwrite message")
assert.Equal(t, "validation: simple error message", err.Error(), "do not modify original")
assert.Equal(t, "validation: overwrite message", err2.Error())
assert.Nil(t, err2.Fields())
assert.Equal(t, "overwrite message", err2.Message())
data, _ = json.Marshal(err2)
assert.JSONEq(t, `{"key":"validation","message":"overwrite message"}`, string(data))

err = gobl.ErrCalculation.WithCause(err2)
assert.Equal(t, "validation: overwrite message", err.Error())

ve := validation.Errors{
"field": errors.New("field error"),
}
err = gobl.ErrValidation.WithCause(ve)
assert.Equal(t, "validation: (field: field error.).", err.Error())
data, _ = json.Marshal(err)
assert.JSONEq(t, `{"key":"validation","fields":{"field":"field error"}}`, string(data))

// check nested error with Is
err = gobl.ErrValidation.WithCause(schema.ErrUnknownSchema)
assert.True(t, errors.Is(err, schema.ErrUnknownSchema))
}

func TestFieldErrors_Error(t *testing.T) {
errs := gobl.FieldErrors{
"B": errors.New("B1"),
"C": errors.New("C1"),
"A": errors.New("A1"),
}
assert.Equal(t, "A: A1; B: B1; C: C1.", errs.Error())

errs = gobl.FieldErrors{
"C": gobl.FieldErrors{
"B": errors.New("B1"),
},
"A": errors.New("A1"),
}
assert.Equal(t, "A: A1; C: (B: B1.).", errs.Error())

errs = gobl.FieldErrors{
"B": errors.New("B1"),
}
assert.Equal(t, "B: B1.", errs.Error())

errs = gobl.FieldErrors{}
assert.Equal(t, "", errs.Error())
}

func TestFieldErrors_MarshalJSON(t *testing.T) {
errs := gobl.FieldErrors{
"A": errors.New("A1"),
"B": gobl.FieldErrors{
"2": errors.New("B1"),
},
"C": validation.Errors{
"3": errors.New("C1"),
},
}
data, err := errs.MarshalJSON()
assert.Nil(t, err)
assert.Equal(t, `{"A":"A1","B":{"2":"B1"},"C":{"3":"C1"}}`, string(data))
}

0 comments on commit 0d14c78

Please sign in to comment.