Skip to content
This repository has been archived by the owner on Dec 8, 2020. It is now read-only.

Commit

Permalink
New: Add JSON support for encoding/transfer
Browse files Browse the repository at this point in the history
  • Loading branch information
nmuldavin authored and impl committed Oct 22, 2019
1 parent 278fe39 commit dc59f19
Show file tree
Hide file tree
Showing 7 changed files with 258 additions and 14 deletions.
4 changes: 4 additions & 0 deletions encoding/transfer/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,9 @@ Basic use when decoding a value:
if err != nil {
// handle error
}
In addition, it provides types for automatically encoding and decoding non-UTF-8 strings in an expanded JSON
format.
*/

package transfer
60 changes: 51 additions & 9 deletions encoding/transfer/encoding.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,18 @@ import (
"encoding/base64"
"fmt"
"strings"
"unicode/utf8"
)

type encodingType string
type EncodingType string

func (p encodingType) String() string {
func (p EncodingType) String() string {
return string(p)
}

const (
Base64EncodingType encodingType = "base64"
NoEncodingType encodingType = ""
Base64EncodingType EncodingType = "base64"
NoEncodingType EncodingType = ""
)

// DefaultEncodingType is the default encodingType. This makes it easier to use this
Expand All @@ -23,14 +24,14 @@ const (
const DefaultEncodingType = Base64EncodingType

// encodingTypeMap is an internal map used to get the encodingType type from a string
var encodingTypeMap = map[string]encodingType{
var encodingTypeMap = map[string]EncodingType{
"base64": Base64EncodingType,
}

// ParseEncodedValue will attempt to split on : and extract an encoding identifer
// from the prefix of the string. It then returns the discovered encodingType and the
// value without the encodingType prefixed.
func ParseEncodedValue(value string) (encodingType, string) {
func ParseEncodedValue(value string) (EncodingType, string) {
parts := strings.SplitN(value, ":", 2)

if len(parts) < 2 {
Expand All @@ -45,12 +46,19 @@ func ParseEncodedValue(value string) (encodingType, string) {
return t, parts[1]
}

// EncodeDecoderFactoryFunc is a function that produces an encoder/decoder.
type EncodeDecoderFactoryFunc func() EncodeDecoder

// EncodeDecoderFactories defines the type that can be used to produce
// encoder/decoders.
type EncodeDecoderFactories map[EncodingType]EncodeDecoderFactoryFunc

// Encoders maps encoding algorithms to their respective EncodeDecoder types.
// Example:
//
// ed := transfer.Encoders[Base64EncodingType]()
// encodedValue, err := ed.EncodeForTransfer("my super secret value")
var Encoders = map[encodingType]func() EncodeDecoder{
var Encoders = EncodeDecoderFactories{
Base64EncodingType: func() EncodeDecoder {
return Base64Encoding{}
},
Expand All @@ -71,6 +79,14 @@ func (e Base64Encoding) EncodeForTransfer(value []byte) (string, error) {
return fmt.Sprintf("%s:%s", Base64EncodingType, s), nil
}

// EncodeJSON encodes the given value as JSON.
func (Base64Encoding) EncodeJSON(value []byte) (JSONOrStr, error) {
return JSONOrStr{JSON: JSON{
EncodingType: Base64EncodingType,
Data: base64.StdEncoding.EncodeToString(value),
}}, nil
}

// DecodeFromTransfer takes a string and attempts to decode using a base64 decoder.
// If an error is returned, it will originate from the Go encoding/base64 package.
func (e Base64Encoding) DecodeFromTransfer(value string) ([]byte, error) {
Expand All @@ -87,19 +103,45 @@ func (e NoEncoding) EncodeForTransfer(value []byte) (string, error) {
return string(value), nil
}

// EncodeJSON encodes the given value as JSON, possibly as a JSON string.
func (NoEncoding) EncodeJSON(value []byte) (JSONOrStr, error) {
if !utf8.Valid(value) {
return JSONOrStr{}, ErrNotEncodable
}

return JSONOrStr{JSON: JSON{EncodingType: NoEncodingType, Data: string(value)}}, nil
}

// DecodeFromTransfer takes a string and casts it to a byte slice. No error is ever
// returned.
func (e NoEncoding) DecodeFromTransfer(value string) ([]byte, error) {
return []byte(value), nil
}

// EncodeForTransfer uses the DefaultEncodingType to encode value.
// encodingTypeOf returns a default UTF-8 transfer-safe encoding for the given
// value. Note that this function requires processing the value.
func encodingTypeOf(value []byte) EncodingType {
if !utf8.Valid(value) {
return Base64EncodingType
}

return NoEncodingType
}

// EncodeForTransfer uses a UTF-8 transfer-safe encoding to encode value.
func EncodeForTransfer(value []byte) (string, error) {
encoder := Encoders[DefaultEncodingType]()
encoder := Encoders[encodingTypeOf(value)]()

return encoder.EncodeForTransfer(value)
}

// EncodeJSON returns a JSON transfer-safe encoding of value.
func EncodeJSON(value []byte) (JSONOrStr, error) {
encoder := Encoders[encodingTypeOf(value)]()

return encoder.EncodeJSON(value)
}

// DecodeFromTransfer uses ParseEncodedValue to find the right encoder then
// decodes value with it.
func DecodeFromTransfer(value string) ([]byte, error) {
Expand Down
30 changes: 25 additions & 5 deletions encoding/transfer/encoding_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package transfer

import (
"encoding/base64"
"encoding/json"
"fmt"
"testing"

Expand All @@ -12,7 +13,7 @@ func TestEncoding(t *testing.T) {
var cases = []struct {
description string
value string
encodingType encodingType
encodingType EncodingType
expected string
customResultTest func(t *testing.T, encoded string, decoded []byte)
}{
Expand Down Expand Up @@ -104,15 +105,20 @@ func TestHelperFuncs(t *testing.T) {
expected string
}{
{
description: "base64 encoding succeeds",
description: "valid UTF-8 is passed through",
value: "super secret token",
expected: "base64:c3VwZXIgc2VjcmV0IHRva2Vu",
expected: "super secret token",
},
{
description: "user encoded base64 wrapped with our base64 encoder",
description: "user encoded base64",
// "super secret token" encoded as base64
value: "c3VwZXIgc2VjcmV0IHRva2Vu",
expected: "base64:YzNWd1pYSWdjMlZqY21WMElIUnZhMlZ1",
expected: "c3VwZXIgc2VjcmV0IHRva2Vu",
},
{
description: "invalid UTF-8 is base64-encoded",
value: "Hello, \x90\xA2\x8A\x45",
expected: "base64:SGVsbG8sIJCiikU=",
},
}

Expand All @@ -131,3 +137,17 @@ func TestHelperFuncs(t *testing.T) {
})
}
}

func ExampleEncodeJSON() {
j, _ := EncodeJSON([]byte("Hello, \x90\xA2\x8A\x45"))
b, _ := json.Marshal(j)
fmt.Println(string(b))
// Output: {"$encoding":"base64","data":"SGVsbG8sIJCiikU="}
}

func ExampleEncodeJSON_plain() {
j, _ := EncodeJSON([]byte("super secret token"))
b, _ := json.Marshal(j)
fmt.Println(string(b))
// Output: "super secret token"
}
8 changes: 8 additions & 0 deletions encoding/transfer/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package transfer

import "errors"

var (
ErrUnknownEncodingType = errors.New("transfer: unknown encoding type")
ErrNotEncodable = errors.New("transfer: the given value cannot be encoded for this transfer mechanism")
)
1 change: 1 addition & 0 deletions encoding/transfer/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package transfer

// Encoder encodes a byte slice and returns a string with the encoding type prefixed
type Encoder interface {
EncodeJSON([]byte) (JSONOrStr, error)
EncodeForTransfer([]byte) (string, error)
}

Expand Down
51 changes: 51 additions & 0 deletions encoding/transfer/json.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package transfer

import "encoding/json"

// JSON is a convenient way to represent an encoding and data tuple in JSON.
type JSON struct {
EncodingType EncodingType `json:"$encoding"`
Data string `json:"data"`

// Factories allows this struct to be configured to use a different set of
// encoder/decoders than the default.
Factories EncodeDecoderFactories `json:"-"`
}

// Decode finds the given encoder for this JSON data and decodes the data using
// it.
func (t JSON) Decode() ([]byte, error) {
factories := t.Factories
if factories == nil {
factories = Encoders
}

encoder, found := factories[t.EncodingType]
if !found {
return nil, ErrUnknownEncodingType
}

return encoder().DecodeFromTransfer(t.Data)
}

// JSONOrStr is like the JSON type, but also allows NoEncodingType to be
// represented as a raw JSON string.
type JSONOrStr struct{ JSON }

func (tos JSONOrStr) MarshalJSON() ([]byte, error) {
if tos.EncodingType == NoEncodingType {
return json.Marshal(tos.Data)
}

return json.Marshal(tos.JSON)
}

func (tos *JSONOrStr) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err == nil {
tos.JSON.Data = s
return nil
}

return json.Unmarshal(data, &tos.JSON)
}
118 changes: 118 additions & 0 deletions encoding/transfer/json_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package transfer_test

import (
"encoding/hex"
"encoding/json"
"fmt"
"testing"

"github.com/puppetlabs/horsehead/v2/encoding/transfer"
"github.com/stretchr/testify/require"
)

type testJSONHexEncoding struct{}

func (testJSONHexEncoding) EncodeForTransfer(value []byte) (string, error) {
return fmt.Sprintf("hex:%s", hex.EncodeToString(value)), nil
}

func (testJSONHexEncoding) EncodeJSON(value []byte) (transfer.JSONOrStr, error) {
return transfer.JSONOrStr{JSON: transfer.JSON{
EncodingType: transfer.EncodingType("hex"),
Data: hex.EncodeToString(value),
}}, nil
}

func (testJSONHexEncoding) DecodeFromTransfer(value string) ([]byte, error) {
return hex.DecodeString(value)
}

var testJSONFactories = transfer.EncodeDecoderFactories{
transfer.EncodingType("hex"): func() transfer.EncodeDecoder { return &testJSONHexEncoding{} },
}

func TestJSONUnmarshal(t *testing.T) {
var cases = []struct {
description string
json string
expected string
factories transfer.EncodeDecoderFactories
err error
}{
{
description: "Base64 encoding succeeds",
json: `{"$encoding": "base64", "data": "c3VwZXIgc2VjcmV0IHRva2Vu"}`,
expected: "super secret token",
},
{
description: "Explicit empty encoding succeeds",
json: `{"$encoding": "", "data": "blah blah blee bloo"}`,
expected: "blah blah blee bloo",
},
{
description: "Invalid encoding errors",
json: `{"$encoding": "invalid", "data": "blah blah blee bloo"}`,
err: transfer.ErrUnknownEncodingType,
},
{
description: "Custom encoder factory",
json: `{"$encoding": "hex", "data": "48656c6c6f20476f7068657221"}`,
expected: "Hello Gopher!",
factories: testJSONFactories,
},
}

for _, c := range cases {
t.Run(c.description, func(t *testing.T) {
j := transfer.JSON{
Factories: c.factories,
}

require.NoError(t, json.Unmarshal([]byte(c.json), &j))
b, err := j.Decode()
if c.err != nil {
require.Equal(t, c.err, err)
return
}

require.NoError(t, err)
require.Equal(t, c.expected, string(b))
})
}
}

func TestJSONOrStrMarshalUnmarshal(t *testing.T) {
var cases = []struct {
description string
input string
expected string
}{
{
description: "Properly encodes utf8 strings",
input: "This is a normal string",
expected: `"This is a normal string"`,
},
{
description: "Properly encodes non-utf8 strings",
input: "Hello, \x90\xA2\x8A\x45",
expected: `{"$encoding":"base64","data":"SGVsbG8sIJCiikU="}`,
},
}

for _, c := range cases {
t.Run(c.description, func(t *testing.T) {
j, err := transfer.EncodeJSON([]byte(c.input))
require.NoError(t, err)

js, err := json.Marshal(j)
require.NoError(t, err)
require.JSONEq(t, c.expected, string(js))

var ju transfer.JSONOrStr
require.NoError(t, json.Unmarshal(js, &ju))

d, err := ju.Decode()
require.Equal(t, c.input, string(d))
})
}
}

0 comments on commit dc59f19

Please sign in to comment.