diff --git a/constants.go b/constants.go index 23288d3..35bbe05 100644 --- a/constants.go +++ b/constants.go @@ -9,6 +9,7 @@ const ( annotationRelation = "relation" annotationOmitEmpty = "omitempty" annotationISO8601 = "iso8601" + annotationRFC3339 = "rfc3339" annotationSeperator = "," iso8601TimeFormat = "2006-01-02T15:04:05Z" diff --git a/models_test.go b/models_test.go index 2d4aae4..f552d4f 100644 --- a/models_test.go +++ b/models_test.go @@ -25,10 +25,14 @@ type WithPointer struct { FloatVal *float32 `jsonapi:"attr,float-val"` } -type Timestamp struct { - ID int `jsonapi:"primary,timestamps"` - Time time.Time `jsonapi:"attr,timestamp,iso8601"` - Next *time.Time `jsonapi:"attr,next,iso8601"` +type TimestampModel struct { + ID int `jsonapi:"primary,timestamps"` + DefaultV time.Time `jsonapi:"attr,defaultv"` + DefaultP *time.Time `jsonapi:"attr,defaultp"` + ISO8601V time.Time `jsonapi:"attr,iso8601v,iso8601"` + ISO8601P *time.Time `jsonapi:"attr,iso8601p,iso8601"` + RFC3339V time.Time `jsonapi:"attr,rfc3339v,rfc3339"` + RFC3339P *time.Time `jsonapi:"attr,rfc3339p,rfc3339"` } type Car struct { diff --git a/request.go b/request.go index b2fa477..f665857 100644 --- a/request.go +++ b/request.go @@ -23,6 +23,9 @@ 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") + // ErrInvalidRFC3339 is returned when a struct has a time.Time type field and includes + // "rfc3339" in the tag spec, but the JSON value was not an RFC3339 timestamp string. + ErrInvalidRFC3339 = errors.New("Only strings can be parsed as dates, RFC3339 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) @@ -445,26 +448,25 @@ func handleStringSlice(attribute interface{}) (reflect.Value, error) { } func handleTime(attribute interface{}, args []string, fieldValue reflect.Value) (reflect.Value, error) { - var isIso8601 bool + var isISO8601, isRFC3339 bool v := reflect.ValueOf(attribute) if len(args) > 2 { for _, arg := range args[2:] { if arg == annotationISO8601 { - isIso8601 = true + isISO8601 = true + } else if arg == annotationRFC3339 { + isRFC3339 = true } } } - if isIso8601 { - var tm string - if v.Kind() == reflect.String { - tm = v.Interface().(string) - } else { + if isISO8601 { + if v.Kind() != reflect.String { return reflect.ValueOf(time.Now()), ErrInvalidISO8601 } - t, err := time.Parse(iso8601TimeFormat, tm) + t, err := time.Parse(iso8601TimeFormat, v.Interface().(string)) if err != nil { return reflect.ValueOf(time.Now()), ErrInvalidISO8601 } @@ -476,6 +478,23 @@ func handleTime(attribute interface{}, args []string, fieldValue reflect.Value) return reflect.ValueOf(t), nil } + if isRFC3339 { + if v.Kind() != reflect.String { + return reflect.ValueOf(time.Now()), ErrInvalidRFC3339 + } + + t, err := time.Parse(time.RFC3339, v.Interface().(string)) + if err != nil { + return reflect.ValueOf(time.Now()), ErrInvalidRFC3339 + } + + if fieldValue.Kind() == reflect.Ptr { + return reflect.ValueOf(&t), nil + } + + return reflect.ValueOf(t), nil + } + var at int64 if v.Kind() == reflect.Float64 { diff --git a/request_test.go b/request_test.go index daa2159..300c7de 100644 --- a/request_test.go +++ b/request_test.go @@ -3,6 +3,7 @@ package jsonapi import ( "bytes" "encoding/json" + "errors" "fmt" "io" "reflect" @@ -341,75 +342,175 @@ func TestUnmarshalSetsAttrs(t *testing.T) { } } -func TestUnmarshalParsesISO8601(t *testing.T) { - payload := &OnePayload{ - Data: &Node{ - Type: "timestamps", - Attributes: map[string]interface{}{ - "timestamp": "2016-08-17T08:27:12Z", +func TestUnmarshal_Times(t *testing.T) { + aTime := time.Date(2016, 8, 17, 8, 27, 12, 0, time.UTC) + + for _, tc := range []struct { + desc string + inputPayload *OnePayload + wantErr bool + verification func(tm *TimestampModel) error + }{ + // Default: + { + desc: "default_byValue", + inputPayload: &OnePayload{ + Data: &Node{ + Type: "timestamps", + Attributes: map[string]interface{}{ + "defaultv": aTime.Unix(), + }, + }, + }, + verification: func(tm *TimestampModel) error { + if !tm.DefaultV.Equal(aTime) { + return errors.New("times not equal!") + } + return nil }, }, - } - - in := bytes.NewBuffer(nil) - json.NewEncoder(in).Encode(payload) - - out := new(Timestamp) - - if err := UnmarshalPayload(in, out); err != nil { - t.Fatal(err) - } - - expected := time.Date(2016, 8, 17, 8, 27, 12, 0, time.UTC) - - if !out.Time.Equal(expected) { - t.Fatal("Parsing the ISO8601 timestamp failed") - } -} - -func TestUnmarshalParsesISO8601TimePointer(t *testing.T) { - payload := &OnePayload{ - Data: &Node{ - Type: "timestamps", - Attributes: map[string]interface{}{ - "next": "2016-08-17T08:27:12Z", + { + desc: "default_byPointer", + inputPayload: &OnePayload{ + Data: &Node{ + Type: "timestamps", + Attributes: map[string]interface{}{ + "defaultp": aTime.Unix(), + }, + }, + }, + verification: func(tm *TimestampModel) error { + if !tm.DefaultP.Equal(aTime) { + return errors.New("times not equal!") + } + return nil }, }, - } - - in := bytes.NewBuffer(nil) - json.NewEncoder(in).Encode(payload) - - out := new(Timestamp) - - if err := UnmarshalPayload(in, out); err != nil { - t.Fatal(err) - } - - expected := time.Date(2016, 8, 17, 8, 27, 12, 0, time.UTC) - - if !out.Next.Equal(expected) { - t.Fatal("Parsing the ISO8601 timestamp failed") - } -} - -func TestUnmarshalInvalidISO8601(t *testing.T) { - payload := &OnePayload{ - Data: &Node{ - Type: "timestamps", - Attributes: map[string]interface{}{ - "timestamp": "17 Aug 16 08:027 MST", + { + desc: "default_invalid", + inputPayload: &OnePayload{ + Data: &Node{ + Type: "timestamps", + Attributes: map[string]interface{}{ + "defaultv": "not a timestamp!", + }, + }, }, + wantErr: true, }, - } - - in := bytes.NewBuffer(nil) - json.NewEncoder(in).Encode(payload) - - out := new(Timestamp) + // ISO 8601: + { + desc: "iso8601_byValue", + inputPayload: &OnePayload{ + Data: &Node{ + Type: "timestamps", + Attributes: map[string]interface{}{ + "iso8601v": "2016-08-17T08:27:12Z", + }, + }, + }, + verification: func(tm *TimestampModel) error { + if !tm.ISO8601V.Equal(aTime) { + return errors.New("times not equal!") + } + return nil + }, + }, + { + desc: "iso8601_byPointer", + inputPayload: &OnePayload{ + Data: &Node{ + Type: "timestamps", + Attributes: map[string]interface{}{ + "iso8601p": "2016-08-17T08:27:12Z", + }, + }, + }, + verification: func(tm *TimestampModel) error { + if !tm.ISO8601P.Equal(aTime) { + return errors.New("times not equal!") + } + return nil + }, + }, + { + desc: "iso8601_invalid", + inputPayload: &OnePayload{ + Data: &Node{ + Type: "timestamps", + Attributes: map[string]interface{}{ + "iso8601v": "not a timestamp", + }, + }, + }, + wantErr: true, + }, + // RFC 3339 + { + desc: "rfc3339_byValue", + inputPayload: &OnePayload{ + Data: &Node{ + Type: "timestamps", + Attributes: map[string]interface{}{ + "rfc3339v": "2016-08-17T08:27:12Z", + }, + }, + }, + verification: func(tm *TimestampModel) error { + if got, want := tm.RFC3339V, aTime; got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }, + { + desc: "rfc3339_byPointer", + inputPayload: &OnePayload{ + Data: &Node{ + Type: "timestamps", + Attributes: map[string]interface{}{ + "rfc3339p": "2016-08-17T08:27:12Z", + }, + }, + }, + verification: func(tm *TimestampModel) error { + if got, want := *tm.RFC3339P, aTime; got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }, + { + desc: "rfc3339_invalid", + inputPayload: &OnePayload{ + Data: &Node{ + Type: "timestamps", + Attributes: map[string]interface{}{ + "rfc3339v": "not a timestamp", + }, + }, + }, + wantErr: true, + }, + } { + t.Run(tc.desc, func(t *testing.T) { + // Serialize the OnePayload using the standard JSON library. + in := bytes.NewBuffer(nil) + if err := json.NewEncoder(in).Encode(tc.inputPayload); err != nil { + t.Fatal(err) + } - if err := UnmarshalPayload(in, out); err != ErrInvalidISO8601 { - t.Fatalf("Expected ErrInvalidISO8601, got %v", err) + out := &TimestampModel{} + err := UnmarshalPayload(in, out) + if got, want := (err != nil), tc.wantErr; got != want { + t.Fatalf("UnmarshalPayload error: got %v, want %v", got, want) + } + if tc.verification != nil { + if err := tc.verification(out); err != nil { + t.Fatal(err) + } + } + }) } } diff --git a/response.go b/response.go index 3f8ab73..b44e4e9 100644 --- a/response.go +++ b/response.go @@ -283,7 +283,7 @@ func visitModelNode(model interface{}, included *map[string]*Node, node.ClientID = clientID } } else if annotation == annotationAttribute { - var omitEmpty, iso8601 bool + var omitEmpty, iso8601, rfc3339 bool if len(args) > 2 { for _, arg := range args[2:] { @@ -292,6 +292,8 @@ func visitModelNode(model interface{}, included *map[string]*Node, omitEmpty = true case annotationISO8601: iso8601 = true + case annotationRFC3339: + rfc3339 = true } } } @@ -309,6 +311,8 @@ func visitModelNode(model interface{}, included *map[string]*Node, if iso8601 { node.Attributes[args[1]] = t.UTC().Format(iso8601TimeFormat) + } else if rfc3339 { + node.Attributes[args[1]] = t.UTC().Format(time.RFC3339) } else { node.Attributes[args[1]] = t.Unix() } @@ -329,6 +333,8 @@ func visitModelNode(model interface{}, included *map[string]*Node, if iso8601 { node.Attributes[args[1]] = tm.UTC().Format(iso8601TimeFormat) + } else if rfc3339 { + node.Attributes[args[1]] = tm.UTC().Format(time.RFC3339) } else { node.Attributes[args[1]] = tm.Unix() } diff --git a/response_test.go b/response_test.go index 5b42595..b1d5967 100644 --- a/response_test.go +++ b/response_test.go @@ -3,6 +3,7 @@ package jsonapi import ( "bytes" "encoding/json" + "fmt" "reflect" "sort" "testing" @@ -470,58 +471,113 @@ func TestOmitsZeroTimes(t *testing.T) { } } -func TestMarshalISO8601Time(t *testing.T) { - testModel := &Timestamp{ - ID: 5, - Time: time.Date(2016, 8, 17, 8, 27, 12, 23849, time.UTC), - } - - 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["timestamp"] != "2016-08-17T08:27:12Z" { - t.Fatal("Timestamp was not serialised into ISO8601 correctly") - } -} - -func TestMarshalISO8601TimePointer(t *testing.T) { - tm := time.Date(2016, 8, 17, 8, 27, 12, 23849, time.UTC) - testModel := &Timestamp{ - ID: 5, - Next: &tm, - } - - 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["next"] != "2016-08-17T08:27:12Z" { - t.Fatal("Next was not serialised into ISO8601 correctly") +func TestMarshal_Times(t *testing.T) { + aTime := time.Date(2016, 8, 17, 8, 27, 12, 23849, time.UTC) + + for _, tc := range []struct { + desc string + input *TimestampModel + verification func(data map[string]interface{}) error + }{ + { + desc: "default_byValue", + input: &TimestampModel{ + ID: 5, + DefaultV: aTime, + }, + verification: func(root map[string]interface{}) error { + v := root["data"].(map[string]interface{})["attributes"].(map[string]interface{})["defaultv"].(float64) + if got, want := int64(v), aTime.Unix(); got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }, + { + desc: "default_byPointer", + input: &TimestampModel{ + ID: 5, + DefaultP: &aTime, + }, + verification: func(root map[string]interface{}) error { + v := root["data"].(map[string]interface{})["attributes"].(map[string]interface{})["defaultp"].(float64) + if got, want := int64(v), aTime.Unix(); got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }, + { + desc: "iso8601_byValue", + input: &TimestampModel{ + ID: 5, + ISO8601V: aTime, + }, + verification: func(root map[string]interface{}) error { + v := root["data"].(map[string]interface{})["attributes"].(map[string]interface{})["iso8601v"].(string) + if got, want := v, aTime.UTC().Format(iso8601TimeFormat); got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }, + { + desc: "iso8601_byPointer", + input: &TimestampModel{ + ID: 5, + ISO8601P: &aTime, + }, + verification: func(root map[string]interface{}) error { + v := root["data"].(map[string]interface{})["attributes"].(map[string]interface{})["iso8601p"].(string) + if got, want := v, aTime.UTC().Format(iso8601TimeFormat); got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }, + { + desc: "rfc3339_byValue", + input: &TimestampModel{ + ID: 5, + RFC3339V: aTime, + }, + verification: func(root map[string]interface{}) error { + v := root["data"].(map[string]interface{})["attributes"].(map[string]interface{})["rfc3339v"].(string) + if got, want := v, aTime.UTC().Format(time.RFC3339); got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }, + { + desc: "rfc3339_byPointer", + input: &TimestampModel{ + ID: 5, + RFC3339P: &aTime, + }, + verification: func(root map[string]interface{}) error { + v := root["data"].(map[string]interface{})["attributes"].(map[string]interface{})["rfc3339p"].(string) + if got, want := v, aTime.UTC().Format(time.RFC3339); got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }, + } { + t.Run(tc.desc, func(t *testing.T) { + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, tc.input); err != nil { + t.Fatal(err) + } + // Use the standard JSON library to traverse the genereated JSON payload. + data := map[string]interface{}{} + json.Unmarshal(out.Bytes(), &data) + if tc.verification != nil { + if err := tc.verification(data); err != nil { + t.Fatal(err) + } + } + }) } }