Skip to content

Commit

Permalink
Support encoding TextMarshaler and TextUnmarshaler (#24)
Browse files Browse the repository at this point in the history
This will allow common types like google/uuid.UUID to be used without
requiring anything special to be implemented.
  • Loading branch information
kpurdon authored Apr 20, 2023
1 parent 4b43213 commit ddf716a
Show file tree
Hide file tree
Showing 6 changed files with 122 additions and 4 deletions.
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions jsonapi_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package jsonapi

import (
"encoding"
"fmt"
"net/http"
"strconv"
Expand Down Expand Up @@ -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)}}

Expand Down Expand Up @@ -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"`
Expand Down
14 changes: 13 additions & 1 deletion marshal.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package jsonapi

import (
"encoding"
"encoding/json"
"fmt"
"net/url"
Expand Down Expand Up @@ -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()
Expand All @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions marshal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
24 changes: 22 additions & 2 deletions unmarshal.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package jsonapi

import (
"encoding"
"encoding/json"
"reflect"
)
Expand Down Expand Up @@ -219,15 +220,34 @@ 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
}
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
Expand Down
20 changes: 20 additions & 0 deletions unmarshal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit ddf716a

Please sign in to comment.