Skip to content

Commit

Permalink
Support custom types (#114)
Browse files Browse the repository at this point in the history
This applies to:
- ID
- Attributes
- Relationships

Custom types need to be registered before usage, using the new
`jsonapi.RegisterType()` function which takes 3 arguments:
- the type to support (`reflect.Type`)
- the function to use when marshalling a response
- the function to use when unmarshalling a response

Example:
````
RegisterType(uuidType,
  func(value interface{}) (string, error) {
    result := value.(*UUID).String()
    return result, nil
  },
  func(value string) (interface{}, error) {
    return UUIDFromString(value)
  })
````

The custom type will be represented as a `string` in the JSON document
in the requests and responses.

Fixes #114

Signed-off-by: Xavier Coulon <[email protected]>
  • Loading branch information
xcoulon committed Sep 13, 2017
1 parent 3e6ead0 commit 91ece5c
Show file tree
Hide file tree
Showing 7 changed files with 424 additions and 37 deletions.
42 changes: 42 additions & 0 deletions custom_types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package jsonapi

import "reflect"

type MarshallingFunc func(interface{}) (string, error)
type UnmarshallingFunc func(string) (interface{}, error)

// map of functions to use to convert the field value into a JSON string
var customTypeMarshallingFuncs map[reflect.Type]MarshallingFunc

// map of functions to use to convert the JSON string value into the target field
var customTypeUnmarshallingFuncs map[reflect.Type]UnmarshallingFunc

// init initializes the maps
func init() {
customTypeMarshallingFuncs = make(map[reflect.Type]MarshallingFunc, 0)
customTypeUnmarshallingFuncs = make(map[reflect.Type]UnmarshallingFunc, 0)
}

// IsRegisteredType checks if the given type `t` is registered as a custom type
func IsRegisteredType(t reflect.Type) bool {
_, ok := customTypeMarshallingFuncs[t]
return ok
}

// RegisterType registers the functions to convert the field from a custom type to a string and vice-versa
// in the JSON requests/responses.
// The `marshallingFunc` must be a function that returns a string (along with an error if something wrong happened)
// and the `unmarshallingFunc` must be a function that takes
// a string as its sole argument and return an instance of `typeName` (along with an error if something wrong happened).
// Eg: `uuid.FromString(string) uuid.UUID {...} and `uuid.String() string {...}
func RegisterType(customType reflect.Type, marshallingFunc MarshallingFunc, unmarshallingFunc UnmarshallingFunc) {
// register the pointer to the type
customTypeMarshallingFuncs[customType] = marshallingFunc
customTypeUnmarshallingFuncs[customType] = unmarshallingFunc
}

// resetCustomTypeRegistrations resets the custom type registration, which is useful during testing
func resetCustomTypeRegistrations() {
customTypeMarshallingFuncs = make(map[reflect.Type]MarshallingFunc, 0)
customTypeUnmarshallingFuncs = make(map[reflect.Type]UnmarshallingFunc, 0)
}
25 changes: 25 additions & 0 deletions custom_types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package jsonapi

import (
"reflect"
"testing"
)

func TestRegisterCustomTypes(t *testing.T) {
for _, uuidType := range []reflect.Type{reflect.TypeOf(UUID{}), reflect.TypeOf(&UUID{})} {
// given
resetCustomTypeRegistrations() // make sure no other registration interferes with this test
// when
RegisterType(uuidType,
func(value interface{}) (string, error) {
return "", nil
},
func(value string) (interface{}, error) {
return nil, nil
})
// then
if !IsRegisteredType(uuidType) {
t.Fatalf("Expected `%v` to be registered but it was not", uuidType)
}
}
}
38 changes: 38 additions & 0 deletions models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,44 @@ type ModelBadTypes struct {
TimePtrField *time.Time `jsonapi:"attr,time_ptr_field"`
}

type ModelWithUUIDs struct {
ID UUID `jsonapi:"primary,customtypes"`
UUIDField UUID `jsonapi:"attr,uuid_field"`
LatestRelatedModel *RelatedModelWithUUIDs `jsonapi:"relation,latest_relatedmodel"`
RelatedModels []*RelatedModelWithUUIDs `jsonapi:"relation,relatedmodels"`
}
type RelatedModelWithUUIDs struct {
ID UUID `jsonapi:"primary,relatedtypes"`
UUIDField UUID `jsonapi:"attr,uuid_field"`
}

type ModelWithUUIDPtrs struct {
ID *UUID `jsonapi:"primary,customtypes"`
UUIDField *UUID `jsonapi:"attr,uuid_field"`
LatestRelatedModel *RelatedModelWithUUIDPtrs `jsonapi:"relation,latest_relatedmodel"`
RelatedModels []*RelatedModelWithUUIDPtrs `jsonapi:"relation,relatedmodels"`
}

type RelatedModelWithUUIDPtrs struct {
ID *UUID `jsonapi:"primary,relatedtypes"`
UUIDField *UUID `jsonapi:"attr,uuid_field"`
}

type UUID struct {
string
}

func UUIDFromString(s string) (*UUID, error) {
return &UUID{s}, nil
}
func (u UUID) String() string {
return u.string
}

func (u UUID) Equal(other UUID) bool {
return u.string == other.string
}

type WithPointer struct {
ID *uint64 `jsonapi:"primary,with-pointers"`
Name *string `jsonapi:"attr,name"`
Expand Down
27 changes: 25 additions & 2 deletions request.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ func UnmarshalManyPayload(in io.Reader, t reflect.Type) ([]interface{}, error) {
func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("data is not a jsonapi representation of '%v'", model.Type())
err = fmt.Errorf("data is not a jsonapi representation of '%v': %v", model.Type(), r)
}
}()

Expand Down Expand Up @@ -168,7 +168,7 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node)
break
}

