Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,8 @@ which credential is active.
- `--debug` - Enable debug logging (includes HTTP request/response details)
- `--version`, `-v` - Show the CLI version
- `--base-url` - Use a custom API backend URL
- `--format` - Change the output format (`auto`, `explore`, `json`, `jsonl`, `pretty`, `raw`, `yaml`)
- `--format-error` - Change the output format for errors (`auto`, `explore`, `json`, `jsonl`, `pretty`, `raw`, `yaml`)
- `--format` - Change the output format (`auto`, `csv`, `explore`, `json`, `jsonl`, `pretty`, `raw`, `yaml`)
- `--format-error` - Change the output format for errors (`auto`, `csv`, `explore`, `json`, `jsonl`, `pretty`, `raw`, `yaml`)
- `--transform` - Transform the data output using [GJSON syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md)
- `--transform-error` - Transform the error output using [GJSON syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md)

Expand Down
14 changes: 12 additions & 2 deletions pkg/cmd/cmdutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import (
"github.com/urfave/cli/v3"
)

var OutputFormats = []string{"auto", "explore", "json", "jsonl", "pretty", "raw", "yaml"}
var OutputFormats = []string{"auto", "csv", "explore", "json", "jsonl", "pretty", "raw", "yaml"}

var Environments = []string{"production", "sandbox"}

Expand Down Expand Up @@ -378,6 +378,8 @@ func formatJSON(res gjson.Result, opts ShowJSONOpts) ([]byte, error) {
}
case "raw":
return []byte(res.Raw + "\n"), nil
case "csv":
return formatCSV(res, opts.csv)
case "yaml":
// Prefix every document with "---" so concatenated outputs (e.g. list
// commands streaming one item at a time) form a valid multi-document
Expand All @@ -403,12 +405,17 @@ const warningExploreNotSupported = "Warning: Output format 'explore' not support
// ShowJSONOpts configures how JSON output is displayed.
type ShowJSONOpts struct {
ExplicitFormat bool // true if the user explicitly passed --format
Format string // output format (auto, explore, json, jsonl, pretty, raw, yaml)
Format string // output format (auto, csv, explore, json, jsonl, pretty, raw, yaml)
RawOutput bool // like jq -r: print strings without JSON quotes
Stderr io.Writer // stderr for warnings; injectable for testing; defaults to os.Stderr
Stdout *os.File // stdout (or pager); injectable for testing; defaults to os.Stdout
Title string // display title
Transform string // GJSON path to extract before displaying

// csv holds the CSV column layout shared across per-item formatJSON calls;
// the pointer survives the by-value copies of ShowJSONOpts so streaming
// output writes one header row for the whole stream.
csv *csvState
}

