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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -988,6 +988,7 @@ fga tuple **read** [--user=<user>] [--relation=<relation>] [--object=<object>]
* `--max-pages`: Max number of pages to get. Set to 0 to get all pages. (default 20)
* `--page-size`: Number of tuples to return per page. Defaults to 100 when max-pages=0, or 50 otherwise. Max is 100.
* `--output-format`: Can be `csv`, `yaml`, `json` or `simple-json`. Use `simple-json` for a simpler json format that can be piped to the write and delete commands
* `--output-file`: Write the output to this file instead of stdout, using the selected `--output-format`. The file is created with `0600` permissions

###### Example
`fga tuple read --store-id=01H0H015178Y2V4CX10C2KGHF4 --user user:anne --relation can_view --object document:roadmap`
Expand Down
109 changes: 80 additions & 29 deletions cmd/tuple/read.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package tuple

import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
Expand Down Expand Up @@ -163,6 +164,81 @@ func read(
return &res, nil
}

// displayReadResponse prints the response to stdout, or writes it to outputFile when that is set.
func displayReadResponse(response *readResponse, outputFormat string, simpleOutput bool, outputFile string) error {
if outputFile != "" {
return writeReadResponseToFile(response, outputFormat, simpleOutput, outputFile)
}

if outputFormat == "csv" {
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

if simpleOutput || outputFormat == "simple-json" {
data = response.simple
}

return dataPrinter.Display(data)
}

// writeReadResponseToFile writes the response, in the selected output format, only to the given file.
// The file is created with 0600 permissions and is only touched once the output has been produced.
func writeReadResponseToFile(response *readResponse, outputFormat string, simpleOutput bool, outputFile string) error {
var content bytes.Buffer

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

if err := output.MarshalCSV(records, &content, readResponseCSVHeaders...); err != nil {
return fmt.Errorf("failed to marshal csv: %w", err)
}
} else {
var data any

data = *response.complete

if simpleOutput || outputFormat == "simple-json" {
data = response.simple
}

rendered, err := output.Render(outputFormat, data)
if err != nil {
return fmt.Errorf("failed to render output: %w", err)
}

content.Write(rendered)
}

if err := os.WriteFile(outputFile, content.Bytes(), 0o600); err != nil {
return fmt.Errorf("failed to write output file %q: %w", outputFile, err)
}

return nil
}

// readCmd represents the read command.
var readCmd = &cobra.Command{
Use: "read",
Expand Down Expand Up @@ -222,36 +298,9 @@ var readCmd = &cobra.Command{

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

if outputFormat == "csv" {
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

if simpleOutput || outputFormat == "simple-json" {
data = response.simple
}

return dataPrinter.Display(data)
return displayReadResponse(response, outputFormat, simpleOutput, outputFile)
},
}

Expand All @@ -264,6 +313,8 @@ func init() {
"Defaults to 100 when max-pages=0, or 50 otherwise. Max is 100.")
readCmd.Flags().String("output-format", "json", "Specifies the format for data presentation. Valid options: "+
"json, simple-json, csv, and yaml.")
readCmd.Flags().String("output-file", "", "Write the output to this file instead of stdout. "+
"The file is created with 0600 permissions. The selected output format is used.")
readCmd.Flags().Bool("simple-output", false, "Output data in simpler version. (It can be used by write and delete commands)") //nolint:lll
readCmd.Flags().String(
"consistency",
Expand Down
84 changes: 84 additions & 0 deletions cmd/tuple/read_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package tuple
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
"testing"
"time"

Expand Down Expand Up @@ -734,3 +737,84 @@ func TestReadWithInvalidPageSize(t *testing.T) {
t.Errorf("Expected specific error message, got: %v", err)
}
}

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

tupleKey := openfga.TupleKey{User: "user:anne", Relation: "reader", Object: "doc:roadmap"}
response := &readResponse{
complete: &openfga.ReadResponse{Tuples: []openfga.Tuple{{Key: tupleKey}}},
simple: []openfga.TupleKey{tupleKey},
}

tests := []struct {
name string
outputFormat string
contains []string
}{
{"json", "json", []string{`"tuples"`, `"user": "user:anne"`}},
{"simple-json", "simple-json", []string{`"user": "user:anne"`, `"object": "doc:roadmap"`}},
{"yaml", "yaml", []string{"tuples:", "user: user:anne"}},
{"csv", "csv", []string{"user_type,user_id", "user,anne,,reader,doc,roadmap"}},
}

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

path := filepath.Join(t.TempDir(), "tuples.out")

require.NoError(t, displayReadResponse(response, test.outputFormat, false, path))

content, err := os.ReadFile(path)
require.NoError(t, err)

for _, want := range test.contains {
assert.Contains(t, string(content), want)
}

if runtime.GOOS != "windows" {
info, err := os.Stat(path)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
}
})
}
}

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

tupleKey := openfga.TupleKey{User: "user:anne", Relation: "reader", Object: "doc:roadmap"}
response := &readResponse{
complete: &openfga.ReadResponse{Tuples: []openfga.Tuple{{Key: tupleKey}}},
simple: []openfga.TupleKey{tupleKey},
}

path := filepath.Join(t.TempDir(), "tuples.json")
require.NoError(t, displayReadResponse(response, "simple-json", false, path))

content, err := os.ReadFile(path)
require.NoError(t, err)

var keys []openfga.TupleKey

require.NoError(t, json.Unmarshal(content, &keys))
assert.Equal(t, []openfga.TupleKey{tupleKey}, keys)
}

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

response := &readResponse{
complete: &openfga.ReadResponse{Tuples: []openfga.Tuple{}},
simple: []openfga.TupleKey{},
}

// The parent directory does not exist, so the file cannot be created.
path := filepath.Join(t.TempDir(), "missing-dir", "tuples.json")

err := displayReadResponse(response, "json", false, path)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to write output file")
}
26 changes: 26 additions & 0 deletions internal/output/marshal.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,32 @@ func (prt UniPrinter) Display(data any) error {
return nil
}

// Render encodes data in the given output format ("yaml", anything else is JSON) without colors,
// and returns the bytes. The result always ends with a newline.
func Render(outputFormat string, data any) ([]byte, error) {
var (
result []byte
err error
)

if outputFormat == "yaml" {
result, err = yaml.Marshal(data)
if err != nil {
return nil, fmt.Errorf("unable to marshal yaml with error %w", err)
}

// yaml.Marshal already ends with a newline.
return result, nil
} else {
result, err = json.MarshalIndent(data, "", " ")
if err != nil {
return nil, fmt.Errorf("unable to marshal json with error %w", err)
}
}

return append(result, '\n'), nil
}

// EmptyStruct is used when we wish to return an empty object.
type EmptyStruct struct{}

Expand Down
14 changes: 14 additions & 0 deletions internal/output/marshal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,17 @@ func TestMarshalCSVRecordError(t *testing.T) {
err := MarshalCSV([]fakeCSVRow{{err: sentinel}}, &buf, "col")
assert.ErrorIs(t, err, sentinel)
}

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

data := map[string]string{"a": "b"}

jsonOut, err := Render("json", data)
require.NoError(t, err)
assert.Equal(t, "{\n \"a\": \"b\"\n}\n", string(jsonOut))

yamlOut, err := Render("yaml", data)
require.NoError(t, err)
assert.Equal(t, "a: b\n", string(yamlOut))
}