diff --git a/CHANGELOG.md b/CHANGELOG.md index 41bdd39a..e4b6fd80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 31fa8e5f..e173c4c5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/tuple/failed_output.go b/cmd/tuple/failed_output.go new file mode 100644 index 00000000..e00ba462 --- /dev/null +++ b/cmd/tuple/failed_output.go @@ -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 + } + + 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 + } +} diff --git a/cmd/tuple/write.go b/cmd/tuple/write.go index 3b9b969e..9bf6da76 100644 --- a/cmd/tuple/write.go +++ b/cmd/tuple/write.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "os" "time" "github.com/openfga/go-sdk/client" @@ -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) diff --git a/tests/fixtures/basic-tuples.csv b/tests/fixtures/basic-tuples.csv new file mode 100644 index 00000000..673da1ce --- /dev/null +++ b/tests/fixtures/basic-tuples.csv @@ -0,0 +1,2 @@ +user_type,user_id,user_relation,relation,object_type,object_id +user,anne,,owner,group,foo diff --git a/tests/fixtures/basic-tuples.yaml b/tests/fixtures/basic-tuples.yaml new file mode 100644 index 00000000..7c4aa70c --- /dev/null +++ b/tests/fixtures/basic-tuples.yaml @@ -0,0 +1,3 @@ +- "user": "user:anne" + "relation": "owner" + "object": "group:foo" \ No newline at end of file