Skip to content

Commit

Permalink
Merge pull request #24 from xmidt-org/feature/decodehooks
Browse files Browse the repository at this point in the history
Feature/decodehooks
  • Loading branch information
johnabass authored Oct 2, 2020
2 parents 44cb677 + 2864449 commit 42e5f9b
Show file tree
Hide file tree
Showing 3 changed files with 292 additions and 0 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## [Unreleased]
- added a viper option for aggregating decode hooks
- added a viper option for decoding encoding.TextUnmarshaler implementations
- added a set of default decode hooks

## [v0.1.7]
- added sonar integration
Expand Down
93 changes: 93 additions & 0 deletions decode.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package arrange

import (
"encoding"
"reflect"

"github.com/mitchellh/mapstructure"
"github.com/spf13/viper"
)
Expand Down Expand Up @@ -79,3 +82,93 @@ func Merge(opts ...[]viper.DecoderConfigOption) viper.DecoderConfigOption {
}
}
}

// DefaultDecodeHooks is a viper option that sets the decode hooks to more useful defaults.
// This includes the ones set by viper itself, plus hooks defined by this package.
//
// Note that you can still use ComposeDecodeHooks with this option as long as you use
// it after this one.
//
// See https://pkg.go.dev/github.com/spf13/viper#DecodeHook
func DefaultDecodeHooks(dc *mapstructure.DecoderConfig) {
dc.DecodeHook = mapstructure.ComposeDecodeHookFunc(
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
TextUnmarshalerHookFunc,
)
}

// ComposeDecodeHooks adds more decode hook functions to mapstructure's DecoderConfig. If
// there are already decode hooks, they are preserved and the given hooks are appended.
//
// See https://pkg.go.dev/github.com/mitchellh/mapstructure#ComposeDecodeHookFunc
func ComposeDecodeHooks(fs ...mapstructure.DecodeHookFunc) viper.DecoderConfigOption {
return func(dc *mapstructure.DecoderConfig) {
if dc.DecodeHook != nil {
dc.DecodeHook = mapstructure.ComposeDecodeHookFunc(
append([]mapstructure.DecodeHookFunc{dc.DecodeHook},
fs...,
)...,
)
} else {
dc.DecodeHook = mapstructure.ComposeDecodeHookFunc(fs...)
}
}
}

var (
textUnmarshalerType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem()
)

// TextUnmarshalerHookFunc is a mapstructure.DecodeHookFunc that honors the destination
// type's encoding.TextUnmarshaler implementation, using it to convert the src. The src
// parameter must be a string, or else this function does not attempt any conversion.
//
// The to type must be one of two kinds:
//
// First, to can be a non-pointer type which implements encoding.TextUnmarshaler through a
// pointer receiver. The time.Time type is an example of this. In this case, this function
// uses reflect.New to create a new instance and invokes UnmarshalText through that pointer.
// The pointer's element is returned, along with any error.
//
// Second, to can be a pointer type which itself implements encoding.TextUnmarshaler. In this
// case reflect.New is used to create a new instances, then UnmarshalText is invoked through
// that pointer. That pointer is then returned, along with any error.
//
// This function explicitly does not support more than one level of indirection. For example,
// **T where *T implements encoding.TextUnmarshaler.
//
// In any case where this function does no conversion, it returns src and a nil error. This
// is the contract required by mapstructure.DecodeHookFunc.
func TextUnmarshalerHookFunc(_, to reflect.Type, src interface{}) (interface{}, error) {
if text, ok := src.(string); ok {
switch {
// the "to" type is not a pointer and a pointer to "to" implements encoding.TextUnmarshaler
// this is by far the most common case. For example:
//
// struct {
// Time time.Time // non-pointer, but *time.Time implements encoding.TextUnmarshaler
// }
case to.Kind() != reflect.Ptr && reflect.PtrTo(to).Implements(textUnmarshalerType):
ptr := reflect.New(to)
tu := ptr.Interface().(encoding.TextUnmarshaler)
err := tu.UnmarshalText([]byte(text))
return ptr.Elem().Interface(), err

// the "to" type is a pointer to a value and it implements encoding.TextUnmarshaler
// commonly occurs with "optional" properties, where a nil value means
// it wasn't set
//
// struct {
// Time *time.Time
// }
case to.Kind() == reflect.Ptr && to.Elem().Kind() != reflect.Ptr && to.Implements(textUnmarshalerType):
ptr := reflect.New(to.Elem()) // this will be the same type as "to"
tu := ptr.Interface().(encoding.TextUnmarshaler)
err := tu.UnmarshalText([]byte(text))
return tu, err
}
}

return src, nil
}
196 changes: 196 additions & 0 deletions decode_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
package arrange

