Skip to content
Closed
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@

#### [Unreleased](https://github.com/openfga/cli/compare/v0.7.0...HEAD)

<<<<<<< codex/implement-fix-for-issue-449
Added:
- Failed tuples are now written to `stderr` in the format of the input file (#449)
=======
Changed:
- Adjusted defaults for `--max-tuples-per-write`, `--max-parallel-requests`, `--max-rps`, and `--rampup-period-in-sec` when `--max-rps` is specified (#517).

>>>>>>> main

#### [0.7.0](https://github.com/openfga/cli/compare/v0.6.6...v0.7.0) (2025-06-10)

Expand Down
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -786,15 +786,13 @@ If using a `json` file, the format should be:
}
```

In some cases you could want to retry failed tuples (e.g. network connectivity error). To achieve that, you can direct the output to a file:
In some cases you may want to retry failed tuples (for example, due to network issues). Failed tuples are now written to `stderr` in the same format as the input file and can be redirected to another file:

`fga tuple write --file tuples.json' --hide-imported-tuples > results.json`
`fga tuple write --file tuples.json --hide-imported-tuples 2> failed_tuples.json > results.json`

Then, process the file with `jq` to convert it to format that you can send the CLI again:
You can then retry them with:

`jq -c '[.failed[] | {user: .tuple_key.user, relation: .tuple_key.relation, object: .tuple_key.object}]' result.json > failed_tuples.json`

`fga tuple write --file failed_tuples.json' --hide-imported-tuples `
`fga tuple write --file failed_tuples.json --hide-imported-tuples`

##### Delete Relationship Tuples

Expand Down
119 changes: 119 additions & 0 deletions cmd/tuple/failed_output.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package tuple

import (
"encoding/json"
"fmt"
"path"
"strings"

"github.com/gocarina/gocsv"
"github.com/openfga/go-sdk/client"
"gopkg.in/yaml.v3"
)

const (
csvFormat = "csv"
yamlFormat = "yaml"
jsonFormat = "json"
)

// tupleCSVDTO represents a tuple in CSV format.
type tupleCSVDTO 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"`
}

func tuplesToCSVDTO(tuples []client.ClientTupleKey) ([]tupleCSVDTO, error) {
result := make([]tupleCSVDTO, 0, len(tuples))

for _, tupleKey := range tuples {
userParts := strings.SplitN(tupleKey.User, ":", 2)
if len(userParts) != 2 {
continue
}

userType := userParts[0]
userIDRel := userParts[1]
userID := userIDRel
userRel := ""

if strings.Contains(userIDRel, "#") {
parts := strings.SplitN(userIDRel, "#", 2)
userID = parts[0]
userRel = parts[1]
}

objParts := strings.SplitN(tupleKey.Object, ":", 2)
if len(objParts) != 2 {
continue
}

Comment on lines +35 to +56
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Dropping malformed tuples silently can hide the very tuples a user needs to retry

If user or object don’t follow type:id[#relation] the write failed, yet we skip the tuple completely when preparing the CSV.
A user then loses visibility of why the write failed.

Instead, include the raw tuple in the CSV with blank split fields or fall back to JSON formatting and return an explicit error to the caller.

-       if len(userParts) != 2 {
-           continue
-       }
+       if len(userParts) != 2 {
+           // keep the tuple – populate only the fields we can parse
+           result = append(result, tupleCSVDTO{
+               UserType: "UNKNOWN",
+               UserID:   tupleKey.User,
+               Relation: tupleKey.Relation,
+               ObjectType: "UNKNOWN",
+               ObjectID:   tupleKey.Object,
+           })
+           continue
+       }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In cmd/tuple/failed_output.go around lines 35 to 56, the code currently skips
tuples with malformed user or object fields without any indication, causing loss
of visibility into why writes failed. Modify the logic to include these
malformed tuples in the CSV output by either leaving the split fields blank or
falling back to JSON formatting for those entries. Additionally, ensure an
explicit error is returned to the caller to indicate the presence of malformed
tuples, so users can understand and retry appropriately.

condName := ""
condCtx := ""

if tupleKey.Condition != nil {
condName = tupleKey.Condition.Name

if tupleKey.Condition.Context != nil {
b, err := json.Marshal(tupleKey.Condition.Context)
if err != nil {
return nil, fmt.Errorf("failed to convert condition context to CSV: %w", err)
}

condCtx = string(b)
}
}

result = append(result, tupleCSVDTO{
UserType: userType,
UserID: userID,
UserRelation: userRel,
Relation: tupleKey.Relation,
ObjectType: objParts[0],
ObjectID: objParts[1],
ConditionName: condName,
ConditionContext: condCtx,
})
}

return result, nil
}

func formatFromExtension(fileName string) string {
switch strings.ToLower(path.Ext(fileName)) {
case ".csv":
return csvFormat
case ".yaml", ".yml":
return yamlFormat
default:
return jsonFormat
}
}

func formatTuples(tuples []client.ClientTupleKey, format string) (string, error) {
switch format {
case csvFormat:
dto, err := tuplesToCSVDTO(tuples)
if err != nil {
return "", err
}

b, err := gocsv.MarshalBytes(dto)

return string(b), err
case yamlFormat:
b, err := yaml.Marshal(tuples)

return string(b), err
default:
b, err := json.MarshalIndent(tuples, "", " ")

return string(b), err
}
}
17 changes: 17 additions & 0 deletions cmd/tuple/write.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"context"
"errors"
"fmt"
"os"
"time"

"github.com/openfga/go-sdk/client"
Expand Down Expand Up @@ -265,6 +266,22 @@ func writeTuplesFromFile(ctx context.Context, flags *flag.FlagSet, fgaClient *cl
outputResponse["failed"] = response.Failed
}

if len(response.Failed) > 0 {
failedFormat := formatFromExtension(fileName)

failedTuples := make([]client.ClientTupleKey, 0, len(response.Failed))
for _, f := range response.Failed {
failedTuples = append(failedTuples, f.TupleKey)
}

out, errFmt2 := formatTuples(failedTuples, failedFormat)
if errFmt2 != nil {
return fmt.Errorf("failed to marshal failed tuples: %w", errFmt2)
}

fmt.Fprint(os.Stderr, out)
}

outputResponse["total_count"] = len(tuples)
outputResponse["successful_count"] = len(response.Successful)
outputResponse["failed_count"] = len(response.Failed)
Expand Down
2 changes: 2 additions & 0 deletions tests/fixtures/basic-tuples.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
user_type,user_id,user_relation,relation,object_type,object_id
user,anne,,owner,group,foo
3 changes: 3 additions & 0 deletions tests/fixtures/basic-tuples.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- "user": "user:anne"
"relation": "owner"
"object": "group:foo"
Loading