Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reject nonunique resources #59

Merged
merged 5 commits into from
Aug 8, 2024
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Add logic for checking uniqueness
20joshuaz committed Aug 3, 2024

Verified

This commit was signed with the committer’s verified signature. The key has expired.
tvdeyen Thomas von Deyen
commit b60cfa907cb2ea1adf3c2fcdc3ef7eb09ee7732b
68 changes: 49 additions & 19 deletions jsonapi.go
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"reflect"
"slices"
)

// ResourceObject is a JSON:API resource object as defined by https://jsonapi.org/format/1.0/#document-resource-objects
@@ -44,6 +45,10 @@ func (ro *resourceObject) UnmarshalJSON(data []byte) error {
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"`
@@ -235,6 +240,16 @@ 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.
@@ -243,20 +258,6 @@ func (d *document) verifyFullLinkage(aliasRelationships bool) error {
return nil
}

getResourceObjectSlice := func(d *document) []*resourceObject {
if d.hasMany {
return d.DataMany
}
if d.DataOne == nil {
return nil
}
return []*resourceObject{d.DataOne}
}

resourceIdentifier := func(ro *resourceObject) string {
return fmt.Sprintf("{Type: %v, ID: %v}", ro.Type, ro.ID)
}

// a list of related resource identifiers, and a flag to mark nodes as visited
type includeNode struct {
included *resourceObject
@@ -270,16 +271,16 @@ func (d *document) verifyFullLinkage(aliasRelationships bool) error {
relatedTo := make([]*resourceObject, 0)

for _, relationship := range included.Relationships {
relatedTo = append(relatedTo, getResourceObjectSlice(relationship)...)
relatedTo = append(relatedTo, relationship.getResourceObjectSlice()...)
}

includeGraph[resourceIdentifier(included)] = &includeNode{included, relatedTo, false}
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[resourceIdentifier(ro)]
node, ok := includeGraph[ro.getIdentifier()]
if !ok {
return
}
@@ -299,10 +300,10 @@ func (d *document) verifyFullLinkage(aliasRelationships bool) error {
}

// visit all include nodes that are accessible from the primary data
primaryData := getResourceObjectSlice(d)
primaryData := d.getResourceObjectSlice()
for _, data := range primaryData {
for _, relationship := range data.Relationships {
for _, ro := range getResourceObjectSlice(relationship) {
for _, ro := range relationship.getResourceObjectSlice() {
visit(ro)
}
}
@@ -322,6 +323,35 @@ func (d *document) verifyFullLinkage(aliasRelationships bool) error {
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]struct{})

for _, ro := range slices.Concat(d.getResourceObjectSlice(), d.Included) {
rid := ro.getIdentifier()
if _, ok := topLevelSeen[rid]; ro.ID != "" && ok {
return false
}
topLevelSeen[rid] = struct{}{}

relSeen := make(map[string]struct{})
for _, rel := range ro.Relationships {
for _, relRo := range rel.getResourceObjectSlice() {
relRid := relRo.getIdentifier()
if _, ok := relSeen[relRid]; relRo.ID != "" && ok {
return false
}
relSeen[relRid] = struct{}{}
}
}
}

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
3 changes: 3 additions & 0 deletions marshal.go
Original file line number Diff line number Diff line change
@@ -205,6 +205,9 @@ func makeDocument(v any, m *Marshaler, isRelationship bool) (*document, error) {
d.Included = append(d.Included, ro)
}

if ok := d.verifyResourceUniqueness(); !ok {
return nil, ErrNonuniqueResource
}
// if we got any included data, verify full-linkage of this compound document.
if err := d.verifyFullLinkage(false); err != nil {
return nil, err
5 changes: 4 additions & 1 deletion unmarshal.go
Original file line number Diff line number Diff line change
@@ -88,6 +88,9 @@ func Unmarshal(data []byte, v any, opts ...UnmarshalOption) (err error) {
}

func (d *document) unmarshal(v any, m *Unmarshaler) (err error) {
if ok := d.verifyResourceUniqueness(); !ok {
return ErrNonuniqueResource
}
// verify full-linkage in-case this is a compound document
if err = d.verifyFullLinkage(true); err != nil {
return
@@ -142,7 +145,7 @@ func unmarshalResourceObjects(ros []*resourceObject, v any, m *Unmarshaler) erro
outType := derefType(reflect.TypeOf(v))
outValue := derefValue(reflect.ValueOf(v))

// first, it must be a struct since we'll be parsing the jsonapi struct tags
// first, it must be a slice since we'll be parsing multiple resource objects
if outType.Kind() != reflect.Slice {
return &TypeError{Actual: outType.String(), Expected: []string{"slice"}}
}