From 49bd16e7a0da71ace44d248fdbe5d60b696a9fd3 Mon Sep 17 00:00:00 2001 From: Kevin Conaway Date: Wed, 13 Mar 2024 15:03:18 -0400 Subject: [PATCH] Add support for unmarshalling embedded structs with relationships This commit adds better support for unmarshaling embedded structs that contain relationship fields --- jsonapi_test.go | 34 +++++++++++++++++++++++++++++----- unmarshal.go | 17 +++++++++++++---- unmarshal_test.go | 21 +++++++++++++++++++++ 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/jsonapi_test.go b/jsonapi_test.go index 256d3e5..38c7a57 100644 --- a/jsonapi_test.go +++ b/jsonapi_test.go @@ -15,11 +15,15 @@ var ( authorBWithMeta = Author{ID: "2", Name: "B", Meta: map[string]any{"count": 10.0}} // comments - commentA = Comment{ID: "1", Body: "A"} - commentB = Comment{ID: "2", Body: "B"} - commentAWithAuthor = Comment{ID: "1", Body: "A", Author: &authorA} - commentArchived = Comment{ID: "1", Body: "A", Archived: true} - commentsAB = []*Comment{&commentA, &commentB} + commentA = Comment{ID: "1", Body: "A"} + commentB = Comment{ID: "2", Body: "B"} + commentAWithAuthor = Comment{ID: "1", Body: "A", Author: &authorA} + commentArchived = Comment{ID: "1", Body: "A", Archived: true} + commentsAB = []*Comment{&commentA, &commentB} + commentEmbeddedFields = CommentFields{Body: "A", Author: Author{ID: "1"}} + commentEmbeddedFieldsB = CommentFields{Body: "B", Author: Author{ID: "1"}} + commentEmbedded = CommentEmbedded{ID: "1", CommentFields: commentEmbeddedFields} + commentEmbeddedB = CommentEmbedded{ID: "2", CommentFields: commentEmbeddedFieldsB} // articles articleA = Article{ID: "1", Title: "A"} @@ -80,6 +84,7 @@ var ( articleRelatedCommentsArchived = ArticleRelated{ID: "1", Title: "A", Comments: []*Comment{&commentArchived}} articleRelatedCommentsNested = ArticleRelated{ID: "1", Title: "A", Comments: []*Comment{&commentAWithAuthor}} articleRelatedComplete = ArticleRelated{ID: "1", Title: "A", Author: &authorAWithMeta, Comments: commentsAB} + articleRelatedEmbedded = ArticleRelatedEmbedded{ID: "1", Title: "A", Comments: []CommentEmbedded{commentEmbedded, commentEmbeddedB}} articlesRelatedComplex = []*ArticleRelated{ { ID: "1", @@ -157,6 +162,7 @@ var ( articleNullWithToplevelMetaBody = `{"data":null,"meta":{"foo":"bar"}}` articleEmptyArrayWithToplevelMetaBody = `{"data":[],"meta":{"foo":"bar"}}` articleEmbeddedBody = `{"data":{"type":"articles","id":"1","attributes":{"title":"A","lastModified":"1989-06-15T00:00:00Z"}}}` + commentEmbeddedBody = `{"data":{"id":"1","type":"comments","attributes":{"body":"A"},"relationships":{"author":{"data":{"id":"1","type":"author"}}}}}` // articles with relationships bodies articleRelatedInvalidEmptyRelationshipBody = `{"data":{"id":"1","type":"articles","attributes":{"title":"A"},"relationships":{"author":{}}}}` @@ -175,6 +181,7 @@ var ( articleRelatedAuthorLinksOnlyBody = `{"data":{"id":"1","type":"articles","attributes":{"title":"A"},"relationships":{"author":{"links":{"self":"http://example.com/articles/1/relationships/author","related":"http://example.com/articles/1/author"}}}}}` articleRelatedAuthorMetaOnlyBody = `{"data":{"id":"1","type":"articles","attributes":{"title":"A"},"relationships":{"author":{"meta":{"foo":"bar"}}}}}` articlesRelatedComplexBody = `{"data":[{"id":"1","type":"articles","attributes":{"title":"Bazel 101"},"relationships":{"author":{"data":{"id":"1","type":"author"},"links":{"self":"http://example.com/articles/1/relationships/author","related":"http://example.com/articles/1/author"}},"comments":{"data":[{"id":"11","type":"comments"},{"id":"12","type":"comments"},{"id":"13","type":"comments"}],"links":{"self":"http://example.com/articles/1/relationships/comments","related":"http://example.com/articles/1/comments"}}}},{"id":"2","type":"articles","attributes":{"title":"Why Should I Use JSON:API?"},"relationships":{"author":{"data":{"id":"2","type":"author"},"meta":{"count":10},"links":{"self":"http://example.com/articles/2/relationships/author","related":"http://example.com/articles/2/author"}},"comments":{"data":[{"id":"21","type":"comments"}],"links":{"self":"http://example.com/articles/2/relationships/comments","related":"http://example.com/articles/2/comments"}}}},{"id":"3","type":"articles","attributes":{"title":"Internal Test Article Created In Production For Some Reason"},"relationships":{"comments":{"data":[{"id":"31","type":"comments"},{"id":"32","type":"comments"}],"links":{"self":"http://example.com/articles/3/relationships/comments","related":"http://example.com/articles/3/comments"}}}},{"id":"4","type":"articles","attributes":{"title":"How to Rewrite Everything in Rust"},"relationships":{"author":{"data":{"id":"1","type":"author"},"links":{"self":"http://example.com/articles/4/relationships/author","related":"http://example.com/articles/4/author"}}}}],"meta":{"meta_kind":"document-level meta"},"jsonapi":{"version":"1.0","meta":{"meta_kind":"jsonapi meta"}},"included":[{"id":"1","type":"author","attributes":{"name":"A"}},{"id":"2","type":"author","attributes":{"name":"B"},"meta":{"count":10}},{"id":"11","type":"comments","attributes":{"archived":true,"body":"Why is Bazel so slow on my computerr?"},"relationships":{"author":{"data":{"id":"2","type":"author"},"meta":{"count":10},"links":{"self":"http://example.com/comments/11/relationships/author","related":"http://example.com/comments/11/author"}}}},{"id":"12","type":"comments","attributes":{"body":"Why is Bazel so slow on my computer?"},"relationships":{"author":{"data":{"id":"2","type":"author"},"meta":{"count":10},"links":{"self":"http://example.com/comments/12/relationships/author","related":"http://example.com/comments/12/author"}}}},{"id":"13","type":"comments","attributes":{"body":"Just use an Apple M1"},"relationships":{"author":{"data":{"id":"1","type":"author"},"links":{"self":"http://example.com/comments/13/relationships/author","related":"http://example.com/comments/13/author"}}}},{"id":"21","type":"comments","attributes":{"body":"I wish they changed the name..."},"relationships":{"author":{"data":{"id":"2","type":"author"},"meta":{"count":10},"links":{"self":"http://example.com/comments/21/relationships/author","related":"http://example.com/comments/21/author"}}}},{"id":"31","type":"comments","attributes":{"body":"test1"}},{"id":"32","type":"comments","attributes":{"body":"test2"}}]}` + articleRelatedEmbeddedBody = `{"data":{"id":"1","type":"articles","attributes":{"title":"A"},"relationships":{"comments":{"data":[{"id":"1","type":"comments"},{"id":"2","type":"comments"}]}}},"included":[{"id":"1","type":"comments","attributes":{"body":"A"},"relationships":{"author":{"data":{"id":"1","type":"author"}}}},{"id":"2","type":"comments","attributes":{"body":"B"},"relationships":{"author":{"data":{"id":"1","type":"author"}}}},{"id":"1","type":"author","attributes":{"name":""}},{"id":"1","type":"author","attributes":{"name":""}}]}` // articles with non-conforming member name bodies authorWithInvalidTypeNameBody = `{"data":{"id":"1","type":"aut%hor"}}` @@ -437,6 +444,12 @@ func (a *ArticleRelated) LinkRelation(relation string) *Link { } } +type ArticleRelatedEmbedded struct { + ID string `jsonapi:"primary,articles"` + Title string `jsonapi:"attribute" json:"title"` + Comments []CommentEmbedded `jsonapi:"relationship" json:"comments,omitempty"` +} + type ArticleRelatedNoOmitEmpty struct { ID string `jsonapi:"primary,articles"` Title string `jsonapi:"attribute" json:"title"` @@ -454,6 +467,17 @@ type Metadata struct { LastModified time.Time `jsonapi:"attribute" json:"lastModified"` } +type CommentFields struct { + Body string `jsonapi:"attribute" json:"body"` + Archived bool `jsonapi:"attribute" json:"archived,omitempty"` + Author Author `jsonapi:"relationship" json:"author,omitempty"` +} + +type CommentEmbedded struct { + ID string `jsonapi:"primary,comments"` + CommentFields +} + type ArticleEmbedded struct { Metadata diff --git a/unmarshal.go b/unmarshal.go index a4d0e54..0348174 100644 --- a/unmarshal.go +++ b/unmarshal.go @@ -170,7 +170,9 @@ func (ro *resourceObject) unmarshal(v any, m *Unmarshaler) error { return &TypeError{Actual: vt.String(), Expected: []string{"struct"}} } - if err := ro.unmarshalFields(v, m); err != nil { + rv := derefValue(reflect.ValueOf(v)) + rt := reflect.TypeOf(rv.Interface()) + if err := ro.unmarshalFields(v, rv, rt, m); err != nil { return err } @@ -178,10 +180,8 @@ func (ro *resourceObject) unmarshal(v any, m *Unmarshaler) error { } // unmarshalFields unmarshals a resource object into all non-attribute struct fields -func (ro *resourceObject) unmarshalFields(v any, m *Unmarshaler) error { +func (ro *resourceObject) unmarshalFields(v any, rv reflect.Value, rt reflect.Type, m *Unmarshaler) error { setPrimary := false - rv := derefValue(reflect.ValueOf(v)) - rt := reflect.TypeOf(rv.Interface()) for i := 0; i < rv.NumField(); i++ { fv := rv.Field(i) @@ -191,6 +191,15 @@ func (ro *resourceObject) unmarshalFields(v any, m *Unmarshaler) error { if err != nil { return err } + // If there is an embedded struct we want to process the fields in that struct recursively + // only if there isn't a relationship defined on the field. + if fv.Kind() == reflect.Struct && jsonapiTag == nil { + if err := ro.unmarshalFields(v, fv, reflect.TypeOf(fv.Interface()), m); err != nil { + return err + } + continue + } + if jsonapiTag == nil { continue } diff --git a/unmarshal_test.go b/unmarshal_test.go index 4662af6..4151bdd 100644 --- a/unmarshal_test.go +++ b/unmarshal_test.go @@ -446,6 +446,27 @@ func TestUnmarshal(t *testing.T) { }, expect: &Article{}, expectError: ErrErrorUnmarshalingNotImplemented, + }, { + description: "CommentEmbedded", + given: commentEmbeddedBody, + do: func(body []byte) (any, error) { + var a CommentEmbedded + err := Unmarshal(body, &a) + return &a, err + }, + expect: &commentEmbedded, + expectError: nil, + }, + { + description: "ArticleRelatedEmbedded", + given: articleRelatedEmbeddedBody, + do: func(body []byte) (any, error) { + var a ArticleRelatedEmbedded + err := Unmarshal(body, &a) + return &a, err + }, + expect: &articleRelatedEmbedded, + expectError: nil, }, }