Feature/marshal many interface (#42)

* add support for direct use of slice of struct pointers for MarshalMany

* Added a test to ensure that whether MarshalManyPayload is called with a []interface{} or []*Struct it will produce the same desired result.

* Added a test to show how the new error type may be returned.

* Ensure the outside accessible Errs have comments.

* Page char width cleanup. Addressed warnings about duplicated "client-id" definition; using the const.
This commit is contained in:
Aren Patel 2016-09-12 22:12:42 -07:00 committed by GitHub
parent a70d58d3c8
commit a1fa2c84a0
5 changed files with 161 additions and 36 deletions

10
node.go
View File

@ -1,17 +1,24 @@
package jsonapi 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 { type OnePayload struct {
Data *Node `json:"data"` Data *Node `json:"data"`
Included []*Node `json:"included,omitempty"` Included []*Node `json:"included,omitempty"`
Links *map[string]string `json:"links,omitempty"` Links *map[string]string `json:"links,omitempty"`
} }
// ManyPayload is used to represent a generic JSON API payload where many
// resources (Nodes) were included in an [] in the "data" key
type ManyPayload struct { type ManyPayload struct {
Data []*Node `json:"data"` Data []*Node `json:"data"`
Included []*Node `json:"included,omitempty"` Included []*Node `json:"included,omitempty"`
Links *map[string]string `json:"links,omitempty"` Links *map[string]string `json:"links,omitempty"`
} }
// Node is used to represent a generic JSON API Resource
type Node struct { type Node struct {
Type string `json:"type"` Type string `json:"type"`
ID string `json:"id"` ID string `json:"id"`
@ -20,11 +27,14 @@ type Node struct {
Relationships map[string]interface{} `json:"relationships,omitempty"` Relationships map[string]interface{} `json:"relationships,omitempty"`
} }
// RelationshipOneNode is used to represent a generic has one JSON API relation
type RelationshipOneNode struct { type RelationshipOneNode struct {
Data *Node `json:"data"` Data *Node `json:"data"`
Links *map[string]string `json:"links,omitempty"` Links *map[string]string `json:"links,omitempty"`
} }
// RelationshipManyNode is used to represent a generic has many JSON API
// relation
type RelationshipManyNode struct { type RelationshipManyNode struct {
Data []*Node `json:"data"` Data []*Node `json:"data"`
Links *map[string]string `json:"links,omitempty"` Links *map[string]string `json:"links,omitempty"`

View File

@ -14,7 +14,6 @@ import (
const ( const (
unsuportedStructTagMsg = "Unsupported jsonapi tag annotation, %s" unsuportedStructTagMsg = "Unsupported jsonapi tag annotation, %s"
clientIDAnnotation = "client-id"
) )
var ( var (

View File

@ -12,13 +12,22 @@ import (
) )
var ( var (
// ErrBadJSONAPIStructTag is returned when the Struct field's JSON API
// annotation is invalid.
ErrBadJSONAPIStructTag = errors.New("Bad jsonapi struct tag format") ErrBadJSONAPIStructTag = errors.New("Bad jsonapi struct tag format")
ErrBadJSONAPIID = errors.New("id should be either string or int") // ErrBadJSONAPIID is returned when the Struct JSON API annotated "id" field
// was not a valid numeric type.
ErrBadJSONAPIID = errors.New("id should be either string, int or uint")
// ErrExpectedSlice is returned when a variable or arugment was expected to
// be a slice of *Structs; MarshalMany will return this error when its
// interface{} argument is invalid.
ErrExpectedSlice = errors.New("models should be a slice of struct pointers")
) )
// MarshalOnePayload writes a jsonapi response with one, with related records sideloaded, into "included" array. // MarshalOnePayload writes a jsonapi response with one, with related records
// This method encodes a response for a single record only. Hence, data will be a single record rather // sideloaded, into "included" array. This method encodes a response for a
// than an array of records. If you want to serialize many records, see, MarshalManyPayload. // single record only. Hence, data will be a single record rather than an array
// of records. If you want to serialize many records, see, MarshalManyPayload.
// //
// See UnmarshalPayload for usage example. // See UnmarshalPayload for usage example.
// //
@ -56,9 +65,9 @@ func MarshalOnePayloadWithoutIncluded(w io.Writer, model interface{}) error {
return nil return nil
} }
// MarshalOne does the same as MarshalOnePayload except it just returns the payload // MarshalOne does the same as MarshalOnePayload except it just returns the
// and doesn't write out results. // payload and doesn't write out results. Useful is you use your JSON rendering
// Useful is you use your JSON rendering library. // library.
func MarshalOne(model interface{}) (*OnePayload, error) { func MarshalOne(model interface{}) (*OnePayload, error) {
included := make(map[string]*Node) included := make(map[string]*Node)
@ -73,12 +82,14 @@ func MarshalOne(model interface{}) (*OnePayload, error) {
return payload, nil return payload, nil
} }
// MarshalManyPayload writes a jsonapi response with many records, with related records sideloaded, into "included" array. // MarshalManyPayload writes a jsonapi response with many records, with related
// This method encodes a response for a slice of records, hence data will be an array of // records sideloaded, into "included" array. This method encodes a response for
// records rather than a single record. To serialize a single record, see MarshalOnePayload // a slice of records, hence data will be an array of records rather than a
// single record. To serialize a single record, see MarshalOnePayload
// //
// For example you could pass it, w, your http.ResponseWriter, and, models, a slice of Blog // For example you could pass it, w, your http.ResponseWriter, and, models, a
// struct instance pointers as interface{}'s to write to the response, // slice of Blog struct instance pointers as interface{}'s to write to the
// response,
// //
// func ListBlogs(w http.ResponseWriter, r *http.Request) { // func ListBlogs(w http.ResponseWriter, r *http.Request) {
// // ... fetch your blogs and filter, offset, limit, etc ... // // ... fetch your blogs and filter, offset, limit, etc ...
@ -95,9 +106,13 @@ func MarshalOne(model interface{}) (*OnePayload, error) {
// //
// Visit https://github.com/google/jsonapi#list for more info. // Visit https://github.com/google/jsonapi#list for more info.
// //
// models []interface{} should be a slice of struct pointers. // models interface{} should be a slice of struct pointers.
func MarshalManyPayload(w io.Writer, models []interface{}) error { func MarshalManyPayload(w io.Writer, models interface{}) error {
payload, err := MarshalMany(models) m, err := convertToSliceInterface(&models)
if err != nil {
return err
}
payload, err := MarshalMany(m)
if err != nil { if err != nil {
return err return err
} }
@ -109,9 +124,9 @@ func MarshalManyPayload(w io.Writer, models []interface{}) error {
return nil return nil
} }
// MarshalMany does the same as MarshalManyPayload except it just returns the payload // MarshalMany does the same as MarshalManyPayload except it just returns the
// and doesn't write out results. // payload and doesn't write out results. Useful is you use your JSON rendering
// Useful is you use your JSON rendering library. // library.
func MarshalMany(models []interface{}) (*ManyPayload, error) { func MarshalMany(models []interface{}) (*ManyPayload, error) {
var data []*Node var data []*Node
included := make(map[string]*Node) included := make(map[string]*Node)
@ -138,16 +153,17 @@ func MarshalMany(models []interface{}) (*ManyPayload, error) {
return payload, nil return payload, nil
} }
// MarshalOnePayloadEmbedded - This method not meant to for use in implementation code, although feel // MarshalOnePayloadEmbedded - This method not meant to for use in
// free. The purpose of this method is for use in tests. In most cases, your // implementation code, although feel free. The purpose of this method is for
// request payloads for create will be embedded rather than sideloaded for related records. // use in tests. In most cases, your request payloads for create will be
// This method will serialize a single struct pointer into an embedded json // embedded rather than sideloaded for related records. This method will
// response. In other words, there will be no, "included", array in the json // serialize a single struct pointer into an embedded json response. In other
// all relationships will be serailized inline in the data. // words, there will be no, "included", array in the json all relationships will
// be serailized inline in the data.
// //
// However, in tests, you may want to construct payloads to post to create methods // However, in tests, you may want to construct payloads to post to create
// that are embedded to most closely resemble the payloads that will be produced by // methods that are embedded to most closely resemble the payloads that will be
// the client. This is what this method is intended for. // produced by the client. This is what this method is intended for.
// //
// model interface{} should be a pointer to a struct. // model interface{} should be a pointer to a struct.
func MarshalOnePayloadEmbedded(w io.Writer, model interface{}) error { func MarshalOnePayloadEmbedded(w io.Writer, model interface{}) error {
@ -190,7 +206,8 @@ func visitModelNode(model interface{}, included *map[string]*Node, sideload bool
annotation := args[0] annotation := args[0]
if (annotation == "client-id" && len(args) != 1) || (annotation != "client-id" && len(args) < 2) { if (annotation == clientIDAnnotation && len(args) != 1) ||
(annotation != clientIDAnnotation && len(args) < 2) {
er = ErrBadJSONAPIStructTag er = ErrBadJSONAPIStructTag
break break
} }
@ -213,7 +230,7 @@ func visitModelNode(model interface{}, included *map[string]*Node, sideload bool
} }
node.Type = args[1] node.Type = args[1]
} else if annotation == "client-id" { } else if annotation == clientIDAnnotation {
clientID := fieldValue.String() clientID := fieldValue.String()
if clientID != "" { if clientID != "" {
node.ClientID = clientID node.ClientID = clientID
@ -282,7 +299,12 @@ func visitModelNode(model interface{}, included *map[string]*Node, sideload bool
} }
if isSlice { if isSlice {
relationship, err := visitModelNodeRelationships(args[1], fieldValue, included, sideload) relationship, err := visitModelNodeRelationships(
args[1],
fieldValue,
included,
sideload,
)
if err == nil { if err == nil {
d := relationship.Data d := relationship.Data
@ -294,7 +316,9 @@ func visitModelNode(model interface{}, included *map[string]*Node, sideload bool
shallowNodes = append(shallowNodes, toShallowNode(n)) shallowNodes = append(shallowNodes, toShallowNode(n))
} }
node.Relationships[args[1]] = &RelationshipManyNode{Data: shallowNodes} node.Relationships[args[1]] = &RelationshipManyNode{
Data: shallowNodes,
}
} else { } else {
node.Relationships[args[1]] = relationship node.Relationships[args[1]] = relationship
} }
@ -303,13 +327,20 @@ func visitModelNode(model interface{}, included *map[string]*Node, sideload bool
break break
} }
} else { } else {
relationship, err := visitModelNode(fieldValue.Interface(), included, sideload) relationship, err := visitModelNode(
fieldValue.Interface(),
included,
sideload)
if err == nil { if err == nil {
if sideload { if sideload {
appendIncluded(included, relationship) appendIncluded(included, relationship)
node.Relationships[args[1]] = &RelationshipOneNode{Data: toShallowNode(relationship)} node.Relationships[args[1]] = &RelationshipOneNode{
Data: toShallowNode(relationship),
}
} else { } else {
node.Relationships[args[1]] = &RelationshipOneNode{Data: relationship} node.Relationships[args[1]] = &RelationshipOneNode{
Data: relationship,
}
} }
} else { } else {
er = err er = err
@ -383,3 +414,15 @@ func nodeMapValues(m *map[string]*Node) []*Node {
return nodes return nodes
} }
func convertToSliceInterface(i *interface{}) ([]interface{}, error) {
vals := reflect.ValueOf(*i)
if vals.Kind() != reflect.Slice {
return nil, ErrExpectedSlice
}
var response []interface{}
for x := 0; x < vals.Len(); x++ {
response = append(response, vals.Index(x).Interface())
}
return response, nil
}

View File

@ -3,6 +3,7 @@ package jsonapi
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"reflect"
"testing" "testing"
"time" "time"
) )
@ -320,6 +321,78 @@ func TestMarshalMany(t *testing.T) {
} }
} }
func TestMarshalMany_WithSliceOfStructPointers(t *testing.T) {
var data []*Blog
for len(data) < 2 {
data = append(data, testBlog())
}
out := bytes.NewBuffer(nil)
if err := MarshalManyPayload(out, data); err != nil {
t.Fatal(err)
}
resp := new(ManyPayload)
if err := json.NewDecoder(out).Decode(resp); err != nil {
t.Fatal(err)
}
d := resp.Data
if len(d) != 2 {
t.Fatalf("data should have two elements")
}
}
func TestMarshalMany_SliceOfInterfaceAndSliceOfStructsSameJSON(t *testing.T) {
structs := []*Book{
&Book{ID: 1, Author: "aren55555", ISBN: "abc"},
&Book{ID: 2, Author: "shwoodard", ISBN: "xyz"},
}
interfaces := []interface{}{}
for _, s := range structs {
interfaces = append(interfaces, s)
}
// Perform Marshals
structsOut := new(bytes.Buffer)
if err := MarshalManyPayload(structsOut, structs); err != nil {
t.Fatal(err)
}
interfacesOut := new(bytes.Buffer)
if err := MarshalManyPayload(interfacesOut, interfaces); err != nil {
t.Fatal(err)
}
// Generic JSON Unmarshal
structsData, interfacesData :=
make(map[string]interface{}), make(map[string]interface{})
if err := json.Unmarshal(structsOut.Bytes(), &structsData); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(interfacesOut.Bytes(), &interfacesData); err != nil {
t.Fatal(err)
}
// Compare Result
if !reflect.DeepEqual(structsData, interfacesData) {
t.Fatal("Was expecting the JSON API generated to be the same")
}
}
func TestMarshalMany_InvalidIntefaceArgument(t *testing.T) {
out := new(bytes.Buffer)
if err := MarshalManyPayload(out, true); err != ErrExpectedSlice {
t.Fatal("Was expecting an error")
}
if err := MarshalManyPayload(out, 25); err != ErrExpectedSlice {
t.Fatal("Was expecting an error")
}
if err := MarshalManyPayload(out, Book{}); err != ErrExpectedSlice {
t.Fatal("Was expecting an error")
}
}
func testBlog() *Blog { func testBlog() *Blog {
return &Blog{ return &Blog{
ID: 5, ID: 5,

View File

@ -66,7 +66,7 @@ func (r *Runtime) MarshalOnePayload(w io.Writer, model interface{}) error {
}) })
} }
func (r *Runtime) MarshalManyPayload(w io.Writer, models []interface{}) error { func (r *Runtime) MarshalManyPayload(w io.Writer, models interface{}) error {
return r.instrumentCall(MarshalStart, MarshalStop, func() error { return r.instrumentCall(MarshalStart, MarshalStop, func() error {
return MarshalManyPayload(w, models) return MarshalManyPayload(w, models)
}) })