-
Notifications
You must be signed in to change notification settings - Fork 8
/
jsonapi.go
385 lines (329 loc) · 10.9 KB
/
jsonapi.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
// Package jsonapi implements encoding and decoding of JSON:API as defined in https://jsonapi.org/format/.
package jsonapi
import (
"encoding/json"
"fmt"
"reflect"
)
// ResourceObject is a JSON:API resource object as defined by https://jsonapi.org/format/1.0/#document-resource-objects
type resourceObject struct {
ID string `json:"id,omitempty"`
Type string `json:"type"`
Attributes map[string]any `json:"attributes,omitempty"`
Relationships map[string]*document `json:"relationships,omitempty"`
Meta any `json:"meta,omitempty"`
Links *Link `json:"links,omitempty"`
}
// UnmarshalJSON implements the json.Unmarshaler interface.
func (ro *resourceObject) UnmarshalJSON(data []byte) error {
type alias resourceObject
auxRaw := &struct {
Rels map[string]json.RawMessage `json:"relationships,omitempty"`
*alias
}{
alias: (*alias)(ro),
}
if err := json.Unmarshal(data, &auxRaw); err != nil {
return err
}
ro.Relationships = make(map[string]*document, len(auxRaw.Rels))
for name, raw := range auxRaw.Rels {
// mark the created sub-documents as relationships so that the document Unmarshaler
// can handle their different member requirements
d := document{isRelationship: true}
if err := json.Unmarshal(raw, &d); err != nil {
return err
}
ro.Relationships[name] = &d
}
return nil
}
func (ro *resourceObject) getIdentifier() string {
return fmt.Sprintf("{Type: %v, ID: %v}", ro.Type, ro.ID)
}
// JSONAPI is a JSON:API object as defined by https://jsonapi.org/format/1.0/#document-jsonapi-object.
type jsonAPI struct {
Version string `json:"version"`
Meta any `json:"meta,omitempty"`
}
// checkMeta returns a type error if the given meta value is not map-like
func checkMeta(m any) *TypeError {
if m == nil {
return nil
}
mt := derefType(reflect.TypeOf(m))
if mt.Kind() == reflect.Struct || mt.Kind() == reflect.Map {
return nil
}
return &TypeError{Actual: mt.String(), Expected: []string{"struct", "map"}}
}
// LinkObject is a links object as defined by https://jsonapi.org/format/1.0/#document-links
type LinkObject struct {
Href string `json:"href,omitempty"`
Meta any `json:"meta,omitempty"`
}
// Link is the top-level links object as defined by https://jsonapi.org/format/1.0/#document-top-level.
// First|Last|Next|Previous are provided to support pagination as defined by https://jsonapi.org/format/1.0/#fetching-pagination.
type Link struct {
Self any `json:"self,omitempty"`
Related any `json:"related,omitempty"`
First string `json:"first,omitempty"`
Last string `json:"last,omitempty"`
Next string `json:"next,omitempty"`
Previous string `json:"previous,omitempty"`
}
func checkLinkValue(linkValue any) (bool, *TypeError) {
var isEmpty bool
switch lv := linkValue.(type) {
case *LinkObject:
if err := checkMeta(lv.Meta); err != nil {
return false, err
}
isEmpty = (lv.Href == "")
case string:
isEmpty = (lv == "")
case nil:
isEmpty = true
default:
return false, &TypeError{Actual: fmt.Sprintf("%T", lv), Expected: []string{"*LinkObject", "string"}}
}
return isEmpty, nil
}
func (l *Link) check() error {
selfIsEmpty, err := checkLinkValue(l.Self)
if err != nil {
return err
}
relatedIsEmpty, err := checkLinkValue(l.Related)
if err != nil {
return err
}
// if both are empty then fail, and if one is empty, it must be set to nil to satisfy omitempty
switch {
case selfIsEmpty && relatedIsEmpty:
return ErrMissingLinkFields
case selfIsEmpty:
l.Self = nil
case relatedIsEmpty:
l.Related = nil
}
return nil
}
// Document is a JSON:API document as defined by https://jsonapi.org/format/1.0/#document-top-level
type document struct {
// Data is a ResourceObject as defined by https://jsonapi.org/format/1.0/#document-resource-objects.
// DataOne/DataMany are translated to Data in document.MarshalJSON
hasMany bool
DataOne *resourceObject `json:"-"`
DataMany []*resourceObject `json:"-"`
// isRelationship marks a document as a relationship sub-document (within primary data)
isRelationship bool `json:"-"`
// Meta is Meta Information as defined by https://jsonapi.org/format/1.0/#document-meta.
Meta any `json:"meta,omitempty"`
// JSONAPI is a JSON:API object as defined by https://jsonapi.org/format/1.0/#document-jsonapi-object.
JSONAPI *jsonAPI `json:"jsonapi,omitempty"`
// Errors is a list of JSON:API error objects as defined by https://jsonapi.org/format/1.1/#error-objects.
Errors []*Error `json:"errors,omitempty"`
// Links is the top-level links object as defined by https://jsonapi.org/format/1.0/#document-top-level.
Links *Link `json:"links,omitempty"`
// Includes contains ResourceObjects creating a compound document as defined by https://jsonapi.org/format/#document-compound-documents.
Included []*resourceObject `json:"included,omitempty"`
}
func newDocument() *document {
return &document{
DataMany: make([]*resourceObject, 0),
Errors: make([]*Error, 0),
}
}
// MarshalJSON implements the json.Marshaler interface.
func (d *document) MarshalJSON() ([]byte, error) {
// if we get errors, force exclusion of the Data field
if len(d.Errors) > 0 {
type alias document
return json.Marshal(&struct{ *alias }{alias: (*alias)(d)})
}
// if DataMany is populated Data is a []*resourceObject
if d.hasMany {
type alias document
return json.Marshal(&struct {
Data []*resourceObject `json:"data"`
*alias
}{
Data: d.DataMany,
alias: (*alias)(d),
})
}
type alias document
return json.Marshal(&struct {
Data *resourceObject `json:"data"`
*alias
}{
Data: d.DataOne,
alias: (*alias)(d),
})
}
// UnmarshalJSON implements the json.Unmarshaler interface.
func (d *document) UnmarshalJSON(data []byte) error {
type alias document
auxRaw := &struct {
Data json.RawMessage `json:"data,omitempty"`
*alias
}{
alias: (*alias)(d),
}
if err := json.Unmarshal(data, &auxRaw); err != nil {
return err
}
switch string(auxRaw.Data) {
case "":
// no "data" field -> check that other required members are present
if d.isRelationship {
if d.Meta == nil && d.Links == nil {
return ErrRelationshipMissingRequiredMembers
}
} else if d.Meta == nil && d.Errors == nil {
return ErrDocumentMissingRequiredMembers
}
return nil
case "{}":
// {"data":{}, ...} is invalid
return ErrEmptyDataObject
case "null":
// {"data":null, ...} is valid
return nil
}
if auxRaw.Data[0] == '[' {
d.hasMany = true
return json.Unmarshal(auxRaw.Data, &auxRaw.DataMany)
}
return json.Unmarshal(auxRaw.Data, &auxRaw.DataOne)
}
// isEmpty returns true if there is no primary data in the given document (i.e. null or []).
func (d *document) isEmpty() bool {
return len(d.DataMany) == 0 && d.DataOne == nil
}
func (d *document) getResourceObjectSlice() []*resourceObject {
if d.hasMany {
return d.DataMany
}
if d.DataOne == nil {
return nil
}
return []*resourceObject{d.DataOne}
}
// verifyFullLinkage returns an error if the given compound document is not fully-linked as
// described by https://jsonapi.org/format/1.1/#document-compound-documents. That is, there must be
// a chain of relationships linking all included data to primary data transitively.
func (d *document) verifyFullLinkage(aliasRelationships bool) error {
if len(d.Included) == 0 {
return nil
}
// a list of related resource identifiers, and a flag to mark nodes as visited
type includeNode struct {
included *resourceObject
relatedTo []*resourceObject
visited bool
}
// compute a graph of relationships between just the included resources
includeGraph := make(map[string]*includeNode)
for _, included := range d.Included {
relatedTo := make([]*resourceObject, 0)
for _, relationship := range included.Relationships {
relatedTo = append(relatedTo, relationship.getResourceObjectSlice()...)
}
includeGraph[included.getIdentifier()] = &includeNode{included, relatedTo, false}
}
// helper to traverse the graph from a given key and mark nodes as visited
var visit func(ro *resourceObject)
visit = func(ro *resourceObject) {
node, ok := includeGraph[ro.getIdentifier()]
if !ok {
return
}
if aliasRelationships {
// fill the relationship document itself with included data
*ro = *node.included
}
if node.visited {
// cycle detected, don't visit adjacent nodes
return
}
node.visited = true
for _, related := range node.relatedTo {
visit(related)
}
}
// visit all include nodes that are accessible from the primary data
primaryData := d.getResourceObjectSlice()
for _, data := range primaryData {
for _, relationship := range data.Relationships {
for _, ro := range relationship.getResourceObjectSlice() {
visit(ro)
}
}
}
invalidResources := make([]string, 0)
for identifier, node := range includeGraph {
if !node.visited {
invalidResources = append(invalidResources, identifier)
}
}
if len(invalidResources) > 0 {
return &PartialLinkageError{invalidResources}
}
return nil
}
// verifyResourceUniqueness checks if the given document contains unique resources as described
// by https://jsonapi.org/format/1.1/#document-resource-object-identification. Resource objects
// across primary data and included must be unique, and resource linkages must be unique in
// any given relationship section.
func (d *document) verifyResourceUniqueness() bool {
topLevelSeen := make(map[string]bool)
for _, ro := range append(d.getResourceObjectSlice(), d.Included...) {
rid := ro.getIdentifier()
if ro.ID != "" && topLevelSeen[rid] {
return false
}
topLevelSeen[rid] = true
relSeen := make(map[string]bool)
for _, rel := range ro.Relationships {
for _, relRo := range rel.getResourceObjectSlice() {
relRid := relRo.getIdentifier()
if relRo.ID != "" && relSeen[relRid] {
return false
}
relSeen[relRid] = true
}
}
}
return true
}
// Linkable can be implemented to marshal resource object links as defined by https://jsonapi.org/format/1.0/#document-resource-object-links.
type Linkable interface {
Link() *Link
}
// LinkableRelation can be implemented to marshal resource object related resource links as defined by https://jsonapi.org/format/1.0/#document-resource-object-related-resource-links.
type LinkableRelation interface {
LinkRelation(relation string) *Link
}
// MarshalIdentifier can be optionally implemented to control marshaling of the primary field to a string.
//
// The order of operations for marshaling the primary field is:
//
// 1. Use MarshalIdentifier if it is implemented
// 2. Use the value directly if it is a string
// 3. Use fmt.Stringer if it is implemented
// 4. Fail
type MarshalIdentifier interface {
MarshalID() string
}
// UnmarshalIdentifier can be optionally implemented to control unmarshaling of the primary field from a string.
//
// The order of operations for unmarshaling the primary field is:
//
// 1. Use UnmarshalIdentifier if it is implemented
// 2. Use the value directly if it is a string
// 3. Fail
type UnmarshalIdentifier interface {
UnmarshalID(id string) error
}