Skip to content

Commit deeffb7

Browse files
committed
implement support for struct fields that implement json.Marshaler/Unmarshaler
1 parent f79a192 commit deeffb7

File tree

5 files changed

+229
-18
lines changed

5 files changed

+229
-18
lines changed

attributes.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package jsonapi
2+
3+
import (
4+
"encoding/json"
5+
"reflect"
6+
"strconv"
7+
"time"
8+
)
9+
10+
const iso8601Layout = "2006-01-02T15:04:05Z07:00"
11+
12+
// ISO8601Datetime represents a ISO8601 formatted datetime
13+
// It is a time.Time instance that marshals and unmarshals to the ISO8601 ref
14+
type ISO8601Datetime struct {
15+
time.Time
16+
}
17+
18+
// MarshalJSON implements the json.Marshaler interface.
19+
func (t *ISO8601Datetime) MarshalJSON() ([]byte, error) {
20+
s := t.Time.Format(iso8601Layout)
21+
return json.Marshal(s)
22+
}
23+
24+
// UnmarshalJSON implements the json.Unmarshaler interface.
25+
func (t *ISO8601Datetime) UnmarshalJSON(data []byte) error {
26+
// Ignore null, like in the main JSON package.
27+
if string(data) == "null" {
28+
return nil
29+
}
30+
// Fractional seconds are handled implicitly by Parse.
31+
var err error
32+
t.Time, err = time.Parse(strconv.Quote(iso8601Layout), string(data))
33+
return err
34+
}
35+
36+
// ISO8601Datetime.String() - override default String() on time
37+
func (t ISO8601Datetime) String() string {
38+
return t.Format(iso8601Layout)
39+
}
40+
41+
// func to help determine json.Marshaler implementation
42+
// checks both pointer and non-pointer implementations
43+
func isJSONMarshaler(fv reflect.Value) (json.Marshaler, bool) {
44+
if u, ok := fv.Interface().(json.Marshaler); ok {
45+
return u, ok
46+
}
47+
48+
if !fv.CanAddr() {
49+
return nil, false
50+
}
51+
52+
u, ok := fv.Addr().Interface().(json.Marshaler)
53+
return u, ok
54+
}
55+
56+
// func to help determine json.Unmarshaler implementation
57+
// checks both pointer and non-pointer implementations
58+
func isJSONUnmarshaler(fv reflect.Value) (json.Unmarshaler, bool) {
59+
if u, ok := fv.Interface().(json.Unmarshaler); ok {
60+
return u, ok
61+
}
62+
63+
if !fv.CanAddr() {
64+
return nil, false
65+
}
66+
67+
u, ok := fv.Addr().Interface().(json.Unmarshaler)
68+
return u, ok
69+
}

