Repository: puppetlabs/wash
Pull request #196 (2019-04-09 21:41:14)

Add munge package + implement MarshalJSON/UnmarshalJSON for EntryAttributes


Patch:
diff --git a/api/helpers.go b/api/helpers.go
index 441be4c..dea4a50 100644
--- a/api/helpers.go
+++ b/api/helpers.go
@@ -5,37 +5,9 @@ import (
 	"fmt"
 	"strings"
 
-	apitypes "github.com/puppetlabs/wash/api/types"
 	"github.com/puppetlabs/wash/plugin"
 )
 
-func toAPIAttr(attr plugin.EntryAttributes) apitypes.EntryAttributes {
-	apiAttr := apitypes.EntryAttributes{}
-
-	if attr.HasAtime() {
-		atime := attr.Atime()
-		apiAttr.Atime = &atime
-	}
-	if attr.HasMtime() {
-		mtime := attr.Mtime()
-		apiAttr.Mtime = &mtime
-	}
-	if attr.HasCtime() {
-		ctime := attr.Ctime()
-		apiAttr.Ctime = &ctime
-	}
-	if attr.HasMode() {
-		mode := attr.Mode()
-		apiAttr.Mode = &mode
-	}
-	if attr.HasSize() {
-		size := attr.Size()
-		apiAttr.Size = &size
-	}
-
-	return apiAttr
-}
-
 func findEntry(ctx context.Context, root plugin.Entry, segments []string) (plugin.Entry, *errorResponse) {
 	path := strings.Join(segments, "/")
 
diff --git a/api/list.go b/api/list.go
index 2b734fd..d1717e2 100644
--- a/api/list.go
+++ b/api/list.go
@@ -60,7 +60,7 @@ var listHandler handler = func(w http.ResponseWriter, r *http.Request, p params)
 			Errors:  make(map[string]*apitypes.ErrorObj),
 		}
 
-		result.Attributes = toAPIAttr(plugin.Attributes(entry))
+		result.Attributes = plugin.Attributes(entry)
 		return result
 	}
 
diff --git a/api/types/list.go b/api/types/list.go
index df410a1..4bc79ce 100644
--- a/api/types/list.go
+++ b/api/types/list.go
@@ -1,29 +1,15 @@
 package apitypes
 
-import (
-	"os"
-	"time"
-)
-
-// EntryAttributes represents an entry's attributes.
-//
-// TODO: Add meta later
-type EntryAttributes struct {
-	Atime *time.Time   `json:"atime,omitempty"`
-	Mtime *time.Time   `json:"mtime,omitempty"`
-	Ctime *time.Time   `json:"ctime,omitempty"`
-	Mode  *os.FileMode `json:"mode,omitempty"`
-	Size  *uint64      `json:"size,omitempty"`
-}
+import "github.com/puppetlabs/wash/plugin"
 
 // ListEntry represents a single entry from the result of issuing a wash 'list' request.
 //
 // swagger:response
 type ListEntry struct {
-	Path       string               `json:"path"`
-	Actions    []string             `json:"actions"`
-	Name       string               `json:"name"`
-	CName      string               `json:"cname"`
-	Attributes EntryAttributes      `json:"attributes"`
-	Errors     map[string]*ErrorObj `json:"errors"`
+	Path       string                 `json:"path"`
+	Actions    []string               `json:"actions"`
+	Name       string                 `json:"name"`
+	CName      string                 `json:"cname"`
+	Attributes plugin.EntryAttributes `json:"attributes"`
+	Errors     map[string]*ErrorObj   `json:"errors"`
 }
