feat: string and message value renderers for textual (#13510)

## Description

Closes: #12713 
Refs: #12878

Sign mode textual value renderers for string and message.

### Author Checklist

*All items are required. Please add a note to the item if the item is not applicable and
please add links to any relevant follow up issues.*

I have...

- [ ] included the correct [type prefix](https://github.com/commitizen/conventional-commit-types/blob/v3.0.0/index.json) in the PR title
- [ ] added `!` to the type prefix if API or client breaking change
- [ ] targeted the correct branch (see [PR Targeting](https://github.com/cosmos/cosmos-sdk/blob/main/CONTRIBUTING.md#pr-targeting))
- [ ] provided a link to the relevant issue or specification
- [ ] followed the guidelines for [building modules](https://github.com/cosmos/cosmos-sdk/blob/main/docs/building-modules)
- [ ] included the necessary unit and integration [tests](https://github.com/cosmos/cosmos-sdk/blob/main/CONTRIBUTING.md#testing)
- [ ] added a changelog entry to `CHANGELOG.md`
- [ ] included comments for [documenting Go code](https://blog.golang.org/godoc)
- [ ] updated the relevant documentation or specification
- [ ] reviewed "Files changed" and left comments if necessary
- [ ] confirmed all CI checks have passed

### Reviewers Checklist

*All items are required. Please add a note if the item is not applicable and please add
your handle next to the items reviewed if you only reviewed selected items.*

I have...

- [ ] confirmed the correct [type prefix](https://github.com/commitizen/conventional-commit-types/blob/v3.0.0/index.json) in the PR title
- [ ] confirmed `!` in the type prefix if API or client breaking change
- [ ] confirmed all author checklist items have been addressed 
- [ ] reviewed state machine logic
- [ ] reviewed API design and naming
- [ ] reviewed documentation is accurate
- [ ] reviewed tests and test coverage
- [ ] manually tested (if applicable)
This commit is contained in:
Jim Larson 2022-10-25 10:20:52 -07:00 committed by GitHub
parent 3c238ebc1d
commit dc3cf4a1e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 1949 additions and 26 deletions

View File

@ -0,0 +1,139 @@
[
{
"proto": {},
"screens": [
{"text": "Foo object"}
]
},
{
"proto": {
"full_name": "nonempty"
},
"screens": [
{"text": "Foo object"},
{"text": "Full name: nonempty", "indent": 1}
]
},
{
"proto": {
"full_name": "thing one",
"nickname": ":thing two"
},
"screens": [
{"text": "Foo object"},
{"text": "Full name: thing one", "indent": 1},
{"text": "Nickname: :thing two", "indent": 1}
]
},
{
"proto": {
"full_name": "special child message",
"mtime": {
"seconds": 1136214245
}
},
"screens": [
{"text": "Foo object"},
{"text": "Full name: special child message", "indent": 1},
{"text": "Mtime: 2006-01-02T15:04:05Z", "indent": 1}
]
},
{
"proto": {
"nickname": "empty child",
"left": {}
},
"screens": [
{"text": "Foo object"},
{"text": "Nickname: empty child", "indent": 1},
{"text": "Left: Foo object", "indent": 1}
]
},
{
"proto": {
"nickname": "empty children",
"left": {},
"right": {},
"bar": {}
},
"screens": [
{"text": "Foo object"},
{"text": "Nickname: empty children", "indent": 1},
{"text": "Left: Foo object", "indent": 1},
{"text": "Right: Foo object", "indent": 1},
{"text": "Bar: Bar object", "indent": 1}
]
},
{
"proto": {
"full_name": "subfield",
"left": {},
"right": {
"nickname": "junior"
},
"bar": {}
},
"screens": [
{"text": "Foo object"},
{"text": "Full name: subfield", "indent": 1},
{"text": "Left: Foo object", "indent": 1},
{"text": "Right: Foo object", "indent": 1},
{"text": "Nickname: junior", "indent": 2},
{"text": "Bar: Bar object", "indent": 1}
]
},
{
"proto": {
"full_name": "deep",
"left": {
"left": {"nickname": "LL"},
"right": {"nickname": "LR"}
},
"right": {
"left": {"nickname": "RL"},
"right": {"nickname": "RR"}
}
},
"screens": [
{"text": "Foo object"},
{"text": "Full name: deep", "indent": 1},
{"text": "Left: Foo object", "indent": 1},
{"text": "Left: Foo object", "indent": 2},
{"text": "Nickname: LL", "indent": 3},
{"text": "Right: Foo object", "indent": 2},
{"text": "Nickname: LR", "indent": 3},
{"text": "Right: Foo object", "indent": 1},
{"text": "Left: Foo object", "indent": 2},
{"text": "Nickname: RL", "indent": 3},
{"text": "Right: Foo object", "indent": 2},
{"text": "Nickname: RR", "indent": 3}
]
},
{
"proto": {
"full_name": " the kitchen sink ",
"mtime": {},
"left": {},
"right": {
"nickname": "blub",
"right": {},
"bar": {
"bar_id": "quux",
"data": [255, 254]
}
}
},
"screens": [
{"text": "Foo object"},
{"text": "Full name: the kitchen sink ", "indent": 1},
{"text": "Mtime: 1970-01-01T00:00:00Z", "indent": 1},
{"text": "Left: Foo object", "indent": 1},
{"text": "Right: Foo object", "indent": 1},
{"text": "Nickname: blub", "indent": 2},
{"text": "Right: Foo object", "indent": 2},
{"text": "Bar: Bar object", "indent": 2},
{"text": "Bar id: quux", "indent": 3},
{"text": "Data: FFFE", "indent": 3}
]
}
]

View File

@ -0,0 +1,11 @@
[
{"text": ""},
{"text": "x"},
{"text": "foo"},
{"text": "\"able"},
{"text": "unresolved\nambiguities\r\ncost\rbillions"},
{"text": "stealth whitespace is significant "},
{"text": "stealth whitespace snailed for your protection @@"},
{"text": "co\u00F6peration"},
{"text": "JSON limits unicode to 16 bits, surrogates must be transmitted as-is \uD852\uDF62"}
]

View File

@ -39,3 +39,19 @@ message A {
double DOUBLE = 110;
map<string, A> MAP = 111;
}
// Foo is a sample message type used for testing message rendering.
message Foo {
string full_name = 1;
string nickname = 2;
google.protobuf.Timestamp mtime = 3;
Foo left = 4;
Foo right = 5;
Bar bar = 8; // skip some field numbers
}
// Bar is a sample message type used for testing message rendering.
message Bar {
string bar_id = 1;
bytes data = 2;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,161 @@
package valuerenderer
import (
"context"
"fmt"
"sort"
"strings"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/reflect/protoregistry"
)
type messageValueRenderer struct {
tr *Textual
msgDesc protoreflect.MessageDescriptor
fds []protoreflect.FieldDescriptor
}
func NewMessageValueRenderer(t *Textual, msgDesc protoreflect.MessageDescriptor) ValueRenderer {
fields := msgDesc.Fields()
fds := make([]protoreflect.FieldDescriptor, 0, fields.Len())
for i := 0; i < fields.Len(); i++ {
fds = append(fds, fields.Get(i))
}
sort.Slice(fds, func(i, j int) bool { return fds[i].Number() < fds[j].Number() })
return &messageValueRenderer{tr: t, msgDesc: msgDesc, fds: fds}
}
func (mr *messageValueRenderer) header() string {
return fmt.Sprintf("%s object", mr.msgDesc.Name())
}
func (mr *messageValueRenderer) Format(ctx context.Context, v protoreflect.Value) ([]Screen, error) {
fullName := v.Message().Descriptor().FullName()
wantFullName := mr.msgDesc.FullName()
if fullName != wantFullName {
return nil, fmt.Errorf(`bad message type: want "%s", got "%s"`, wantFullName, fullName)
}
screens := make([]Screen, 1)
screens[0].Text = mr.header()
for _, fd := range mr.fds {
vr, err := mr.tr.GetValueRenderer(fd)
if err != nil {
return nil, err
}
// Skip default values.
if !v.Message().Has(fd) {
continue
}
subscreens, err := vr.Format(ctx, v.Message().Get(fd))
if err != nil {
return nil, err
}
if len(subscreens) == 0 {
return nil, fmt.Errorf("empty rendering for field %s", fd.Name())
}
headerScreen := Screen{
Text: fmt.Sprintf("%s: %s", formatFieldName(string(fd.Name())), subscreens[0].Text),
Indent: subscreens[0].Indent + 1,
Expert: subscreens[0].Expert,
}
screens = append(screens, headerScreen)
for i := 1; i < len(subscreens); i++ {
extraScreen := Screen{
Text: subscreens[i].Text,
Indent: subscreens[i].Indent + 1,
Expert: subscreens[i].Expert,
}
screens = append(screens, extraScreen)
}
}
return screens, nil
}
// formatFieldName formats a field name in sentence case, as specified in:
// https://github.com/cosmos/cosmos-sdk/blob/b6f867d0b674d62e56b27aa4d00f5b6042ebac9e/docs/architecture/adr-050-sign-mode-textual-annex1.md?plain=1#L110
func formatFieldName(name string) string {
if len(name) == 0 {
return name
}
return strings.ToTitle(name[0:1]) + strings.ReplaceAll(name[1:], "_", " ")
}
var nilValue = protoreflect.Value{}
func (mr *messageValueRenderer) Parse(ctx context.Context, screens []Screen) (protoreflect.Value, error) {
if len(screens) == 0 {
return nilValue, fmt.Errorf("expect at least one screen")
}
wantHeader := fmt.Sprintf("%s object", mr.msgDesc.Name())
if screens[0].Text != wantHeader {
return nilValue, fmt.Errorf(`bad header: want "%s", got "%s"`, wantHeader, screens[0].Text)
}
if screens[0].Indent != 0 {
return nilValue, fmt.Errorf("bad message indentation: want 0, got %d", screens[0].Indent)
}
msgType, err := protoregistry.GlobalTypes.FindMessageByName(mr.msgDesc.FullName())
if err != nil {
return nilValue, err
}
msg := msgType.New()
idx := 1
for _, fd := range mr.fds {
if idx >= len(screens) {
// remaining fields are default
break
}
vr, err := mr.tr.GetValueRenderer(fd)
if err != nil {
return nilValue, err
}
if screens[idx].Indent != 1 {
return nilValue, fmt.Errorf("bad message indentation: want 1, got %d", screens[idx].Indent)
}
prefix := formatFieldName(string(fd.Name())) + ": "
if !strings.HasPrefix(screens[idx].Text, prefix) {
// we must have skipped this fd because of a default value
continue
}
// Make a new screen without the prefix
subscreens := make([]Screen, 1)
subscreens[0] = screens[idx]
subscreens[0].Text = strings.TrimPrefix(screens[idx].Text, prefix)
subscreens[0].Indent--
idx++
// Gather nested screens
for idx < len(screens) && screens[idx].Indent > 1 {
scr := screens[idx]
scr.Indent--
subscreens = append(subscreens, scr)
idx++
}
val, err := vr.Parse(ctx, subscreens)
if err != nil {
return nilValue, err
}
msg.Set(fd, val)
}
if idx > len(screens) {
return nilValue, fmt.Errorf("leftover screens")
}
return protoreflect.ValueOfMessage(msg), nil
}

View File

@ -0,0 +1,53 @@
package valuerenderer_test
import (
"context"
"encoding/json"
"fmt"
"os"
"testing"
"cosmossdk.io/tx/textual/valuerenderer"
"github.com/stretchr/testify/require"
bankv1beta1 "cosmossdk.io/api/cosmos/bank/v1beta1"
"cosmossdk.io/tx/textual/internal/testpb"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
)
func EmptyCoinMetadataQuerier(ctx context.Context, denom string) (*bankv1beta1.Metadata, error) {
return nil, nil
}
type messageJsonTest struct {
Proto *testpb.Foo
Screens []valuerenderer.Screen
}
func TestMessageJsonTestcases(t *testing.T) {
raw, err := os.ReadFile("../internal/testdata/message.json")
require.NoError(t, err)
var testcases []messageJsonTest
err = json.Unmarshal(raw, &testcases)
require.NoError(t, err)
tr := valuerenderer.NewTextual(EmptyCoinMetadataQuerier)
for i, tc := range testcases {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
rend := valuerenderer.NewMessageValueRenderer(&tr, (&testpb.Foo{}).ProtoReflect().Descriptor())
screens, err := rend.Format(context.Background(), protoreflect.ValueOf(tc.Proto.ProtoReflect()))
require.NoError(t, err)
require.Equal(t, tc.Screens, screens)
val, err := rend.Parse(context.Background(), screens)
require.NoError(t, err)
msg := val.Message().Interface()
require.IsType(t, &testpb.Foo{}, msg)
foo := msg.(*testpb.Foo)
require.True(t, proto.Equal(foo, tc.Proto))
})
}
}

View File

@ -0,0 +1,28 @@
package valuerenderer
import (
"context"
"fmt"
"google.golang.org/protobuf/reflect/protoreflect"
)
type stringValueRenderer struct {
}
// NewStringValueRenderer returns a ValueRenderer for protocol buffer string values.
// It renders the string as-is without quotation.
func NewStringValueRenderer() ValueRenderer {
return stringValueRenderer{}
}
func (sr stringValueRenderer) Format(_ context.Context, v protoreflect.Value) ([]Screen, error) {
return []Screen{{Text: v.String()}}, nil
}
func (sr stringValueRenderer) Parse(_ context.Context, screens []Screen) (protoreflect.Value, error) {
if len(screens) != 1 {
return protoreflect.Value{}, fmt.Errorf("expected single screen: %v", screens)
}
return protoreflect.ValueOfString(screens[0].Text), nil
}

View File

@ -0,0 +1,55 @@
package valuerenderer_test
import (
"context"
"encoding/json"
"fmt"
"os"
"testing"
"cosmossdk.io/tx/textual/valuerenderer"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/reflect/protoreflect"
)
type stringJsonTest struct {
Text string
}
func TestStringJsonTestcases(t *testing.T) {
raw, err := os.ReadFile("../internal/testdata/string.json")
require.NoError(t, err)
var testcases []stringJsonTest
err = json.Unmarshal(raw, &testcases)
require.NoError(t, err)
for i, tc := range testcases {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
rend := valuerenderer.NewStringValueRenderer()
screens, err := rend.Format(context.Background(), protoreflect.ValueOfString(tc.Text))
require.NoError(t, err)
require.Equal(t, 1, len(screens))
require.Equal(t, tc.Text, screens[0].Text)
val, err := rend.Parse(context.Background(), screens)
require.NoError(t, err)
require.Equal(t, tc.Text, val.String())
})
}
}
func TestStringHighUnicode(t *testing.T) {
// We cannot encode Unicode characters beyond the BMP directly in JSON,
// so this case must be a native Go test.
s := "\U00101234"
rend := valuerenderer.NewStringValueRenderer()
screens, err := rend.Format(context.Background(), protoreflect.ValueOfString(s))
require.NoError(t, err)
require.Equal(t, 1, len(screens))
require.Equal(t, s, screens[0].Text)
val, err := rend.Parse(context.Background(), screens)
require.NoError(t, err)
require.Equal(t, s, val.String())
}

View File

@ -75,6 +75,9 @@ func (r Textual) GetValueRenderer(fd protoreflect.FieldDescriptor) (ValueRendere
return NewIntValueRenderer(), nil
}
case fd.Kind() == protoreflect.StringKind:
return stringValueRenderer{}, nil
case fd.Kind() == protoreflect.MessageKind:
md := fd.Message()
fullName := md.FullName()
@ -83,8 +86,14 @@ func (r Textual) GetValueRenderer(fd protoreflect.FieldDescriptor) (ValueRendere
if found {
return vr, nil
}
// TODO default message renderer
return nil, fmt.Errorf("no value renderer for message %s", fullName)
if fd.IsMap() {
return nil, fmt.Errorf("value renderers cannot format value of type map")
}
if fd.IsList() {
// This will be implemented in https://github.com/cosmos/cosmos-sdk/issues/12714
return nil, fmt.Errorf("repeated field renderer not yet implemented")
}
return NewMessageValueRenderer(&r, md), nil
default:
return nil, fmt.Errorf("value renderers cannot format value of type %s", fd.Kind())