diff --git a/request.go b/request.go index 501ed9a..d1c83f5 100644 --- a/request.go +++ b/request.go @@ -12,16 +12,27 @@ import ( "time" ) -const unsuportedStructTagMsg = "Unsupported jsonapi tag annotation, %s" - -var ( - ErrInvalidTime = errors.New("Only numbers can be parsed as dates, unix timestamps") - ErrUnknownFieldNumberType = errors.New("The struct field was not of a known number type") +const ( + unsuportedStructTagMsg = "Unsupported jsonapi tag annotation, %s" + clientIDAnnotation = "client-id" ) -// Convert an io into a struct instance using jsonapi tags on struct fields. -// Method supports single request payloads only, at the moment. Bulk creates and updates -// are not supported yet. +var ( + // ErrInvalidTime is returned when a struct has a time.Time type field, but + // the JSON value was not a unix timestamp integer. + ErrInvalidTime = errors.New("Only numbers can be parsed as dates, unix 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 +// struct fields. This method supports single request payloads only, at the +// moment. Bulk creates and updates are not supported yet. // // Will Unmarshal embedded and sideloaded payloads. The latter is only possible if the // object graph is complete. That is, in the "relationships" data there are type and id, @@ -67,10 +78,8 @@ func UnmarshalPayload(in io.Reader, model interface{}) error { } return unmarshalNode(payload.Data, reflect.ValueOf(model), &includedMap) - } else { - return unmarshalNode(payload.Data, reflect.ValueOf(model), nil) } - + return unmarshalNode(payload.Data, reflect.ValueOf(model), nil) } func UnmarshalManyPayload(in io.Reader, t reflect.Type) ([]interface{}, error) { @@ -97,22 +106,21 @@ func UnmarshalManyPayload(in io.Reader, t reflect.Type) ([]interface{}, error) { models = append(models, model.Interface()) } - return models, nil - } else { - var models []interface{} - - for _, data := range payload.Data { - model := reflect.New(t.Elem()) - err := unmarshalNode(data, model, nil) - if err != nil { - return nil, err - } - models = append(models, model.Interface()) - } - return models, nil } + var models []interface{} + + for _, data := range payload.Data { + model := reflect.New(t.Elem()) + err := unmarshalNode(data, model, nil) + if err != nil { + return nil, err + } + models = append(models, model.Interface()) + } + + return models, nil } func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) (err error) { @@ -145,7 +153,8 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) annotation := args[0] - if (annotation == "client-id" && len(args) != 1) || (annotation != "client-id" && len(args) < 2) { + if (annotation == clientIDAnnotation && len(args) != 1) || + (annotation != clientIDAnnotation && len(args) < 2) { er = ErrBadJSONAPIStructTag break } @@ -173,7 +182,7 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) er = ErrBadJSONAPIID break } - } else if annotation == "client-id" { + } else if annotation == clientIDAnnotation { if data.ClientID == "" { continue } @@ -194,6 +203,7 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) v := reflect.ValueOf(val) + // Handle field of type time.Time if fieldValue.Type() == reflect.TypeOf(time.Time{}) { var at int64 @@ -244,8 +254,8 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) continue } + // JSON value was a float (numeric) if v.Kind() == reflect.Float64 { - // Handle JSON numeric case floatValue := v.Interface().(float64) // The field may or may not be a pointer to a numeric; the kind var @@ -297,6 +307,8 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) n := float64(floatValue) numericValue = reflect.ValueOf(&n) default: + // We had a JSON float (numeric), but our field was a non numeric + // type er = ErrUnknownFieldNumberType break } @@ -309,7 +321,40 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) continue } + + // Field was a Pointer type + if fieldValue.Kind() == reflect.Ptr { + var concreteVal reflect.Value + + switch cVal := val.(type) { + case string: + concreteVal = reflect.ValueOf(&cVal) + case bool: + concreteVal = reflect.ValueOf(&cVal) + case complex64: + concreteVal = reflect.ValueOf(&cVal) + case complex128: + concreteVal = reflect.ValueOf(&cVal) + case uintptr: + concreteVal = reflect.ValueOf(&cVal) + default: + er = ErrUnsupportedPtrType + break + } + + 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 + } + + fieldValue.Set(concreteVal) + continue + } + fieldValue.Set(reflect.ValueOf(val)) + } else if annotation == "relation" { isSlice := fieldValue.Type().Kind() == reflect.Slice @@ -331,7 +376,11 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) for _, n := range data { m := reflect.New(fieldValue.Type().Elem().Elem()) - if err := unmarshalNode(fullNode(n, included), m, included); err != nil { + if err := unmarshalNode( + fullNode(n, included), + m, + included, + ); err != nil { er = err break } @@ -345,12 +394,18 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) buf := bytes.NewBuffer(nil) - json.NewEncoder(buf).Encode(data.Relationships[args[1]]) + json.NewEncoder(buf).Encode( + data.Relationships[args[1]], + ) json.NewDecoder(buf).Decode(relationship) m := reflect.New(fieldValue.Type().Elem()) - if err := unmarshalNode(fullNode(relationship.Data, included), m, included); err != nil { + if err := unmarshalNode( + fullNode(relationship.Data, included), + m, + included, + ); err != nil { er = err break } diff --git a/request_test.go b/request_test.go index ed1c295..cc2afd1 100644 --- a/request_test.go +++ b/request_test.go @@ -13,6 +13,94 @@ type BadModel struct { ID int `jsonapi:"primary"` } +type WithPointer struct { + ID string `jsonapi:"primary,with-pointers"` + Name *string `jsonapi:"attr,name"` + IsActive *bool `jsonapi:"attr,is-active"` + IntVal *int `jsonapi:"attr,int-val"` + FloatVal *float32 `jsonapi:"attr,float-val"` +} + +func TestUnmarshalToStructWithPointerAttr(t *testing.T) { + out := new(WithPointer) + in := map[string]interface{}{ + "name": "The name", + "is-active": true, + "int-val": 8, + "float-val": 1.1, + } + if err := UnmarshalPayload(sampleWithPointerPayload(in), out); err != nil { + t.Fatal(err) + } + if *out.Name != "The name" { + t.Fatalf("Error unmarshalling to string ptr") + } + if *out.IsActive != true { + t.Fatalf("Error unmarshalling to bool ptr") + } + if *out.IntVal != 8 { + t.Fatalf("Error unmarshalling to int ptr") + } + if *out.FloatVal != 1.1 { + t.Fatalf("Error unmarshalling to float ptr") + } +} + +func TestUnmarshalToStructWithPointerAttr_AbsentVal(t *testing.T) { + out := new(WithPointer) + in := map[string]interface{}{ + "name": "The name", + "is-active": true, + } + + if err := UnmarshalPayload(sampleWithPointerPayload(in), out); err != nil { + t.Fatalf("Error unmarshalling to Foo") + } + + // these were present in the payload -- expect val to be not nil + if out.Name == nil || out.IsActive == nil { + t.Fatalf("Error unmarshalling; expected ptr to be not nil") + } + + // these were absent in the payload -- expect val to be nil + if out.IntVal != nil || out.FloatVal != nil { + t.Fatalf("Error unmarshalling; expected ptr to be nil") + } +} + +func TestStringPointerField(t *testing.T) { + // Build Book payload + description := "Hello World!" + data := map[string]interface{}{ + "data": map[string]interface{}{ + "type": "books", + "id": "5", + "attributes": map[string]interface{}{ + "author": "aren55555", + "description": description, + "isbn": "", + }, + }, + } + payload, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + + // Parse JSON API payload + book := new(Book) + if err := UnmarshalPayload(bytes.NewReader(payload), book); err != nil { + t.Fatal(err) + } + + if book.Description == nil { + t.Fatal("Was not expecting a nil pointer for book.Description") + } + if expected, actual := description, *book.Description; expected != actual { + t.Fatalf("Was expecting descript to be `%s`, got `%s`", expected, actual) + } +} + func TestMalformedTag(t *testing.T) { out := new(BadModel) err := UnmarshalPayload(samplePayload(), out) @@ -264,6 +352,7 @@ func samplePayloadWithoutIncluded() (result []byte, err error) { }, }, } + result, err = json.Marshal(data) return } @@ -333,7 +422,6 @@ func samplePayload() io.Reader { } out := bytes.NewBuffer(nil) - json.NewEncoder(out).Encode(payload) return out @@ -352,7 +440,21 @@ func samplePayloadWithID() io.Reader { } out := bytes.NewBuffer(nil) + json.NewEncoder(out).Encode(payload) + return out +} + +func sampleWithPointerPayload(m map[string]interface{}) io.Reader { + payload := &OnePayload{ + Data: &Node{ + ID: "2", + Type: "with-pointers", + Attributes: m, + }, + } + + out := bytes.NewBuffer(nil) json.NewEncoder(out).Encode(payload) return out @@ -440,7 +542,6 @@ func sampleSerializedEmbeddedTestModel() *Blog { MarshalOnePayloadEmbedded(out, testModel()) blog := new(Blog) - UnmarshalPayload(out, blog) return blog diff --git a/response_test.go b/response_test.go index 35a4f89..57b7729 100644 --- a/response_test.go +++ b/response_test.go @@ -37,11 +37,12 @@ type Comment struct { } type Book struct { - ID int `jsonapi:"primary,books"` - Author string `jsonapi:"attr,author"` - ISBN string `jsonapi:"attr,isbn"` - Title string `jsonapi:"attr,title,omitempty"` - Pages *uint `jsonapi:"attr,pages,omitempty"` + ID int `jsonapi:"primary,books"` + Author string `jsonapi:"attr,author"` + ISBN string `jsonapi:"attr,isbn"` + Title string `jsonapi:"attr,title,omitempty"` + Description *string `jsonapi:"attr,description"` + Pages *uint `jsonapi:"attr,pages,omitempty"` PublishedAt time.Time }