diff --git a/cmd/ls.go b/cmd/ls.go
index fed426a..26dc8ca 100644
--- a/cmd/ls.go
+++ b/cmd/ls.go
@@ -36,10 +36,10 @@ func formatListEntries(apiPath string, ls []apitypes.ListEntry) string {
 	table := make([][]string, len(ls))
 	for i, entry := range ls {
 		var ctimeStr string
-		if entry.Attributes.Ctime == nil {
-			ctimeStr = "<unknown>"
+		if entry.Attributes.HasCtime() {
+			ctimeStr = entry.Attributes.Ctime().Format(time.RFC822)
 		} else {
-			ctimeStr = entry.Attributes.Ctime.Format(time.RFC822)
+			ctimeStr = "<unknown>"
 		}
 
 		actions := entry.Actions
diff --git a/go.mod b/go.mod
index 7dfe803..d3679be 100644
--- a/go.mod
+++ b/go.mod
@@ -14,6 +14,7 @@ require (
 	github.com/InVisionApp/tabular v0.3.0
 	github.com/Microsoft/go-winio v0.4.12 // indirect
 	github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
+	github.com/araddon/dateparse v0.0.0-20190329160016-74dc0e29b01f
 	github.com/aws/aws-sdk-go v1.19.7
 	github.com/coreos/etcd v3.3.12+incompatible // indirect
 	github.com/docker/distribution v2.7.1+incompatible // indirect
diff --git a/go.sum b/go.sum
index 7dfe528..9428f62 100644
--- a/go.sum
+++ b/go.sum
@@ -18,6 +18,8 @@ github.com/MikaelSmith/aws-sdk-go v1.15.31-0.20190409174045-425882cd3d0c h1:/Frp
 github.com/MikaelSmith/aws-sdk-go v1.15.31-0.20190409174045-425882cd3d0c/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
 github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
+github.com/araddon/dateparse v0.0.0-20190329160016-74dc0e29b01f h1:wK+B1a8vR5Ew04VA3qxBfXC8E8NghEREybZfotsfHp8=
+github.com/araddon/dateparse v0.0.0-20190329160016-74dc0e29b01f/go.mod h1:SLqhdZcd+dF3TEVL2RMoob5bBP5R1P1qkox+HtCBgGI=
 github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6 h1:G1bPvciwNyF7IUmKXNt9Ak3m6u9DE1rF+RmtIkBpVdA=
 github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
 github.com/aws/aws-sdk-go v1.19.7 h1:BEKPfM8DPsXIsz1zL98nSbYqsOUjUM2lm93XLZL0qY8=
diff --git a/munge/mode.go b/munge/mode.go
new file mode 100644
index 0000000..846579f
--- /dev/null
+++ b/munge/mode.go
@@ -0,0 +1,60 @@
+package munge
+
+import (
+	"fmt"
+	"os"
+	"strconv"
+)
+
+func parseMode(mode interface{}) (uint64, error) {
+	switch t := mode.(type) {
+	case uint64:
+		return t, nil
+	case int64:
+		return uint64(t), nil
+	case float64:
+		if t != float64(uint64(t)) {
+			return 0, fmt.Errorf("could not parse mode: the provided mode %v is a decimal number", t)
+		}
+		return uint64(t), nil
+	case string:
+		if intMode, err := strconv.ParseUint(t, 0, 32); err == nil {
+			return intMode, nil
+		}
+		return 0, fmt.Errorf("could not parse mode: the provided mode %v is not a octal/hex/decimal number", t)
+	default:
+		return 0, fmt.Errorf("could not parse mode: the provided mode %v is not a uint64, int64, float64, or string", mode)
+	}
+}
+
+// ToFileMode converts a given mode into an os.FileMode object.
+// The mode can be either an integer or a string representing
+// an octal/hex/decimal number.
+func ToFileMode(mode interface{}) (os.FileMode, error) {
+	if fileMode, ok := mode.(os.FileMode); ok {
+		return fileMode, nil
+	}
+	intMode, err := parseMode(mode)
+	if err != nil {
+		return 0, err
+	}
+	fileMode := os.FileMode(intMode & 0777)
+	for bits, mod := range map[uint64]os.FileMode{
+		0140000: os.ModeSocket,
+		0120000: os.ModeSymlink,
+		// Skip file, absence of these implies a regular file.
+		0060000: os.ModeDevice,
+		0040000: os.ModeDir,
+		0020000: os.ModeCharDevice,
+		0010000: os.ModeNamedPipe,
+		0004000: os.ModeSetuid,
+		0002000: os.ModeSetgid,
+		0001000: os.ModeSticky,
+	} {
+		// Ensure exact match of all bits in the mask.
+		if intMode&bits == bits {
+			fileMode |= mod
+		}
+	}
+	return fileMode, nil
+}
diff --git a/munge/size.go b/munge/size.go
new file mode 100644
index 0000000..7f21c03
--- /dev/null
+++ b/munge/size.go
@@ -0,0 +1,34 @@
+package munge
+
+import (
+	"fmt"
+)
+
+func intToSize(size int64) (uint64, error) {
+	if size < 0 {
+		return 0, fmt.Errorf("%v is a negative size", size)
+	}
+	return uint64(size), nil
+}
+
+// ToSize converts v to a uint64 that's meant to represent
+// content size
+func ToSize(v interface{}) (uint64, error) {
+	switch sz := v.(type) {
+	case uint64:
+		return sz, nil
+	case int:
+		return intToSize(int64(sz))
+	case int32:
+		return intToSize(int64(sz))
+	case int64:
+		return intToSize(sz)
+	case float64:
+		if sz != float64(int64(sz)) {
+			return 0, fmt.Errorf("%v is a decimal size", sz)
+		}
+		return intToSize(int64(sz))
+	default:
+		return 0, fmt.Errorf("%v is not a valid size type. Valid size types are uint64, int, int32, int64, float64", v)
+	}
+}
diff --git a/munge/time.go b/munge/time.go
new file mode 100644
index 0000000..ca1622e
--- /dev/null
+++ b/munge/time.go
@@ -0,0 +1,45 @@
+package munge
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/araddon/dateparse"
+)
+
+func unixSecondsToTime(s int64) time.Time {
+	if s <= 0 {
+		return time.Time{}
+	}
+	return time.Unix(s, 0)
+}
+
+// ToTime converts v to a time.Time object.
+func ToTime(v interface{}) (time.Time, error) {
+	switch t := v.(type) {
+	case time.Time:
+		return t, nil
+	case int64:
+		return unixSecondsToTime(t), nil
+	case float64:
+		if t != float64(int64(t)) {
+			return time.Time{}, fmt.Errorf("could not convert to time.Time: the provided time %v is a decimal number", t)
+		}
+		return unixSecondsToTime(int64(t)), nil
+	case string:
+		// time.Time objects are marshaled into RFC3339 formatted strings.
+		// Thus, try parsing in that format first since it is the common
+		// case. If that fails, then delegate to the dateparse library.
+		// We do this for performance reasons.
+		tm, err := time.Parse(time.RFC3339, t)
+		if err != nil {
+			tm, err = dateparse.ParseAny(t)
+			if err != nil {
+				err = fmt.Errorf("could not parse %v into a time.Time object: %v", t, err)
+			}
+		}
+		return tm, err
+	default:
+		return time.Time{}, fmt.Errorf("%v is not a valid time.Time type. Valid time.Time types are time.Time, int64, and string", v)
+	}
+}
diff --git a/plugin/entryAttributes.go b/plugin/entryAttributes.go
index 5ccee09..afeb487 100644
--- a/plugin/entryAttributes.go
+++ b/plugin/entryAttributes.go
@@ -2,10 +2,12 @@ package plugin
 
 import (
 	"encoding/json"
+	"fmt"
 	"os"
 	"time"
 
 	"github.com/Benchkram/errz"
+	"github.com/puppetlabs/wash/munge"
 )
 
 // EntryMetadata represents the entry's metadata
@@ -173,3 +175,89 @@ func (a *EntryAttributes) SetMeta(meta EntryMetadata) *EntryAttributes {
 	a.meta = meta
 	return a
 }
+
+// ToMap converts the entry's attributes to a map,
+// which makes it easier to write generic code on
+// them.
+func (a *EntryAttributes) ToMap() map[string]interface{} {
+	mp := make(map[string]interface{})
+	if a.HasAtime() {
+		mp["atime"] = a.Atime()
+	}
+	if a.HasMtime() {
+		mp["mtime"] = a.Mtime()
+	}
+	if a.HasCtime() {
+		mp["ctime"] = a.Ctime()
+	}
+	if a.HasMode() {
+		mp["mode"] = a.Mode()
+	}
+	if a.HasSize() {
+		mp["size"] = a.Size()
+	}
+	mp["meta"] = a.Meta()
+	return mp
+}
+
+// MarshalJSON marshals the entry's attributes to JSON.
+func (a *EntryAttributes) MarshalJSON() ([]byte, error) {
+	return json.Marshal(a.ToMap())
+}
+
+// UnmarshalJSON unmarshals the entry's attributes from JSON.
+func (a *EntryAttributes) UnmarshalJSON(data []byte) error {
+	mp := make(map[string]interface{})
+	err := json.Unmarshal(data, &mp)
+	if err != nil {
+		return fmt.Errorf("plugin.EntryAttributes.UnmarshalJSON received a non-JSON object")
+	}
+	if atime, ok := mp["atime"]; ok {
+		t, err := munge.ToTime(atime)
+		if err != nil {
+			return attrMungeError("atime", err)
+		}
+		a.SetAtime(t)
+	}
+	if mtime, ok := mp["mtime"]; ok {
+		t, err := munge.ToTime(mtime)
+		if err != nil {
+			return attrMungeError("mtime", err)
+		}
+		a.SetMtime(t)
+	}
+	if ctime, ok := mp["ctime"]; ok {
+		t, err := munge.ToTime(ctime)
+		if err != nil {
+			return attrMungeError("ctime", err)
+		}
+		a.SetCtime(t)
+	}
+	if mode, ok := mp["mode"]; ok {
+		m, err := munge.ToFileMode(mode)
+		if err != nil {
+			return attrMungeError("mode", err)
+		}
+		a.SetMode(m)
+	}
+	if size, ok := mp["size"]; ok {
+		sz, err := munge.ToSize(size)
+		if err != nil {
+			return attrMungeError("size", err)
+		}
+		a.SetSize(sz)
+	}
+	var meta EntryMetadata
+	if rawMeta, ok := mp["meta"]; ok {
+		meta, ok = rawMeta.(map[string]interface{})
+		if !ok {
+			return fmt.Errorf("meta is not a JSON object")
+		}
+	}
+	a.SetMeta(meta)
+	return nil
+}
+
+func attrMungeError(name string, err error) error {
+	return fmt.Errorf("plugin.EntryAttributes.UnmarshalJSON: could not munge the %v attribute: %v", name, err)
+}
diff --git a/plugin/externalPluginEntry.go b/plugin/externalPluginEntry.go
index 3baa28f..13c5d40 100644
--- a/plugin/externalPluginEntry.go
+++ b/plugin/externalPluginEntry.go
@@ -15,49 +15,6 @@ import (
 	"github.com/puppetlabs/wash/journal"
 )
 
-/*
-TODO: Come back and rethink attributes once all the attributes/metadata work is finished.
-*/
-type decodedAttributes struct {
-	// Atime, Mtime, and Ctime are in Unix time
-	Atime int64         `json:"atime"`
-	Mtime int64         `json:"mtime"`
-	Ctime int64         `json:"ctime"`
-	Mode  interface{}   `json:"mode"`
-	Size  uint64        `json:"size"`
-	Valid time.Duration `json:"valid"`
-}
-
-func unixSecondsToTimeAttr(seconds int64) time.Time {
-	if seconds <= 0 {
-		return time.Time{}
-	}
-
-	return time.Unix(seconds, 0)
-}
-
-/*
-func (a decodedAttributes) toAttributes() (Attributes, error) {
-	attr := Attributes{
-		Atime: unixSecondsToTimeAttr(a.Atime),
-		Mtime: unixSecondsToTimeAttr(a.Mtime),
-		Ctime: unixSecondsToTimeAttr(a.Ctime),
-		Size:  a.Size,
-		Valid: a.Valid,
-	}
-
-	if a.Mode != nil {
-		mode, err := ToFileMode(a.Mode)
-		if err != nil {
-			return Attributes{}, err
-		}
-		attr.Mode = mode
-	}
-
-	return attr, nil
-}
-*/
-
 type decodedCacheTTLs struct {
 	List     time.Duration `json:"list"`
 	Open     time.Duration `json:"open"`
@@ -66,12 +23,12 @@ type decodedCacheTTLs struct {
 
 // decodedExternalPluginEntry describes a decoded serialized entry.
 type decodedExternalPluginEntry struct {
-	Name                 string            `json:"name"`
-	SupportedActions     []string          `json:"supported_actions"`
-	SlashReplacementChar string            `json:"slash_replacement_char"`
-	CacheTTLs            decodedCacheTTLs  `json:"cache_ttls"`
-	Attributes           decodedAttributes `json:"attributes"`
-	State                string            `json:"state"`
+	Name                 string           `json:"name"`
+	SupportedActions     []string         `json:"supported_actions"`
+	SlashReplacementChar string           `json:"slash_replacement_char"`
+	CacheTTLs            decodedCacheTTLs `json:"cache_ttls"`
+	Attributes           EntryAttributes  `json:"attributes"`
+	State                string           `json:"state"`
 }
 
 func (e decodedExternalPluginEntry) toExternalPluginEntry() (*externalPluginEntry, error) {
@@ -87,6 +44,7 @@ func (e decodedExternalPluginEntry) toExternalPluginEntry() (*externalPluginEntr
 		supportedActions: e.SupportedActions,
 		state:            e.State,
 	}
+	entry.SetAttributes(e.Attributes)
 	entry.setCacheTTLs(e.CacheTTLs)
 	if e.SlashReplacementChar != "" {
 		if len([]rune(e.SlashReplacementChar)) > 1 {
diff --git a/plugin/helpers.go b/plugin/helpers.go
index e0faac5..61f4b3c 100644
--- a/plugin/helpers.go
+++ b/plugin/helpers.go
@@ -3,9 +3,7 @@ package plugin
 import (
 	"fmt"
 	"io"
-	"os"
 	"os/exec"
-	"strconv"
 	"strings"
 	"syscall"
 	"time"
@@ -95,66 +93,6 @@ func (e ErrCouldNotDetermineSizeAttr) Error() string {
 	return fmt.Sprintf("could not determine the size attribute: %v", e.reason)
 }
 
-func parseMode(mode interface{}) (uint64, error) {
-	if uintMode, ok := mode.(uint64); ok {
-		return uintMode, nil
-	}
-
-	if intMode, ok := mode.(int64); ok {
-		return uint64(intMode), nil
-	}
-
-	if floatMode, ok := mode.(float64); ok {
-		if floatMode != float64(uint64(floatMode)) {
-			return 0, fmt.Errorf("could not parse mode: the provided mode %v is a decimal number", floatMode)
-		}
-
-		return uint64(floatMode), nil
-	}
-
-	strMode, ok := mode.(string)
-	if !ok {
-		return 0, fmt.Errorf("could not parse mode: the provided mode %v is not a uint64, int64, float64, or string", strMode)
-	}
-
-	if intMode, err := strconv.ParseUint(strMode, 0, 32); err == nil {
-		return intMode, nil
-	}
-
-	return 0, fmt.Errorf("could not parse mode: the provided mode %v is not a octal/hex/decimal number", strMode)
-}
-
-// ToFileMode converts a given mode into an os.FileMode object.
-// The mode can be either an integer or a string representing
-// an octal/hex/decimal number.
-func ToFileMode(mode interface{}) (os.FileMode, error) {
-	intMode, err := parseMode(mode)
-	if err != nil {
-		return 0, err
-	}
-
-	fileMode := os.FileMode(intMode & 0777)
-	for bits, mod := range map[uint64]os.FileMode{
-		0140000: os.ModeSocket,
-		0120000: os.ModeSymlink,
-		// Skip file, absence of these implies a regular file.
-		0060000: os.ModeDevice,
-		0040000: os.ModeDir,
-		0020000: os.ModeCharDevice,
-		0010000: os.ModeNamedPipe,
-		0004000: os.ModeSetuid,
-		0002000: os.ModeSetgid,
-		0001000: os.ModeSticky,
-	} {
-		// Ensure exact match of all bits in the mask.
-		if intMode&bits == bits {
-			fileMode |= mod
-		}
-	}
-
-	return fileMode, nil
-}
-
 // Attributes returns the entry's attribtues
 func Attributes(e Entry) EntryAttributes {
 	return e.attributes()
diff --git a/volume/helpers.go b/volume/helpers.go
index 6fa44b9..8167e6c 100644
--- a/volume/helpers.go
+++ b/volume/helpers.go
@@ -15,6 +15,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/puppetlabs/wash/munge"
 	"github.com/puppetlabs/wash/plugin"
 )
 
@@ -71,7 +72,7 @@ func StatParse(line string) (plugin.EntryAttributes, string, error) {
 	}
 	attr.SetCtime(ctime)
 
-	mode, err := plugin.ToFileMode("0x" + segments[4])
+	mode, err := munge.ToFileMode("0x" + segments[4])
 	if err != nil {
 		return attr, "", err
 	}


Test patch:
diff --git a/api/helpers_test.go b/api/helpers_test.go
index 908c260..600e084 100644
--- a/api/helpers_test.go
+++ b/api/helpers_test.go
@@ -2,11 +2,8 @@ package api
 
 import (
 	"context"
-	"os"
 	"testing"
-	"time"
 
-	apitypes "github.com/puppetlabs/wash/api/types"
 	"github.com/puppetlabs/wash/plugin"
 	"github.com/stretchr/testify/suite"
 )
@@ -32,74 +29,6 @@ func (suite *HelpersTestSuite) TearDownSuite() {
 	plugin.UnsetTestCache()
 }
 
-func (suite *HelpersTestSuite) TestToAPIAttrAtime() {
-	a := plugin.EntryAttributes{}
-	suite.Equal(apitypes.EntryAttributes{}, toAPIAttr(a))
-
-	a.SetAtime(time.Now())
-	apiAttr := toAPIAttr(a)
-	if suite.NotNil(apiAttr.Atime) {
-		suite.Equal(a.Atime(), *apiAttr.Atime)
-	}
-}
-
-func (suite *HelpersTestSuite) TestToAPIAttrMtime() {
-	a := plugin.EntryAttributes{}
-	suite.Equal(apitypes.EntryAttributes{}, toAPIAttr(a))
-
-	a.SetMtime(time.Now())
-	apiAttr := toAPIAttr(a)
-	if suite.NotNil(apiAttr.Mtime) {
-		suite.Equal(a.Mtime(), *apiAttr.Mtime)
-	}
-}
-
-func (suite *HelpersTestSuite) TestToAPIAttrCtime() {
-	a := plugin.EntryAttributes{}
-	suite.Equal(apitypes.EntryAttributes{}, toAPIAttr(a))
-
-	a.SetCtime(time.Now())
-	apiAttr := toAPIAttr(a)
-	if suite.NotNil(apiAttr.Ctime) {
-		suite.Equal(a.Ctime(), *apiAttr.Ctime)
-	}
-}
-
-func (suite *HelpersTestSuite) TestToAPIAttrMode() {
-	a := plugin.EntryAttributes{}
-	suite.Equal(apitypes.EntryAttributes{}, toAPIAttr(a))
-
-	a.SetMode(0777)
-	apiAttr := toAPIAttr(a)
-	if suite.NotNil(apiAttr.Mode) {
-		suite.Equal(a.Mode(), *apiAttr.Mode)
-	}
-}
-
-func (suite *HelpersTestSuite) TestToAPIAttrSize() {
-	a := plugin.EntryAttributes{}
-	suite.Equal(apitypes.EntryAttributes{}, toAPIAttr(a))
-
-	a.SetSize(10)
-	apiAttr := toAPIAttr(a)
-	if suite.NotNil(apiAttr.Size) {
-		suite.Equal(a.Size(), *apiAttr.Size)
-	}
-}
-
-func (suite *HelpersTestSuite) TestToAPIAttrMultipleAttr() {
-	a := plugin.EntryAttributes{}
-	suite.Equal(apitypes.EntryAttributes{}, toAPIAttr(a))
-
-	a.SetMode(os.FileMode(0777))
-	a.SetSize(10)
-	apiAttr := toAPIAttr(a)
-	if suite.NotNil(apiAttr.Mode) && suite.NotNil(apiAttr.Size) {
-		suite.Equal(a.Mode(), *apiAttr.Mode)
-		suite.Equal(a.Size(), *apiAttr.Size)
-	}
-}
-
 func (suite *HelpersTestSuite) TestFindEntry() {
 	type testcase struct {
 		segments      []string
diff --git a/munge/common_test.go b/munge/common_test.go
new file mode 100644
index 0000000..6ca98e5
--- /dev/null
+++ b/munge/common_test.go
@@ -0,0 +1,58 @@
+package munge
+
+import (
+	"fmt"
+	"regexp"
+
+	"github.com/stretchr/testify/suite"
+)
+
+// This file stores some common test setup that's shared by all the
+// munge functions.
+
+type testCase struct {
+	input    interface{}
+	expected interface{}
+	errRegex *regexp.Regexp
+}
+
+// nTC => newTestCase. It's meant to save some typing.
+func nTC(input interface{}, v interface{}) testCase {
+	tc := testCase{input: input}
+	tc.expected = v
+	return tc
+}
+
+// nETC => newErrorTestCase. It's meant to save some typing.
+func nETC(input interface{}, errRegex string) testCase {
+	tc := testCase{input: input}
+	tc.errRegex = regexp.MustCompile(errRegex)
+	return tc
+}
+
+type MungeTestSuite struct {
+	suite.Suite
+	// This should be set in each test.
+	mungeFunc func(interface{}) (interface{}, error)
+}
+
+func (suite *MungeTestSuite) runTestCases(cases ...testCase) {
+	var input interface{}
+	defer func() {
+		if r := recover(); r != nil {
+			fmt.Printf("Panicked on input %t\n", input)
+			panic(r)
+		}
+	}()
+	for _, c := range cases {
+		input = c.input
+		actual, err := suite.mungeFunc(input)
+		if c.errRegex != nil {
+			suite.Regexp(c.errRegex, err, "Input was %t", input)
+		} else {
+			if suite.NoError(err, "Input was %t", input) {
+				suite.Equal(c.expected, actual, "Input was %t", input)
+			}
+		}
+	}
+}
diff --git a/munge/mode_test.go b/munge/mode_test.go
new file mode 100644
index 0000000..02f7c9a
--- /dev/null
+++ b/munge/mode_test.go
@@ -0,0 +1,52 @@
+package munge
+
+import (
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/suite"
+)
+
+type ModeTestSuite struct {
+	MungeTestSuite
+}
+
+func (suite *ModeTestSuite) TestParseMode() {
+	suite.mungeFunc = func(v interface{}) (interface{}, error) {
+		return parseMode(v)
+	}
+	suite.runTestCases(
+		nTC(uint64(10), uint64(10)),
+		nTC(int64(10), uint64(10)),
+		nTC(float64(10.0), uint64(10)),
+		nETC(float64(10.5), "decimal.*number"),
+		nETC([]byte("invalid mode type"), "uint64.*int64.*float64.*string"),
+		nTC("15", uint64(15)),
+		nTC("0777", uint64(511)),
+		nTC("0xf", uint64(15)),
+		nETC("not a number", "not a number"),
+	)
+}
+
+func (suite *ModeTestSuite) TestToFileMode() {
+	// toFM saves having to type os.FileMode(v) when creating the
+	// test cases
+	toFM := func(v os.FileMode) os.FileMode { return v }
+	suite.mungeFunc = func(v interface{}) (interface{}, error) {
+		return ToFileMode(v)
+	}
+	suite.runTestCases(
+		nTC(os.FileMode(0777), toFM(0777)),
+		nETC("not a number", "not a number"),
+		// 16877 is 0x41ed in decimal
+		nTC("0x41ed", toFM(0755|os.ModeDir)),
+		nTC(float64(16877), toFM(0755|os.ModeDir)),
+		// 33188 is 0x81a4 in decimal
+		nTC("0x81a4", toFM(0644)),
+		nTC(float64(33188), toFM(0644)),
+	)
+}
+
+func TestMode(t *testing.T) {
+	suite.Run(t, new(ModeTestSuite))
+}
diff --git a/munge/size_test.go b/munge/size_test.go
new file mode 100644
index 0000000..ddd75b5
--- /dev/null
+++ b/munge/size_test.go
@@ -0,0 +1,31 @@
+package munge
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/suite"
+)
+
+type SizeTestSuite struct {
+	MungeTestSuite
+}
+
+func (suite *SizeTestSuite) TestToSize() {
+	suite.mungeFunc = func(v interface{}) (interface{}, error) {
+		return ToSize(v)
+	}
+	suite.runTestCases(
+		nTC(uint64(10), uint64(10)),
+		nETC(int(-1), "-1.*negative.*size"),
+		nTC(int(10), uint64(10)),
+		nTC(int32(10), uint64(10)),
+		nTC(int64(10), uint64(10)),
+		nETC(float64(10.5), "10.5.*decimal.*size"),
+		nTC(float64(10.0), uint64(10)),
+		nETC("foo", "foo.*valid.*size.*uint64.*int.*int32.*int64.*float64"),
+	)
+}
+
+func TestToSize(t *testing.T) {
+	suite.Run(t, new(SizeTestSuite))
+}
diff --git a/munge/time_test.go b/munge/time_test.go
new file mode 100644
index 0000000..99c104d
--- /dev/null
+++ b/munge/time_test.go
@@ -0,0 +1,43 @@
+package munge
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/araddon/dateparse"
+	"github.com/stretchr/testify/suite"
+)
+
+type TimeTestSuite struct {
+	MungeTestSuite
+}
+
+func (suite *TimeTestSuite) TestToTime() {
+	mustParse := func(v string) time.Time {
+		t, err := dateparse.ParseAny(v)
+		if err != nil {
+			panic(fmt.Sprintf("Required %v to parse to a time.Time{} object but received error: %v", v, err))
+		}
+		return t
+	}
+	suite.mungeFunc = func(v interface{}) (interface{}, error) {
+		return ToTime(v)
+	}
+	curTime := time.Now()
+	suite.runTestCases(
+		nTC(curTime, curTime),
+		nTC(int64(-1), time.Time{}),
+		nTC(int64(10000), time.Unix(10000, 0)),
+		nETC(float64(10.5), "10.5.*decimal"),
+		nTC(float64(-1), time.Time{}),
+		nTC(float64(10000), time.Unix(10000, 0)),
+		nTC("2006-01-02T15:04:05+7:00", mustParse("2006-01-02T15:04:05+7:00")),
+		nTC("2006-01-02T15:04:05Z", mustParse("2006-01-02T15:04:05Z")),
+		nETC("foo", "foo.*time.Time"),
+	)
+}
+
+func TestToTime(t *testing.T) {
+	suite.Run(t, new(TimeTestSuite))
+}
diff --git a/plugin/entryAttributes_test.go b/plugin/entryAttributes_test.go
index 03a3372..9f1a00f 100644
--- a/plugin/entryAttributes_test.go
+++ b/plugin/entryAttributes_test.go
@@ -1,6 +1,7 @@
 package plugin
 
 import (
+	"encoding/json"
 	"os"
 	"testing"
 	"time"
@@ -29,45 +30,100 @@ func (suite *EntryAttributesTestSuite) TestToMeta() {
 	}
 }
 
-// Easier to have a single test for everything since they're
-// all redundant
+// Easier to have a single test for everything since there's a lot
+// of redundant, performance-optimized code.
 func (suite *EntryAttributesTestSuite) TestEntryAttributes() {
+	// timeNow returns the current time by marshalling time.Now() into JSON
+	// and then unmarshalling it. We need this function for the unmarshal JSON
+	// tests on the time attributes, because time.Now() includes the monotonic
+	// clock + location information, both of which are lost when it is marshaled
+	// into JSON. Hence without timeNow, the unmarshal JSON tests would always fail.
+	timeNow := func() time.Time {
+		t := time.Now()
+		bytes, _ := json.Marshal(t)
+		unmarshaledTime := time.Time{}
+		_ = json.Unmarshal(bytes, &unmarshaledTime)
+		return unmarshaledTime
+	}
 	attr := EntryAttributes{}
+	attr.meta = EntryMetadata{}
+	expectedMp := make(map[string]interface{})
+	expectedMp["meta"] = EntryMetadata{}
+	doUnmarshalJSONTests := func() {
+		attrJSON, err := json.Marshal(expectedMp)
+		if err != nil {
+			panic("assertUnmarshalJSON: could not marshal expectedMp, which is a map[string]interface{} object")
+		}
+		unmarshaledAttr := EntryAttributes{}
+		err = json.Unmarshal(attrJSON, &unmarshaledAttr)
+		if suite.NoError(err) {
+			suite.Equal(attr, unmarshaledAttr)
+		}
+	}
 
+	// Tests for Atime
 	suite.Equal(false, attr.HasAtime())
-	t := time.Now()
+	suite.Equal(expectedMp, attr.ToMap())
+	t := timeNow()
 	attr.SetAtime(t)
+	expectedMp["atime"] = t
 	suite.Equal(t, attr.Atime())
 	suite.Equal(true, attr.HasAtime())
+	suite.Equal(expectedMp, attr.ToMap())
+	doUnmarshalJSONTests()
 
+	// Tests for Mtime
 	suite.Equal(false, attr.HasMtime())
-	t = time.Now()
+	suite.Equal(expectedMp, attr.ToMap())
+	t = timeNow()
 	attr.SetMtime(t)
+	expectedMp["mtime"] = t
 	suite.Equal(t, attr.Mtime())
 	suite.Equal(true, attr.HasMtime())
+	suite.Equal(expectedMp, attr.ToMap())
+	doUnmarshalJSONTests()
 
+	// Tests for Ctime
 	suite.Equal(false, attr.HasCtime())
-	t = time.Now()
+	suite.Equal(expectedMp, attr.ToMap())
+	t = timeNow()
 	attr.SetCtime(t)
+	expectedMp["ctime"] = t
 	suite.Equal(t, attr.Ctime())
 	suite.Equal(true, attr.HasCtime())
+	suite.Equal(expectedMp, attr.ToMap())
+	doUnmarshalJSONTests()
 
+	// Tests for Mode
 	suite.Equal(false, attr.HasMode())
+	suite.Equal(expectedMp, attr.ToMap())
 	m := os.FileMode(0777)
 	attr.SetMode(m)
+	expectedMp["mode"] = m
 	suite.Equal(m, attr.Mode())
 	suite.Equal(true, attr.HasMode())
+	suite.Equal(expectedMp, attr.ToMap())
+	doUnmarshalJSONTests()
 
+	// Tests for Size
 	suite.Equal(false, attr.HasSize())
+	suite.Equal(expectedMp, attr.ToMap())
 	sz := uint64(10)
 	attr.SetSize(sz)
+	expectedMp["size"] = sz
 	suite.Equal(sz, attr.Size())
 	suite.Equal(true, attr.HasSize())
+	suite.Equal(expectedMp, attr.ToMap())
+	doUnmarshalJSONTests()
 
+	// Tests for Meta
 	suite.Equal(EntryMetadata{}, attr.Meta())
 	meta := EntryMetadata{"foo": "bar"}
 	attr.SetMeta(meta)
+	expectedMp["meta"] = meta
 	suite.Equal(meta, attr.Meta())
+	suite.Equal(expectedMp, attr.ToMap())
+	doUnmarshalJSONTests()
 }
 
 func TestEntryAttributes(t *testing.T) {
diff --git a/plugin/externalPluginEntry_test.go b/plugin/externalPluginEntry_test.go
index 5200c47..8018409 100644
--- a/plugin/externalPluginEntry_test.go
+++ b/plugin/externalPluginEntry_test.go
@@ -37,60 +37,6 @@ type ExternalPluginEntryTestSuite struct {
 	suite.Suite
 }
 
-func (suite *ExternalPluginEntryTestSuite) EqualTimeAttr(expected time.Time, actual time.Time, msgAndArgs ...interface{}) {
-	suite.WithinDuration(expected, actual, 1*time.Second, msgAndArgs...)
-}
-
-func (suite *ExternalPluginEntryTestSuite) TestUnixSecondsToTimeAttr() {
-	suite.EqualTimeAttr(time.Time{}, unixSecondsToTimeAttr(0))
-
-	t := time.Now()
-	suite.EqualTimeAttr(t, unixSecondsToTimeAttr(t.Unix()))
-}
-
-/*
-func (suite *ExternalPluginEntryTestSuite) TestDecodeAttributes() {
-	atime := time.Now()
-	mtime := time.Now()
-	ctime := time.Now()
-
-	decodedAttributes := decodedAttributes{
-		Atime: atime.Unix(),
-		Mtime: mtime.Unix(),
-		Ctime: ctime.Unix(),
-		Size:  10,
-		Valid: 1 * time.Second,
-	}
-
-	// Test that the attributes are correctly decoded
-	attributes, err := decodedAttributes.toAttributes()
-	if suite.NoError(err) {
-		suite.EqualTimeAttr(atime, attributes.Atime)
-		suite.EqualTimeAttr(mtime, attributes.Mtime)
-		suite.EqualTimeAttr(ctime, attributes.Ctime)
-		suite.Equal(uint64(10), attributes.Size)
-		suite.Equal(
-			os.FileMode(0),
-			attributes.Mode,
-			"Expected the decoded attributes to have no mode field",
-		)
-	}
-
-	// Test that the mode is correctly decoded
-	decodedAttributes.Mode = "0xff"
-	attributes, err = decodedAttributes.toAttributes()
-	if suite.NoError(err) {
-		suite.Equal(os.FileMode(255), attributes.Mode)
-	}
-
-	// Test that toAttributes() returns an error when the mode
-	// cannot be decoded
-	decodedAttributes.Mode = "not a number"
-	attributes, err = decodedAttributes.toAttributes()
-	suite.Error(err)
-}
-*/
-
 func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryRequiredFields() {
 	decodedEntry := decodedExternalPluginEntry{}
 
@@ -150,20 +96,17 @@ func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithSlas
 	}
 }
 
-/*
 func (suite *ExternalPluginEntryTestSuite) TestDecodeExternalPluginEntryWithAttributes() {
 	decodedEntry := newMockDecodedEntry("name")
-	decodedEntry.Attributes = decodedAttributes{Size: 10}
+	t := time.Now()
+	decodedEntry.Attributes.SetCtime(t)
 	entry, err := decodedEntry.toExternalPluginEntry()
 	if suite.NoError(err) {
-		suite.Equal(Attributes{Size: 10}, entry.attr)
+		expectedAttr := EntryAttributes{}
+		expectedAttr.SetCtime(t)
+		suite.Equal(expectedAttr, entry.attr)
 	}
-
-	decodedEntry.Attributes = decodedAttributes{Mode: "invalid mode"}
-	_, err = decodedEntry.toExternalPluginEntry()
-	suite.Error(err)
 }
-*/
 
 func (suite *ExternalPluginEntryTestSuite) TestSetCacheTTLs() {
 	decodedTTLs := decodedCacheTTLs{
diff --git a/plugin/helpers_test.go b/plugin/helpers_test.go
index 04cdf88..0f1bf33 100644
--- a/plugin/helpers_test.go
+++ b/plugin/helpers_test.go
@@ -4,7 +4,6 @@ import (
 	"fmt"
 	"os"
 	"os/exec"
-	"regexp"
 	"testing"
 	"time"
 
@@ -45,66 +44,6 @@ func (suite *HelpersTestSuite) TestPath() {
 	suite.Equal(Path(&e), "/foo/bar")
 }
 
-func (suite *HelpersTestSuite) TestParseMode() {
-	type testCase struct {
-		input    interface{}
-		expected uint64
-		errRegex string
-	}
-
-	cases := []testCase{
-		{input: uint64(10), expected: 10},
-		{input: int64(10), expected: 10},
-		{input: float64(10.0), expected: 10},
-		{input: float64(10.5), errRegex: "decimal.*number"},
-		{input: []byte("invalid mode type"), errRegex: "uint64.*int64.*float64.*string"},
-		{input: "15", expected: 15},
-		{input: "0777", expected: 511},
-		{input: "0xf", expected: 15},
-		{input: "not a number", errRegex: "not a number"},
-	}
-
-	for _, c := range cases {
-		actual, err := parseMode(c.input)
-		if c.errRegex != "" {
-			suite.Regexp(regexp.MustCompile(c.errRegex), err)
-		} else {
-			if suite.NoError(err) {
-				suite.Equal(c.expected, actual)
-			}
-		}
-	}
-}
-
-func (suite *HelpersTestSuite) TestToFileMode() {
-	type testCase struct {
-		input    interface{}
-		expected os.FileMode
-		errRegex string
-	}
-
-	cases := []testCase{
-		{input: "not a number", errRegex: "not a number"},
-		// 16877 is 0x41ed in decimal
-		{input: "0x41ed", expected: 0755 | os.ModeDir},
-		{input: float64(16877), expected: 0755 | os.ModeDir},
-		// 33188 is 0x81a4 in decimal
-		{input: "0x81a4", expected: 0644},
-		{input: float64(33188), expected: 0644},
-	}
-
-	for _, c := range cases {
-		actual, err := ToFileMode(c.input)
-		if c.errRegex != "" {
-			suite.Regexp(regexp.MustCompile(c.errRegex), err)
-		} else {
-			if suite.NoError(err) {
-				suite.Equal(c.expected, actual)
-			}
-		}
-	}
-}
-
 type helpersTestsMockEntry struct {
 	EntryBase
 	mock.Mock