diff --git a/README.md b/README.md index 0044aff..257542e 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,23 @@ You can implement the following on the field types themselves if they are not al 3. Use the value directly if it is a string 4. Fail +## Resource Identifier Metadata + +JSON:API permits a resource identifier object in relationship data to contain a `meta` object. Implement `jsonapi.MarshalResourceIdentifierMeta` on the related resource type to add this object. + +```go +type Comment struct { + ID string `jsonapi:"primary,comments"` + Index int +} + +func (c Comment) MarshalResourceIdentifierMeta() any { + return map[string]any{"index": c.Index} +} +``` + +The marshaler uses this interface only for relationship data. Return `nil` to omit the `meta` member. The `jsonapi:"meta"` directive continues to control metadata on the relationship object. + ## Links [Links](https://jsonapi.org/format/1.0/#document-links) are supported via two interfaces and the [Link](https://pkg.go.dev/github.com/DataDog/jsonapi#Link) type. To include links you must implement one or both of the following interfaces. diff --git a/jsonapi.go b/jsonapi.go index 0f8aa2f..73b0a12 100644 --- a/jsonapi.go +++ b/jsonapi.go @@ -363,6 +363,14 @@ type LinkableRelation interface { LinkRelation(relation string) *Link } +// MarshalResourceIdentifierMeta can be implemented to add meta to a resource identifier object. +// The marshaler uses this interface only when it marshals a resource as relationship data. +// The method must return a map or struct. +// Return nil to omit the meta member. +type MarshalResourceIdentifierMeta interface { + MarshalResourceIdentifierMeta() any +} + // 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: diff --git a/marshal.go b/marshal.go index 955a8dc..9d1f8fa 100644 --- a/marshal.go +++ b/marshal.go @@ -473,6 +473,16 @@ func (d *document) makeResourceObject(v any, vt reflect.Type, m *Marshaler) (*re return nil, ErrEmptyPrimaryField } + // Resource identifier objects can have their own meta. Keep this separate from + // the relationship document meta that the jsonapi:"meta" directive controls. + if d.isRelationship { + metaObject, err := marshalResourceIdentifierMeta(v) + if err != nil { + return nil, err + } + ro.Meta = metaObject + } + // if Linkable is implemented include ResourceObject.Links if lv, ok := v.(Linkable); ok { link := lv.Link() @@ -485,6 +495,28 @@ func (d *document) makeResourceObject(v any, vt reflect.Type, m *Marshaler) (*re return ro, nil } +func marshalResourceIdentifierMeta(v any) (any, error) { + vm, ok := v.(MarshalResourceIdentifierMeta) + if !ok { + return nil, nil + } + + metaObject := vm.MarshalResourceIdentifierMeta() + if err := checkMeta(metaObject); err != nil { + return nil, err + } + if metaObject == nil { + return nil, nil + } + + metaValue := reflect.ValueOf(metaObject) + if canBeNil(metaValue) && metaValue.IsNil() { + return nil, nil + } + + return metaObject, nil +} + func getFlattenedFields(iface interface{}) []struct { v reflect.Value f reflect.StructField diff --git a/marshal_resource_identifier_meta_test.go b/marshal_resource_identifier_meta_test.go new file mode 100644 index 0000000..92bcbb0 --- /dev/null +++ b/marshal_resource_identifier_meta_test.go @@ -0,0 +1,137 @@ +package jsonapi + +import ( + "testing" + + "github.com/DataDog/jsonapi/internal/is" +) + +type resourceIdentifierMetaTestNode struct { + ID string `jsonapi:"primary,nodes"` + IdentifierMeta any + RelationshipMeta any `jsonapi:"meta"` +} + +func (n resourceIdentifierMetaTestNode) MarshalResourceIdentifierMeta() any { + return n.IdentifierMeta +} + +type resourceIdentifierMetaTestLayer struct { + ID string `jsonapi:"primary,layers"` + Node *resourceIdentifierMetaTestNode `jsonapi:"relationship" json:"node,omitempty"` + Nodes []resourceIdentifierMetaTestNode `jsonapi:"relationship" json:"nodes,omitempty"` +} + +func TestMarshalResourceIdentifierMeta(t *testing.T) { + t.Parallel() + + tests := []struct { + description string + given any + expect string + expectError error + }{ + { + description: "to-one relationship", + given: &resourceIdentifierMetaTestLayer{ + ID: "layer-a", + Node: &resourceIdentifierMetaTestNode{ + ID: "node-a", + IdentifierMeta: map[string]any{"index": 0}, + }, + }, + expect: `{"data":{"id":"layer-a","type":"layers","relationships":{"node":{"data":{"id":"node-a","type":"nodes","meta":{"index":0}}}}}}`, + }, + { + description: "to-many relationship with distinct metadata", + given: &resourceIdentifierMetaTestLayer{ + ID: "layer-a", + Nodes: []resourceIdentifierMetaTestNode{ + {ID: "node-a", IdentifierMeta: map[string]any{"index": 0}}, + {ID: "node-b", IdentifierMeta: map[string]any{"index": 1}}, + }, + }, + expect: `{"data":{"id":"layer-a","type":"layers","relationships":{"nodes":{"data":[{"id":"node-a","type":"nodes","meta":{"index":0}},{"id":"node-b","type":"nodes","meta":{"index":1}}]}}}}`, + }, + { + description: "nil metadata", + given: &resourceIdentifierMetaTestLayer{ + ID: "layer-a", + Node: &resourceIdentifierMetaTestNode{ + ID: "node-a", + }, + }, + expect: `{"data":{"id":"layer-a","type":"layers","relationships":{"node":{"data":{"id":"node-a","type":"nodes"}}}}}`, + }, + { + description: "typed nil metadata", + given: &resourceIdentifierMetaTestLayer{ + ID: "layer-a", + Node: &resourceIdentifierMetaTestNode{ + ID: "node-a", + IdentifierMeta: (*struct{})(nil), + }, + }, + expect: `{"data":{"id":"layer-a","type":"layers","relationships":{"node":{"data":{"id":"node-a","type":"nodes"}}}}}`, + }, + { + description: "empty metadata object", + given: &resourceIdentifierMetaTestLayer{ + ID: "layer-a", + Node: &resourceIdentifierMetaTestNode{ + ID: "node-a", + IdentifierMeta: map[string]any{}, + }, + }, + expect: `{"data":{"id":"layer-a","type":"layers","relationships":{"node":{"data":{"id":"node-a","type":"nodes","meta":{}}}}}}`, + }, + { + description: "invalid metadata", + given: &resourceIdentifierMetaTestLayer{ + ID: "layer-a", + Node: &resourceIdentifierMetaTestNode{ + ID: "node-a", + IdentifierMeta: "invalid", + }, + }, + expectError: &TypeError{Actual: "string", Expected: []string{"struct", "map"}}, + }, + { + description: "resource identifier and relationship metadata", + given: &resourceIdentifierMetaTestLayer{ + ID: "layer-a", + Node: &resourceIdentifierMetaTestNode{ + ID: "node-a", + IdentifierMeta: map[string]any{"index": 0}, + RelationshipMeta: map[string]any{"total": 2}, + }, + }, + expect: `{"data":{"id":"layer-a","type":"layers","relationships":{"node":{"data":{"id":"node-a","type":"nodes","meta":{"index":0}},"meta":{"total":2}}}}}`, + }, + { + description: "top-level resource ignores resource identifier metadata", + given: &resourceIdentifierMetaTestNode{ + ID: "node-a", + IdentifierMeta: map[string]any{"index": 0}, + }, + expect: `{"data":{"id":"node-a","type":"nodes"}}`, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.description, func(t *testing.T) { + t.Parallel() + + actual, err := Marshal(tc.given) + if tc.expectError != nil { + is.EqualError(t, tc.expectError, err) + is.Nil(t, actual) + return + } + + is.MustNoError(t, err) + is.EqualJSON(t, tc.expect, string(actual)) + }) + } +}