Skip to content

feat(tuple): add --output-file to tuple read - #784

Open
cvas22 wants to merge 1 commit into
openfga:mainfrom
cvas22:feat/569-tuple-read-output-file
Open

cvas22 wants to merge 1 commit into
openfga:mainfrom
cvas22:feat/569-tuple-read-output-file

Conversation

@cvas22

@cvas22 cvas22 commented Oct 23, 2026 •

Copy link
Copy Markdown

Fixes #569

Adds an optional --output-file flag to fga tuple read, following the proposal in the issue.

  • Without the flag nothing changes: output goes to stdout.
  • With the flag, the output is written only to that file, in the selected --output-format (json, simple-json, yaml or csv).
  • The file is created with 0600 permissions, and only after the output has been produced, so a conversion error does not leave a partial file. An error is returned if the file cannot be written.

This adds output.Render (JSON/YAML without colors, returning bytes) and splits the display code in cmd/tuple/read.go into displayReadResponse and writeReadResponseToFile. The README documents the new flag.

Tests: file output for json, simple-json, yaml and csv (content and permissions), that the simple-json file parses back into tuple keys, the write error case, and Render. I ran go test ./cmd/tuple -run "DisplayReadResponse|TestRead" and go test ./internal/output. A different test in cmd/tuple (TestParseTuplesFileData) fails on Windows because it compares an OS error message; it is unrelated to this change.

Summary by CodeRabbit

  • New Features
    • Added an --output-file option to tuple read to save results in the selected format instead of printing them to standard output. Output files are created with restricted permissions.
  • Documentation
    • Updated the command documentation to describe the new option.

@cvas22
cvas22 requested a review from a team as a code owner October 23, 2026 23:34
@coderabbitai

coderabbitai Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c3c52ab5-0a48-4e1e-8f1f-28e417c1cd7c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review

No actionable comments were generated in the recent review.

Walkthrough

fga tuple read now accepts --output-file and writes results in the selected format to that file. Without the flag, results continue to go to stdout. File writes use 0600 permissions.

Changes

Tuple read output

Layer / File(s) Summary
Format rendering
internal/output/marshal.go, internal/output/marshal_test.go
Render returns YAML or indented JSON bytes. Tests check the expected output for both formats.
Tuple read file output
cmd/tuple/read.go, cmd/tuple/read_test.go, README.md
The command routes output to stdout or a file. File output handles CSV conversion or renders other formats, writes with 0600 permissions, and returns errors. Tests cover content, permissions, tuple keys, and write failures. The README documents the flag.

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant TupleRead
  participant DisplayReadResponse
  participant FileWriter
  participant OutputRender
  participant OutputFile
  TupleRead->>DisplayReadResponse: pass response, format, and output path
  DisplayReadResponse->>FileWriter: write response to the selected path
  FileWriter->>OutputRender: render non-CSV output
  OutputRender-->>FileWriter: return formatted bytes
  FileWriter->>OutputFile: write bytes with 0600 permissions
Loading

Suggested reviewers: soulpancake

Merge Risk: Minimal · previous review 1042d

This change adds an optional --output-file flag to tuple read and leaves stdout behavior unchanged when the flag is absent. No concrete merge-blocking risk remains.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 4 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed

Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: adding the --output-file option to tuple read.
Linked Issues check Passed Issue #569 asks for an --output-file option on fga tuple read that writes tuples to a file instead of stdout. At head, cmd/tuple/read.go registers the flag (readCmd.Flags().String("output-file", "", .…
Out of Scope Changes check Passed All changed areas support the --output-file feature. cmd/tuple/read.go adds the flag and the file-write path, and extracts displayReadResponse for the shared stdout and file logic. internal/output/mar…
Full details: Linked Issues check

Explanation

