From ddf716a339f810a1981d4e13e4aecb7c0f1bb3e3 Mon Sep 17 00:00:00 2001 From: Kyle Purdon Date: Thu, 20 Apr 2023 08:09:25 -0600 Subject: [PATCH] Support encoding TextMarshaler and TextUnmarshaler (#24) This will allow common types like google/uuid.UUID to be used without requiring anything special to be implemented. --- README.md | 28 +++++++++++++++++++++++++++- jsonapi_test.go | 30 ++++++++++++++++++++++++++++++ marshal.go | 14 +++++++++++++- marshal_test.go | 10 ++++++++++ unmarshal.go | 24 ++++++++++++++++++++++-- unmarshal_test.go | 20 ++++++++++++++++++++ 6 files changed, 122 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ad5e810..1a18844 100644 --- a/README.md +++ b/README.md @@ -100,12 +100,38 @@ Both [jsonapi.Marshal](https://pkg.go.dev/github.com/DataDog/jsonapi#Marshal) an [Identification](https://jsonapi.org/format/1.0/#document-resource-object-identification) MUST be represented as a `string` regardless of the actual type in Go. To support non-string types for the primary field you can implement optional interfaces. +You can implement the following on the parent types (that contain non-string fields): + | Context | Interface | | --- | --- | -| Marshal | [fmt.Stringer](https://pkg.go.dev/fmt#Stringer) | | Marshal | [jsonapi.MarshalIdentifier](https://pkg.go.dev/github.com/DataDog/jsonapi#MarshalIdentifier) | | Unmarshal | [jsonapi.UnmarshalIdentifier](https://pkg.go.dev/github.com/DataDog/jsonapi#UnmarshalIdentifier) | +You can implement the following on the field types themselves if they are not already implemented. + +| Context | Interface | +| --- | --- | +| Marshal | [fmt.Stringer](https://pkg.go.dev/fmt#Stringer) | +| Marshal | [encoding.TextMarshaler](https://pkg.go.dev/encoding#TextMarshaler) | +| Unmarshal | [encoding.TextUnmarshaler](https://pkg.go.dev/encoding#TextUnmarshaler) | + +### Order of Operations + +#### Marshaling + +1. Use MarshalIdentifier if it is implemented on the parent type +2. Use the value directly if it is a string +3. Use fmt.Stringer if it is implemented +4. Use encoding.TextMarshaler if it is implemented +5. Fail + +#### Unmarshaling + +1. Use UnmarshalIdentifier if it is implemented on the parent type +2. Use encoding.TextUnmarshaler if it is implemented +3. Use the value directly if it is a string +4. Fail + ## 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_test.go b/jsonapi_test.go index 22a685d..cd195ab 100644 --- a/jsonapi_test.go +++ b/jsonapi_test.go @@ -1,6 +1,7 @@ package jsonapi import ( + "encoding" "fmt" "net/http" "strconv" @@ -54,6 +55,9 @@ var ( articleAIntIDID = ArticleIntIDID{ID: IntID(1), Title: "A"} articleBIntIDID = ArticleIntIDID{ID: IntID(2), Title: "B"} articlesIntIDIDABPtr = []*ArticleIntIDID{&articleAIntIDID, &articleBIntIDID} + articleAEncodingIntID = ArticleEncodingIntID{ID: EncodingIntID(1), Title: "A"} + articleBEncodingIntID = ArticleEncodingIntID{ID: EncodingIntID(2), Title: "B"} + articlesEncodingIntIDABPtr = []*ArticleEncodingIntID{&articleAEncodingIntID, &articleBEncodingIntID} articleEmbedded = ArticleEmbedded{ID: "1", Title: "A", Metadata: Metadata{LastModified: time.Date(1989, 06, 15, 0, 0, 0, 0, time.UTC)}} articleEmbeddedPointer = ArticleEmbeddedPointer{ID: "1", Title: "A", Metadata: &Metadata{LastModified: time.Date(1989, 06, 15, 0, 0, 0, 0, time.UTC)}} @@ -312,6 +316,32 @@ func (a *ArticleIntIDID) UnmarshalID(id string) error { return nil } +var ( + // ensure EncodingIntID implements encoding.[TextMarshaler|TextUnmarshaler] + _ encoding.TextMarshaler = (*EncodingIntID)(nil) + _ encoding.TextUnmarshaler = (*EncodingIntID)(nil) +) + +type EncodingIntID int + +func (i EncodingIntID) MarshalText() ([]byte, error) { + return []byte(fmt.Sprintf("%d", i)), nil +} + +func (i *EncodingIntID) UnmarshalText(text []byte) error { + v, err := strconv.Atoi(string(text)) + if err != nil { + return err + } + *i = EncodingIntID(v) + return nil +} + +type ArticleEncodingIntID struct { + ID EncodingIntID `jsonapi:"primary,articles"` + Title string `jsonapi:"attribute" json:"title"` +} + type ArticleWithResourceObjectMeta struct { ID string `jsonapi:"primary,articles"` Title string `jsonapi:"attribute" json:"title"` diff --git a/marshal.go b/marshal.go index 5b4e96a..1cc6c6e 100644 --- a/marshal.go +++ b/marshal.go @@ -1,6 +1,7 @@ package jsonapi import ( + "encoding" "encoding/json" "fmt" "net/url" @@ -365,7 +366,8 @@ func makeResourceObject(v any, vt reflect.Type, m *Marshaler, isRelationship boo // 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 + // 4. Use encoding.TextMarshaler if it is implemented + // 5. Fail if vm, ok := v.(MarshalIdentifier); ok { ro.ID = vm.MarshalID() @@ -387,6 +389,16 @@ func makeResourceObject(v any, vt reflect.Type, m *Marshaler, isRelationship boo continue } + if fvm, ok := fv.(encoding.TextMarshaler); ok { + vb, err := fvm.MarshalText() + if err != nil { + return nil, err + } + ro.ID = string(vb) + foundPrimary = true + continue + } + return nil, ErrMarshalInvalidPrimaryField case attribute: if isRelationship { diff --git a/marshal_test.go b/marshal_test.go index 9d82cac..e10820e 100644 --- a/marshal_test.go +++ b/marshal_test.go @@ -166,6 +166,16 @@ func TestMarshal(t *testing.T) { given: &articlesIntIDIDABPtr, expect: articlesABBody, expectError: nil, + }, { + description: "*ArticleEncodingIntID (encoding.TextMarshaler)", + given: &articleAEncodingIntID, + expect: articleABody, + expectError: nil, + }, { + description: "[]*ArticleEncodinfIntID (encoding.TextMarshaler)", + given: &articlesEncodingIntIDABPtr, + expect: articlesABBody, + expectError: nil, }, { description: "non-string id", given: &struct { diff --git a/unmarshal.go b/unmarshal.go index 740661f..ad31991 100644 --- a/unmarshal.go +++ b/unmarshal.go @@ -1,6 +1,7 @@ package jsonapi import ( + "encoding" "encoding/json" "reflect" ) @@ -219,8 +220,9 @@ func (ro *resourceObject) unmarshalFields(v any, m *Unmarshaler) error { } // to unmarshal the id we follow these rules // 1. Use UnmarshalIdentifier if it is implemented - // 2. Use the value directly if it is a string - // 3. Fail + // 2. Use encoding.TextUnmarshaler if it is implemented + // 3. Use the value directly if it is a string + // 4. Fail if vu, ok := v.(UnmarshalIdentifier); ok { if err := vu.UnmarshalID(ro.ID); err != nil { return err @@ -228,6 +230,24 @@ func (ro *resourceObject) unmarshalFields(v any, m *Unmarshaler) error { setPrimary = true continue } + + // get the underlying fields interface + var fvi any + switch fv.CanAddr() { + case true: + fvi = fv.Addr().Interface() + default: + fvi = fv.Interface() + } + + if fviu, ok := fvi.(encoding.TextUnmarshaler); ok { + if err := fviu.UnmarshalText([]byte(ro.ID)); err != nil { + return err + } + setPrimary = true + continue + } + if fv.Kind() == reflect.String { fv.SetString(ro.ID) setPrimary = true diff --git a/unmarshal_test.go b/unmarshal_test.go index 5a89f91..be4da3f 100644 --- a/unmarshal_test.go +++ b/unmarshal_test.go @@ -119,6 +119,26 @@ func TestUnmarshal(t *testing.T) { }, expect: []*ArticleIntIDID{&articleAIntIDID, &articleBIntIDID}, expectError: nil, + }, { + description: "*ArticleEncodingIntID", + given: articleABody, + do: func(body []byte) (any, error) { + var a ArticleEncodingIntID + err := Unmarshal(body, &a) + return &a, err + }, + expect: &articleAEncodingIntID, + expectError: nil, + }, { + description: "[]*ArticleEncodingIntID", + given: articlesABBody, + do: func(body []byte) (any, error) { + var a []*ArticleEncodingIntID + err := Unmarshal(body, &a) + return a, err + }, + expect: []*ArticleEncodingIntID{&articleAEncodingIntID, &articleBEncodingIntID}, + expectError: nil, }, { description: "*ArticleWithMeta", given: articleAWithMetaBody,