diff --git a/constants.go b/constants.go index 23288d3..e3dc092 100644 --- a/constants.go +++ b/constants.go @@ -9,6 +9,7 @@ const ( annotationRelation = "relation" annotationOmitEmpty = "omitempty" annotationISO8601 = "iso8601" + annotationString = "string" annotationSeperator = "," iso8601TimeFormat = "2006-01-02T15:04:05Z" diff --git a/models_test.go b/models_test.go index a53dd61..7b789b7 100644 --- a/models_test.go +++ b/models_test.go @@ -31,6 +31,14 @@ type Timestamp struct { Next *time.Time `jsonapi:"attr,next,iso8601"` } +type StringInt struct { + ID int `jsonapi:"primary,string-ints"` + ParentID int `jsonapi:"attr,parent-id,string"` + OptParentID *int `jsonapi:"attr,opt-parent-id,string"` + BigParentID int64 `jsonapi:"attr,big-parent-id,string"` + OptBigParentID *int64 `jsonapi:"attr,opt-big-parent-id,string"` +} + type Car struct { ID *string `jsonapi:"primary,cars"` Make *string `jsonapi:"attr,make,omitempty"` diff --git a/request.go b/request.go index fe29706..f4ca70b 100644 --- a/request.go +++ b/request.go @@ -32,6 +32,9 @@ var ( 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. + // ErrInvalidNumberString is returned when the JSON value was a string, but + // could not be parsed as a number. + ErrInvalidNumberString = errors.New("The string value could not be parsed as a number") ) // UnmarshalPayload converts an io into a struct instance using jsonapi tags on @@ -248,12 +251,15 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) continue } - var iso8601 bool + var iso8601, str bool if len(args) > 2 { for _, arg := range args[2:] { - if arg == annotationISO8601 { + switch arg { + case annotationISO8601: iso8601 = true + case annotationString: + str = true } } } @@ -418,6 +424,40 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) continue } + // We have a string and there is a `string` struct tag on the model we're + // unmarshalling. + if str && v.Kind() == reflect.String { + var numericValue reflect.Value + + stringVal := v.Interface().(string) + + // The field may or may not be a pointer to a numeric; the kind var + // will not contain a pointer type + var kind reflect.Kind + if fieldValue.Kind() == reflect.Ptr { + kind = fieldType.Type.Elem().Kind() + } else { + kind = fieldType.Type.Kind() + } + + switch kind { + case reflect.Int64: + n, err := strconv.ParseInt(stringVal, 10, 64) + if err != nil { + return ErrInvalidNumberString + } + numericValue = reflect.ValueOf(&n) + case reflect.Int: + n, err := strconv.Atoi(stringVal) + if err != nil { + return ErrInvalidNumberString + } + numericValue = reflect.ValueOf(&n) + } + assign(fieldValue, numericValue) + continue + } + // Field was a Pointer type if fieldValue.Kind() == reflect.Ptr { var concreteVal reflect.Value diff --git a/request_test.go b/request_test.go index 2206449..22809de 100644 --- a/request_test.go +++ b/request_test.go @@ -290,6 +290,64 @@ func TestUnmarshalParsesISO8601(t *testing.T) { } } +func TestUnmarshalParsesStringInts(t *testing.T) { + payload := &OnePayload{ + Data: &Node{ + Type: "string-ints", + Attributes: map[string]interface{}{ + "parent-id": "2", + "opt-parent-id": "3", + "big-parent-id": "1362085645204177151", + "opt-big-parent-id": "1362085645204177151", + }, + }, + } + + in := bytes.NewBuffer(nil) + json.NewEncoder(in).Encode(payload) + + out := new(StringInt) + + if err := UnmarshalPayload(in, out); err != nil { + t.Fatal(err) + } + + if out.ParentID != 2 { + t.Fatal("Parsing the string encoded int failed") + } + if *out.OptParentID != 3 { + t.Fatal("Parsing the string encoded *int failed") + } + if out.BigParentID != 1362085645204177151 { + t.Fatal("Parsing the string encoded int64 failed") + } + if *out.OptBigParentID != 1362085645204177151 { + t.Fatal("Parsing the string encoded *int64 failed") + } +} + +func TestUnmarshalParsesStringIntInvalidInputs(t *testing.T) { + payload := &OnePayload{ + Data: &Node{ + Type: "string-ints", + Attributes: map[string]interface{}{ + "parent-id": "one", + "opt-parent-id": "Lorem ipsum", + }, + }, + } + + in := bytes.NewBuffer(nil) + json.NewEncoder(in).Encode(payload) + + out := new(StringInt) + err := UnmarshalPayload(in, out) + + if err == nil || err != ErrInvalidNumberString { + t.Fatal("Parsing did not give the proper error message for an invalid string") + } +} + func TestUnmarshalParsesISO8601TimePointer(t *testing.T) { payload := &OnePayload{ Data: &Node{ diff --git a/response.go b/response.go index bbe6d2c..3d7c164 100644 --- a/response.go +++ b/response.go @@ -286,7 +286,7 @@ func visitModelNode(model interface{}, included *map[string]*Node, node.ClientID = clientID } } else if annotation == annotationAttribute { - var omitEmpty, iso8601 bool + var omitEmpty, iso8601, str bool if len(args) > 2 { for _, arg := range args[2:] { @@ -295,6 +295,8 @@ func visitModelNode(model interface{}, included *map[string]*Node, omitEmpty = true case annotationISO8601: iso8601 = true + case annotationString: + str = true } } } @@ -345,10 +347,44 @@ func visitModelNode(model interface{}, included *map[string]*Node, continue } - strAttr, ok := fieldValue.Interface().(string) - if ok { - node.Attributes[args[1]] = strAttr - } else { + // Modify the output if the user specified that we need to string encode + // integer types. + switch attr := fieldValue.Interface().(type) { + case string: + node.Attributes[args[1]] = attr + case *int: + if attr == nil { + node.Attributes[args[1]] = nil + } else { + if str { + node.Attributes[args[1]] = strconv.Itoa(*attr) + } else { + node.Attributes[args[1]] = fieldValue.Interface() + } + } + case int: + if str { + node.Attributes[args[1]] = strconv.Itoa(attr) + } else { + node.Attributes[args[1]] = fieldValue.Interface() + } + case *int64: + if attr == nil { + node.Attributes[args[1]] = nil + } else { + if str { + node.Attributes[args[1]] = strconv.FormatInt(*attr, 10) + } else { + node.Attributes[args[1]] = fieldValue.Interface() + } + } + case int64: + if str { + node.Attributes[args[1]] = strconv.FormatInt(attr, 10) + } else { + node.Attributes[args[1]] = fieldValue.Interface() + } + default: node.Attributes[args[1]] = fieldValue.Interface() } } diff --git a/response_test.go b/response_test.go index 9b0bf57..62ade60 100644 --- a/response_test.go +++ b/response_test.go @@ -413,6 +413,47 @@ func TestMarshalISO8601Time(t *testing.T) { } } +func TestMarshalIntString(t *testing.T) { + optParentID := 2 + optBigParentID := int64(1362085645204177151) + testModel := &StringInt{ + ID: 5, + ParentID: 1, + OptParentID: &optParentID, + BigParentID: 1362085645204177151, + OptBigParentID: &optBigParentID, + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, testModel); err != nil { + t.Fatal(err) + } + + resp := new(OnePayload) + if err := json.NewDecoder(out).Decode(resp); err != nil { + t.Fatal(err) + } + + data := resp.Data + + if data.Attributes == nil { + t.Fatalf("Expected attributes") + } + + if data.Attributes["parent-id"] != "1" { + t.Fatal("ParentID was not serialised as a string correctly") + } + if data.Attributes["opt-parent-id"] != "2" { + t.Fatal("OptParentID was not serialised as a string correctly") + } + if data.Attributes["big-parent-id"] != "1362085645204177151" { + t.Fatal("BigParentID was not serialised as a string correctly") + } + if data.Attributes["opt-big-parent-id"] != "1362085645204177151" { + t.Fatal("OptBigParentID was not serialised as a string correctly") + } +} + func TestMarshalISO8601TimePointer(t *testing.T) { tm := time.Date(2016, 8, 17, 8, 27, 12, 23849, time.UTC) testModel := &Timestamp{