import (
"fmt"
"reflect"
"strconv"
"testing"
"time"

"github.com/mitchellh/mapstructure"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestErrorUnused(t *testing.T) {
Expand Down Expand Up @@ -151,3 +156,194 @@ func TestMerge(t *testing.T) {
dc,
)
}

func TestDefaultDecodeHooks(t *testing.T) {
var (
assert = assert.New(t)
require = require.New(t)

config mapstructure.DecoderConfig
)

const timeString = "1998-11-13T12:11:56Z"

expectedTime, err := time.Parse(time.RFC3339, timeString)
require.NoError(err)

DefaultDecodeHooks(&config)
require.NotNil(config.DecodeHook)

result, err := mapstructure.DecodeHookExec(
config.DecodeHook,
reflect.TypeOf(""),
reflect.TypeOf(time.Duration(0)),
"15s",
)

assert.Equal(15*time.Second, result)
assert.NoError(err)

result, err = mapstructure.DecodeHookExec(
config.DecodeHook,
reflect.TypeOf(""),
reflect.TypeOf([]string{}),
"a,b,c",
)

assert.Equal([]string{"a", "b", "c"}, result)
assert.NoError(err)

result, err = mapstructure.DecodeHookExec(
config.DecodeHook,
reflect.TypeOf(""),
reflect.TypeOf(time.Time{}),
timeString,
)

assert.Equal(expectedTime, result)
assert.NoError(err)
}

func testComposeDecodeHooksInitiallyNil(t *testing.T) {
for _, length := range []int{1, 2, 5} {
t.Run(fmt.Sprintf("len=%d", length), func(t *testing.T) {
var (
assert = assert.New(t)
require = require.New(t)

hooks []mapstructure.DecodeHookFunc
expectedOrder []int
actualOrder []int
config mapstructure.DecoderConfig
)

for i := 0; i < length; i++ {
i := i
expectedOrder = append(expectedOrder, i)
hooks = append(hooks, func(from, to reflect.Type, src interface{}) (interface{}, error) {
assert.Equal(reflect.TypeOf(""), from)
assert.Equal(reflect.TypeOf(int(0)), to)
assert.Equal("test", src)
actualOrder = append(actualOrder, i)
return src, nil
})
}

ComposeDecodeHooks(hooks...)(&config)
require.NotNil(config.DecodeHook)

mapstructure.DecodeHookExec(
config.DecodeHook,
reflect.TypeOf(""),
reflect.TypeOf(int(0)),
"test",
)

assert.Equal(expectedOrder, actualOrder)
})
}
}

func testComposeDecodeHooksAppendToExisting(t *testing.T) {
for _, length := range []int{1, 2, 5} {
t.Run(fmt.Sprintf("len=%d", length), func(t *testing.T) {
var (
assert = assert.New(t)
require = require.New(t)

hooks []mapstructure.DecodeHookFunc
expectedOrder = []int{0}
actualOrder []int
config = mapstructure.DecoderConfig{
DecodeHook: func(from, to reflect.Type, src interface{}) (interface{}, error) {
assert.Equal(reflect.TypeOf(""), from)
assert.Equal(reflect.TypeOf(int(0)), to)
assert.Equal("test", src)
actualOrder = append(actualOrder, 0)
return src, nil
},
}
)

for i := 0; i < length; i++ {
i := i
expectedOrder = append(expectedOrder, i+1)
hooks = append(hooks, func(from, to reflect.Type, src interface{}) (interface{}, error) {
assert.Equal(reflect.TypeOf(""), from)
assert.Equal(reflect.TypeOf(int(0)), to)
assert.Equal("test", src)
actualOrder = append(actualOrder, i+1)
return src, nil
})
}

ComposeDecodeHooks(hooks...)(&config)
require.NotNil(config.DecodeHook)

mapstructure.DecodeHookExec(
config.DecodeHook,
reflect.TypeOf(""),
reflect.TypeOf(int(0)),
"test",
)

assert.Equal(expectedOrder, actualOrder)
})
}
}

func TestComposeDecodeHooks(t *testing.T) {
t.Run("InitiallyNil", testComposeDecodeHooksInitiallyNil)
t.Run("AppendToExisting", testComposeDecodeHooksAppendToExisting)
}

func TestTextUnmarshalerHookFunc(t *testing.T) {
const timeString = "2013-07-11T09:13:07Z"

expectedTime, err := time.Parse(time.RFC3339, timeString)
if err != nil {
t.Fatal(err)
}

var (
testData = []struct {
from reflect.Type
to reflect.Type
src interface{}

expected interface{}
expectsErr bool
}{
{
from: reflect.TypeOf(int(0)),
to: reflect.TypeOf(""),
src: 123,
expected: 123,
},
{
from: reflect.TypeOf(""),
to: reflect.TypeOf(time.Time{}),
src: timeString,
expected: expectedTime,
},
{
from: reflect.TypeOf(""),
to: reflect.TypeOf(new(time.Time)),
src: timeString,
expected: &expectedTime,
},
}
)

for i, record := range testData {
t.Run(strconv.Itoa(i), func(t *testing.T) {
var (
assert = assert.New(t)
actual, actualErr = TextUnmarshalerHookFunc(record.from, record.to, record.src)
)

assert.Equal(record.expected, actual)
assert.Equal(record.expectsErr, actualErr != nil)
})
}
}

0 comments on commit 42e5f9b

Please sign in to comment.