Issue #569 asks for an --output-file option on fga tuple read that writes tuples to a file instead of stdout. At head, cmd/tuple/read.go registers the flag (readCmd.Flags().String("output-file", "", ...)). The read command passes outputFile to displayReadResponse. When outputFile is non-empty, displayReadResponse calls writeReadResponseToFile. That function builds the content in the selected format (CSV via MarshalCSV, other formats via output.Render) and writes it with os.WriteFile(..., 0o600) only after rendering succeeds. When the flag is empty, the stdout path keeps the previous CSV and UniPrinter behavior. internal/output/marshal.go (read at head) defines Render, which returns JSON or YAML bytes without colors and ends with a newline. The issue sets no further acceptance criteria. The PR summary reports tests for file output in all four formats, permissions, and write errors. I did not read the test files directly. The marshal.go diff was unavailable, so I relied on the file read at head for Render. The conclusion rests on the read.go diff and the head file content.

Full details: Out of Scope Changes check

Explanation

All changed areas support the --output-file feature. cmd/tuple/read.go adds the flag and the file-write path, and extracts displayReadResponse for the shared stdout and file logic. internal/output/marshal.go adds Render, which the file path uses for JSON and YAML output. README.md documents the new flag. The test files cover the new file-output behavior and Render. The stdout path keeps its previous behavior. The diff shows no unrelated refactoring or behavior change. The PR description notes an unrelated Windows failure in TestParseTuplesFileData. That test does not appear in the summarized changes, so it does not count as an out-of-scope change here.

Full details: Docstring Coverage

Explanation

Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 4 files. (1 skipped: 1 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Finishing Touches

🧪 Generate unit tests (beta)

  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

ℹ️ Recent review info

⚙️ Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: b82ba097-ae9b-4753-b832-782876d64db6
📥 Commits

Reviewing files that changed from the base of the PR and between ed9e19d and 1042d02.

📒 Files selected for processing: 5

  • README.md
  • cmd/tuple/read.go
  • cmd/tuple/read_test.go
  • internal/output/marshal.go
  • internal/output/marshal_test.go

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 1 remain after this review.

Fixes openfga#569

Signed-off-by: Srinivas Chippagiri <cvas22@gmail.com>
@cvas22
cvas22 force-pushed the feat/569-tuple-read-output-file branch from 1042d02 to e02b91d Compare November 6, 2026 04:37

@Siddhant-K-code Siddhant-K-code left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked against a local OpenFGA: stdout is unchanged from main for all four formats (TTY colors included), the file matches NO_COLOR=1 stdout (YAML without the extra trailing blank line), new files get 0600, and a failed read leaves an existing file untouched. Suggestions below are non-blocking.

This conflicts with #791 in cmd/tuple/read.go and bypasses its --jq filter, so whichever lands second should reject --jq with --output-file up front.

Comment thread cmd/tuple/read.go
Comment on lines +167 to +240
// 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
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

writeReadResponseToFile repeats the CSV conversion and the simple/complete selection. Only colored JSON/YAML to stdout needs UniPrinter; everything else can go through one buffer. This also gives #791 a single place to hook in. Drop the bufio import with it. Verified: stdout and file bytes match this PR for all formats, tests and lint pass.

Suggested change
// 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
}
// 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 {
var data any = *response.complete
if simpleOutput || outputFormat == "simple-json" {
data = response.simple
}
if outputFile == "" && outputFormat != "csv" {
return output.NewUniPrinter(outputFormat).Display(data)
}
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 {
rendered, err := output.Render(outputFormat, data)
if err != nil {
return fmt.Errorf("failed to render output: %w", err)
}
content.Write(rendered)
}
if outputFile == "" {
if _, err := os.Stdout.Write(content.Bytes()); err != nil {
return fmt.Errorf("failed to display csv: %w", err)
}
return nil
}
if err := os.WriteFile(outputFile, content.Bytes(), 0o600); err != nil {
return fmt.Errorf("failed to write output file %q: %w", outputFile, err)
}
return nil
}

Comment thread cmd/tuple/read.go
Comment on lines +316 to +317
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.")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0600 only applies to new files. An existing file is truncated and keeps its mode (a 0644 file stays 0644), with no overwrite prompt unlike store export. Worth saying so here and in README.md:991.

Suggested change
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().String("output-file", "", "Write the output to this file instead of stdout. "+
"Uses --output-format. Overwrites an existing file; new files are created with 0600 permissions.")

Comment on lines +165 to +185
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drop the else after return and the pre-declared vars.

Suggested change
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
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
}
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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add an --output-file option to fga tuple read

2 participants