Skip to content

Commit 3d76dd3

Browse files
authored
Fix Unmarshaling of relationships with only links (#35)
Documents with relationships which do not have data members will now successfully unmarshal, so long as at least one other of the required members exists.
1 parent a3ed5bc commit 3d76dd3

File tree

5 files changed

+93
-24
lines changed

5 files changed

+93
-24
lines changed

errors.go

+6-3
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,14 @@ var (
2828
// ErrMissingLinkFields indicates that a LinkObject is not valid.
2929
ErrMissingLinkFields = errors.New("at least one of Links.Self or Links.Related must be set to a nonempty string or *LinkObject")
3030

31-
// ErrMissingDataField indicates that a *jsonapi.document is missing data in an invalid way
32-
ErrMissingDataField = errors.New("document is missing a required top-level or relationship-level data member")
33-
3431
// ErrEmptyDataObject indicates that a primary or relationship data member is incorrectly represented by an empty JSON object {}
3532
ErrEmptyDataObject = errors.New("resource \"data\" members may not be represented by an empty object {}")
33+
34+
// ErrDocumentMissingRequiredMembers indicates that a document does not have at least one required top-level member
35+
ErrDocumentMissingRequiredMembers = errors.New("document is missing required top-level members; must have one of: \"data\", \"meta\", \"errors\"")
36+
37+
// ErrRelationshipMissingRequiredMembers indicates that a relationship does not have at least one required member
38+
ErrRelationshipMissingRequiredMembers = errors.New("relationship is missing required top-level members; must have one of: \"data\", \"meta\", \"links\"")
3639
)
3740

3841
// TypeError indicates that an unexpected type was encountered.

jsonapi.go

+39-8
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"encoding/json"
66
"fmt"
77
"reflect"
8-
"strings"
98
)
109

1110
// ResourceObject is a JSON:API resource object as defined by https://jsonapi.org/format/1.0/#document-resource-objects
@@ -18,6 +17,33 @@ type resourceObject struct {
1817
Links *Link `json:"links,omitempty"`
1918
}
2019

