diff --git a/jsonapi.go b/jsonapi.go index 7f811ba..0f8aa2f 100644 --- a/jsonapi.go +++ b/jsonapi.go @@ -385,3 +385,27 @@ type MarshalIdentifier interface { type UnmarshalIdentifier interface { UnmarshalID(id string) error } + +// MarshalType can be optionally implemented to control marshaling of the type field. +// +// The order of operations for marshaling the type field is: +// +// 1. Use MarshalType if it is implemented +// 2. Use the value from the jsonapi tag on the primary field +// 3. Fail +type MarshalType interface { + MarshalType() string +} + +// UnmarshalType can be optionally implemented to control unmarshaling of the type field from a string. +// Since the type is not typically set as a field on the object, this is an opportunity to return an error +// if the passed in type from the payload is unexpected. This allows customization of the expected type field. +// +// The order of operations for checking the type field is: +// +// 1. Use UnmarshalType if it is implemented, fail if it returns an error +// 2. Compare against the type provided in the jsonapi tag on the primary field +// 3. Fail +type UnmarshalType interface { + UnmarshalType(jsonapiType string) error +} diff --git a/jsonapi_test.go b/jsonapi_test.go index d59f4ec..7d8134b 100644 --- a/jsonapi_test.go +++ b/jsonapi_test.go @@ -56,6 +56,7 @@ var ( articleLinkedInvalidSelfMeta = ArticleLinkedInvalidSelfMeta{ID: "1"} articleOmitTitleFull = ArticleOmitTitle{ID: "1"} articleOmitTitlePartial = ArticleOmitTitle{ID: "1", Subtitle: "A"} + articleACustomType = ArticleTypeOverride{ID: "1", Title: "A"} articleAIntID = ArticleIntID{ID: 1, Title: "A"} articleBIntID = ArticleIntID{ID: 2, Title: "B"} articlesIntIDABPtr = []*ArticleIntID{&articleAIntID, &articleBIntID} @@ -150,6 +151,7 @@ var ( emptySingleBody = `{"data":{}}` emptyManyBody = `{"data":[]}` articleABody = `{"data":{"type":"articles","id":"1","attributes":{"title":"A"}}}` + articleACustomTypeBody = `{"data":{"type":"customarticles","id":"1","attributes":{"title":"A"}}}` articleANoIDBody = `{"data":{"type":"articles","attributes":{"title":"A"}}}` articleAInvalidTypeBody = `{"data":{"type":"not-articles","id":"1","attributes":{"title":"A"}}}` articleOmitTitleFullBody = `{"data":{"type":"articles","id":"1"}}` @@ -352,6 +354,22 @@ type ArticleIntID struct { Title string `jsonapi:"attribute" json:"title"` } +type ArticleTypeOverride struct { + ID string `jsonapi:"primary,articles"` + Title string `jsonapi:"attribute" json:"title"` +} + +func (a *ArticleTypeOverride) MarshalType() string { + return "customarticles" +} + +func (a *ArticleTypeOverride) UnmarshalType(jsonapiType string) error { + if jsonapiType != "customarticles" { + return fmt.Errorf("invalid type: %s", jsonapiType) + } + return nil +} + func (a *ArticleIntID) MarshalID() string { return fmt.Sprintf("%d", a.ID) } diff --git a/marshal.go b/marshal.go index 996c90e..955a8dc 100644 --- a/marshal.go +++ b/marshal.go @@ -355,6 +355,9 @@ func (d *document) makeResourceObject(v any, vt reflect.Type, m *Marshaler) (*re switch tag.directive { case primary: ro.Type = tag.resourceType + if vmt, ok := v.(MarshalType); ok { + ro.Type = vmt.MarshalType() + } if !isValidMemberName(ro.Type, m.memberNameValidationMode) { // type names count as member names return nil, &MemberNameValidationError{ro.Type} diff --git a/marshal_test.go b/marshal_test.go index 50f461b..57f194c 100644 --- a/marshal_test.go +++ b/marshal_test.go @@ -158,6 +158,11 @@ func TestMarshal(t *testing.T) { given: []string{"a", "b"}, expect: "", expectError: &TypeError{Actual: "string", Expected: []string{"struct"}}, + }, { + description: "*ArticleMarshalType (MarshalType)", + given: &articleACustomType, + expect: articleACustomTypeBody, + expectError: nil, }, { description: "*ArticleIntID (MarshalIdentifier)", given: &articleAIntID, diff --git a/unmarshal.go b/unmarshal.go index c8c8e30..92eafb3 100644 --- a/unmarshal.go +++ b/unmarshal.go @@ -3,6 +3,7 @@ package jsonapi import ( "encoding" "encoding/json" + "errors" "reflect" ) @@ -232,7 +233,17 @@ func (ro *resourceObject) unmarshalFields(v any, rv reflect.Value, rt reflect.Ty if setPrimary { return ErrUnmarshalDuplicatePrimaryField } - if ro.Type != jsonapiTag.resourceType { + + if vut, ok := v.(UnmarshalType); ok { + err := vut.UnmarshalType(ro.Type) + if err != nil { + return errors.Join( + &TypeError{Actual: ro.Type, Expected: []string{jsonapiTag.resourceType}}, + err, + ) + } + + } else if ro.Type != jsonapiTag.resourceType { return &TypeError{Actual: ro.Type, Expected: []string{jsonapiTag.resourceType}} } if !isValidMemberName(ro.Type, m.memberNameValidationMode) { diff --git a/unmarshal_test.go b/unmarshal_test.go index 67ae5a4..91b1ad0 100644 --- a/unmarshal_test.go +++ b/unmarshal_test.go @@ -149,6 +149,16 @@ func TestUnmarshal(t *testing.T) { }, expect: []*Article{&articleA, &articleB}, expectError: nil, + }, { + description: "*ArticleUnmarshalType", + given: articleACustomTypeBody, + do: func(body []byte) (any, error) { + var a ArticleTypeOverride + err := Unmarshal(body, &a) + return &a, err + }, + expect: &articleACustomType, + expectError: nil, }, { description: "*ArticleIntID", given: articleABody,