diff --git a/README.md b/README.md index 99327e61..7b86d7da 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ all of your data easily. ## Example App -[examples/app.go](https://github.com/google/jsonapi/blob/master/examples/app.go) +[examples/app.go](https://github.com/hashicorp/jsonapi/blob/main/examples/app.go) This program demonstrates the implementation of a create, a show, and a list [http.Handler](http://golang.org/pkg/net/http#Handler). It @@ -179,6 +179,60 @@ 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. + +#### `polyrelation` + +``` +`jsonapi:"polyrelation,,"` +``` + +Polymorphic relations can be represented exactly as relations, except that +an intermediate type is needed within your model struct that provides a choice +for the actual value to be populated within. + +Example: + +```go +type Video struct { + ID int `jsonapi:"primary,videos"` + SourceURL string `jsonapi:"attr,source-url"` + CaptionsURL string `jsonapi:"attr,captions-url"` +} + +type Image struct { + ID int `jsonapi:"primary,images"` + SourceURL string `jsonapi:"attr,src"` + AltText string `jsonapi:"attr,alt"` +} + +type OneOfMedia struct { + Video *Video + Image *Image +} + +type Post struct { + ID int `jsonapi:"primary,posts"` + Title string `jsonapi:"attr,title"` + Body string `jsonapi:"attr,body"` + Gallery []*OneOfMedia `jsonapi:"polyrelation,gallery"` + Hero *OneOfMedia `jsonapi:"polyrelation,hero"` +} +``` + +During decoding, the `polyrelation` annotation instructs jsonapi to assign each relationship +to either `Video` or `Image` within the value of the associated field, provided that the +payload contains either a "videos" or "images" type. This field value must be +a pointer to a special choice type struct (also known as a tagged union, or sum type) containing +other pointer fields to jsonapi models. The actual field assignment depends on that type having +a jsonapi "primary" annotation with a type matching the relationship type found in the response. +All other fields will be remain empty. If no matching types are represented by the choice type, +all fields will be empty. + +During encoding, the very first non-nil field will be used to populate the payload. Others +will be ignored. Therefore, it's critical to set the value of only one field within the choice +struct. When accepting input values on this type of choice type, it would a good idea to enforce +and check that the value is set on only one field. + #### `links` *Note: This annotation is an added feature independent of the canonical google/jsonapi package* @@ -471,13 +525,13 @@ I use git subtrees to manage dependencies rather than `go get` so that the src is committed to my repo. ``` -git subtree add --squash --prefix=src/github.com/google/jsonapi https://github.com/google/jsonapi.git master +git subtree add --squash --prefix=src/github.com/hashicorp/jsonapi https://github.com/hashicorp/jsonapi.git main ``` To update, ``` -git subtree pull --squash --prefix=src/github.com/google/jsonapi https://github.com/google/jsonapi.git master +git subtree pull --squash --prefix=src/github.com/hashicorp/jsonapi https://github.com/hashicorp/jsonapi.git main ``` This assumes that I have my repo structured with a `src` dir containing diff --git a/constants.go b/constants.go index 7e443f47..591d1caf 100644 --- a/constants.go +++ b/constants.go @@ -2,16 +2,17 @@ package jsonapi const ( // StructTag annotation strings - annotationJSONAPI = "jsonapi" - annotationPrimary = "primary" - annotationClientID = "client-id" - annotationAttribute = "attr" - annotationRelation = "relation" - annotationLinks = "links" - annotationOmitEmpty = "omitempty" - annotationISO8601 = "iso8601" - annotationRFC3339 = "rfc3339" - annotationSeperator = "," + annotationJSONAPI = "jsonapi" + annotationPrimary = "primary" + annotationClientID = "client-id" + annotationAttribute = "attr" + annotationRelation = "relation" + annotationPolyRelation = "polyrelation" + annotationLinks = "links" + annotationOmitEmpty = "omitempty" + annotationISO8601 = "iso8601" + annotationRFC3339 = "rfc3339" + annotationSeparator = "," iso8601TimeFormat = "2006-01-02T15:04:05Z" diff --git a/examples/app.go b/examples/app.go index 2b29e0d8..e94a1011 100644 --- a/examples/app.go +++ b/examples/app.go @@ -10,7 +10,7 @@ import ( "net/http/httptest" "time" - "github.com/google/jsonapi" + "github.com/hashicorp/jsonapi" ) func main() { diff --git a/examples/handler.go b/examples/handler.go index 77894c79..4500ca89 100644 --- a/examples/handler.go +++ b/examples/handler.go @@ -4,7 +4,7 @@ import ( "net/http" "strconv" - "github.com/google/jsonapi" + "github.com/hashicorp/jsonapi" ) const ( diff --git a/examples/handler_test.go b/examples/handler_test.go index 34c0bc5d..20adc298 100644 --- a/examples/handler_test.go +++ b/examples/handler_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "testing" - "github.com/google/jsonapi" + "github.com/hashicorp/jsonapi" ) func TestExampleHandler_post(t *testing.T) { diff --git a/examples/models.go b/examples/models.go index 080790e7..48423616 100644 --- a/examples/models.go +++ b/examples/models.go @@ -4,7 +4,7 @@ import ( "fmt" "time" - "github.com/google/jsonapi" + "github.com/hashicorp/jsonapi" ) // Blog is a model representing a blog site diff --git a/models_test.go b/models_test.go index 4be23065..889142a5 100644 --- a/models_test.go +++ b/models_test.go @@ -212,3 +212,27 @@ type CustomAttributeTypes struct { Float CustomFloatType `jsonapi:"attr,float"` String CustomStringType `jsonapi:"attr,string"` } + +type Image struct { + ID string `jsonapi:"primary,images"` + Src string `jsonapi:"attr,src"` +} + +type Video struct { + ID string `jsonapi:"primary,videos"` + Captions string `jsonapi:"attr,captions"` +} + +type OneOfMedia struct { + Image *Image + random int + Video *Video + RandomStuff *string +} + +type BlogPostWithPoly struct { + ID string `jsonapi:"primary,blogs"` + Title string `jsonapi:"attr,title"` + Hero *OneOfMedia `jsonapi:"polyrelation,hero-media,omitempty"` + Media []*OneOfMedia `jsonapi:"polyrelation,media,omitempty"` +} diff --git a/request.go b/request.go index de866240..a12c2b13 100644 --- a/request.go +++ b/request.go @@ -32,7 +32,8 @@ var ( ErrUnknownFieldNumberType = errors.New("The struct field was not of a known number type") // ErrInvalidType is returned when the given type is incompatible with the expected type. ErrInvalidType = errors.New("Invalid type provided") // I wish we used punctuation. - + // ErrTypeNotFound is returned when the given type not found on the model. + ErrTypeNotFound = errors.New("no primary type annotation found on model") ) // ErrUnsupportedPtrType is returned when the Struct field was a pointer but @@ -70,24 +71,23 @@ func newErrUnsupportedPtrType(rf reflect.Value, t reflect.Type, structField refl // For example you could pass it, in, req.Body and, model, a BlogPost // struct instance to populate in an http handler, // -// func CreateBlog(w http.ResponseWriter, r *http.Request) { -// blog := new(Blog) -// -// if err := jsonapi.UnmarshalPayload(r.Body, blog); err != nil { -// http.Error(w, err.Error(), 500) -// return -// } +// func CreateBlog(w http.ResponseWriter, r *http.Request) { +// blog := new(Blog) // -// // ...do stuff with your blog... +// if err := jsonapi.UnmarshalPayload(r.Body, blog); err != nil { +// http.Error(w, err.Error(), 500) +// return +// } // -// w.Header().Set("Content-Type", jsonapi.MediaType) -// w.WriteHeader(201) +// // ...do stuff with your blog... // -// if err := jsonapi.MarshalPayload(w, blog); err != nil { -// http.Error(w, err.Error(), 500) -// } -// } +// w.Header().Set("Content-Type", jsonapi.MediaType) +// w.WriteHeader(201) // +// if err := jsonapi.MarshalPayload(w, blog); err != nil { +// http.Error(w, err.Error(), 500) +// } +// } // // Visit https://github.com/google/jsonapi#create for more info. // @@ -142,6 +142,164 @@ func UnmarshalManyPayload(in io.Reader, t reflect.Type) ([]interface{}, error) { return models, nil } +// jsonapiTypeOfModel returns a jsonapi primary type string +// given a struct type that has typical jsonapi struct tags +// +// Example: +// For this type, "posts" is returned. An error is returned if +// no properly-formatted "primary" tag is found for jsonapi +// annotations +// +// type Post struct { +// ID string `jsonapi:"primary,posts"` +// } +func jsonapiTypeOfModel(structModel reflect.Type) (string, error) { + for i := 0; i < structModel.NumField(); i++ { + fieldType := structModel.Field(i) + args, err := getStructTags(fieldType) + + // A jsonapi tag was found, but it was improperly structured + if err != nil { + return "", err + } + + if len(args) < 2 { + continue + } + + if args[0] == annotationPrimary { + return args[1], nil + } + } + + return "", ErrTypeNotFound +} + +// structFieldIndex holds a bit of information about a type found at a struct field index +type structFieldIndex struct { + Type reflect.Type + FieldNum int +} + +// choiceStructMapping reflects on a value that may be a slice +// of choice type structs or a choice type struct. A choice type +// struct is a struct comprised of pointers to other jsonapi models, +// only one of which is populated with a value by the decoder. +// +// The specified type is probed and a map is generated that maps the +// underlying model type (its 'primary' type) to the field number +// within the choice type struct. This data can then be used to correctly +// assign each data relationship node to the correct choice type +// struct field. +// +// For example, if the `choice` type was +// +// type OneOfMedia struct { +// Video *Video +// Image *Image +// } +// +// then the resulting map would be +// +// { +// "videos" => {Video, 0} +// "images" => {Image, 1} +// } +// +// where `"videos"` is the value of the `primary` annotation on the `Video` model +func choiceStructMapping(choice reflect.Type) (result map[string]structFieldIndex) { + result = make(map[string]structFieldIndex) + + for choice.Kind() != reflect.Struct { + choice = choice.Elem() + } + + for i := 0; i < choice.NumField(); i++ { + fieldType := choice.Field(i) + + // Must be a pointer + if fieldType.Type.Kind() != reflect.Ptr { + continue + } + + subtype := fieldType.Type.Elem() + + // Must be a pointer to struct + if subtype.Kind() != reflect.Struct { + continue + } + + if t, err := jsonapiTypeOfModel(subtype); err == nil { + result[t] = structFieldIndex{ + Type: subtype, + FieldNum: i, + } + } + } + + return result +} + +func getStructTags(field reflect.StructField) ([]string, error) { + tag := field.Tag.Get("jsonapi") + if tag == "" { + return []string{}, nil + } + + args := strings.Split(tag, ",") + if len(args) < 1 { + return nil, ErrBadJSONAPIStructTag + } + + annotation := args[0] + + if (annotation == annotationClientID && len(args) != 1) || + (annotation != annotationClientID && len(args) < 2) { + return nil, ErrBadJSONAPIStructTag + } + + return args, nil +} + +// unmarshalNodeMaybeChoice populates a model that may or may not be +// a choice type struct that corresponds to a polyrelation or relation +func unmarshalNodeMaybeChoice(m *reflect.Value, data *Node, annotation string, choiceTypeMapping map[string]structFieldIndex, included *map[string]*Node) error { + // This will hold either the value of the choice type model or the actual + // model, depending on annotation + var actualModel = *m + var choiceElem *structFieldIndex = nil + + if annotation == annotationPolyRelation { + c, ok := choiceTypeMapping[data.Type] + if !ok { + // If there is no valid choice field to assign this type of relation, + // this shouldn't necessarily be an error because a newer version of + // the API could be communicating with an older version of the client + // library, in which case all choice variants would be nil. + return nil + } + choiceElem = &c + actualModel = reflect.New(choiceElem.Type) + } + + if err := unmarshalNode( + fullNode(data, included), + actualModel, + included, + ); err != nil { + return err + } + + if choiceElem != nil { + // actualModel is a pointer to the model type + // m is a pointer to a struct that should hold the actualModel + // at choiceElem.FieldNum + v := m.Elem() + v.Field(choiceElem.FieldNum).Set(actualModel) + } + return nil +} + func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) (err error) { defer func() { if r := recover(); r != nil { @@ -155,27 +313,18 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) var er error for i := 0; i < modelValue.NumField(); i++ { - fieldType := modelType.Field(i) - tag := fieldType.Tag.Get("jsonapi") - if tag == "" { - continue - } - fieldValue := modelValue.Field(i) + fieldType := modelType.Field(i) - args := strings.Split(tag, ",") - if len(args) < 1 { - er = ErrBadJSONAPIStructTag + args, err := getStructTags(fieldType) + if err != nil { + er = err break } - - annotation := args[0] - - if (annotation == annotationClientID && len(args) != 1) || - (annotation != annotationClientID && len(args) < 2) { - er = ErrBadJSONAPIStructTag - break + if len(args) == 0 { + continue } + annotation := args[0] if annotation == annotationPrimary { // Check the JSON API Type @@ -257,16 +406,26 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) } assign(fieldValue, value) - } else if annotation == annotationRelation { + } else if annotation == annotationRelation || annotation == annotationPolyRelation { isSlice := fieldValue.Type().Kind() == reflect.Slice + // No relations of the given name were provided if data.Relationships == nil || data.Relationships[args[1]] == nil { continue } + // If this is a polymorphic relation, each data relationship needs to be assigned + // to it's appropriate choice field and fieldValue should be a choice + // struct type field. + var choiceMapping map[string]structFieldIndex = nil + if annotation == annotationPolyRelation { + choiceMapping = choiceStructMapping(fieldValue.Type()) + } + if isSlice { // to-many relationship relationship := new(RelationshipManyNode) + sliceType := fieldValue.Type() buf := bytes.NewBuffer(nil) @@ -274,16 +433,18 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) json.NewDecoder(buf).Decode(relationship) data := relationship.Data - models := reflect.New(fieldValue.Type()).Elem() + + // This will hold either the value of the slice of choice type models or + // the slice of models, depending on the annotation + models := reflect.New(sliceType).Elem() for _, n := range data { - m := reflect.New(fieldValue.Type().Elem().Elem()) + // This will hold either the value of the choice type model or the actual + // model, depending on annotation + m := reflect.New(sliceType.Elem().Elem()) - if err := unmarshalNode( - fullNode(n, included), - m, - included, - ); err != nil { + err = unmarshalNodeMaybeChoice(&m, n, annotation, choiceMapping, included) + if err != nil { er = err break } @@ -313,20 +474,18 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) continue } + // This will hold either the value of the choice type model or the actual + // model, depending on annotation m := reflect.New(fieldValue.Type().Elem()) - if err := unmarshalNode( - fullNode(relationship.Data, included), - m, - included, - ); err != nil { + + err = unmarshalNodeMaybeChoice(&m, relationship.Data, annotation, choiceMapping, included) + if err != nil { er = err break } fieldValue.Set(m) - } - } else if annotation == annotationLinks { if data.Links == nil { continue diff --git a/request_test.go b/request_test.go index af6c34f7..7eb9bdae 100644 --- a/request_test.go +++ b/request_test.go @@ -607,6 +607,185 @@ func TestUnmarshalRelationships(t *testing.T) { } } +func Test_UnmarshalPayload_polymorphicRelations(t *testing.T) { + in := bytes.NewReader([]byte(`{ + "data": { + "type": "blogs", + "id": "3", + "attributes": { + "title": "Hello, World" + }, + "relationships": { + "hero-media": { + "data": { + "type": "videos", + "id": "1" + } + }, + "media": { + "data": [ + { + "type": "images", + "id": "1" + }, + { + "type": "videos", + "id": "2" + } + ] + } + } + }, + "included": [ + { + "type": "videos", + "id": "1", + "attributes": { + "captions": "It's Awesome!" + } + }, + { + "type": "images", + "id": "1", + "attributes": { + "src": "/media/clear1x1.gif" + } + }, + { + "type": "videos", + "id": "2", + "attributes": { + "captions": "Oh, I didn't see you there" + } + } + ] + }`)) + out := new(BlogPostWithPoly) + + if err := UnmarshalPayload(in, out); err != nil { + t.Fatal(err) + } + + if out.Title != "Hello, World" { + t.Errorf("expected Title %q but got %q", "Hello, World", out.Title) + } + + if out.Hero.Image != nil { + t.Errorf("expected Hero image to be nil but got %+v", out.Hero.Image) + } + + if out.Hero.Video == nil || out.Hero.Video.Captions != "It's Awesome!" { + t.Errorf("expected Hero to be the expected video relation but got %+v", out.Hero.Video) + } + + // Unmarshals included records + if out.Media[0].Image == nil || out.Media[0].Image.Src != "/media/clear1x1.gif" { + t.Errorf("expected Media 0 to be the expected image relation but got %+v", out.Media[0].Image) + } + + if out.Media[1].Video == nil || out.Media[1].Video.Captions != "Oh, I didn't see you there" { + t.Errorf("expected Media 1 to be the expected video relation but got %+v", out.Media[1].Video) + } +} + +func Test_UnmarshalPayload_polymorphicRelations_no_choice(t *testing.T) { + type pointerToOne struct { + ID string `jsonapi:"primary,blogs"` + Title string `jsonapi:"attr,title"` + Hero *OneOfMedia `jsonapi:"polyrelation,hero-media,omitempty"` + } + + in := bytes.NewReader([]byte(`{ + "data": { + "type": "blogs", + "id": "3", + "attributes": { + "title": "Hello, World" + }, + "relationships": { + "hero-media": { + "data": { + "type": "absolutely-not", + "id": "1", + "attributes": { + "captions": "It's Awesome!" + } + } + } + } + } + }`)) + out := new(pointerToOne) + + if err := UnmarshalPayload(in, out); err != nil { + t.Fatal(err) + } + + if out.Title != "Hello, World" { + t.Errorf("expected Title %q but got %q", "Hello, World", out.Title) + } + + if out.Hero == nil { + t.Fatal("expected Hero to not be nil") + } + + if out.Hero.Image != nil || out.Hero.Video != nil { + t.Fatal("expected both Hero fields to be nil") + } +} + +func Test_UnmarshalPayload_polymorphicRelations_omitted(t *testing.T) { + type pointerToOne struct { + ID string `jsonapi:"primary,blogs"` + Title string `jsonapi:"attr,title"` + Hero *OneOfMedia `jsonapi:"polyrelation,hero-media"` + } + + in := bytes.NewReader([]byte(`{ + "data": { + "type": "blogs", + "id": "3", + "attributes": { + "title": "Hello, World" + } + } + }`)) + out := new(pointerToOne) + + if err := UnmarshalPayload(in, out); err != nil { + t.Fatal(err) + } + + if out.Title != "Hello, World" { + t.Errorf("expected Title %q but got %q", "Hello, World", out.Title) + } + + if out.Hero != nil { + t.Fatalf("expected Hero to be nil, but got %+v", out.Hero) + } +} + +func Test_choiceStructMapping(t *testing.T) { + cases := []struct { + val reflect.Type + }{ + {val: reflect.TypeOf(&OneOfMedia{})}, + {val: reflect.TypeOf([]*OneOfMedia{{}})}, + } + + for _, c := range cases { + result := choiceStructMapping(c.val) + imageField, ok := result["images"] + if !ok || imageField.FieldNum != 0 { + t.Errorf("expected \"images\" to be the first field, but got %d", imageField.FieldNum) + } + videoField, ok := result["videos"] + if !ok || videoField.FieldNum != 2 { + t.Errorf("expected \"videos\" to be the third field, but got %d", videoField.FieldNum) + } + } +} + func TestUnmarshalNullRelationship(t *testing.T) { sample := map[string]interface{}{ "data": map[string]interface{}{ diff --git a/response.go b/response.go index e21ac915..602b16b8 100644 --- a/response.go +++ b/response.go @@ -26,6 +26,8 @@ var ( // ErrUnexpectedType is returned when marshalling an interface; the interface // had to be a pointer or a slice; otherwise this error is returned. ErrUnexpectedType = errors.New("models should be a struct pointer or slice of struct pointers") + // ErrUnexpectedNil is returned when a slice of relation structs contains nil values + ErrUnexpectedNil = errors.New("slice of struct pointers cannot contain nil") ) // MarshalPayload writes a jsonapi response for one or many records. The @@ -51,17 +53,16 @@ var ( // Many Example: you could pass it, w, your http.ResponseWriter, and, models, a // slice of Blog struct instance pointers to be written to the response body: // -// func ListBlogs(w http.ResponseWriter, r *http.Request) { -// blogs := []*Blog{} +// func ListBlogs(w http.ResponseWriter, r *http.Request) { +// blogs := []*Blog{} // -// w.Header().Set("Content-Type", jsonapi.MediaType) -// w.WriteHeader(http.StatusOK) +// w.Header().Set("Content-Type", jsonapi.MediaType) +// w.WriteHeader(http.StatusOK) // -// if err := jsonapi.MarshalPayload(w, blogs); err != nil { -// http.Error(w, err.Error(), http.StatusInternalServerError) +// if err := jsonapi.MarshalPayload(w, blogs); err != nil { +// http.Error(w, err.Error(), http.StatusInternalServerError) +// } // } -// } -// func MarshalPayload(w io.Writer, models interface{}) error { payload, err := Marshal(models) if err != nil { @@ -192,6 +193,34 @@ func MarshalOnePayloadEmbedded(w io.Writer, model interface{}) error { return json.NewEncoder(w).Encode(payload) } +// selectChoiceTypeStructField returns the first non-nil struct pointer field in the +// specified struct value that has a jsonapi type field defined within it. +// An error is returned if there are no fields matching that definition. +func selectChoiceTypeStructField(structValue reflect.Value) (reflect.Value, error) { + for i := 0; i < structValue.NumField(); i++ { + choiceFieldValue := structValue.Field(i) + choiceTypeField := choiceFieldValue.Type() + + // Must be a pointer + if choiceTypeField.Kind() != reflect.Ptr { + continue + } + + // Must not be nil + if choiceFieldValue.IsNil() { + continue + } + + subtype := choiceTypeField.Elem() + _, err := jsonapiTypeOfModel(subtype) + if err == nil { + return choiceFieldValue, nil + } + } + + return reflect.Value{}, errors.New("no non-nil choice field was found in the specified struct") +} + func visitModelNode(model interface{}, included *map[string]*Node, sideload bool) (*Node, error) { node := new(Node) @@ -206,16 +235,16 @@ func visitModelNode(model interface{}, included *map[string]*Node, modelType := value.Type().Elem() for i := 0; i < modelValue.NumField(); i++ { + fieldValue := modelValue.Field(i) structField := modelValue.Type().Field(i) tag := structField.Tag.Get(annotationJSONAPI) if tag == "" { continue } - fieldValue := modelValue.Field(i) fieldType := modelType.Field(i) - args := strings.Split(tag, annotationSeperator) + args := strings.Split(tag, annotationSeparator) if len(args) < 1 { er = ErrBadJSONAPIStructTag @@ -355,7 +384,7 @@ func visitModelNode(model interface{}, included *map[string]*Node, node.Attributes[args[1]] = fieldValue.Interface() } } - } else if annotation == annotationRelation { + } else if annotation == annotationRelation || annotation == annotationPolyRelation { var omitEmpty bool //add support for 'omitempty' struct tag for marshaling as absent @@ -370,6 +399,70 @@ func visitModelNode(model interface{}, included *map[string]*Node, continue } + if annotation == annotationPolyRelation { + // for polyrelation, we'll snoop out the actual relation model + // through the choice type value by choosing the first non-nil + // field that has a jsonapi type annotation and overwriting + // `fieldValue` so normal annotation-assisted marshaling + // can continue + if !isSlice { + choiceValue := fieldValue + + // must be a pointer type + if choiceValue.Type().Kind() != reflect.Ptr { + er = ErrUnexpectedType + break + } + + if choiceValue.IsNil() { + fieldValue = reflect.ValueOf(nil) + } + structValue := choiceValue.Elem() + + // Short circuit if field is omitted from model + if !structValue.IsValid() { + break + } + + if found, err := selectChoiceTypeStructField(structValue); err == nil { + fieldValue = found + } + } else { + // A slice polyrelation field can be... polymorphic... meaning + // that we might snoop different types within each slice element. + // Each snooped value will added to this collection and then + // the recursion will take care of the rest. The only special case + // is nil. For that, we'll just choose the first + collection := make([]interface{}, 0) + + for i := 0; i < fieldValue.Len(); i++ { + itemValue := fieldValue.Index(i) + // Once again, must be a pointer type + if itemValue.Type().Kind() != reflect.Ptr { + er = ErrUnexpectedType + break + } + + if itemValue.IsNil() { + er = ErrUnexpectedNil + break + } + + structValue := itemValue.Elem() + + if found, err := selectChoiceTypeStructField(structValue); err == nil { + collection = append(collection, found.Interface()) + } + } + + if er != nil { + break + } + + fieldValue = reflect.ValueOf(collection) + } + } + if node.Relationships == nil { node.Relationships = make(map[string]interface{}) } @@ -499,7 +592,12 @@ func visitModelNodeRelationships(models reflect.Value, included *map[string]*Nod nodes := []*Node{} for i := 0; i < models.Len(); i++ { - n := models.Index(i).Interface() + model := models.Index(i) + if !model.IsValid() || model.IsNil() { + return nil, ErrUnexpectedNil + } + + n := model.Interface() node, err := visitModelNode(n, included, sideload) if err != nil { diff --git a/response_test.go b/response_test.go index a95bb39b..599d9998 100644 --- a/response_test.go +++ b/response_test.go @@ -38,8 +38,167 @@ func TestMarshalPayload(t *testing.T) { } } -func TestMarshalPayloadWithNulls(t *testing.T) { +func TestMarshalPayloadWithHasOnePolyrelation(t *testing.T) { + blog := &BlogPostWithPoly{ + ID: "1", + Title: "Hello, World", + Hero: &OneOfMedia{ + Image: &Image{ + ID: "2", + }, + }, + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(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{}) + if relationships == nil { + t.Fatal("No relationships defined in unmarshaled JSON") + } + heroMedia := relationships["hero-media"].(map[string]interface{})["data"].(map[string]interface{}) + if heroMedia == nil { + t.Fatal("No hero-media relationship defined in unmarshaled JSON") + } + + if heroMedia["id"] != "2" { + t.Fatal("Expected ID \"2\" in unmarshaled JSON") + } + + if heroMedia["type"] != "images" { + t.Fatal("Expected type \"images\" in unmarshaled JSON") + } +} +func TestMarshalPayloadWithHasManyPolyrelation(t *testing.T) { + blog := &BlogPostWithPoly{ + ID: "1", + Title: "Hello, World", + Media: []*OneOfMedia{ + { + Image: &Image{ + ID: "2", + }, + }, + { + Video: &Video{ + ID: "3", + }, + }, + }, + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(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{}) + if relationships == nil { + t.Fatal("No relationships defined in unmarshaled JSON") + } + + heroMedia := relationships["media"].(map[string]interface{}) + if heroMedia == nil { + t.Fatal("No hero-media relationship defined in unmarshaled JSON") + } + + heroMediaData := heroMedia["data"].([]interface{}) + + if len(heroMediaData) != 2 { + t.Fatal("Expected 2 items in unmarshaled JSON") + } + + imageData := heroMediaData[0].(map[string]interface{}) + videoData := heroMediaData[1].(map[string]interface{}) + + if imageData["id"] != "2" || imageData["type"] != "images" { + t.Fatal("Expected images ID \"2\" in unmarshaled JSON") + } + + if videoData["id"] != "3" || videoData["type"] != "videos" { + t.Fatal("Expected videos ID \"3\" in unmarshaled JSON") + } +} + +func TestMarshalPayloadWithHasManyPolyrelationWithNils(t *testing.T) { + blog := &BlogPostWithPoly{ + ID: "1", + Title: "Hello, World", + Media: []*OneOfMedia{ + nil, + { + Image: &Image{ + ID: "2", + }, + }, + nil, + }, + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, blog); err != ErrUnexpectedNil { + t.Fatal("expected error but got none") + } +} + +func TestMarshalPayloadWithHasOneNilPolyrelation(t *testing.T) { + blog := &BlogPostWithPoly{ + ID: "1", + Title: "Hello, World", + Hero: nil, + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, blog); err != nil { + t.Fatalf("expected no error but got %s", err) + } +} + +func TestMarshalPayloadWithHasOneOmittedPolyrelation(t *testing.T) { + blog := &BlogPostWithPoly{ + ID: "1", + Title: "Hello, World", + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, blog); err != nil { + t.Fatalf("expected no error but got %s", err) + } +} + +func TestMarshalPayloadWithHasOneNilRelation(t *testing.T) { + blog := &Blog{ + ID: 1, + Title: "Hello, World", + Posts: []*Post{ + nil, + { + ID: 2, + }, + nil, + }, + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, blog); err != ErrUnexpectedNil { + t.Fatal("expected error but got none") + } +} + +func TestMarshalPayloadWithNulls(t *testing.T) { books := []*Book{nil, {ID: 101}, nil} var jsonData map[string]interface{}