Skip to content
Merged
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
58 changes: 46 additions & 12 deletions cmd/tuple/read.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ limitations under the License.
package tuple

import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"

openfga "github.com/openfga/go-sdk"
Expand Down Expand Up @@ -48,14 +50,38 @@ type readResponse struct {
}

type readResponseCSVDTO struct {
UserType string `csv:"user_type"`
UserID string `csv:"user_id"`
UserRelation string `csv:"user_relation,omitempty"`
Relation string `csv:"relation"`
ObjectType string `csv:"object_type"`
ObjectID string `csv:"object_id"`
ConditionName string `csv:"condition_name,omitempty"`
ConditionContext string `csv:"condition_context,omitempty"`
UserType string
UserID string
UserRelation string
Relation string
ObjectType string
ObjectID string
ConditionName string
ConditionContext string
}

var readResponseCSVHeaders = []string{
"user_type",
"user_id",
"user_relation",
"relation",
"object_type",
"object_id",
"condition_name",
"condition_context",
}

func (dto readResponseCSVDTO) MarshalCSV() ([]string, error) {
return []string{
dto.UserType,
dto.UserID,
dto.UserRelation,
dto.Relation,
dto.ObjectType,
dto.ObjectID,
dto.ConditionName,
dto.ConditionContext,
}, nil
}

func (r readResponse) toCsvDTO() ([]readResponseCSVDTO, error) {
Expand Down Expand Up @@ -196,19 +222,27 @@ var readCmd = &cobra.Command{

simpleOutput, _ := cmd.Flags().GetBool("simple-output")
outputFormat, _ := cmd.Flags().GetString("output-format")
dataPrinter := output.NewUniPrinter(outputFormat)

if outputFormat == "csv" {
data, _ := response.toCsvDTO()

err := dataPrinter.Display(data)
records, err := response.toCsvDTO()
if err != nil {
return fmt.Errorf("failed to convert response to csv: %w", err)
}

writer := bufio.NewWriter(os.Stdout)
if err := output.MarshalCSV(records, writer, readResponseCSVHeaders...); err != nil {
return fmt.Errorf("failed to marshal csv: %w", err)
}

if err := writer.Flush(); err != nil {
return fmt.Errorf("failed to display csv: %w", err)
}

return nil
}

dataPrinter := output.NewUniPrinter(outputFormat)

var data any

data = *response.complete
Expand Down
38 changes: 38 additions & 0 deletions cmd/tuple/read_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"time"

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

openfga "github.com/openfga/go-sdk"
"github.com/openfga/go-sdk/client"
Expand Down Expand Up @@ -487,6 +488,43 @@ func TestReadResponseCSVDTOParser(t *testing.T) {
}
}

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

list := []readResponseCSVDTO{
{
UserType: "user",
UserID: "anne",
Relation: "reader",
ObjectType: "document",
ObjectID: "secret.doc",
ConditionName: "inOfficeIP",
ConditionContext: `{"ip_addr":"10.0.0.1"}`,
},
{
UserType: "user",
UserID: "john",
Relation: "writer",
ObjectType: "document",
ObjectID: "abc.doc",
},
}

rows := make([][]string, 0, len(list))

for _, record := range list {
row, err := record.MarshalCSV()
require.NoError(t, err)

rows = append(rows, row)
}

assert.Equal(t, [][]string{
{"user", "anne", "", "reader", "document", "secret.doc", "inOfficeIP", `{"ip_addr":"10.0.0.1"}`},
{"user", "john", "", "writer", "document", "abc.doc", "", ""},
}, rows)
}

func toPointer[T any](p T) *T {
return &p
}
Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ go 1.25.7
toolchain go1.26.4