20+
// UnmarshalJSON implements the json.Unmarshaler interface.
21+
func (ro *resourceObject) UnmarshalJSON(data []byte) error {
22+
type alias resourceObject
23+
24+
auxRaw := &struct {
25+
Rels map[string]json.RawMessage `json:"relationships,omitempty"`
26+
*alias
27+
}{
28+
alias: (*alias)(ro),
29+
}
30+
if err := json.Unmarshal(data, &auxRaw); err != nil {
31+
return err
32+
}
33+
34+
ro.Relationships = make(map[string]*document, len(auxRaw.Rels))
35+
for name, raw := range auxRaw.Rels {
36+
// mark the created sub-documents as relationships so that the document Unmarshaler
37+
// can handle their different member requirements
38+
d := document{isRelationship: true}
39+
if err := json.Unmarshal(raw, &d); err != nil {
40+
return err
41+
}
42+
ro.Relationships[name] = &d
43+
}
44+
return nil
45+
}
46+
2147
// JSONAPI is a JSON:API object as defined by https://jsonapi.org/format/1.0/#document-jsonapi-object.
2248
type jsonAPI struct {
2349
Version string `json:"version"`
@@ -108,6 +134,9 @@ type document struct {
108134
DataOne *resourceObject `json:"-"`
109135
DataMany []*resourceObject `json:"-"`
110136

137+
// isRelationship marks a document as a relationship sub-document (within primary data)
138+
isRelationship bool `json:"-"`
139+
111140
// Meta is Meta Information as defined by https://jsonapi.org/format/1.0/#document-meta.
112141
Meta any `json:"meta,omitempty"`
113142

@@ -175,13 +204,15 @@ func (d *document) UnmarshalJSON(data []byte) error {
175204
return err
176205
}
177206

178-
rawData := string(auxRaw.Data)
179-
switch rawData {
207+
switch string(auxRaw.Data) {
180208
case "":
181-
// no "data" member
182-
if auxRaw.Errors == nil && auxRaw.Meta == nil {
183-
// missing required top-level fields
184-
return ErrMissingDataField
209+
// no "data" field -> check that other required members are present
210+
if d.isRelationship {
211+
if d.Meta == nil && d.Links == nil {
212+
return ErrRelationshipMissingRequiredMembers
213+
}
214+
} else if d.Meta == nil && d.Errors == nil {
215+
return ErrDocumentMissingRequiredMembers
185216
}
186217
return nil
187218
case "{}":
@@ -192,7 +223,7 @@ func (d *document) UnmarshalJSON(data []byte) error {
192223
return nil
193224
}
194225

195-
if strings.HasPrefix(rawData, "[") {
226+
if auxRaw.Data[0] == '[' {
196227
d.hasMany = true
197228
return json.Unmarshal(auxRaw.Data, &auxRaw.DataMany)
198229
}

jsonapi_test.go

+3
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ var (
160160

161161
// articles with relationships bodies
162162
articleRelatedInvalidEmptyRelationshipBody = `{"data":{"id":"1","type":"articles","attributes":{"title":"A"},"relationships":{"author":{}}}}`
163+
articleRelatedInvalidEmptyDataBody = `{"data":{"id":"1","type":"articles","attributes":{"title":"A"},"relationships":{"author":{"data":{}}}}}`
163164
articleRelatedNoOmitEmptyBody = `{"data":{"id":"1","type":"articles","attributes":{"title":"A"},"relationships":{"author":{"data":null},"comments":{"data":[]}}}}`
164165
articleRelatedAuthorBody = `{"data":{"id":"1","type":"articles","attributes":{"title":"A"},"relationships":{"author":{"data":{"id":"1","type":"author"},"links":{"self":"http://example.com/articles/1/relationships/author","related":"http://example.com/articles/1/author"}}}}}`
165166
articleRelatedAuthorTwiceBody = `{"data":[{"id":"1","type":"articles","attributes":{"title":"A"},"relationships":{"author":{"data":{"id":"1","type":"author"}}}},{"id":"2","type":"articles","attributes":{"title":"B"},"relationships":{"author":{"data":{"id":"1","type":"author"}}}}]}`
@@ -171,6 +172,8 @@ var (
171172
articleRelatedCompleteWithIncludeBody = `{"data":{"id":"1","type":"articles","attributes":{"title":"A"},"relationships":{"author":{"data":{"id":"1","type":"author"}},"comments":{"data":[{"id":"1","type":"comments"},{"id":"2","type":"comments"}]}}},"included":[{"id":"1","type":"author","attributes":{"name":"A"}},{"id":"1","type":"comments","attributes":{"body":"A"}},{"id":"2","type":"comments","attributes":{"body":"B"}}]}`
172173
articleRelatedCommentsNestedWithIncludeBody = `{"data":{"id":"1","type":"articles","attributes":{"title":"A"},"relationships":{"comments":{"data":[{"id":"1","type":"comments"}],"links":{"self":"http://example.com/articles/1/relationships/comments","related":"http://example.com/articles/1/comments"}}}},"included":[{"id":"1","type":"comments","attributes":{"body":"A"},"relationships":{"author":{"data":{"id":"1","type":"author"},"links":{"self":"http://example.com/comments/1/relationships/author","related":"http://example.com/comments/1/author"}}}},{"id":"1","type":"author","attributes":{"name":"A"}}]}`
173174
articleWithIncludeOnlyBody = `{"data":{"id":"1","type":"articles","attributes":{"title":"A"}},"included":[{"id":"1","type":"author","attributes":{"name":"A"}}]}`
175+
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"}}}}}`
176+
articleRelatedAuthorMetaOnlyBody = `{"data":{"id":"1","type":"articles","attributes":{"title":"A"},"relationships":{"author":{"meta":{"foo":"bar"}}}}}`
174177
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"}}]}`
175178

176179
// articles with non-conforming member name bodies

marshal.go

+8-7
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ func makeDocument(v any, m *Marshaler, isRelationship bool) (*document, error) {
151151

152152
// at this point we have no errors, so lets make the document
153153
d = newDocument()
154+
d.isRelationship = isRelationship
154155

155156
// the given "v" is the resource object (or a slice of them)
156157
//
@@ -172,7 +173,7 @@ func makeDocument(v any, m *Marshaler, isRelationship bool) (*document, error) {
172173
rv := derefValue(reflect.ValueOf(v))
173174
for i := 0; i < rv.Len(); i++ {
174175
iv := rv.Index(i).Interface()
175-
ro, err := makeResourceObject(iv, reflect.TypeOf(iv), m, isRelationship)
176+
ro, err := d.makeResourceObject(iv, reflect.TypeOf(iv), m)
176177
if err != nil {
177178
return nil, err
178179
}
@@ -185,7 +186,7 @@ func makeDocument(v any, m *Marshaler, isRelationship bool) (*document, error) {
185186
break
186187
}
187188
// if we get a struct we just make a single resource object
188-
ro, err := makeResourceObject(v, vt, m, isRelationship)
189+
ro, err := d.makeResourceObject(v, vt, m)
189190
if err != nil {
190191
return nil, err
191192
}
@@ -196,7 +197,7 @@ func makeDocument(v any, m *Marshaler, isRelationship bool) (*document, error) {
196197

197198
// if we got any included data, build the resource object/s and include them
198199
for _, v := range m.included {
199-
ro, err := makeResourceObject(v, reflect.TypeOf(v), m, isRelationship)
200+
ro, err := d.makeResourceObject(v, reflect.TypeOf(v), m)
200201
if err != nil {
201202
return nil, err
202203
}
@@ -305,7 +306,7 @@ func makeDocumentErrors(v any, m *Marshaler) (*document, error) {
305306
return d, nil
306307
}
307308

308-
func makeResourceObject(v any, vt reflect.Type, m *Marshaler, isRelationship bool) (*resourceObject, error) {
309+
func (d *document) makeResourceObject(v any, vt reflect.Type, m *Marshaler) (*resourceObject, error) {
309310
// the given "v" here is a single resource object
310311

311312
// first, it must be a struct since we'll be parsing the jsonapi struct tags
@@ -385,7 +386,7 @@ func makeResourceObject(v any, vt reflect.Type, m *Marshaler, isRelationship boo
385386

386387
return nil, ErrMarshalInvalidPrimaryField
387388
case attribute:
388-
if isRelationship {
389+
if d.isRelationship {
389390
// relationships must only be resource identifier objects so skip attributes
390391
continue
391392
}
@@ -408,14 +409,14 @@ func makeResourceObject(v any, vt reflect.Type, m *Marshaler, isRelationship boo
408409
metaObject = nil
409410
}
410411

411-
if isRelationship {
412+
if d.isRelationship {
412413
// let meta become document-level for relationships (treated as nested documents)
413414
m.meta = metaObject
414415
} else {
415416
ro.Meta = metaObject
416417
}
417418
case relationship:
418-
if isRelationship {
419+
if d.isRelationship {
419420
// relationship nesting must occur in include data, not the relationship fields
420421
continue
421422
}

unmarshal_test.go

+37-6
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ func TestUnmarshal(t *testing.T) {
257257
return &a, err
258258
},
259259
expect: new(Article),
260-
expectError: ErrMissingDataField,
260+
expectError: ErrDocumentMissingRequiredMembers,
261261
}, {
262262
description: "*Article (invalid type)",
263263
given: articleAInvalidTypeBody,
@@ -289,18 +289,18 @@ func TestUnmarshal(t *testing.T) {
289289
expect: new(Article),
290290
expectError: &PartialLinkageError{[]string{"{Type: author, ID: 1}"}},
291291
}, {
292-
description: "*ArticleRelated empty relationships (invalid)",
292+
description: "*ArticleRelated empty relationships object",
293293
given: articleRelatedInvalidEmptyRelationshipBody,
294294
do: func(body []byte) (any, error) {
295295
var a ArticleRelated
296296
err := Unmarshal(body, &a)
297297
return &a, err
298298
},
299299
expect: &ArticleRelated{},
300-
expectError: ErrMissingDataField,
300+
expectError: ErrRelationshipMissingRequiredMembers,
301301
}, {
302-
// this test verifies that empty relationship bodies (null and []) unmarshal
303-
description: "*ArticleRelated empty relationships",
302+
// this test verifies that relationship data objects that are null or [] unmarshal
303+
description: "*ArticleRelated empty relationships data (valid)",
304304
given: articleRelatedNoOmitEmptyBody,
305305
do: func(body []byte) (any, error) {
306306
var a ArticleRelated
@@ -309,6 +309,17 @@ func TestUnmarshal(t *testing.T) {
309309
},
310310
expect: &ArticleRelated{ID: "1", Title: "A"},
311311
expectError: nil,
312+
}, {
313+
// this test verifies that empty relationship data objects do not unmarshal
314+
description: "*ArticleRelated empty relationships data (invalid)",
315+
given: articleRelatedInvalidEmptyDataBody,
316+
do: func(body []byte) (any, error) {
317+
var a ArticleRelated
318+
err := Unmarshal(body, &a)
319+
return &a, err
320+
},
321+
expect: &ArticleRelated{},
322+
expectError: ErrEmptyDataObject,
312323
}, {
313324
description: "*ArticleRelated.Author",
314325
given: articleRelatedAuthorBody,
@@ -323,6 +334,26 @@ func TestUnmarshal(t *testing.T) {
323334
Author: &Author{ID: "1"},
324335
},
325336
expectError: nil,
337+
}, {
338+
description: "*ArticleRelated.Author (links only)",
339+
given: articleRelatedAuthorLinksOnlyBody,
340+
do: func(body []byte) (any, error) {
341+
var a ArticleRelated
342+
err := Unmarshal(body, &a)
343+
return &a, err
344+
},
345+
expect: &ArticleRelated{ID: "1", Title: "A"},
346+
expectError: nil,
347+
}, {
348+
description: "*ArticleRelated.Author (meta only)",
349+
given: articleRelatedAuthorMetaOnlyBody,
350+
do: func(body []byte) (any, error) {
351+
var a ArticleRelated
352+
err := Unmarshal(body, &a)
353+
return &a, err
354+
},
355+
expect: &ArticleRelated{ID: "1", Title: "A"},
356+
expectError: nil,
326357
}, {
327358
description: "[]*ArticleRelated.Author twice",
328359
given: articleRelatedAuthorTwiceBody,
@@ -393,7 +424,7 @@ func TestUnmarshal(t *testing.T) {
393424
return &a, err
394425
},
395426
expect: &Article{},
396-
expectError: ErrMissingDataField,
427+
expectError: ErrDocumentMissingRequiredMembers,
397428
}, {
398429
description: "[]*ArticleRelated complex relationships with include",
399430
given: articlesRelatedComplexBody,

0 commit comments

Comments
 (0)