diff --git a/README.md b/README.md index 8304bd1..14dffad 100644 --- a/README.md +++ b/README.md @@ -566,8 +566,11 @@ fga tuple **write** --store-id= * ``: Object * `--store-id`: Specifies the store id * `--model-id`: Specifies the model id to target (optional) +* `--file`: Specifies the file name, `yaml` and `json` files are supported +* `--max-tuples-per-write`: Max tuples to send in a single write (optional, default=1) +* `--max-parallel-requests`: Max requests to send in parallel (optional, default=4) -###### Example +###### Example (with arguments) `fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap` ###### Response @@ -575,6 +578,32 @@ fga tuple **write** --store-id= {} ``` +###### Example (with file) +`fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.json` + +###### Response +```json5 +{ + "successful": [ + { + "object":"document:roadmap", + "relation":"writer", + "user":"user:annie" + } + ], + "failed": [ + { + "tuple_key": { + "object":"document:roadmap", + "relation":"writer", + "user":"carl" + }, + "reason":"Write validation error ..." + } + ] +} +``` + ##### Delete Relationship Tuples ###### Command @@ -585,8 +614,12 @@ fga tuple **delete** --store-id= * ``: Relation * ``: Object * `--store-id`: Specifies the store id +* `--model-id`: Specifies the model id to target (optional) +* `--file`: Specifies the file name, `yaml` and `json` files are supported +* `--max-tuples-per-write`: Max tuples to send in a single write (optional, default=1) +* `--max-parallel-requests`: Max requests to send in parallel (optional, default=4) -###### Example +###### Example (with arguments) `fga tuple delete --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap` ###### Response @@ -594,10 +627,37 @@ fga tuple **delete** --store-id= {} ``` +###### Example (with file) +`fga tuple delete --store-id=01H0H015178Y2V4CX10C2KGHF4 --file tuples.json` + +###### Response +```json5 +{ + "successful": [ + { + "object":"document:roadmap", + "relation":"writer", + "user":"user:annie" + } + ], + "failed": [ + { + "tuple_key": { + "object":"document:roadmap", + "relation":"writer", + "user":"carl" + }, + "reason":"Write validation error ..." + } + ] +} +``` + If you want to delete all the tuples in a store, you can use the following code: ``` -fga tuple read | jq -r '.tuples[] | "fga tuple delete \(.key.user) \(.key.relation) \(.key.object)"' | xargs -0 bash -c +fga tuple read | jq '[.tuples[] | { user: .key.user, relation: .key.relation, object: .key.object }]' > tuples.json +fga tuple delete --file tuples.json ``` ##### Read Relationship Tuples diff --git a/cmd/tuple/delete.go b/cmd/tuple/delete.go index 911fa44..c68ae72 100644 --- a/cmd/tuple/delete.go +++ b/cmd/tuple/delete.go @@ -19,26 +19,66 @@ package tuple import ( "context" "fmt" + "os" "github.com/openfga/cli/internal/cmdutils" "github.com/openfga/cli/internal/output" "github.com/openfga/go-sdk/client" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) // deleteCmd represents the delete command. var deleteCmd = &cobra.Command{ Use: "delete", Short: "Delete Relationship Tuples", + Args: ExactArgsOrFlag(3, "file"), //nolint:gomnd Long: "Delete relationship tuples from the store.", Example: "fga tuple delete --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap", - Args: cobra.ExactArgs(3), //nolint:gomnd RunE: func(cmd *cobra.Command, args []string) error { clientConfig := cmdutils.GetClientConfig(cmd) fgaClient, err := clientConfig.GetFgaClient() if err != nil { return fmt.Errorf("failed to initialize FGA Client due to %w", err) } + fileName, err := cmd.Flags().GetString("file") + if err != nil { + return fmt.Errorf("failed to parse file name due to %w", err) + } + if fileName != "" { + var tuples []client.ClientTupleKey + + data, err := os.ReadFile(fileName) + if err != nil { + return fmt.Errorf("failed to read file %s due to %w", fileName, err) + } + + err = yaml.Unmarshal(data, &tuples) + if err != nil { + return fmt.Errorf("failed to parse input tuples due to %w", err) + } + + maxTuplesPerWrite, err := cmd.Flags().GetInt("max-tuples-per-write") + if err != nil { + return fmt.Errorf("failed to parse max tuples per write due to %w", err) + } + + maxParallelRequests, err := cmd.Flags().GetInt("max-parallel-requests") + if err != nil { + return fmt.Errorf("failed to parse parallel requests due to %w", err) + } + + deleteRequest := client.ClientWriteRequest{ + Deletes: &tuples, + Writes: &[]client.ClientTupleKey{}, + } + response, err := importTuples(fgaClient, deleteRequest, maxTuplesPerWrite, maxParallelRequests) + if err != nil { + return err + } + + return output.Display(*response) //nolint:wrapcheck + } body := &client.ClientDeleteTuplesBody{ client.ClientTupleKey{ User: args[0], @@ -57,4 +97,18 @@ var deleteCmd = &cobra.Command{ } func init() { + deleteCmd.Flags().String("file", "", "Tuples file") + deleteCmd.Flags().String("model-id", "", "Model ID") + deleteCmd.Flags().Int("max-tuples-per-write", MaxTuplesPerWrite, "Max tuples per write chunk.") + deleteCmd.Flags().Int("max-parallel-requests", MaxParallelRequests, "Max number of requests to issue to the server in parallel.") //nolint:lll +} + +func ExactArgsOrFlag(n int, flag string) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + if len(args) != n && !cmd.Flags().Changed(flag) { + return fmt.Errorf("at least %d arg(s) are required OR the flag --%s", n, flag) //nolint:goerr113 + } + + return nil + } } diff --git a/cmd/tuple/import.go b/cmd/tuple/import.go index ce638a1..b41683d 100644 --- a/cmd/tuple/import.go +++ b/cmd/tuple/import.go @@ -44,9 +44,14 @@ type importResponse struct { Failed []failedWriteResponse `json:"failed"` } +// importTuples receives a client.ClientWriteRequest and imports the tuples to the store. It can be used to import +// either writes or deletes. +// It returns a pointer to an importResponse and an error. +// The importResponse contains the tuples that were successfully imported and the tuples that failed to be imported. +// Deletes and writes are put together in the same importResponse. func importTuples( fgaClient client.SdkClient, - tuples []client.ClientTupleKey, + body client.ClientWriteRequest, maxTuplesPerWrite int, maxParallelRequests int, ) (*importResponse, error) { @@ -58,22 +63,29 @@ func importTuples( }, } - deletes := []client.ClientTupleKey{} - body := &client.ClientWriteRequest{ - Writes: &tuples, - Deletes: &deletes, - } - - response, err := fgaClient.Write(context.Background()).Body(*body).Options(options).Execute() + response, err := fgaClient.Write(context.Background()).Body(body).Options(options).Execute() if err != nil { return nil, fmt.Errorf("failed to import tuples due to %w", err) } - successfulWrites := []client.ClientTupleKey{} - failedWrites := []failedWriteResponse{} + successfulWrites, failedWrites := processWrites(response.Writes) + successfulDeletes, failedDeletes := processWrites(response.Deletes) + + result := importResponse{ + Successful: append(successfulWrites, successfulDeletes...), + Failed: append(failedWrites, failedDeletes...), + } + + return &result, nil +} + +func processWrites(writes []client.ClientWriteSingleResponse) ([]client.ClientTupleKey, []failedWriteResponse) { + var ( + successfulWrites []client.ClientTupleKey + failedWrites []failedWriteResponse + ) - for index := 0; index < len(response.Writes); index++ { - write := response.Writes[index] + for _, write := range writes { if write.Status == client.SUCCESS { successfulWrites = append(successfulWrites, write.TupleKey) } else { @@ -84,15 +96,14 @@ func importTuples( } } - result := importResponse{Successful: successfulWrites, Failed: failedWrites} - - return &result, nil + return successfulWrites, failedWrites } // importCmd represents the import command. var importCmd = &cobra.Command{ - Use: "import", - Short: "Import Relationship Tuples", + Use: "import", + Short: "Import Relationship Tuples", + Deprecated: "use the write/delete command with the flag --file instead", Long: "Imports Relationship Tuples to the store. " + "This will write the tuples in chunks and at the end will report the tuple chunks that failed.", RunE: func(cmd *cobra.Command, args []string) error { @@ -130,7 +141,12 @@ var importCmd = &cobra.Command{ return fmt.Errorf("failed to parse input tuples due to %w", err) } - result, err := importTuples(fgaClient, tuples, maxTuplesPerWrite, maxParallelRequests) + writeRequest := client.ClientWriteRequest{ + Writes: &tuples, + Deletes: &[]client.ClientTupleKey{}, + } + + result, err := importTuples(fgaClient, writeRequest, maxTuplesPerWrite, maxParallelRequests) if err != nil { return err } diff --git a/cmd/tuple/write.go b/cmd/tuple/write.go index 212d2ba..3139f68 100644 --- a/cmd/tuple/write.go +++ b/cmd/tuple/write.go @@ -19,11 +19,13 @@ package tuple import ( "context" "fmt" + "os" "github.com/openfga/cli/internal/cmdutils" "github.com/openfga/cli/internal/output" "github.com/openfga/go-sdk/client" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) // writeCmd represents the write command. @@ -31,14 +33,53 @@ var writeCmd = &cobra.Command{ Use: "write", Short: "Create Relationship Tuples", Long: "Add relationship tuples to the store.", + Args: ExactArgsOrFlag(3, "file"), //nolint:gomnd Example: "fga tuple write --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap", - Args: cobra.ExactArgs(3), //nolint:gomnd RunE: func(cmd *cobra.Command, args []string) error { clientConfig := cmdutils.GetClientConfig(cmd) fgaClient, err := clientConfig.GetFgaClient() if err != nil { return fmt.Errorf("failed to initialize FGA Client due to %w", err) } + + fileName, err := cmd.Flags().GetString("file") + if err != nil { + return fmt.Errorf("failed to parse file name due to %w", err) + } + if fileName != "" { + maxTuplesPerWrite, err := cmd.Flags().GetInt("max-tuples-per-write") + if err != nil { + return fmt.Errorf("failed to parse max tuples per write due to %w", err) + } + + maxParallelRequests, err := cmd.Flags().GetInt("max-parallel-requests") + if err != nil { + return fmt.Errorf("failed to parse parallel requests due to %w", err) + } + + var tuples []client.ClientTupleKey + + data, err := os.ReadFile(fileName) + if err != nil { + return fmt.Errorf("failed to read file %s due to %w", fileName, err) + } + + err = yaml.Unmarshal(data, &tuples) + if err != nil { + return fmt.Errorf("failed to parse input tuples due to %w", err) + } + + writeRequest := client.ClientWriteRequest{ + Writes: &tuples, + Deletes: &[]client.ClientTupleKey{}, + } + response, err := importTuples(fgaClient, writeRequest, maxTuplesPerWrite, maxParallelRequests) + if err != nil { + return err + } + + return output.Display(*response) //nolint:wrapcheck + } body := &client.ClientWriteTuplesBody{ client.ClientTupleKey{ User: args[0], @@ -58,4 +99,7 @@ var writeCmd = &cobra.Command{ func init() { writeCmd.Flags().String("model-id", "", "Model ID") + writeCmd.Flags().String("file", "", "Tuples file") + writeCmd.Flags().Int("max-tuples-per-write", MaxTuplesPerWrite, "Max tuples per write chunk.") + writeCmd.Flags().Int("max-parallel-requests", MaxParallelRequests, "Max number of requests to issue to the server in parallel.") //nolint:lll }