require (
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1
github.com/mattn/go-isatty v0.0.22
github.com/muesli/mango-cobra v1.3.0
github.com/muesli/roff v0.1.0
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,6 @@ github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 h1:FWNFq4fM1wPfcK40yHE5UO3RUdSNPaBC+j3PokzA6OQ=
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
Expand Down
42 changes: 29 additions & 13 deletions internal/output/marshal.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ limitations under the License.
package output

import (
"encoding/csv"
"encoding/json"
"fmt"
"io"
"os"

"github.com/gocarina/gocsv"
"gopkg.in/yaml.v3"

"github.com/mattn/go-isatty"
Expand All @@ -38,9 +39,6 @@ type Printer interface {
// jsonPrinter implements the Printer interface for JSON output.
type jsonPrinter struct{}

// csvPrinter implements the Printer interface for CSV output.
type csvPrinter struct{}

// yamlPrinter implements the Printer interface for YAML output.
type yamlPrinter struct{}

Expand Down Expand Up @@ -69,17 +67,37 @@ func (prt *jsonPrinter) DisplayColor(data any) error {
return nil
}

func (prt *csvPrinter) DisplayColor(data any) error {
return prt.DisplayNoColor(data)
// CSVMarshaler is implemented by a value that can encode itself as a CSV record.
type CSVMarshaler interface {
MarshalCSV() ([]string, error)
}

func (prt *csvPrinter) DisplayNoColor(data any) error {
b, err := gocsv.MarshalBytes(data)
if err != nil {
return fmt.Errorf("unable to marshal CSV with error: %w", err)
// MarshalCSV writes an optional header row followed by one record per element to w.
func MarshalCSV[T CSVMarshaler](records []T, w io.Writer, header ...string) error {
writer := csv.NewWriter(w)

if len(header) > 0 {
if err := writer.Write(header); err != nil {
return fmt.Errorf("failed to write csv header: %w", err)
}
}

fmt.Println(string(b))
for _, record := range records {
row, err := record.MarshalCSV()
if err != nil {
return fmt.Errorf("failed to marshal csv record: %w", err)
}

if err := writer.Write(row); err != nil {
return fmt.Errorf("failed to write csv record: %w", err)
}
}

writer.Flush()

if err := writer.Error(); err != nil {
return fmt.Errorf("failed to flush csv: %w", err)
}

return nil
}
Expand Down Expand Up @@ -115,8 +133,6 @@ func NewUniPrinter(outputFormat string) *UniPrinter {
switch outputFormat {
case "yaml":
uniPrinter.Printer = &yamlPrinter{}
case "csv":
uniPrinter.Printer = &csvPrinter{}
default:
uniPrinter.Printer = &jsonPrinter{}
}
Expand Down
105 changes: 105 additions & 0 deletions internal/output/marshal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package output

import (
"bytes"
"errors"
"testing"

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

type fakeCSVRow struct {
fields []string
err error
}

func (r fakeCSVRow) MarshalCSV() ([]string, error) {
return r.fields, r.err
}

func rows(values [][]string) []fakeCSVRow {
records := make([]fakeCSVRow, len(values))
for i, v := range values {
records[i] = fakeCSVRow{fields: v}
}

return records
}

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

headers := []string{"user_type", "user_id", "relation", "object_type", "object_id", "condition_context"}

tests := []struct {
name string
records [][]string
expected string
}{
{
name: "no records writes only headers",
records: nil,
expected: "user_type,user_id,relation,object_type,object_id,condition_context\n",
},
{
name: "single record",
records: [][]string{
{"user", "john", "writer", "document", "abc.doc", ""},
},
expected: "user_type,user_id,relation,object_type,object_id,condition_context\n" +
"user,john,writer,document,abc.doc,\n",
},
{
name: "multiple records",
records: [][]string{
{"user", "anne", "reader", "document", "x", ""},
{"group", "eng", "owner", "repo", "y", ""},
},
expected: "user_type,user_id,relation,object_type,object_id,condition_context\n" +
"user,anne,reader,document,x,\n" +
"group,eng,owner,repo,y,\n",
},
{
name: "values with commas, quotes and newlines are escaped",
records: [][]string{
{"user", "a,b", "say \"hi\"", "doc", "line\nbreak", `{"ip_addr":"10.0.0.1"}`},
},
expected: "user_type,user_id,relation,object_type,object_id,condition_context\n" +
"user,\"a,b\",\"say \"\"hi\"\"\",doc,\"line\nbreak\",\"{\"\"ip_addr\"\":\"\"10.0.0.1\"\"}\"\n",
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()

var buf bytes.Buffer

err := MarshalCSV(rows(test.records), &buf, headers...)
require.NoError(t, err)
assert.Equal(t, test.expected, buf.String())
})
}
}

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

var buf bytes.Buffer

err := MarshalCSV(rows([][]string{{"user", "john"}}), &buf)
require.NoError(t, err)
assert.Equal(t, "user,john\n", buf.String())
}

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

sentinel := errors.New("boom")

var buf bytes.Buffer

err := MarshalCSV([]fakeCSVRow{{err: sentinel}}, &buf, "col")
assert.ErrorIs(t, err, sentinel)
}
Loading