diff --git a/.travis.yml b/.travis.yml index ab1c021..bef5ab9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,8 @@ language: go go: - - 1.4.3 - - 1.5.3 + - 1.4 + - 1.5 - 1.6 + - 1.7 - tip script: go test -v . diff --git a/README.md b/README.md index 25dd7d1..bcd7f9f 100644 --- a/README.md +++ b/README.md @@ -166,14 +166,16 @@ field when `count` has a value of `0`). Lastly, the spec indicates that #### `relation` ``` -`jsonapi:"relation,"` +`jsonapi:"relation,,"` ``` Relations are struct fields that represent a one-to-one or one-to-many relationship with other structs. JSON API will traverse the graph of relationships and marshal or unmarshal records. The first argument must be, `relation`, and the second should be the name of the relationship, -used as the key in the `relationships` hash for the record. +used as the key in the `relationships` hash for the record. The optional +third argument is `omitempty` - if present will prevent non existent to-one and +to-many from being serialized. ## Methods Reference diff --git a/constants.go b/constants.go new file mode 100644 index 0000000..23288d3 --- /dev/null +++ b/constants.go @@ -0,0 +1,55 @@ +package jsonapi + +const ( + // StructTag annotation strings + annotationJSONAPI = "jsonapi" + annotationPrimary = "primary" + annotationClientID = "client-id" + annotationAttribute = "attr" + annotationRelation = "relation" + annotationOmitEmpty = "omitempty" + annotationISO8601 = "iso8601" + annotationSeperator = "," + + iso8601TimeFormat = "2006-01-02T15:04:05Z" + + // MediaType is the identifier for the JSON API media type + // + // see http://jsonapi.org/format/#document-structure + MediaType = "application/vnd.api+json" + + // Pagination Constants + // + // http://jsonapi.org/format/#fetching-pagination + + // KeyFirstPage is the key to the links object whose value contains a link to + // the first page of data + KeyFirstPage = "first" + // KeyLastPage is the key to the links object whose value contains a link to + // the last page of data + KeyLastPage = "last" + // KeyPreviousPage is the key to the links object whose value contains a link + // to the previous page of data + KeyPreviousPage = "prev" + // KeyNextPage is the key to the links object whose value contains a link to + // the next page of data + KeyNextPage = "next" + + // QueryParamPageNumber is a JSON API query parameter used in a page based + // pagination strategy in conjunction with QueryParamPageSize + QueryParamPageNumber = "page[number]" + // QueryParamPageSize is a JSON API query parameter used in a page based + // pagination strategy in conjunction with QueryParamPageNumber + QueryParamPageSize = "page[size]" + + // QueryParamPageOffset is a JSON API query parameter used in an offset based + // pagination strategy in conjunction with QueryParamPageLimit + QueryParamPageOffset = "page[offset]" + // QueryParamPageLimit is a JSON API query parameter used in an offset based + // pagination strategy in conjunction with QueryParamPageOffset + QueryParamPageLimit = "page[limit]" + + // QueryParamPageCursor is a JSON API query parameter used with a cursor-based + // strategy + QueryParamPageCursor = "page[cursor]" +) diff --git a/node.go b/node.go index ef8fbaf..f6b960f 100644 --- a/node.go +++ b/node.go @@ -1,7 +1,5 @@ package jsonapi -const clientIDAnnotation = "client-id" - // OnePayload is used to represent a generic JSON API payload where a single // resource (Node) was included as an {} in the "data" key type OnePayload struct { diff --git a/request.go b/request.go index 0cdc855..7d23739 100644 --- a/request.go +++ b/request.go @@ -84,6 +84,8 @@ func UnmarshalPayload(in io.Reader, model interface{}) error { return unmarshalNode(payload.Data, reflect.ValueOf(model), nil) } +// UnmarshalManyPayload converts an io into a set of struct instances using +// jsonapi tags on the type's struct fields. func UnmarshalManyPayload(in io.Reader, t reflect.Type) ([]interface{}, error) { payload := new(ManyPayload) @@ -155,8 +157,8 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) annotation := args[0] - if (annotation == clientIDAnnotation && len(args) != 1) || - (annotation != clientIDAnnotation && len(args) < 2) { + if (annotation == annotationClientID && len(args) != 1) || + (annotation != annotationClientID && len(args) < 2) { er = ErrBadJSONAPIStructTag break } @@ -244,7 +246,7 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) } assign(fieldValue, idValue) - } else if annotation == clientIDAnnotation { + } else if annotation == annotationClientID { if data.ClientID == "" { continue } @@ -472,6 +474,7 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) } if isSlice { + // to-many relationship relationship := new(RelationshipManyNode) buf := bytes.NewBuffer(nil) @@ -499,6 +502,7 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) fieldValue.Set(models) } else { + // to-one relationships relationship := new(RelationshipOneNode) buf := bytes.NewBuffer(nil) @@ -508,8 +512,17 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) ) json.NewDecoder(buf).Decode(relationship) - m := reflect.New(fieldValue.Type().Elem()) + /* + http://jsonapi.org/format/#document-resource-object-relationships + http://jsonapi.org/format/#document-resource-object-linkage + relationship can have a data node set to null (e.g. to disassociate the relationship) + so unmarshal and set fieldValue only if data obj is not null + */ + if relationship.Data == nil { + continue + } + m := reflect.New(fieldValue.Type().Elem()) if err := unmarshalNode( fullNode(relationship.Data, included), m, @@ -520,6 +533,7 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) } fieldValue.Set(m) + } } else { diff --git a/request_test.go b/request_test.go index 45430a5..eb389e3 100644 --- a/request_test.go +++ b/request_test.go @@ -301,6 +301,72 @@ func TestUnmarshalRelationships(t *testing.T) { } } +func TestUnmarshalNullRelationship(t *testing.T) { + sample := map[string]interface{}{ + "data": map[string]interface{}{ + "type": "posts", + "id": "1", + "attributes": map[string]interface{}{ + "body": "Hello", + "title": "World", + }, + "relationships": map[string]interface{}{ + "latest_comment": map[string]interface{}{ + "data": nil, // empty to-one relationship + }, + }, + }, + } + data, err := json.Marshal(sample) + if err != nil { + t.Fatal(err) + } + + in := bytes.NewReader(data) + out := new(Post) + + if err := UnmarshalPayload(in, out); err != nil { + t.Fatal(err) + } + + if out.LatestComment != nil { + t.Fatalf("Latest Comment was not set to nil") + } +} + +func TestUnmarshalNullRelationshipInSlice(t *testing.T) { + sample := map[string]interface{}{ + "data": map[string]interface{}{ + "type": "posts", + "id": "1", + "attributes": map[string]interface{}{ + "body": "Hello", + "title": "World", + }, + "relationships": map[string]interface{}{ + "comments": map[string]interface{}{ + "data": []interface{}{}, // empty to-many relationships + }, + }, + }, + } + data, err := json.Marshal(sample) + if err != nil { + t.Fatal(err) + } + + in := bytes.NewReader(data) + out := new(Post) + + if err := UnmarshalPayload(in, out); err != nil { + t.Fatal(err) + } + + if len(out.Comments) != 0 { + t.Fatalf("Wrong number of comments; Comments should be empty") + } +} + func TestUnmarshalNestedRelationships(t *testing.T) { out, err := unmarshalSamplePayload() if err != nil { diff --git a/response.go b/response.go index 2cf7ade..4f4045b 100644 --- a/response.go +++ b/response.go @@ -25,8 +25,6 @@ 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 @@ -176,7 +174,8 @@ func MarshalOnePayloadEmbedded(w io.Writer, model interface{}) error { return nil } -func visitModelNode(model interface{}, included *map[string]*Node, sideload bool) (*Node, error) { +func visitModelNode(model interface{}, included *map[string]*Node, + sideload bool) (*Node, error) { node := new(Node) var er error @@ -186,7 +185,7 @@ func visitModelNode(model interface{}, included *map[string]*Node, sideload bool for i := 0; i < modelValue.NumField(); i++ { structField := modelValue.Type().Field(i) - tag := structField.Tag.Get("jsonapi") + tag := structField.Tag.Get(annotationJSONAPI) if tag == "" { continue } @@ -194,7 +193,7 @@ func visitModelNode(model interface{}, included *map[string]*Node, sideload bool fieldValue := modelValue.Field(i) fieldType := modelType.Field(i) - args := strings.Split(tag, ",") + args := strings.Split(tag, annotationSeperator) if len(args) < 1 { er = ErrBadJSONAPIStructTag @@ -203,13 +202,13 @@ func visitModelNode(model interface{}, included *map[string]*Node, sideload bool annotation := args[0] - if (annotation == clientIDAnnotation && len(args) != 1) || - (annotation != clientIDAnnotation && len(args) < 2) { + if (annotation == annotationClientID && len(args) != 1) || + (annotation != annotationClientID && len(args) < 2) { er = ErrBadJSONAPIStructTag break } - if annotation == "primary" { + if annotation == annotationPrimary { v := fieldValue // Deal with PTRS @@ -253,20 +252,20 @@ func visitModelNode(model interface{}, included *map[string]*Node, sideload bool } node.Type = args[1] - } else if annotation == clientIDAnnotation { + } else if annotation == annotationClientID { clientID := fieldValue.String() if clientID != "" { node.ClientID = clientID } - } else if annotation == "attr" { + } else if annotation == annotationAttribute { var omitEmpty, iso8601 bool if len(args) > 2 { for _, arg := range args[2:] { switch arg { - case "omitempty": + case annotationOmitEmpty: omitEmpty = true - case "iso8601": + case annotationISO8601: iso8601 = true } } @@ -325,10 +324,18 @@ func visitModelNode(model interface{}, included *map[string]*Node, sideload bool node.Attributes[args[1]] = fieldValue.Interface() } } - } else if annotation == "relation" { - isSlice := fieldValue.Type().Kind() == reflect.Slice + } else if annotation == annotationRelation { + var omitEmpty bool - if (isSlice && fieldValue.Len() < 1) || (!isSlice && fieldValue.IsNil()) { + //add support for 'omitempty' struct tag for marshaling as absent + if len(args) > 2 { + omitEmpty = args[2] == annotationOmitEmpty + } + + isSlice := fieldValue.Type().Kind() == reflect.Slice + if omitEmpty && + (isSlice && fieldValue.Len() < 1 || + (!isSlice && fieldValue.IsNil())) { continue } @@ -337,53 +344,58 @@ func visitModelNode(model interface{}, included *map[string]*Node, sideload bool } if isSlice { + // to-many relationship relationship, err := visitModelNodeRelationships( args[1], fieldValue, included, sideload, ) - - if err == nil { - d := relationship.Data - if sideload { - var shallowNodes []*Node - - for _, n := range d { - appendIncluded(included, n) - shallowNodes = append(shallowNodes, toShallowNode(n)) - } - - node.Relationships[args[1]] = &RelationshipManyNode{ - Data: shallowNodes, - } - } else { - node.Relationships[args[1]] = relationship - } - } else { + if err != nil { er = err break } + + if sideload { + shallowNodes := []*Node{} + for _, n := range relationship.Data { + appendIncluded(included, n) + shallowNodes = append(shallowNodes, toShallowNode(n)) + } + + node.Relationships[args[1]] = &RelationshipManyNode{ + Data: shallowNodes, + } + } else { + node.Relationships[args[1]] = relationship + } } else { + // to-one relationships + + // Handle null relationship case + if fieldValue.IsNil() { + node.Relationships[args[1]] = &RelationshipOneNode{Data: nil} + continue + } + relationship, err := visitModelNode( fieldValue.Interface(), included, - sideload) - if err == nil { - if sideload { - appendIncluded(included, relationship) - node.Relationships[args[1]] = &RelationshipOneNode{ - Data: toShallowNode(relationship), - } - } else { - node.Relationships[args[1]] = &RelationshipOneNode{ - Data: relationship, - } - } - } else { + sideload, + ) + if err != nil { er = err break } + + if sideload { + appendIncluded(included, relationship) + node.Relationships[args[1]] = &RelationshipOneNode{ + Data: toShallowNode(relationship), + } + } else { + node.Relationships[args[1]] = &RelationshipOneNode{Data: relationship} + } } } else { @@ -395,7 +407,6 @@ func visitModelNode(model interface{}, included *map[string]*Node, sideload bool if er != nil { return nil, er } - return node, nil } @@ -406,15 +417,13 @@ func toShallowNode(node *Node) *Node { } } -func visitModelNodeRelationships(relationName string, models reflect.Value, included *map[string]*Node, sideload bool) (*RelationshipManyNode, error) { - var nodes []*Node - - if models.Len() == 0 { - nodes = make([]*Node, 0) - } +func visitModelNodeRelationships(relationName string, models reflect.Value, + included *map[string]*Node, sideload bool) (*RelationshipManyNode, error) { + nodes := []*Node{} for i := 0; i < models.Len(); i++ { n := models.Index(i).Interface() + node, err := visitModelNode(n, included, sideload) if err != nil { return nil, err diff --git a/response_test.go b/response_test.go index cc0f982..bc48983 100644 --- a/response_test.go +++ b/response_test.go @@ -47,6 +47,127 @@ type Book struct { PublishedAt time.Time } +func TestWithoutOmitsEmptyAnnotationOnRelation(t *testing.T) { + blog := &Blog{} + + out := bytes.NewBuffer(nil) + if err := MarshalOnePayload(out, blog); err != nil { + t.Fatal(err) + } + + var jsonData map[string]interface{} + if err := json.Unmarshal(out.Bytes(), &jsonData); err != nil { + t.Fatal(err) + } + relationships := jsonData["data"].(map[string]interface{})["relationships"].(map[string]interface{}) + + // Verifiy the "posts" relation was an empty array + posts, ok := relationships["posts"] + if !ok { + t.Fatal("Was expecting the data.relationships.posts key/value to have been present") + } + postsMap, ok := posts.(map[string]interface{}) + if !ok { + t.Fatal("data.relationships.posts was not a map") + } + postsData, ok := postsMap["data"] + if !ok { + t.Fatal("Was expecting the data.relationships.posts.data key/value to have been present") + } + postsDataSlice, ok := postsData.([]interface{}) + if !ok { + t.Fatal("data.relationships.posts.data was not a slice []") + } + if len(postsDataSlice) != 0 { + t.Fatal("Was expecting the data.relationships.posts.data value to have been an empty array []") + } + + // Verifiy the "current_post" was a null + currentPost, postExists := relationships["current_post"] + if !postExists { + t.Fatal("Was expecting the data.relationships.current_post key/value to have NOT been omitted") + } + currentPostMap, ok := currentPost.(map[string]interface{}) + if !ok { + t.Fatal("data.relationships.current_post was not a map") + } + currentPostData, ok := currentPostMap["data"] + if !ok { + t.Fatal("Was expecting the data.relationships.current_post.data key/value to have been present") + } + if currentPostData != nil { + t.Fatal("Was expecting the data.relationships.current_post.data value to have been nil/null") + } +} + +func TestWithOmitsEmptyAnnotationOnRelation(t *testing.T) { + type BlogOptionalPosts struct { + ID int `jsonapi:"primary,blogs"` + Title string `jsonapi:"attr,title"` + Posts []*Post `jsonapi:"relation,posts,omitempty"` + CurrentPost *Post `jsonapi:"relation,current_post,omitempty"` + } + + blog := &BlogOptionalPosts{ID: 999} + + out := bytes.NewBuffer(nil) + if err := MarshalOnePayload(out, blog); err != nil { + t.Fatal(err) + } + + var jsonData map[string]interface{} + if err := json.Unmarshal(out.Bytes(), &jsonData); err != nil { + t.Fatal(err) + } + payload := jsonData["data"].(map[string]interface{}) + + // Verify relationship was NOT set + if val, exists := payload["relationships"]; exists { + t.Fatalf("Was expecting the data.relationships key/value to have been empty - it was not and had a value of %v", val) + } +} + +func TestWithOmitsEmptyAnnotationOnRelation_MixedData(t *testing.T) { + type BlogOptionalPosts struct { + ID int `jsonapi:"primary,blogs"` + Title string `jsonapi:"attr,title"` + Posts []*Post `jsonapi:"relation,posts,omitempty"` + CurrentPost *Post `jsonapi:"relation,current_post,omitempty"` + } + + blog := &BlogOptionalPosts{ + ID: 999, + CurrentPost: &Post{ + ID: 123, + }, + } + + out := bytes.NewBuffer(nil) + if err := MarshalOnePayload(out, blog); err != nil { + t.Fatal(err) + } + + var jsonData map[string]interface{} + if err := json.Unmarshal(out.Bytes(), &jsonData); err != nil { + t.Fatal(err) + } + payload := jsonData["data"].(map[string]interface{}) + + // Verify relationship was set + if _, exists := payload["relationships"]; !exists { + t.Fatal("Was expecting the data.relationships key/value to have NOT been empty") + } + + relationships := payload["relationships"].(map[string]interface{}) + + // Verify the relationship was not omitted, and is not null + if val, exists := relationships["current_post"]; !exists { + t.Fatal("Was expecting the data.relationships.current_post key/value to have NOT been omitted") + } else if val.(map[string]interface{})["data"] == nil { + t.Fatal("Was expecting the data.relationships.current_post value to have NOT been nil/null") + } +} + type Timestamp struct { ID int `jsonapi:"primary,timestamps"` Time time.Time `jsonapi:"attr,timestamp,iso8601"`