attributes_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package jsonapi
2+
3+
import (
4+
"reflect"
5+
"strconv"
6+
"testing"
7+
"time"
8+
)
9+
10+
func TestISO8601Datetime(t *testing.T) {
11+
pacific, err := time.LoadLocation("America/Los_Angeles")
12+
if err != nil {
13+
t.Fatal(err)
14+
}
15+
16+
type test struct {
17+
stringVal string
18+
dtVal ISO8601Datetime
19+
}
20+
21+
tests := []*test{
22+
&test{
23+
stringVal: strconv.Quote("2017-04-06T13:00:00-07:00"),
24+
dtVal: ISO8601Datetime{Time: time.Date(2017, time.April, 6, 13, 0, 0, 0, pacific)},
25+
},
26+
&test{
27+
stringVal: strconv.Quote("2007-05-06T13:00:00-07:00"),
28+
dtVal: ISO8601Datetime{Time: time.Date(2007, time.May, 6, 13, 0, 0, 0, pacific)},
29+
},
30+
&test{
31+
stringVal: strconv.Quote("2016-12-08T15:18:54Z"),
32+
dtVal: ISO8601Datetime{Time: time.Date(2016, time.December, 8, 15, 18, 54, 0, time.UTC)},
33+
},
34+
}
35+
36+
for _, test := range tests {
37+
// unmarshal stringVal by calling UnmarshalJSON()
38+
dt := &ISO8601Datetime{}
39+
if err := dt.UnmarshalJSON([]byte(test.stringVal)); err != nil {
40+
t.Fatal(err)
41+
}
42+
43+
// compare unmarshaled stringVal to dtVal
44+
if !dt.Time.Equal(test.dtVal.Time) {
45+
t.Errorf("\n\tE=%+v\n\tA=%+v", test.dtVal.UnixNano(), dt.UnixNano())
46+
}
47+
48+
// marshal dtVal by calling MarshalJSON()
49+
b, err := test.dtVal.MarshalJSON()
50+
if err != nil {
51+
t.Fatal(err)
52+
}
53+
54+
// compare marshaled dtVal to stringVal
55+
if test.stringVal != string(b) {
56+
t.Errorf("\n\tE=%+v\n\tA=%+v", test.stringVal, string(b))
57+
}
58+
}
59+
}
60+
61+
func TestIsJSONMarshaler(t *testing.T) {
62+
{ // positive
63+
isoDateTime := ISO8601Datetime{}
64+
v := reflect.ValueOf(&isoDateTime)
65+
if _, ok := isJSONMarshaler(v); !ok {
66+
t.Error("got false; expected ISO8601Datetime to implement json.Marshaler")
67+
}
68+
}
69+
{ // negative
70+
type customString string
71+
input := customString("foo")
72+
v := reflect.ValueOf(&input)
73+
if _, ok := isJSONMarshaler(v); ok {
74+
t.Error("got true; expected customString to not implement json.Marshaler")
75+
}
76+
}
77+
}
78+
79+
func TestIsJSONUnmarshaler(t *testing.T) {
80+
{ // positive
81+
isoDateTime := ISO8601Datetime{}
82+
v := reflect.ValueOf(&isoDateTime)
83+
if _, ok := isJSONUnmarshaler(v); !ok {
84+
t.Error("expected ISO8601Datetime to implement json.Unmarshaler")
85+
}
86+
}
87+
{ // negative
88+
type customString string
89+
input := customString("foo")
90+
v := reflect.ValueOf(&input)
91+
if _, ok := isJSONUnmarshaler(v); ok {
92+
t.Error("got true; expected customString to not implement json.Unmarshaler")
93+
}
94+
}
95+
}

request.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,11 @@ func handleAttributeUnmarshal(data *Node, args []string, fieldType reflect.Struc
589589
return nil
590590
}
591591

592+
// TODO: refactor the time type handlers to implement json.Unmarshaler and move this higher
593+
if isJSONUnmarshaler, err := handleJSONUnmarshalerAttributes(data, args, fieldValue); isJSONUnmarshaler {
594+
return err
595+
}
596+
592597
// As a final catch-all, ensure types line up to avoid a runtime panic.
593598
// Ignore interfaces since interfaces are poly
594599
if fieldValue.Kind() != reflect.Interface && fieldValue.Kind() != v.Kind() {
@@ -601,6 +606,35 @@ func handleAttributeUnmarshal(data *Node, args []string, fieldType reflect.Struc
601606
return nil
602607
}
603608

609+
func handleJSONUnmarshalerAttributes(data *Node, args []string, fieldValue reflect.Value) (bool, error) {
610+
val := data.Attributes[args[1]]
611+
612+
// handle struct fields that implement json.Unmarshaler
613+
if fieldValue.Type().NumMethod() > 0 {
614+
jsonUnmarshaler, ok := isJSONUnmarshaler(fieldValue)
615+
// if field doesn't implement the json.Unmarshaler, ignore and move on
616+
if !ok {
617+
return ok, nil
618+
}
619+
620+
b, err := json.Marshal(val)
621+
if err != nil {
622+
return ok, err
623+
}
624+
625+
if err := jsonUnmarshaler.UnmarshalJSON(b); err != nil {
626+
return ok, err
627+
}
628+
629+
// success; clear value
630+
delete(data.Attributes, args[1])
631+
return ok, nil
632+
}
633+
634+
// field does not implement any methods, including json.Unmarshaler; continue
635+
return false, nil
636+
}
637+
604638
func fullNode(n *Node, included *map[string]*Node) *Node {
605639
includedKey := fmt.Sprintf("%s,%s", n.Type, n.ID)
606640

response.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,11 +215,12 @@ func visitModelNode(model interface{}, included *map[string]*Node,
215215
structField := modelValue.Type().Field(i)
216216
tag := structField.Tag.Get(annotationJSONAPI)
217217

218+
if shouldIgnoreField(tag) {
219+
continue
220+
}
221+
218222
// handles embedded structs
219223
if isEmbeddedStruct(structField) {
220-
if shouldIgnoreField(tag) {
221-
continue
222-
}
223224
model := modelValue.Field(i).Addr().Interface()
224225
embNode, err := visitModelNode(model, included, sideload)
225226
if err != nil {
@@ -360,6 +361,11 @@ func visitModelNode(model interface{}, included *map[string]*Node,
360361
continue
361362
}
362363

364+
if jsonMarshaler, ok := isJSONMarshaler(fieldValue); ok {
365+
node.Attributes[args[1]] = jsonMarshaler
366+
continue
367+
}
368+
363369
strAttr, ok := fieldValue.Interface().(string)
364370
if ok {
365371
node.Attributes[args[1]] = strAttr

response_test.go

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,12 +1099,17 @@ func TestMarshalUnmarshalCompositeStruct(t *testing.T) {
10991099

11001100
{
11011101
type Model struct {
1102-
Thing `jsonapi:"-"`
1103-
ModelID int `jsonapi:"primary,models"`
1104-
Foo string `jsonapi:"attr,foo"`
1105-
Bar string `jsonapi:"attr,bar"`
1106-
Bat string `jsonapi:"attr,bat"`
1107-
Buzz int `jsonapi:"attr,buzz"`
1102+
Thing `jsonapi:"-"`
1103+
ModelID int `jsonapi:"primary,models"`
1104+
Foo string `jsonapi:"attr,foo"`
1105+
Bar string `jsonapi:"attr,bar"`
1106+
Bat string `jsonapi:"attr,bat"`
1107+
Buzz int `jsonapi:"attr,buzz"`
1108+
CreateDate ISO8601Datetime `jsonapi:"attr,create-date"`
1109+
}
1110+
1111+
isoDate := ISO8601Datetime{
1112+
Time: time.Date(2016, time.December, 8, 15, 18, 54, 0, time.UTC),
11081113
}
11091114

11101115
scenarios = append(scenarios, test{
@@ -1115,19 +1120,21 @@ func TestMarshalUnmarshalCompositeStruct(t *testing.T) {
11151120
Type: "models",
11161121
ID: "1",
11171122
Attributes: map[string]interface{}{
1118-
"bar": "barry",
1119-
"bat": "batty",
1120-
"buzz": 99,
1121-
"foo": "fooey",
1123+
"bar": "barry",
1124+
"bat": "batty",
1125+
"buzz": 99,
1126+
"foo": "fooey",
1127+
"create-date": isoDate.String(),
11221128
},
11231129
},
11241130
},
11251131
expected: &Model{
1126-
ModelID: 1,
1127-
Foo: "fooey",
1128-
Bar: "barry",
1129-
Bat: "batty",
1130-
Buzz: 99,
1132+
ModelID: 1,
1133+
Foo: "fooey",
1134+
Bar: "barry",
1135+
Bat: "batty",
1136+
Buzz: 99,
1137+
CreateDate: isoDate,
11311138
},
11321139
})
11331140
}

0 commit comments

Comments
 (0)