forked from Mirrors/jsonapi
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:
parent
a1fa2c84a0
commit
b6c6609ff2
7
doc.go
7
doc.go
|
@ -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.
|
||||||
|
|
55
request.go
55
request.go
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
21
response.go
21
response.go
|
@ -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())
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue