-
Notifications
You must be signed in to change notification settings - Fork 18
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(cli): add methods to print to console (#111)
* 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
Showing
2 changed files
with
274 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | ||
} |