Skimata's null relationships + fixes (#62)

* Add support to nullify relationship; http://jsonapi.org/format/#document-resource-object-linkage

* Fixed: [null] is not valid as an empty relationship.

* add support for 'omitempty' on relationships; default behavior of marshalling empty/nil relations (i.e. w/o 'omitempty' tag) marshals with null data relation

* cleanup whitespace

* Added go 1.7 to test versions; fixed the marshaling of empty relations to return an empty array rather than a null/nil. Added a more robust test case for the marshaling of non omitted relations.

* Cleanup.

* Added a comment to UnmarshalMany

* Document the ‘omitempty’ annotation on a relation.

* Add common JSON API values as exported jsonapi pkg constants.
This commit is contained in:
Aren Patel 2017-01-20 16:13:04 -08:00 committed by Sam Woodard
parent bb88592460
commit 2cb19b89ed
8 changed files with 329 additions and 63 deletions

View File

@ -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 .

View File

@ -166,14 +166,16 @@ field when `count` has a value of `0`). Lastly, the spec indicates that
#### `relation`
```
`jsonapi:"relation,<key name in relationships hash>"`
`jsonapi:"relation,<key name in relationships hash>,<optional: omitempty>"`
```
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

55
constants.go Normal file
View File

@ -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]"
)

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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

View File

@ -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"`