diff --git a/README.md b/README.md index 7efab89..98d481b 100644 --- a/README.md +++ b/README.md @@ -359,6 +359,54 @@ func (post Post) JSONAPIRelationshipLinks(relation string) *map[string]interface } ``` +### Errors +This package also implements support for JSON API compatible `errors` payloads using the following types. + +#### `MarshalErrors` +```go +MarshalErrors(w io.Writer, errs []*ErrorObject) error +``` + +Writes a JSON API response using the given `[]error`. + +#### `ErrorsPayload` +```go +type ErrorsPayload struct { + Errors []*ErrorObject `json:"errors"` +} +``` + +ErrorsPayload is a serializer struct for representing a valid JSON API errors payload. + +#### `ErrorObject` +```go +type ErrorObject struct { ... } + +// Error implements the `Error` interface. +func (e *ErrorObject) Error() string { + return fmt.Sprintf("Error: %s %s\n", e.Title, e.Detail) +} +``` + +ErrorObject is an `Error` implementation as well as an implementation of the JSON API error object. + +The main idea behind this struct is that you can use it directly in your code as an error type and pass it directly to `MarshalErrors` to get a valid JSON API errors payload. + +##### Errors Example Code +```go +// An error has come up in your code, so set an appropriate status, and serialize the error. +if err := validate(&myStructToValidate); err != nil { + context.SetStatusCode(http.StatusBadRequest) // Or however you need to set a status. + jsonapi.MarshalErrors(w, []*ErrorObject{{ + Title: "Validation Error", + Detail: "Given request body was invalid.", + Status: "400", + Meta: map[string]interface{}{"field": "some_field", "error": "bad type", "expected": "string", "received": "float64"}, + }}) + return +} +``` + ## Testing ### `MarshalOnePayloadEmbedded` diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..ed7fa9f --- /dev/null +++ b/errors.go @@ -0,0 +1,55 @@ +package jsonapi + +import ( + "encoding/json" + "fmt" + "io" +) + +// MarshalErrors writes a JSON API response using the given `[]error`. +// +// For more information on JSON API error payloads, see the spec here: +// http://jsonapi.org/format/#document-top-level +// and here: http://jsonapi.org/format/#error-objects. +func MarshalErrors(w io.Writer, errorObjects []*ErrorObject) error { + if err := json.NewEncoder(w).Encode(&ErrorsPayload{Errors: errorObjects}); err != nil { + return err + } + return nil +} + +// ErrorsPayload is a serializer struct for representing a valid JSON API errors payload. +type ErrorsPayload struct { + Errors []*ErrorObject `json:"errors"` +} + +// ErrorObject is an `Error` implementation as well as an implementation of the JSON API error object. +// +// The main idea behind this struct is that you can use it directly in your code as an error type +// and pass it directly to `MarshalErrors` to get a valid JSON API errors payload. +// For more information on Golang errors, see: https://golang.org/pkg/errors/ +// For more information on the JSON API spec's error objects, see: http://jsonapi.org/format/#error-objects +type ErrorObject struct { + // ID is a unique identifier for this particular occurrence of a problem. + ID string `json:"id,omitempty"` + + // Title is a short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization. + Title string `json:"title,omitempty"` + + // Detail is a human-readable explanation specific to this occurrence of the problem. Like title, this field’s value can be localized. + Detail string `json:"detail,omitempty"` + + // Status is the HTTP status code applicable to this problem, expressed as a string value. + Status string `json:"status,omitempty"` + + // Code is an application-specific error code, expressed as a string value. + Code string `json:"code,omitempty"` + + // Meta is an object containing non-standard meta-information about the error. + Meta *map[string]interface{} `json:"meta,omitempty"` +} + +// Error implements the `Error` interface. +func (e *ErrorObject) Error() string { + return fmt.Sprintf("Error: %s %s\n", e.Title, e.Detail) +} diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..e251d09 --- /dev/null +++ b/errors_test.go @@ -0,0 +1,52 @@ +package jsonapi + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "reflect" + "testing" +) + +func TestErrorObjectWritesExpectedErrorMessage(t *testing.T) { + err := &ErrorObject{Title: "Title test.", Detail: "Detail test."} + var input error = err + + output := input.Error() + + if output != fmt.Sprintf("Error: %s %s\n", err.Title, err.Detail) { + t.Fatal("Unexpected output.") + } +} + +func TestMarshalErrorsWritesTheExpectedPayload(t *testing.T) { + var marshalErrorsTableTasts = []struct { + In []*ErrorObject + Out map[string]interface{} + }{ + { // This tests that given fields are turned into the appropriate JSON representation. + In: []*ErrorObject{{ID: "0", Title: "Test title.", Detail: "Test detail", Status: "400", Code: "E1100"}}, + Out: map[string]interface{}{"errors": []interface{}{ + map[string]interface{}{"id": "0", "title": "Test title.", "detail": "Test detail", "status": "400", "code": "E1100"}, + }}, + }, + { // This tests that the `Meta` field is serialized properly. + In: []*ErrorObject{{Title: "Test title.", Detail: "Test detail", Meta: &map[string]interface{}{"key": "val"}}}, + Out: map[string]interface{}{"errors": []interface{}{ + map[string]interface{}{"title": "Test title.", "detail": "Test detail", "meta": map[string]interface{}{"key": "val"}}, + }}, + }, + } + for _, testRow := range marshalErrorsTableTasts { + buffer, output := bytes.NewBuffer(nil), map[string]interface{}{} + var writer io.Writer = buffer + + _ = MarshalErrors(writer, testRow.In) + json.Unmarshal(buffer.Bytes(), &output) + + if !reflect.DeepEqual(output, testRow.Out) { + t.Fatalf("Expected: \n%#v \nto equal: \n%#v", output, testRow.Out) + } + } +} diff --git a/request.go b/request.go index 4639024..fd7fbc0 100644 --- a/request.go +++ b/request.go @@ -13,6 +13,8 @@ import ( ) const ( + invalidTypeErrorTitle = "Invalid Type" + invalidTypeErrorDetail = "Invalid type encountered while unmarshalling." unsuportedStructTagMsg = "Unsupported jsonapi tag annotation, %s" ) @@ -23,13 +25,6 @@ var ( // ErrInvalidISO8601 is returned when a struct has a time.Time type field and includes // "iso8601" in the tag spec, but the JSON value was not an ISO8601 timestamp string. ErrInvalidISO8601 = errors.New("Only strings can be parsed as dates, ISO8601 timestamps") - // ErrUnknownFieldNumberType is returned when the JSON value was a float - // (numeric) but the Struct field was a non numeric type (i.e. not int, uint, - // float, etc) - ErrUnknownFieldNumberType = errors.New("The struct field was not of a known number type") - // ErrUnsupportedPtrType is returned when the Struct field was a pointer but - // the JSON value was of a different type - ErrUnsupportedPtrType = errors.New("Pointer type in struct is not supported") ) // UnmarshalPayload converts an io into a struct instance using jsonapi tags on @@ -294,8 +289,12 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) } else if v.Kind() == reflect.Int { at = v.Int() } else { - er = ErrInvalidTime - break + // Return error immediately to ensure a runtime panic doesn't swallow it. + return &ErrorObject{ + Title: invalidTypeErrorTitle, + Detail: invalidTypeErrorDetail, + Meta: &map[string]interface{}{"field": args[1], "received": v.Kind().String(), "expected": reflect.Int64.String()}, + } } t := time.Unix(at, 0) @@ -346,8 +345,12 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) } else if v.Kind() == reflect.Int { at = v.Int() } else { - er = ErrInvalidTime - break + // Return error immediately to ensure a runtime panic doesn't swallow it. + return &ErrorObject{ + Title: invalidTypeErrorTitle, + Detail: invalidTypeErrorDetail, + Meta: &map[string]interface{}{"field": args[1], "received": v.Kind().String(), "expected": reflect.Int64.String()}, + } } v := time.Unix(at, 0) @@ -408,13 +411,15 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) n := float32(floatValue) numericValue = reflect.ValueOf(&n) case reflect.Float64: - n := float64(floatValue) + n := floatValue numericValue = reflect.ValueOf(&n) default: - // We had a JSON float (numeric), but our field was a non numeric - // type - er = ErrUnknownFieldNumberType - break + // Return error immediately to ensure a runtime panic doesn't swallow it. + return &ErrorObject{ + Title: invalidTypeErrorTitle, + Detail: invalidTypeErrorDetail, + Meta: &map[string]interface{}{"field": args[1], "received": reflect.Float64.String(), "expected": kind.String()}, + } } assign(fieldValue, numericValue) @@ -437,21 +442,35 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) case uintptr: concreteVal = reflect.ValueOf(&cVal) default: - er = ErrUnsupportedPtrType - break + // Return error immediately to ensure a runtime panic doesn't swallow it. + return &ErrorObject{ + Title: invalidTypeErrorTitle, + Detail: invalidTypeErrorDetail, + Meta: &map[string]interface{}{"field": args[1], "received": v.Kind().String(), "expected": fieldType.Type.Elem().String()}, + } } if fieldValue.Type() != concreteVal.Type() { - // TODO: use fmt.Errorf so that you can have a more informative - // message that reports the attempted type that was not supported. - er = ErrUnsupportedPtrType - break + // Return error immediately to ensure a runtime panic doesn't swallow it. + return &ErrorObject{ + Title: invalidTypeErrorTitle, + Detail: invalidTypeErrorDetail, + Meta: &map[string]interface{}{"field": args[1], "received": v.Kind().String(), "expected": fieldType.Type.Elem().String()}, + } } fieldValue.Set(concreteVal) continue } + // As a final catch-all, ensure types line up to avoid a runtime panic. + if fieldValue.Kind() != v.Kind() { + return &ErrorObject{ + Title: invalidTypeErrorTitle, + Detail: invalidTypeErrorDetail, + Meta: &map[string]interface{}{"field": args[1], "received": v.Kind().String(), "expected": fieldValue.Kind().String()}, + } + } fieldValue.Set(reflect.ValueOf(val)) } else if annotation == annotationRelation { @@ -529,11 +548,7 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) } } - if er != nil { - return er - } - - return nil + return er } func fullNode(n *Node, included *map[string]*Node) *Node { diff --git a/request_test.go b/request_test.go index 4670bfc..bfdcaec 100644 --- a/request_test.go +++ b/request_test.go @@ -3,6 +3,7 @@ package jsonapi import ( "bytes" "encoding/json" + "fmt" "io" "reflect" "strings" @@ -22,6 +23,14 @@ type WithPointer struct { FloatVal *float32 `jsonapi:"attr,float-val"` } +type ModelBadTypes struct { + ID string `jsonapi:"primary,badtypes"` + StringField string `jsonapi:"attr,string_field"` + FloatField float64 `jsonapi:"attr,float_field"` + TimeField time.Time `jsonapi:"attr,time_field"` + TimePtrField *time.Time `jsonapi:"attr,time_ptr_field"` +} + func TestUnmarshalToStructWithPointerAttr(t *testing.T) { out := new(WithPointer) in := map[string]interface{}{ @@ -36,7 +45,7 @@ func TestUnmarshalToStructWithPointerAttr(t *testing.T) { if *out.Name != "The name" { t.Fatalf("Error unmarshalling to string ptr") } - if *out.IsActive != true { + if !*out.IsActive { t.Fatalf("Error unmarshalling to bool ptr") } if *out.IntVal != 8 { @@ -98,6 +107,27 @@ func TestUnmarshalPayloadWithPointerAttr_AbsentVal(t *testing.T) { } } +func TestUnmarshalToStructWithPointerAttr_BadType(t *testing.T) { + out := new(WithPointer) + in := map[string]interface{}{ + "name": true, // This is the wrong type. + } + expectedError := &ErrorObject{Title: invalidTypeErrorTitle, Detail: invalidTypeErrorDetail, Meta: &map[string]interface{}{"field": "name", "received": "bool", "expected": "string"}} + expectedErrorMessage := fmt.Sprintf("Error: %s %s\n", expectedError.Title, expectedError.Detail) + + err := UnmarshalPayload(sampleWithPointerPayload(in), out) + + if err == nil { + t.Fatalf("Expected error due to invalid type.") + } + if err.Error() != expectedErrorMessage { + t.Fatalf("Unexpected error message: %s", err.Error()) + } + if e, ok := err.(*ErrorObject); !ok || !reflect.DeepEqual(e, expectedError) { + t.Fatalf("Unexpected error type.") + } +} + func TestStringPointerField(t *testing.T) { // Build Book payload description := "Hello World!" @@ -150,6 +180,37 @@ func TestUnmarshalInvalidJSON(t *testing.T) { } } +func TestUnmarshalInvalidJSON_BadType(t *testing.T) { + var badTypeTests = []struct { + Field string + BadValue interface{} + Error *ErrorObject + }{ // The `Field` values here correspond to the `ModelBadTypes` jsonapi fields. + {Field: "string_field", BadValue: 0, Error: &ErrorObject{Title: invalidTypeErrorTitle, Detail: invalidTypeErrorDetail, Meta: &map[string]interface{}{"field": "string_field", "received": "float64", "expected": "string"}}}, + {Field: "float_field", BadValue: "A string.", Error: &ErrorObject{Title: invalidTypeErrorTitle, Detail: invalidTypeErrorDetail, Meta: &map[string]interface{}{"field": "float_field", "received": "string", "expected": "float64"}}}, + {Field: "time_field", BadValue: "A string.", Error: &ErrorObject{Title: invalidTypeErrorTitle, Detail: invalidTypeErrorDetail, Meta: &map[string]interface{}{"field": "time_field", "received": "string", "expected": "int64"}}}, + {Field: "time_ptr_field", BadValue: "A string.", Error: &ErrorObject{Title: invalidTypeErrorTitle, Detail: invalidTypeErrorDetail, Meta: &map[string]interface{}{"field": "time_ptr_field", "received": "string", "expected": "int64"}}}, + } + for _, test := range badTypeTests { + out := new(ModelBadTypes) + in := map[string]interface{}{} + in[test.Field] = test.BadValue + expectedErrorMessage := fmt.Sprintf("Error: %s %s\n", test.Error.Title, test.Error.Detail) + + err := UnmarshalPayload(samplePayloadWithBadTypes(in), out) + + if err == nil { + t.Fatalf("Expected error due to invalid type.") + } + if err.Error() != expectedErrorMessage { + t.Fatalf("Unexpected error message: %s", err.Error()) + } + if e, ok := err.(*ErrorObject); !ok || !reflect.DeepEqual(e, test.Error) { + t.Fatalf("Expected:\n%#v%#v\nto equal:\n%#v%#v", e, *e.Meta, test.Error, *test.Error.Meta) + } + } +} + func TestUnmarshalSetsID(t *testing.T) { in := samplePayloadWithID() out := new(Blog) @@ -759,6 +820,21 @@ func samplePayloadWithID() io.Reader { return out } +func samplePayloadWithBadTypes(m map[string]interface{}) io.Reader { + payload := &OnePayload{ + Data: &Node{ + ID: "2", + Type: "badtypes", + Attributes: m, + }, + } + + out := bytes.NewBuffer(nil) + json.NewEncoder(out).Encode(payload) + + return out +} + func sampleWithPointerPayload(m map[string]interface{}) io.Reader { payload := &OnePayload{ Data: &Node{