func (o *ShowJSONOpts) setDefaults() {
Expand All @@ -418,6 +425,9 @@ func (o *ShowJSONOpts) setDefaults() {
if o.Stdout == nil {
o.Stdout = os.Stdout
}
if o.csv == nil {
o.csv = &csvState{}
}
}

// ShowJSON displays a single JSON result to the user.
Expand Down
100 changes: 100 additions & 0 deletions pkg/cmd/csvformat.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package cmd

import (
"bytes"
"encoding/csv"

"github.com/tidwall/gjson"
)

// csvState carries the column layout across formatJSON calls so that streaming
// display paths (ShowJSONIterator emits one item at a time) write the header
// row exactly once and keep every subsequent record aligned to it. The columns
// are derived from the first item; later items missing a column produce an
// empty cell, and fields not present in the first item are dropped.
type csvState struct {
columns []string
}

// formatCSV renders a JSON object (one record) or array of objects (one
// record each) as CSV. Nested objects are flattened into dot-separated column
// names; arrays and other non-object values are emitted as their raw JSON.
func formatCSV(res gjson.Result, state *csvState) ([]byte, error) {
rows := []gjson.Result{res}
if res.IsArray() {
rows = res.Array()
}

if state == nil {
state = &csvState{}
}

var buf bytes.Buffer
w := csv.NewWriter(&buf)
for _, row := range rows {
columns, values := flattenForCSV(row)
if state.columns == nil {
state.columns = columns
if err := w.Write(state.columns); err != nil {
return nil, err
}
}
record := make([]string, len(state.columns))
for i, column := range state.columns {
record[i] = values[column]
}
if err := w.Write(record); err != nil {
return nil, err
}
}
w.Flush()
return buf.Bytes(), w.Error()
}

// flattenForCSV walks a single record and returns its column names in
// document order alongside the rendered value for each column. Nested objects
// contribute dot-separated columns (e.g. "details.address.city"); a
// non-object record becomes a single "value" column.
func flattenForCSV(row gjson.Result) ([]string, map[string]string) {
columns := []string{}
values := map[string]string{}

var walk func(prefix string, obj gjson.Result)
walk = func(prefix string, obj gjson.Result) {
obj.ForEach(func(key, value gjson.Result) bool {
name := key.String()
if prefix != "" {
name = prefix + "." + name
}
if value.IsObject() {
walk(name, value)
} else {
columns = append(columns, name)
values[name] = csvValue(value)
}
return true
})
}

if row.IsObject() {
walk("", row)
} else {
columns = append(columns, "value")
values["value"] = csvValue(row)
}
return columns, values
}

// csvValue renders a single JSON value as a CSV cell: strings unquoted, null
// as empty, and everything else (numbers, booleans, arrays) as raw JSON.
// Quoting and escaping are left to encoding/csv.
func csvValue(v gjson.Result) string {
switch v.Type {
case gjson.Null:
return ""
case gjson.String:
return v.Str
default:
return v.Raw
}
}
88 changes: 88 additions & 0 deletions pkg/cmd/csvformat_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package cmd

import (
"os"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)

func TestFormatCSV(t *testing.T) {
t.Parallel()

t.Run("SingleObject", func(t *testing.T) {
t.Parallel()

res := gjson.Parse(`{"id":"abc123","amount":-12.5,"note":null}`)
formatted, err := formatJSON(res, ShowJSONOpts{Format: "csv", Stdout: os.Stdout})
require.NoError(t, err)
require.Equal(t, "id,amount,note\nabc123,-12.5,\n", string(formatted))
})

t.Run("ArrayOfObjects", func(t *testing.T) {
t.Parallel()

res := gjson.Parse(`[{"id":"abc","name":"first"},{"id":"def","name":"second"}]`)
formatted, err := formatJSON(res, ShowJSONOpts{Format: "csv", Stdout: os.Stdout})
require.NoError(t, err)
require.Equal(t, "id,name\nabc,first\ndef,second\n", string(formatted))
})

t.Run("NestedObjectsFlattenToDottedColumns", func(t *testing.T) {
t.Parallel()

res := gjson.Parse(`{"id":"abc","details":{"address":{"city":"Portland"},"kind":"wire"}}`)
formatted, err := formatJSON(res, ShowJSONOpts{Format: "csv", Stdout: os.Stdout})
require.NoError(t, err)
require.Equal(t, "id,details.address.city,details.kind\nabc,Portland,wire\n", string(formatted))
})

t.Run("ArraysEmittedAsRawJSON", func(t *testing.T) {
t.Parallel()

res := gjson.Parse(`{"id":"abc","attachments":[{"url":"x"}]}`)
formatted, err := formatJSON(res, ShowJSONOpts{Format: "csv", Stdout: os.Stdout})
require.NoError(t, err)
require.Equal(t, "id,attachments\nabc,\"[{\"\"url\"\":\"\"x\"\"}]\"\n", string(formatted))
})

t.Run("QuotingAndEscaping", func(t *testing.T) {
t.Parallel()

res := gjson.Parse(`{"note":"has, comma and \"quotes\"","bank":"plain"}`)
formatted, err := formatJSON(res, ShowJSONOpts{Format: "csv", Stdout: os.Stdout})
require.NoError(t, err)
require.Equal(t, "note,bank\n\"has, comma and \"\"quotes\"\"\",plain\n", string(formatted))
})

t.Run("MissingFieldsAlignToFirstRowColumns", func(t *testing.T) {
t.Parallel()

res := gjson.Parse(`[{"id":"abc","note":"n1"},{"id":"def"},{"id":"ghi","note":"n3","extra":"dropped"}]`)
formatted, err := formatJSON(res, ShowJSONOpts{Format: "csv", Stdout: os.Stdout})
require.NoError(t, err)
require.Equal(t, "id,note\nabc,n1\ndef,\nghi,n3\n", string(formatted))
})

t.Run("ScalarBecomesValueColumn", func(t *testing.T) {
t.Parallel()

res := gjson.Parse(`{"id":"abc123"}`)
formatted, err := formatJSON(res, ShowJSONOpts{Format: "csv", Stdout: os.Stdout, Transform: "id"})
require.NoError(t, err)
require.Equal(t, "value\nabc123\n", string(formatted))
})

t.Run("IteratorWritesSingleHeader", func(t *testing.T) {
t.Parallel()

iter := &sliceIterator[map[string]any]{items: []map[string]any{
{"id": "abc", "name": "first"},
{"id": "def", "name": "second"},
}}
captured := captureShowJSONIterator(t, iter, "csv", "", -1)
assert.Equal(t, "id,name\nabc,first\ndef,second\n", captured)
})
}
4 changes: 2 additions & 2 deletions pkg/cmd/overrides.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,11 @@ func init() {
}
case "format":
if sf, ok := f.(*cli.StringFlag); ok {
sf.Usage = "Output format (auto|json|jsonl|pretty|raw|yaml|explore)"
sf.Usage = "Output format (auto|json|jsonl|pretty|raw|yaml|csv|explore)"
}
case "format-error":
if sf, ok := f.(*cli.StringFlag); ok {
sf.Usage = "Error format (auto|json|jsonl|pretty|raw|yaml|explore)"
sf.Usage = "Error format (auto|json|jsonl|pretty|raw|yaml|csv|explore)"
}
case "transform":
if sf, ok := f.(*cli.StringFlag); ok {
Expand Down
Loading