From c16bb466a0f350dbfa8703efb312ac539f7ac584 Mon Sep 17 00:00:00 2001 From: Evgenii Stratonikov Date: Wed, 29 Jul 2020 12:39:52 +0300 Subject: [PATCH] stackitem: implement marshaling to JSON with types There are 2 kinds of JSON marshaling: 1. Lossy raw marshaling, when type information is lost and map keys are expected to be valid utf-8 strings. 2. Almost lossless marshaling, which can handle any non-recursive item. Interop value preserves only type. This commit implements the second. Signed-off-by: Evgenii Stratonikov --- pkg/vm/stackitem/json.go | 64 ++++++++++++++++++++++++++++ pkg/vm/stackitem/json_test.go | 79 +++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) diff --git a/pkg/vm/stackitem/json.go b/pkg/vm/stackitem/json.go index 81b67f769..cc588ddaa 100644 --- a/pkg/vm/stackitem/json.go +++ b/pkg/vm/stackitem/json.go @@ -200,3 +200,67 @@ func (d *decoder) decodeMap() (*Map, error) { m.Add(NewByteArray([]byte(k)), val) } } + +// ToJSONWithTypes serializes any stackitem to JSON in a lossless way. +func ToJSONWithTypes(item Item) ([]byte, error) { + result, err := toJSONWithTypes(item, make(map[Item]bool)) + if err != nil { + return nil, err + } + return json.Marshal(result) +} + +func toJSONWithTypes(item Item, seen map[Item]bool) (interface{}, error) { + typ := item.Type() + result := map[string]interface{}{ + "type": typ.String(), + } + var value interface{} + switch it := item.(type) { + case *Array, *Struct: + if seen[item] { + return "", errors.New("recursive structures can't be serialized to json") + } + seen[item] = true + arr := []interface{}{} + for _, elem := range it.Value().([]Item) { + s, err := toJSONWithTypes(elem, seen) + if err != nil { + return "", err + } + arr = append(arr, s) + } + value = arr + case *Bool: + value = it.value + case *Buffer, *ByteArray: + value = base64.StdEncoding.EncodeToString(it.Value().([]byte)) + case *BigInteger: + value = it.value.String() + case *Map: + if seen[item] { + return "", errors.New("recursive structures can't be serialized to json") + } + seen[item] = true + arr := []interface{}{} + for i := range it.value { + // map keys are primitive types and can always be converted to json + key, _ := toJSONWithTypes(it.value[i].Key, seen) + val, err := toJSONWithTypes(it.value[i].Value, seen) + if err != nil { + return "", err + } + arr = append(arr, map[string]interface{}{ + "key": key, + "value": val, + }) + } + value = arr + case *Pointer: + value = it.pos + } + if value != nil { + result["value"] = value + } + return result, nil +} diff --git a/pkg/vm/stackitem/json_test.go b/pkg/vm/stackitem/json_test.go index 1b1ef7718..e2eb00c5c 100644 --- a/pkg/vm/stackitem/json_test.go +++ b/pkg/vm/stackitem/json_test.go @@ -5,6 +5,7 @@ import ( "math/big" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -103,3 +104,81 @@ func TestFromToJSON(t *testing.T) { }) }) } + +// This test is taken from the C# code +// https://github.com/neo-project/neo/blob/master/tests/neo.UnitTests/VM/UT_Helper.cs#L30 +func TestToJSONWithTypeCompat(t *testing.T) { + items := []Item{ + Make(5), Make("hello world"), + Make([]byte{1, 2, 3}), Make(true), + } + + // Note: we use `Equal` and not `JSONEq` because there are no spaces and maps so the order is well-defined. + s, err := ToJSONWithTypes(items[0]) + assert.NoError(t, err) + assert.Equal(t, `{"type":"Integer","value":"5"}`, string(s)) + + s, err = ToJSONWithTypes(items[1]) + assert.NoError(t, err) + assert.Equal(t, `{"type":"ByteString","value":"aGVsbG8gd29ybGQ="}`, string(s)) + + s, err = ToJSONWithTypes(items[2]) + assert.NoError(t, err) + assert.Equal(t, `{"type":"ByteString","value":"AQID"}`, string(s)) + + s, err = ToJSONWithTypes(items[3]) + assert.NoError(t, err) + assert.Equal(t, `{"type":"Boolean","value":true}`, string(s)) + + s, err = ToJSONWithTypes(NewArray(items)) + assert.NoError(t, err) + assert.Equal(t, `{"type":"Array","value":[{"type":"Integer","value":"5"},{"type":"ByteString","value":"aGVsbG8gd29ybGQ="},{"type":"ByteString","value":"AQID"},{"type":"Boolean","value":true}]}`, string(s)) + + item := NewMap() + item.Add(Make(1), NewPointer(0, []byte{0})) + s, err = ToJSONWithTypes(item) + assert.NoError(t, err) + assert.Equal(t, `{"type":"Map","value":[{"key":{"type":"Integer","value":"1"},"value":{"type":"Pointer","value":0}}]}`, string(s)) +} + +func TestToJSONWithTypes(t *testing.T) { + testCases := []struct { + name string + item Item + result string + }{ + {"Null", Null{}, `{"type":"Any"}`}, + {"Integer", NewBigInteger(big.NewInt(42)), `{"type":"Integer","value":"42"}`}, + {"ByteString", NewByteArray([]byte{1, 2, 3}), `{"type":"ByteString","value":"AQID"}`}, + {"Buffer", NewBuffer([]byte{1, 2, 3}), `{"type":"Buffer","value":"AQID"}`}, + {"BoolTrue", NewBool(true), `{"type":"Boolean","value":true}`}, + {"BoolFalse", NewBool(false), `{"type":"Boolean","value":false}`}, + {"Struct", NewStruct([]Item{Make(11)}), + `{"type":"Struct","value":[{"type":"Integer","value":"11"}]}`}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s, err := ToJSONWithTypes(tc.item) + require.NoError(t, err) + require.Equal(t, tc.result, string(s)) + }) + } + + t.Run("Invalid", func(t *testing.T) { + t.Run("RecursiveArray", func(t *testing.T) { + arr := NewArray(nil) + arr.value = []Item{Make(5), arr, Make(true)} + + _, err := ToJSONWithTypes(arr) + require.Error(t, err) + }) + t.Run("RecursiveMap", func(t *testing.T) { + m := NewMap() + m.Add(Make(3), Make(true)) + m.Add(Make(5), m) + + _, err := ToJSONWithTypes(m) + require.Error(t, err) + }) + }) +}