Skip to content
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ go.work
# IDEs
.idea/
*.iml
.vscode

# Built files
dist/
Expand All @@ -33,4 +34,4 @@ dist/
/mocks

# Test files
/tests/fixtures/identifiers
/tests/fixtures/identifiers
17 changes: 5 additions & 12 deletions cmd/tuple/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,15 @@ package tuple
import (
"context"
"fmt"
"os"
"time"

"github.com/openfga/go-sdk/client"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"

"github.com/openfga/cli/internal/cmdutils"
"github.com/openfga/cli/internal/output"
"github.com/openfga/cli/internal/tuple"
"github.com/openfga/cli/internal/tuplefile"
)

// deleteCmd represents the delete command.
Expand All @@ -51,17 +50,11 @@ var deleteCmd = &cobra.Command{
if fileName != "" {
startTime := time.Now()

var tuples []client.ClientTupleKeyWithoutCondition

data, err := os.ReadFile(fileName)
clientTupleKeys, err := tuplefile.ReadTupleFile(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)
}
clientTupleKeyWithoutCondition := tuple.TupleKeysToTupleKeysWithoutCondition(clientTupleKeys...)

maxTuplesPerWrite, err := cmd.Flags().GetInt("max-tuples-per-write")
if err != nil {
Expand All @@ -74,7 +67,7 @@ var deleteCmd = &cobra.Command{
}

writeRequest := client.ClientWriteRequest{
Deletes: tuples,
Deletes: clientTupleKeyWithoutCondition,
}

response, err := tuple.ImportTuplesWithoutRampUp(
Expand All @@ -98,7 +91,7 @@ var deleteCmd = &cobra.Command{
outputResponse["failed"] = response.Failed
}

outputResponse["total_count"] = len(tuples)
outputResponse["total_count"] = len(clientTupleKeyWithoutCondition)
outputResponse["successful_count"] = len(response.Successful)
outputResponse["failed_count"] = len(response.Failed)
outputResponse["time_spent"] = timeSpent
Expand Down
206 changes: 206 additions & 0 deletions cmd/tuple/delete_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package tuple

import (
"testing"

openfga "github.com/openfga/go-sdk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/openfga/cli/internal/tuple"
"github.com/openfga/cli/internal/tuplefile"
)

func TestDeleteTuplesFileData(t *testing.T) {
t.Parallel()

tests := []struct {
name string
file string
expectedTuples []openfga.TupleKeyWithoutCondition
expectedError string
}{
{
name: "it can correctly parse a csv file",
file: "testdata/tuples.csv",
expectedTuples: []openfga.TupleKeyWithoutCondition{
{
User: "user:anne",
Relation: "owner",
Object: "folder:product",
},
{
User: "folder:product",
Relation: "parent",
Object: "folder:product-2021",
},
{
User: "team:fga#member",
Relation: "viewer",
Object: "folder:product-2021",
},
},
},
{
name: "it can correctly parse a csv file regardless of columns order",
file: "testdata/tuples_other_columns_order.csv",
expectedTuples: []openfga.TupleKeyWithoutCondition{
{
User: "user:anne",
Relation: "owner",
Object: "folder:product",
},
{
User: "folder:product",
Relation: "parent",
Object: "folder:product-2021",
},
{
User: "team:fga#member",
Relation: "viewer",
Object: "folder:product-2021",
},
},
},
{
name: "it can correctly parse a csv file without optional fields",
file: "testdata/tuples_without_optional_fields.csv",
expectedTuples: []openfga.TupleKeyWithoutCondition{
{
User: "user:anne",
Relation: "owner",
Object: "folder:product",
},
{
User: "folder:product",
Relation: "parent",
Object: "folder:product-2021",
},
},
},
{
name: "it can correctly parse a csv file with condition_name header but no condition_context header",
file: "testdata/tuples_with_condition_name_but_no_condition_context.csv",
expectedTuples: []openfga.TupleKeyWithoutCondition{
{
User: "user:anne",
Relation: "owner",
Object: "folder:product",
},
{
User: "folder:product",
Relation: "parent",
Object: "folder:product-2021",
},
{
User: "team:fga#member",
Relation: "viewer",
Object: "folder:product-2021",
},
},
},
{
name: "it can correctly parse a json file",
file: "testdata/tuples.json",
expectedTuples: []openfga.TupleKeyWithoutCondition{
{
User: "user:anne",
Relation: "owner",
Object: "folder:product",
},
{
User: "folder:product",
Relation: "parent",
Object: "folder:product-2021",
},
{
User: "user:beth",
Relation: "viewer",
Object: "folder:product-2021",
},
},
},
{
name: "it can correctly parse a yaml file",
file: "testdata/tuples.yaml",
expectedTuples: []openfga.TupleKeyWithoutCondition{
{
User: "user:anne",
Relation: "owner",
Object: "folder:product",
},
{
User: "folder:product",
Relation: "parent",
Object: "folder:product-2021",
},
{
User: "user:beth",
Relation: "viewer",
Object: "folder:product-2021",
},
},
},
{
name: "it fails to parse a non-supported file format",
file: "testdata/tuples.toml",
expectedError: "failed to parse input tuples: unsupported file format \".toml\"",
},
{
name: "it fails to parse a csv file with wrong headers",
file: "testdata/tuples_wrong_headers.csv",
expectedError: "failed to parse input tuples: invalid header \"a\", valid headers are " +
"user_type,user_id,user_relation,relation,object_type,object_id,condition_name,condition_context",
},
{
name: "it fails to parse a csv file with missing required headers",
file: "testdata/tuples_missing_required_headers.csv",
expectedError: "failed to parse input tuples: csv header missing (\"object_id\")",
},
{
name: "it fails to parse a csv file with missing condition_name header when condition_context is present",
file: "testdata/tuples_missing_condition_name_header.csv",
expectedError: "failed to parse input tuples: missing \"condition_name\"" +
" header which is required when \"condition_context\" is present",
},
{
name: "it fails to parse an empty csv file",
file: "testdata/tuples_empty.csv",
expectedError: "failed to parse input tuples: failed to read csv headers: EOF",
},
{
name: "it fails to parse a csv file with invalid rows",
file: "testdata/tuples_with_invalid_rows.csv",
expectedError: "failed to parse input tuples: failed to read tuple from csv file:" +
" record on line 2: wrong number of fields",
},
{
name: "empty json file should throw a warning",
file: "testdata/tuples_empty.json",
expectedError: "failed to parse input tuples: tuples file is empty (json)",
},
{
name: "empty yaml file should throw a warning",
file: "testdata/tuples_empty.yaml",
expectedError: "failed to parse input tuples: tuples file is empty (yaml)",
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()

actualTuples, err := tuplefile.ReadTupleFile(test.file)
deleteTuples := tuple.TupleKeysToTupleKeysWithoutCondition(actualTuples...)

if test.expectedError != "" {
require.EqualError(t, err, test.expectedError)

return
}

require.NoError(t, err)
assert.Equal(t, test.expectedTuples, deleteTuples)
})
}
}
Empty file.
Empty file.
5 changes: 5 additions & 0 deletions internal/clierrors/clierrors.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ var (
ErrAuthorizationModelNotFound = errors.New("authorization model not found")
ErrModelInputMissing = errors.New("model input not provided")
ErrRequiredCsvHeaderMissing = errors.New("csv header missing")
ErrEmptyTuplesFile = errors.New("tuples file is empty")
)

func ValidationError(op string, details string) error {
Expand All @@ -38,3 +39,7 @@ func ValidationError(op string, details string) error {
func MissingRequiredCsvHeaderError(headerName string) error {
return fmt.Errorf("%w (\"%s\")", ErrRequiredCsvHeaderMissing, headerName)
}

func EmptyTuplesFileError(extName string) error {
return fmt.Errorf("%w (%s)", ErrEmptyTuplesFile, extName)
}
25 changes: 25 additions & 0 deletions internal/tuple/read.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,28 @@ func Read(

return &openfga.ReadResponse{Tuples: tuples}, nil
}

// TupleKeyToTupleKeyWithoutCondition converts a ClientTupleKey to a
// ClientTupleKeyWithoutCondition, stripping out condition-related fields.
//
//nolint:revive
func TupleKeyToTupleKeyWithoutCondition(tk client.ClientTupleKey) client.ClientTupleKeyWithoutCondition {
return client.ClientTupleKeyWithoutCondition{
Object: tk.GetObject(),
Relation: tk.GetRelation(),
User: tk.GetUser(),
}
}

// TupleKeysToTupleKeysWithoutCondition converts ClientTupleKeys to a slice of
// ClientTupleKeyWithoutCondition, stripping out condition-related fields.
//
//nolint:revive
func TupleKeysToTupleKeysWithoutCondition(tks ...client.ClientTupleKey) []client.ClientTupleKeyWithoutCondition {
converted := make([]client.ClientTupleKeyWithoutCondition, 0, len(tks))
for _, tk := range tks {
converted = append(converted, TupleKeyToTupleKeyWithoutCondition(tk))
}

return converted
}
6 changes: 6 additions & 0 deletions internal/tuplefile/read.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import (
"fmt"
"os"
"path"
"strings"

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

"github.com/openfga/cli/internal/clierrors"
)

func ReadTupleFile(fileName string) ([]client.ClientTupleKey, error) {
Expand All @@ -20,6 +23,9 @@ func ReadTupleFile(fileName string) ([]client.ClientTupleKey, error) {
switch path.Ext(fileName) {
case ".json", ".yaml", ".yml":
err = yaml.Unmarshal(data, &tuples)
if err == nil && len(tuples) == 0 {
err = clierrors.EmptyTuplesFileError(strings.TrimPrefix(path.Ext(fileName), "."))
}
case ".csv":
err = parseTuplesFromCSV(data, &tuples)
default:
Expand Down
Loading