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..af300a2 100644 --- a/request.go +++ b/request.go @@ -30,6 +30,8 @@ var ( // 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") + // ErrInvalidType is returned when the given type is incompatible with the expected type. + ErrInvalidType = errors.New("Invalid type provided") // I wish we used punctuation. ) // UnmarshalPayload converts an io into a struct instance using jsonapi tags on @@ -294,8 +296,7 @@ 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 ErrInvalidTime } t := time.Unix(at, 0) @@ -346,8 +347,7 @@ 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 ErrInvalidTime } v := time.Unix(at, 0) @@ -408,13 +408,10 @@ 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 ErrUnknownFieldNumberType } assign(fieldValue, numericValue) @@ -437,21 +434,21 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) case uintptr: concreteVal = reflect.ValueOf(&cVal) default: - er = ErrUnsupportedPtrType - break + return ErrUnsupportedPtrType } 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 ErrUnsupportedPtrType } fieldValue.Set(concreteVal) continue } + // As a final catch-all, ensure types line up to avoid a runtime panic. + if fieldValue.Kind() != v.Kind() { + return ErrInvalidType + } fieldValue.Set(reflect.ValueOf(val)) } else if annotation == annotationRelation { @@ -529,11 +526,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..4fcb3b6 100644 --- a/request_test.go +++ b/request_test.go @@ -22,6 +22,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 +44,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 +106,23 @@ 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. + } + expectedErrorMessage := ErrUnsupportedPtrType.Error() + + 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()) + } +} + func TestStringPointerField(t *testing.T) { // Build Book payload description := "Hello World!" @@ -150,6 +175,35 @@ func TestUnmarshalInvalidJSON(t *testing.T) { } } +func TestUnmarshalInvalidJSON_BadType(t *testing.T) { + var badTypeTests = []struct { + Field string + BadValue interface{} + Error error + }{ // The `Field` values here correspond to the `ModelBadTypes` jsonapi fields. + {Field: "string_field", BadValue: 0, Error: ErrUnknownFieldNumberType}, // Expected string. + {Field: "float_field", BadValue: "A string.", Error: ErrInvalidType}, // Expected float64. + {Field: "time_field", BadValue: "A string.", Error: ErrInvalidTime}, // Expected int64. + {Field: "time_ptr_field", BadValue: "A string.", Error: ErrInvalidTime}, // Expected *time / int64. + } + for idx, test := range badTypeTests { + println("index:", idx) + out := new(ModelBadTypes) + in := map[string]interface{}{} + in[test.Field] = test.BadValue + expectedErrorMessage := test.Error.Error() + + 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()) + } + } +} + func TestUnmarshalSetsID(t *testing.T) { in := samplePayloadWithID() out := new(Blog) @@ -759,6 +813,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{