Add support for iso8601 struct tag argument (#47)

* 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.
This commit is contained in:
Geoff Garside 2016-09-22 21:58:07 +01:00 committed by Sam Woodard
parent a1fa2c84a0
commit b6c6609ff2
5 changed files with 215 additions and 5 deletions

7
doc.go
View File

@ -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 the second must be the name that should appear in the "type" field for all data
objects that represent this type of model. 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 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 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 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>" 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. Relations are struct fields that represent a one-to-one or one-to-many to other structs.

View File

@ -20,6 +20,9 @@ var (
// ErrInvalidTime is returned when a struct has a time.Time type field, but // ErrInvalidTime is returned when a struct has a time.Time type field, but
// the JSON value was not a unix timestamp integer. // the JSON value was not a unix timestamp integer.
ErrInvalidTime = errors.New("Only numbers can be parsed as dates, unix timestamps") 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 // 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, // (numeric) but the Struct field was a non numeric type (i.e. not int, uint,
// float, etc) // float, etc)
@ -193,6 +196,16 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node)
continue continue
} }
var iso8601 bool
if len(args) > 2 {
for _, arg := range args[2:] {
if arg == "iso8601" {
iso8601 = true
}
}
}
val := attributes[args[1]] val := attributes[args[1]]
// continue if the attribute was not included in the request // continue if the attribute was not included in the request
@ -204,6 +217,26 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node)
// Handle field of type time.Time // Handle field of type time.Time
if fieldValue.Type() == reflect.TypeOf(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 var at int64
if v.Kind() == reflect.Float64 { if v.Kind() == reflect.Float64 {
@ -234,6 +267,28 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node)
} }
if fieldValue.Type() == reflect.TypeOf(new(time.Time)) { 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 var at int64
if v.Kind() == reflect.Float64 { if v.Kind() == reflect.Float64 {

View File

@ -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) { func TestUnmarshalRelationshipsWithoutIncluded(t *testing.T) {
data, _ := samplePayloadWithoutIncluded() data, _ := samplePayloadWithoutIncluded()
in := bytes.NewReader(data) in := bytes.NewReader(data)

View File

@ -24,6 +24,8 @@ var (
ErrExpectedSlice = errors.New("models should be a slice of struct pointers") 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 // MarshalOnePayload writes a jsonapi response with one, with related records
// sideloaded, into "included" array. This method encodes a response for a // 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 // single record only. Hence, data will be a single record rather than an array
@ -236,10 +238,17 @@ func visitModelNode(model interface{}, included *map[string]*Node, sideload bool
node.ClientID = clientID node.ClientID = clientID
} }
} else if annotation == "attr" { } else if annotation == "attr" {
var omitEmpty bool var omitEmpty, iso8601 bool
if len(args) > 2 { 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 { if node.Attributes == nil {
@ -253,7 +262,11 @@ func visitModelNode(model interface{}, included *map[string]*Node, sideload bool
continue continue
} }
if iso8601 {
node.Attributes[args[1]] = t.UTC().Format(iso8601TimeFormat)
} else {
node.Attributes[args[1]] = t.Unix() node.Attributes[args[1]] = t.Unix()
}
} else if fieldValue.Type() == reflect.TypeOf(new(time.Time)) { } else if fieldValue.Type() == reflect.TypeOf(new(time.Time)) {
// A time pointer may be nil // A time pointer may be nil
if fieldValue.IsNil() { if fieldValue.IsNil() {
@ -269,8 +282,12 @@ func visitModelNode(model interface{}, included *map[string]*Node, sideload bool
continue continue
} }
if iso8601 {
node.Attributes[args[1]] = tm.UTC().Format(iso8601TimeFormat)
} else {
node.Attributes[args[1]] = tm.Unix() node.Attributes[args[1]] = tm.Unix()
} }
}
} else { } else {
// Dealing with a fieldValue that is not a time // Dealing with a fieldValue that is not a time
emptyValue := reflect.Zero(fieldValue.Type()) emptyValue := reflect.Zero(fieldValue.Type())

View File

@ -47,6 +47,12 @@ type Book struct {
PublishedAt time.Time 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) { func TestOmitsEmptyAnnotation(t *testing.T) {
book := &Book{ book := &Book{
Author: "aren55555", Author: "aren55555",
@ -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) { func TestRelations(t *testing.T) {
testModel := testBlog() testModel := testBlog()