// ID will have to be transmitted as astring per the JSON API spec
// ID will have to be transmitted as a string per the JSON API spec
v := reflect.ValueOf(data.ID)

// Deal with PTRS
Expand All @@ -184,6 +184,17 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node)
assign(fieldValue, v)
continue
}
// custom type that can be unmarshalled from a string
if v.Kind() == reflect.String && IsRegisteredType(fieldType.Type) {
unmashalFunc := customTypeUnmarshallingFuncs[fieldType.Type]
r, err := unmashalFunc(data.ID)
if err != nil {
er = err
} else {
fieldValue.Set(reflect.ValueOf(r))
}
continue
}

// Value was not a string... only other supported type was a numeric,
// which would have been sent as a float value.
Expand Down Expand Up @@ -418,6 +429,18 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node)
continue
}

// custom type that can be unmarshalled from a string
if v.Kind() == reflect.String && IsRegisteredType(fieldType.Type) {
unmashalFunc := customTypeUnmarshallingFuncs[fieldType.Type]
r, err := unmashalFunc(val.(string))
if err != nil {
er = err
} else {
fieldValue.Set(reflect.ValueOf(r))
}
continue
}

// Field was a Pointer type
if fieldValue.Kind() == reflect.Ptr {
var concreteVal reflect.Value
Expand Down
149 changes: 148 additions & 1 deletion request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
"time"
)

