Repository: sacloud/usacloud
Pull request #806 (2021-01-20 04:55:54)

Print JSON as pretty
`--output-type=json`や`--query`指定時のJSON出力に色つけを行う。
実装には github.com/hokaccha/go-prettyjson (ライセンス:MIT) を利用している。

<img width="719" alt="スクリーンショット 2021-01-20 13 40 07" src="https://user-images.githubusercontent.com/1644816/105128736-ebed1480-5b26-11eb-9832-f64bdb5fe8cf.png">

`--no-color`にも対応済み。


Patch:
diff --git a/go.mod b/go.mod
index ebe5030c..013db910 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@ require (
 	github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32
 	github.com/go-playground/validator/v10 v10.4.1
 	github.com/hashicorp/go-version v1.2.1
+	github.com/hokaccha/go-prettyjson v0.0.0-20210113012101-fb4e108d2519
 	github.com/huandu/xstrings v1.3.0
 	github.com/itchyny/gojq v0.12.1
 	github.com/jmespath/go-jmespath v0.4.0
diff --git a/go.sum b/go.sum
index 4fffd91c..6ec57bbd 100644
--- a/go.sum
+++ b/go.sum
@@ -196,6 +196,8 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO
 github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
 github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
 github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
+github.com/hokaccha/go-prettyjson v0.0.0-20210113012101-fb4e108d2519 h1:nqAlWFEdqI0ClbTDrhDvE/8LeQ4pftrqKUX9w5k0j3s=
+github.com/hokaccha/go-prettyjson v0.0.0-20210113012101-fb4e108d2519/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI=
 github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/huandu/xstrings v1.2.0 h1:yPeWdRnmynF7p+lLYz0H2tthW9lqhMJrQV/U7yy4wX0=
diff --git a/pkg/cli/context.go b/pkg/cli/context.go
index 2a781385..041d04d6 100644
--- a/pkg/cli/context.go
+++ b/pkg/cli/context.go
@@ -210,12 +210,12 @@ func getOutputWriter(io IO, globalOption *config.Config, columnDefs []output.Col
 		return output.NewFreeOutput(out, err, options)
 	}
 	if options.QueryFlagValue() != "" {
-		return output.NewJSONOutput(out, err, options.QueryFlagValue(), queryDriver)
+		return output.NewJSONOutput(out, err, globalOption.NoColor, options.QueryFlagValue(), queryDriver)
 	}
 
 	switch outputType {
 	case "json":
-		return output.NewJSONOutput(out, err, options.QueryFlagValue(), queryDriver)
+		return output.NewJSONOutput(out, err, globalOption.NoColor, options.QueryFlagValue(), queryDriver)
 	case "yaml":
 		return output.NewYAMLOutput(out, err)
 	default:
diff --git a/pkg/output/json_output.go b/pkg/output/json_output.go
index 0d7e1493..9e7fb78a 100644
--- a/pkg/output/json_output.go
+++ b/pkg/output/json_output.go
@@ -15,30 +15,31 @@
 package output
 
 import (
-	"encoding/json"
 	"fmt"
 	"io"
 	"os"
 
-	"github.com/sacloud/usacloud/pkg/query"
-
 	"github.com/fatih/structs"
+	"github.com/hokaccha/go-prettyjson"
+	"github.com/sacloud/usacloud/pkg/query"
 	"github.com/sacloud/usacloud/pkg/util"
 )
 
 type jsonOutput struct {
-	out    io.Writer
-	err    io.Writer
-	query  string
-	driver string
+	out     io.Writer
+	err     io.Writer
+	noColor bool
+	query   string
+	driver  string
 }
 
-func NewJSONOutput(out io.Writer, err io.Writer, query string, driver string) Output {
+func NewJSONOutput(out io.Writer, err io.Writer, noColor bool, query string, driver string) Output {
 	return &jsonOutput{
-		out:    out,
-		err:    err,
-		query:  query,
-		driver: driver,
+		out:     out,
+		err:     err,
+		noColor: noColor,
+		query:   query,
+		driver:  driver,
 	}
 }
 
@@ -102,9 +103,13 @@ func (o *jsonOutput) printWithQuery(values []interface{}) error {
 }
 
 func (o *jsonOutput) printOutput(v interface{}) error {
-	data, err := json.MarshalIndent(v, "", "    ")
+	formatter := prettyjson.NewFormatter()
+	formatter.DisabledColor = o.noColor
+	formatter.Indent = 4
+
+	data, err := formatter.Marshal(v)
 	if err != nil {
-		return fmt.Errorf("JSONOutput:Print: MarshalIndent failed: %s", err)
+		return fmt.Errorf("JSONOutput:Print: prettyjson.Marshal failed: %s", err)
 	}
 
 	if _, err := o.out.Write(data); err != nil {
diff --git a/vendor/github.com/hokaccha/go-prettyjson/.gitignore b/vendor/github.com/hokaccha/go-prettyjson/.gitignore
new file mode 100644
index 00000000..daf913b1
--- /dev/null
+++ b/vendor/github.com/hokaccha/go-prettyjson/.gitignore
@@ -0,0 +1,24 @@
+# Compiled Object files, Static and Dynamic libs (Shared Objects)
+*.o
+*.a
+*.so
+
+# Folders
+_obj
+_test
+
+# Architecture specific extensions/prefixes
+*.[568vq]
+[568vq].out
+
+*.cgo1.go
+*.cgo2.c
+_cgo_defun.c
+_cgo_gotypes.go
+_cgo_export.*
+
+_testmain.go
+
+*.exe
+*.test
+*.prof
diff --git a/vendor/github.com/hokaccha/go-prettyjson/LICENSE b/vendor/github.com/hokaccha/go-prettyjson/LICENSE
new file mode 100644
index 00000000..fa2897d6
--- /dev/null
+++ b/vendor/github.com/hokaccha/go-prettyjson/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2018 Kazuhito Hokamura
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/vendor/github.com/hokaccha/go-prettyjson/README.md b/vendor/github.com/hokaccha/go-prettyjson/README.md
new file mode 100644
index 00000000..0aa36e40
--- /dev/null
+++ b/vendor/github.com/hokaccha/go-prettyjson/README.md
@@ -0,0 +1,26 @@
+# prettyjson
+
+JSON pretty print for Golang.
+
+## Example
+
+```go
+v := map[string]interface{}{
+    "str": "foo",
+    "num": 100,
+    "bool": false,
+    "null": nil,
+    "array": []string{"foo", "bar", "baz"},
+    "map": map[string]interface{}{
+        "foo": "bar",
+    },
+}
+s, _ := prettyjson.Marshal(v)
+fmt.Println(string(s))
+```
+
+![Output](http://i.imgur.com/cUFj5os.png)
+
+## License
+
+MIT
diff --git a/vendor/github.com/hokaccha/go-prettyjson/prettyjson.go b/vendor/github.com/hokaccha/go-prettyjson/prettyjson.go
new file mode 100644
index 00000000..10c79e48
--- /dev/null
+++ b/vendor/github.com/hokaccha/go-prettyjson/prettyjson.go
@@ -0,0 +1,200 @@
+// Package prettyjson provides JSON pretty print.
+package prettyjson
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"sort"
+	"strconv"
+	"strings"
+
+	"github.com/fatih/color"
+)
+
+// Formatter is a struct to format JSON data. `color` is github.com/fatih/color: https://github.com/fatih/color
+type Formatter struct {
+	// JSON key color. Default is `color.New(color.FgBlue, color.Bold)`.
+	KeyColor *color.Color
+
+	// JSON string value color. Default is `color.New(color.FgGreen, color.Bold)`.
+	StringColor *color.Color
+
+	// JSON boolean value color. Default is `color.New(color.FgYellow, color.Bold)`.
+	BoolColor *color.Color
+
+	// JSON number value color. Default is `color.New(color.FgCyan, color.Bold)`.
+	NumberColor *color.Color
+
+	// JSON null value color. Default is `color.New(color.FgBlack, color.Bold)`.
+	NullColor *color.Color
+
+	// Max length of JSON string value. When the value is 1 and over, string is truncated to length of the value.
+	// Default is 0 (not truncated).
+	StringMaxLength int
+
+	// Boolean to disable color. Default is false.
+	DisabledColor bool
+
+	// Indent space number. Default is 2.
+	Indent int
+
+	// Newline string. To print without new lines set it to empty string. Default is \n.
+	Newline string
+}
+
+// NewFormatter returns a new formatter with following default values.
+func NewFormatter() *Formatter {
+	return &Formatter{
+		KeyColor:        color.New(color.FgBlue, color.Bold),
+		StringColor:     color.New(color.FgGreen, color.Bold),
+		BoolColor:       color.New(color.FgYellow, color.Bold),
+		NumberColor:     color.New(color.FgCyan, color.Bold),
+		NullColor:       color.New(color.FgBlack, color.Bold),
+		StringMaxLength: 0,
+		DisabledColor:   false,
+		Indent:          2,
+		Newline:         "\n",
+	}
+}
+
+// Marshal marshals and formats JSON data.
+func (f *Formatter) Marshal(v interface{}) ([]byte, error) {
+	data, err := json.Marshal(v)
+	if err != nil {
+		return nil, err
+	}
+
+	return f.Format(data)
+}
+
+// Format formats JSON string.
+func (f *Formatter) Format(data []byte) ([]byte, error) {
+	var v interface{}
+	decoder := json.NewDecoder(bytes.NewReader(data))
+	decoder.UseNumber()
+	if err := decoder.Decode(&v); err != nil {
+		return nil, err
+	}
+
+	return []byte(f.pretty(v, 1)), nil
+}
+
+func (f *Formatter) sprintfColor(c *color.Color, format string, args ...interface{}) string {
+	if f.DisabledColor || c == nil {
+		return fmt.Sprintf(format, args...)
+	}
+	return c.SprintfFunc()(format, args...)
+}
+
+func (f *Formatter) sprintColor(c *color.Color, s string) string {
+	if f.DisabledColor || c == nil {
+		return fmt.Sprint(s)
+	}
+	return c.SprintFunc()(s)
+}
+
+func (f *Formatter) pretty(v interface{}, depth int) string {
+	switch val := v.(type) {
+	case string:
+		return f.processString(val)
+	case float64:
+		return f.sprintColor(f.NumberColor, strconv.FormatFloat(val, 'f', -1, 64))
+	case json.Number:
+		return f.sprintColor(f.NumberColor, string(val))
+	case bool:
+		return f.sprintColor(f.BoolColor, strconv.FormatBool(val))
+	case nil:
+		return f.sprintColor(f.NullColor, "null")
+	case map[string]interface{}:
+		return f.processMap(val, depth)
+	case []interface{}:
+		return f.processArray(val, depth)
+	}
+
+	return ""
+}
+
+func (f *Formatter) processString(s string) string {
+	r := []rune(s)
+	if f.StringMaxLength != 0 && len(r) >= f.StringMaxLength {
+		s = string(r[0:f.StringMaxLength]) + "..."
+	}
+
+	buf := &bytes.Buffer{}
+	encoder := json.NewEncoder(buf)
+	encoder.SetEscapeHTML(false)
+	encoder.Encode(s)
+	s = string(buf.Bytes())
+	s = strings.TrimSuffix(s, "\n")
+
+	return f.sprintColor(f.StringColor, s)
+}
+
+func (f *Formatter) processMap(m map[string]interface{}, depth int) string {
+	if len(m) == 0 {
+		return "{}"
+	}
+
+	currentIndent := f.generateIndent(depth - 1)
+	nextIndent := f.generateIndent(depth)
+	rows := []string{}
+	keys := []string{}
+
+	for key := range m {
+		keys = append(keys, key)
+	}
+
+	sort.Strings(keys)
+
+	for _, key := range keys {
+		val := m[key]
+		buf := &bytes.Buffer{}
+		encoder := json.NewEncoder(buf)
+		encoder.SetEscapeHTML(false)
+		encoder.Encode(key)
+		s := strings.TrimSuffix(string(buf.Bytes()), "\n")
+		k := f.sprintColor(f.KeyColor, s)
+		v := f.pretty(val, depth+1)
+
+		valueIndent := " "
+		if f.Newline == "" {
+			valueIndent = ""
+		}
+		row := fmt.Sprintf("%s%s:%s%s", nextIndent, k, valueIndent, v)
+		rows = append(rows, row)
+	}
+
+	return fmt.Sprintf("{%s%s%s%s}", f.Newline, strings.Join(rows, ","+f.Newline), f.Newline, currentIndent)
+}
+
+func (f *Formatter) processArray(a []interface{}, depth int) string {
+	if len(a) == 0 {
+		return "[]"
+	}
+
+	currentIndent := f.generateIndent(depth - 1)
+	nextIndent := f.generateIndent(depth)
+	rows := []string{}
+
+	for _, val := range a {
+		c := f.pretty(val, depth+1)
+		row := nextIndent + c
+		rows = append(rows, row)
+	}
+	return fmt.Sprintf("[%s%s%s%s]", f.Newline, strings.Join(rows, ","+f.Newline), f.Newline, currentIndent)
+}
+
+func (f *Formatter) generateIndent(depth int) string {
+	return strings.Repeat(" ", f.Indent*depth)
+}
+
+// Marshal JSON data with default options.
+func Marshal(v interface{}) ([]byte, error) {
+	return NewFormatter().Marshal(v)
+}
+
+// Format JSON string with default options.
+func Format(data []byte) ([]byte, error) {
+	return NewFormatter().Format(data)
+}
diff --git a/vendor/modules.txt b/vendor/modules.txt
index f254d5ac..17399739 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -37,6 +37,9 @@ github.com/hashicorp/go-retryablehttp
 # github.com/hashicorp/go-version v1.2.1
 ## explicit
 github.com/hashicorp/go-version
+# github.com/hokaccha/go-prettyjson v0.0.0-20210113012101-fb4e108d2519
+## explicit
+github.com/hokaccha/go-prettyjson
 # github.com/huandu/xstrings v1.3.0
 ## explicit
 github.com/huandu/xstrings


Test patch:
diff --git a/pkg/cli/context_test.go b/pkg/cli/context_test.go
index 117c3427..aa624cef 100644
--- a/pkg/cli/context_test.go
+++ b/pkg/cli/context_test.go
@@ -136,7 +136,7 @@ func Test_getOutputWriter(t *testing.T) {
 					defaultOutputType: "table",
 				},
 			},
-			want: output.NewJSONOutput(io.Out(), io.Err(), "dummy", "jmespath"),
+			want: output.NewJSONOutput(io.Out(), io.Err(), false, "dummy", "jmespath"),
 		},
 		{
 			name: "with json output-type",
@@ -146,7 +146,7 @@ func Test_getOutputWriter(t *testing.T) {
 				columnDefs:   nil,
 				rawOptions:   &dummyOption{outputType: "json"},
 			},
-			want: output.NewJSONOutput(io.Out(), io.Err(), "", ""),
+			want: output.NewJSONOutput(io.Out(), io.Err(), false, "", ""),
 		},
 		{
 			name: "with yaml output-type",
@@ -182,7 +182,7 @@ func Test_getOutputWriter(t *testing.T) {
 				columnDefs:   nil,
 				rawOptions:   &dummyOption{},
 			},
-			want: output.NewJSONOutput(io.Out(), io.Err(), "", ""),
+			want: output.NewJSONOutput(io.Out(), io.Err(), false, "", ""),
 		},
 		{
 			name: "with both of DefaultOutputType and output-type",