From 1042d02110d2cdec45ccc64fcaffe42bfb8c1433 Mon Sep 17 00:00:00 2001 From: Srinivas Chippagiri Date: Fri, 23 Oct 2026 07:50:48 -0700 Subject: [PATCH] feat(tuple): add --output-file to tuple read Fixes #569 Signed-off-by: Srinivas Chippagiri --- README.md | 1 + cmd/tuple/read.go | 109 +++++++++++++++++++++++--------- cmd/tuple/read_test.go | 84 ++++++++++++++++++++++++ internal/output/marshal.go | 26 ++++++++ internal/output/marshal_test.go | 14 ++++ 5 files changed, 205 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 888c3388..de7f1d57 100644 --- a/README.md +++ b/README.md @@ -988,6 +988,7 @@ fga tuple **read** [--user=] [--relation=] [--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` diff --git a/cmd/tuple/read.go b/cmd/tuple/read.go index 3360b1ca..cafa8e09 100644 --- a/cmd/tuple/read.go +++ b/cmd/tuple/read.go @@ -18,6 +18,7 @@ package tuple import ( "bufio" + "bytes" "context" "encoding/json" "errors" @@ -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", @@ -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) }, } @@ -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", diff --git a/cmd/tuple/read_test.go b/cmd/tuple/read_test.go index 7061d1b0..075e49ba 100644 --- a/cmd/tuple/read_test.go +++ b/cmd/tuple/read_test.go @@ -3,6 +3,9 @@ package tuple import ( "encoding/json" "errors" + "os" + "path/filepath" + "runtime" "testing" "time" @@ -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") +} diff --git a/internal/output/marshal.go b/internal/output/marshal.go index 3875a82d..1614986f 100644 --- a/internal/output/marshal.go +++ b/internal/output/marshal.go @@ -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{} diff --git a/internal/output/marshal_test.go b/internal/output/marshal_test.go index ecdb80fd..f37fc788 100644 --- a/internal/output/marshal_test.go +++ b/internal/output/marshal_test.go @@ -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)) +}