Skip to content

Commit

Permalink
Add support for iso8601 struct tag argument (google#47)
Browse files Browse the repository at this point in the history
* Add support for iso8601 struct tag.

Currently only unix timestamps are supported for serialising time.Time objects. The JSON.API specification doesn't make a specific requirement on time serialisation format, though it does make a recommendation for using ISO8601.

This adds support for an additional struct tag `iso8601` which controls serialisation and deserialisation using the ISO8601 timestamp format.

* Update doc.go with information regarding iso8601 and omitempty tag arguments.
  • Loading branch information
geoffgarside authored and shwoodard committed Sep 22, 2016
1 parent a1fa2c8 commit b6c6609
Show file tree
Hide file tree
Showing 5 changed files with 215 additions and 5 deletions.
7 changes: 6 additions & 1 deletion doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,17 @@ value arguments are comma separated. The first argument must be, "primary", and
the second must be the name that should appear in the "type" field for all data
objects that represent this type of model.
Value, attr: "attr,<key name in attributes hash>"
Value, attr: "attr,<key name in attributes hash>[,<extra arguments>]"
These fields' values should end up in the "attribute" hash for a record. The first
argument must be, "attr', and the second should be the name for the key to display in
the the "attributes" hash for that record.
The following extra arguments are also supported:
"omitempty": excludes the fields value from the "attribute" hash.
"iso8601": uses the ISO8601 timestamp format when serialising or deserialising the time.Time value.
Value, relation: "relation,<key name in relationships hash>"
Relations are struct fields that represent a one-to-one or one-to-many to other structs.
Expand Down
55 changes: 55 additions & 0 deletions request.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ 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")
// 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")
// 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)
Expand Down Expand Up @@ -193,6 +196,16 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node)
continue
}

var iso8601 bool

if len(args) > 2 {
for _, arg := range args[2:] {
if arg == "iso8601" {
iso8601 = true
}
}
}

val := attributes[args[1]]

// continue if the attribute was not included in the request
Expand All @@ -204,6 +217,26 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node)

// Handle field of type time.Time
if fieldValue.Type() == reflect.TypeOf(time.Time{}) {
if iso8601 {
var tm string
if v.Kind() == reflect.String {
tm = v.Interface().(string)
} else {
er = ErrInvalidISO8601
break
}

t, err := time.Parse(iso8601TimeFormat, tm)
if err != nil {
er = ErrInvalidISO8601
break
}

fieldValue.Set(reflect.ValueOf(t))

continue
}

var at int64

if v.Kind() == reflect.Float64 {
Expand Down Expand Up @@ -234,6 +267,28 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node)
}

if fieldValue.Type() == reflect.TypeOf(new(time.Time)) {
if iso8601 {
var tm string
if v.Kind() == reflect.String {
tm = v.Interface().(string)
} else {
er = ErrInvalidISO8601
break
}

v, err := time.Parse(iso8601TimeFormat, tm)
if err != nil {
er = ErrInvalidISO8601
break
}

t := &v

fieldValue.Set(reflect.ValueOf(t))

continue
}

var at int64

if v.Kind() == reflect.Float64 {
Expand Down
72 changes: 72 additions & 0 deletions request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,78 @@ 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",
},
},
}

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",
},
},
}

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",
},
},
}

in := bytes.NewBuffer(nil)
json.NewEncoder(in).Encode(payload)

out := new(Timestamp)

if err := UnmarshalPayload(in, out); err != ErrInvalidISO8601 {
t.Fatalf("Expected ErrInvalidISO8601, got %v", err)
}
}

func TestUnmarshalRelationshipsWithoutIncluded(t *testing.T) {
data, _ := samplePayloadWithoutIncluded()
in := bytes.NewReader(data)
Expand Down
25 changes: 21 additions & 4 deletions response.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ var (
ErrExpectedSlice = errors.New("models should be a slice of struct pointers")
)

const iso8601TimeFormat = "2006-01-02T15:04:05Z"

// MarshalOnePayload writes a jsonapi response with one, with related records
// sideloaded, into "included" array. This method encodes a response for a
// single record only. Hence, data will be a single record rather than an array
Expand Down Expand Up @@ -236,10 +238,17 @@ func visitModelNode(model interface{}, included *map[string]*Node, sideload bool
node.ClientID = clientID
}
} else if annotation == "attr" {
var omitEmpty bool
var omitEmpty, iso8601 bool

if len(args) > 2 {
omitEmpty = args[2] == "omitempty"
for _, arg := range args[2:] {
switch arg {
case "omitempty":
omitEmpty = true
case "iso8601":
iso8601 = true
}
}
}

if node.Attributes == nil {
Expand All @@ -253,7 +262,11 @@ func visitModelNode(model interface{}, included *map[string]*Node, sideload bool
continue
}

node.Attributes[args[1]] = t.Unix()
if iso8601 {
node.Attributes[args[1]] = t.UTC().Format(iso8601TimeFormat)
} else {
node.Attributes[args[1]] = t.Unix()
}
} else if fieldValue.Type() == reflect.TypeOf(new(time.Time)) {
// A time pointer may be nil
if fieldValue.IsNil() {
Expand All @@ -269,7 +282,11 @@ func visitModelNode(model interface{}, included *map[string]*Node, sideload bool
continue
}

node.Attributes[args[1]] = tm.Unix()
if iso8601 {
node.Attributes[args[1]] = tm.UTC().Format(iso8601TimeFormat)
} else {
node.Attributes[args[1]] = tm.Unix()
}
}
} else {
// Dealing with a fieldValue that is not a time
Expand Down
61 changes: 61 additions & 0 deletions response_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ type Book struct {
PublishedAt time.Time
}

type Timestamp struct {
ID int `jsonapi:"primary,timestamps"`
Time time.Time `jsonapi:"attr,timestamp,iso8601"`
Next *time.Time `jsonapi:"attr,next,iso8601"`
}

func TestOmitsEmptyAnnotation(t *testing.T) {
book := &Book{
Author: "aren55555",
Expand Down Expand Up @@ -168,6 +174,61 @@ 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 := MarshalOnePayload(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 := MarshalOnePayload(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 TestRelations(t *testing.T) {
testModel := testBlog()

Expand Down

0 comments on commit b6c6609

Please sign in to comment.