func TestUnmarshall_attrStringSlice(t *testing.T) {
func TestUnmarshal_attrStringSlice(t *testing.T) {
out := &Book{}
tags := []string{"fiction", "sale"}
data := map[string]interface{}{
Expand Down Expand Up @@ -249,6 +249,89 @@ func TestUnmarshal_nonNumericID(t *testing.T) {
}
}

func TestUnmarshal_CustomType(t *testing.T) {
// given
// register the custom `UUID` type
uuidType := reflect.TypeOf(UUID{})
RegisterType(uuidType,
func(value interface{}) (string, error) {
return value.(UUID).String(), nil
},
func(value string) (interface{}, error) {
result, err := UUIDFromString(value)
if err != nil {
fmt.Println("Error while converting from string to UUID: " + err.Error())
return nil, err
}
return *result, nil
})
in := sampleModelCustomType()
// when
out := new(ModelWithUUIDs)
if err := UnmarshalPayload(in, out); err != nil {
t.Fatal(err)
}
// then
if !out.ID.Equal(UUID{"12345678-abcd-1234-abcd-123456789012"}) {
t.Fatalf("Did not set ID on dst interface: '%v'", out.ID)
}
if !out.UUIDField.Equal(UUID{"87654321-dcba-4321-dcba-210987654321"}) {
t.Fatalf("Did not set UUIDField on dst interface: '%v'", out.UUIDField)
}
if !out.LatestRelatedModel.ID.Equal(UUID{"12345678-abcd-1234-abcd-111111111111"}) {
t.Fatalf("Did not set LatestRelatedModel.ID on dst interface: '%v'", out.LatestRelatedModel.ID)
}
if !out.LatestRelatedModel.UUIDField.Equal(UUID{"87654321-dcba-4321-dcba-111111111111"}) {
t.Fatalf("Did not set LatestRelatedModel.UUIDField on dst interface: '%v'", out.LatestRelatedModel.UUIDField)
}
if !out.RelatedModels[0].ID.Equal(UUID{"12345678-abcd-1234-abcd-222222222222"}) {
t.Fatalf("Did not set RelatedModels[0].ID on dst interface: '%v'", out.LatestRelatedModel.ID)
}
if !out.RelatedModels[0].UUIDField.Equal(UUID{"87654321-dcba-4321-dcba-222222222222"}) {
t.Fatalf("Did not set LatestRelatedModel.UUIDField on dst interface: '%v'", out.LatestRelatedModel.UUIDField)
}

}

func TestUnmarshal_CustomType_Ptr(t *testing.T) {
// given
// register the custom `*UUID` type
uuidType := reflect.TypeOf(&UUID{})
RegisterType(uuidType,
func(value interface{}) (string, error) {
result := value.(*UUID).String()
return result, nil
},
func(value string) (interface{}, error) {
return UUIDFromString(value)
})
in := sampleModelCustomTypeWithPtrs()
// when
out := new(ModelWithUUIDs)
if err := UnmarshalPayload(in, out); err != nil {
t.Fatal(err)
}
// then
if !out.ID.Equal(UUID{"12345678-abcd-1234-abcd-123456789012"}) {
t.Fatalf("Did not set ID on dst interface: '%v'", out.ID)
}
if !out.UUIDField.Equal(UUID{"87654321-dcba-4321-dcba-210987654321"}) {
t.Fatalf("Did not set UUIDField on dst interface: '%v'", out.UUIDField)
}
if !out.LatestRelatedModel.ID.Equal(UUID{"12345678-abcd-1234-abcd-111111111111"}) {
t.Fatalf("Did not set LatestRelatedModel.ID on dst interface: '%v'", out.LatestRelatedModel.ID)
}
if !out.LatestRelatedModel.UUIDField.Equal(UUID{"87654321-dcba-4321-dcba-111111111111"}) {
t.Fatalf("Did not set LatestRelatedModel.UUIDField on dst interface: '%v'", out.LatestRelatedModel.UUIDField)
}
if !out.RelatedModels[0].ID.Equal(UUID{"12345678-abcd-1234-abcd-222222222222"}) {
t.Fatalf("Did not set RelatedModels[0].ID on dst interface: '%v'", out.LatestRelatedModel.ID)
}
if !out.RelatedModels[0].UUIDField.Equal(UUID{"87654321-dcba-4321-dcba-222222222222"}) {
t.Fatalf("Did not set LatestRelatedModel.UUIDField on dst interface: '%v'", out.LatestRelatedModel.UUIDField)
}
}

func TestUnmarshalSetsAttrs(t *testing.T) {
out, err := unmarshalSamplePayload()
if err != nil {
Expand Down Expand Up @@ -945,3 +1028,67 @@ func sampleSerializedEmbeddedTestModel() *Blog {

return blog
}

func sampleModelCustomType() io.Reader {
model := sampleModelCustomTypePayload()
out := bytes.NewBuffer(nil)
err := MarshalPayload(out, model)
if err != nil {
fmt.Printf("Marshalled Custom Type failed: %s\n", err.Error())
}
fmt.Printf("Marshalled Custom Type: %s\n", out.String())
return out
}

func sampleModelCustomTypePayload() *ModelWithUUIDs {
return &ModelWithUUIDs{
ID: UUID{"12345678-abcd-1234-abcd-123456789012"},
UUIDField: UUID{"87654321-dcba-4321-dcba-210987654321"},
LatestRelatedModel: &RelatedModelWithUUIDs{
ID: UUID{"12345678-abcd-1234-abcd-111111111111"},
UUIDField: UUID{"87654321-dcba-4321-dcba-111111111111"},
},
RelatedModels: []*RelatedModelWithUUIDs{
&RelatedModelWithUUIDs{
ID: UUID{"12345678-abcd-1234-abcd-222222222222"},
UUIDField: UUID{"87654321-dcba-4321-dcba-222222222222"},
},
&RelatedModelWithUUIDs{
ID: UUID{"12345678-abcd-1234-abcd-333333333333"},
UUIDField: UUID{"87654321-dcba-4321-dcba-333333333333"},
},
},
}
}

func sampleModelCustomTypeWithPtrs() io.Reader {
model := sampleModelCustomTypeWithPtrs()
out := bytes.NewBuffer(nil)
err := MarshalPayload(out, model)
if err != nil {
fmt.Printf("Marshalled Custom Type failed: %s\n", err.Error())
}
fmt.Printf("Marshalled Custom Type (with pointers): '%s'\n", out.String())
return out
}

func sampleModelCustomTypeWithPtrsPayload() *ModelWithUUIDPtrs {
return &ModelWithUUIDPtrs{
ID: &UUID{"12345678-abcd-1234-abcd-123456789012"},
UUIDField: &UUID{"87654321-dcba-4321-dcba-210987654321"},
LatestRelatedModel: &RelatedModelWithUUIDPtrs{
ID: &UUID{"12345678-abcd-1234-abcd-111111111111"},
UUIDField: &UUID{"87654321-dcba-4321-dcba-111111111111"},
},
RelatedModels: []*RelatedModelWithUUIDPtrs{
&RelatedModelWithUUIDPtrs{
ID: &UUID{"12345678-abcd-1234-abcd-222222222222"},
UUIDField: &UUID{"87654321-dcba-4321-dcba-222222222222"},
},
&RelatedModelWithUUIDPtrs{
ID: &UUID{"12345678-abcd-1234-abcd-333333333333"},
UUIDField: &UUID{"87654321-dcba-4321-dcba-333333333333"},
},
},
}
}
Loading

0 comments on commit 91ece5c

Please sign in to comment.