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:
parent
3c238ebc1d
commit
dc3cf4a1e5
139
tx/textual/internal/testdata/message.json
vendored
Normal file
139
tx/textual/internal/testdata/message.json
vendored
Normal 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}
|
||||
]
|
||||
}
|
||||
]
|
||||
11
tx/textual/internal/testdata/string.json
vendored
Normal file
11
tx/textual/internal/testdata/string.json
vendored
Normal 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"}
|
||||
]
|
||||
@ -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
161
tx/textual/valuerenderer/message.go
Normal file
161
tx/textual/valuerenderer/message.go
Normal 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
|
||||
}
|
||||
53
tx/textual/valuerenderer/message_test.go
Normal file
53
tx/textual/valuerenderer/message_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
28
tx/textual/valuerenderer/string.go
Normal file
28
tx/textual/valuerenderer/string.go
Normal 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
|
||||
}
|
||||
55
tx/textual/valuerenderer/string_test.go
Normal file
55
tx/textual/valuerenderer/string_test.go
Normal 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())
|
||||
}
|
||||
@ -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())
|
||||
|
||||
Loading…
Reference in New Issue
Block a user