Skip to content

Commit

Permalink
feat(cli): add methods to print to console (#111)
Browse files Browse the repository at this point in the history
* feat(cli): add methods to print to console

- Columnize() is using text/tabwriter standard library to print fields and vertically aligned in columns, split by the `|` delimiter character.
- GetValueByJSONTag() recursively search for a string key within the JSON tags of a given object to return the value associated with it. This metho
d is used with command flags like `--field` where a single return value is expected.
- The format for the printing - minwidth, tabwidth, padding, padchar - is configurable.
- FromStruct() converts a struct into a Printable using, when available, JSON field names as keys.
- customFormat() is an utility function to format numbers and avoid scientific notation and ensure they are printed as integers.

* test(cli): add unit tests for print methods

* test(cli): add additional assert

* fix: base64 converted to hex
  • Loading branch information
czar0 authored Jan 6, 2024
1 parent 94396f7 commit 8330d0b
Show file tree
Hide file tree
Showing 2 changed files with 274 additions and 0 deletions.
179 changes: 179 additions & 0 deletions cmd/ethkit/print.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package main

import (
"bytes"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"reflect"
"strconv"
"strings"
"text/tabwriter"
)

type printableFormat struct {
minwidth int
tabwidth int
padding int
padchar byte
}

// NewPrintableFormat returns a customized configuration format
func NewPrintableFormat(minwidth, tabwidth, padding int, padchar byte) *printableFormat {
return &printableFormat{minwidth, tabwidth, padding, padchar}
}

// Printable is a generic key-value (map) structure that could contain nested objects.
type Printable map[string]any

// PrettyJSON prints an object in "prettified" JSON format
func PrettyJSON(toJSON any) (*string, error) {
b, err := json.MarshalIndent(toJSON, "", " ")
if err != nil {
return nil, err
}
jsonString := string(b)

// remove the trailing newline character ("%")
if jsonString[len(jsonString)-1] == '\n' {
jsonString = jsonString[:len(jsonString)-1]
}

return &jsonString, nil
}

// FromStruct converts a struct into a Printable using, when available, JSON field names as keys
func (p *Printable) FromStruct(input any) error {
bytes, err := json.Marshal(input)
if err != nil {
return err
}
if err := json.Unmarshal(bytes, &p); err != nil {
return err
}

return nil
}

// Columnize returns a formatted-in-columns (vertically aligned) string based on a provided configuration.
func (p *Printable) Columnize(pf printableFormat) string {
var buf bytes.Buffer
w := tabwriter.NewWriter(&buf, pf.minwidth, pf.tabwidth, pf.padding, pf.padchar, tabwriter.Debug)
// NOTE: Order is not maintained whilst looping over map's . Results from different execution may differ.
for k, v := range *p {
printKeyValue(w, k, v)
}
w.Flush()

return buf.String()
}

func printKeyValue(w *tabwriter.Writer, key string, value any) {
switch t := value.(type) {
// NOTE: Printable is not directly inferred as map[string]any therefore explicit reference is necessary
case map[string]any:
fmt.Fprintln(w, key, "\t")
for tk, tv := range t {
printKeyValue(w, "\t "+tk, tv)
}
case []any:
fmt.Fprintln(w, key, "\t")
for _, elem := range t {
elemMap, ok := elem.(map[string]any)
if ok {
for tk, tv := range elemMap {
printKeyValue(w, "\t "+tk, tv)
}
fmt.Fprintln(w, "\t", "\t")
} else {
fmt.Fprintln(w, "\t", customFormat(elem))
}
}
default:
// custom format for numbers to avoid scientific notation
fmt.Fprintf(w, "%s\t %s\n", key, customFormat(value))
}
}

func customFormat(value any) string {
switch v := value.(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
return fmt.Sprintf("%d", v)
case float32, float64:
return formatFloat(v)
default:
return fmt.Sprintf("%v", base64ToHex(value))
}
}

func formatFloat(f any) string {
str := fmt.Sprintf("%v", f)
if strings.ContainsAny(str, "eE.") {
if floatValue, err := strconv.ParseFloat(str, 64); err == nil {
return strconv.FormatFloat(floatValue, 'f', -1, 64)
}
}

return str
}

func base64ToHex(str any) any {
_, ok := str.(string); if !ok {
return str
}
decoded, err := base64.StdEncoding.DecodeString(str.(string))
if err != nil {
return str
}

return "0x" + hex.EncodeToString(decoded)
}

// GetValueByJSONTag returns the value of a struct field matching a JSON tag provided in input.
func GetValueByJSONTag(input any, jsonTag string) any {
// TODO: Refactor to support both nil values and errors when key not found
return findField(reflect.ValueOf(input), jsonTag)
}

func findField(val reflect.Value, jsonTag string) any {
seen := make(map[uintptr]bool)

// take the value the pointer val points to
if val.Kind() == reflect.Ptr {
val = val.Elem()
}

// return if the element is not a struct
if val.Kind() != reflect.Struct {
return nil
}

// check if the struct has already been processed to avoid infinite recursion
if val.CanAddr() {
ptr := val.Addr().Pointer()
if seen[ptr] {
return nil
}
seen[ptr] = true
}

t := val.Type()

for i := 0; i < val.NumField(); i++ {
field := t.Field(i)
tag := field.Tag.Get("json")

fieldValue := val.Field(i)
if fieldValue.Kind() == reflect.Struct {
// recursively process fields including embedded ones
return findField(fieldValue, jsonTag)
} else {
if strings.EqualFold(strings.ToLower(tag), strings.ToLower(jsonTag)) {
return val.Field(i).Interface()
}
}
}

return nil
}
95 changes: 95 additions & 0 deletions cmd/ethkit/print_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package main

import (
"fmt"
"strconv"
"strings"
"testing"

"github.com/stretchr/testify/assert"
)

var (
complex = struct {
Name string `json:"name"`
List []any `json:"list"`
Nested Nested `json:"nested"`
Object map[string]any `json:"object"`
ObjList []map[string]any `json:"objList"`
}{
Name: "complex",
List: []any{
"first",
"second",
},
Nested: Nested{
Title: "hello",
Value: 500000000,
},
Object: map[string]any{
"obj1": 1,
"obj2": -2,
},
ObjList: []map[string]any{
{
"item1": 1,
"item2": 2,
},
{
"item3": 3e7,
"item4": 2E7,
"item5": 2.123456e7,
},
},
}

s string
rows []string
p Printable
minwidth = 24
tabwidth = 0
padding = 0
padchar = byte(' ')
)

type Nested struct {
Title string `json:"title"`
Value uint `json:"value"`
}

func setup() {
if err := p.FromStruct(complex); err != nil {
panic(err)
}
s = p.Columnize(*NewPrintableFormat(minwidth, tabwidth, padding, padchar))
fmt.Println(s)
rows = strings.Split(s, "\n")
}

func Test_Columnize(t *testing.T) {
setup()
for i := 0; i < len(rows); i++ {
if rows[i] != "" {
// the delimiter should be in the same position in all the rows
assert.Equal(t, strings.Index(rows[i], "|"), minwidth)
if strings.Contains(rows[i], "item5") {
v := strconv.FormatFloat(complex.ObjList[1]["item5"].(float64), 'f', -1, 64)
// the value of the nested object should be indented to the 3rd column and it should be parsed as an integer
// left bound: 2*\t + 1*' ' = 3 , right bound: 3 + len(21234560) + 1 = 11
assert.Equal(t, rows[i][minwidth*2+3:minwidth*2+11], v)
}
}
}
}

func Test_GetValueByJSONTag(t *testing.T) {
setup()
tag := "title"
assert.Equal(t, GetValueByJSONTag(complex, tag), complex.Nested.Title)
}

func Test_GetValueByJSONTag_FailWhenNotStruct(t *testing.T) {
setup()
tag := "title"
assert.Nil(t, GetValueByJSONTag(p, tag))
}

0 comments on commit 8330d0b

Please sign in to comment.