Skip to content

Commit

Permalink
feat(tuple import): csv optional fields and support any columns order (
Browse files Browse the repository at this point in the history
  • Loading branch information
rhamzeh authored Jan 23, 2024
2 parents 9690505 + 5300c8d commit 43481a8
Show file tree
Hide file tree
Showing 8 changed files with 217 additions and 64 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
user_type,user_id,relation,object_type,object_id,condition_context
2 changes: 1 addition & 1 deletion cmd/tuple/testdata/tuples_missing_required_headers.csv
Original file line number Diff line number Diff line change
@@ -1 +1 @@
user_type,user_id,user_relation,relation,object_type,object_identifier,condition_name,condition_context
user_type,user_id,user_relation,relation,object_type,condition_name,condition_context
4 changes: 4 additions & 0 deletions cmd/tuple/testdata/tuples_other_columns_order.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
user_id,user_type,user_relation,object_id,object_type,relation,condition_name,condition_context
anne,user,,product,folder,owner,inOfficeIP,
product,folder,,product-2021,folder,parent,inOfficeIP,"{""ip_addr"":""10.0.0.1""}"
fga,team,member,product-2021,folder,viewer,,
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
user_type,user_id,user_relation,relation,object_type,object_id,condition_name
user,anne,,owner,folder,product,inOfficeIP
folder,product,,parent,folder,product-2021,inOfficeIP
team,fga,member,viewer,folder,product-2021,
3 changes: 3 additions & 0 deletions cmd/tuple/testdata/tuples_without_optional_fields.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
user_type,user_id,relation,object_type,object_id
user,anne,owner,folder,product
folder,product,parent,folder,product-2021
177 changes: 116 additions & 61 deletions cmd/tuple/write.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import (
"path"
"strings"

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

openfga "github.com/openfga/go-sdk"
"github.com/openfga/go-sdk/client"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -179,15 +181,12 @@ func parseTuplesFileData(fileName string) ([]client.ClientTupleKey, error) {
func parseTuplesFromCSV(data []byte, tuples *[]client.ClientTupleKey) error {
reader := csv.NewReader(bytes.NewReader(data))

for index := 0; true; index++ {
if index == 0 {
if err := guardAgainstInvalidHeaderWithinCSV(reader); err != nil {
return err
}

continue
}
columns, err := readHeaders(reader)
if err != nil {
return err
}

for index := 0; true; index++ {
tuple, err := reader.Read()
if err != nil {
if errors.Is(err, io.EOF) {
Expand All @@ -197,40 +196,20 @@ func parseTuplesFromCSV(data []byte, tuples *[]client.ClientTupleKey) error {
return fmt.Errorf("failed to read tuple from csv file: %w", err)
}

const (
UserType = iota
UserID
UserRelation
Relation
ObjectType
ObjectID
ConditionName
ConditionContext
)

tupleUserKey := tuple[UserType] + ":" + tuple[UserID]
if tuple[UserRelation] != "" {
tupleUserKey += "#" + tuple[UserRelation]
tupleUserKey := tuple[columns.UserType] + ":" + tuple[columns.UserID]
if columns.UserRelation != -1 && tuple[columns.UserRelation] != "" {
tupleUserKey += "#" + tuple[columns.UserRelation]
}

var condition *openfga.RelationshipCondition

if tuple[ConditionName] != "" {
conditionContext, err := cmdutils.ParseQueryContextInner(tuple[ConditionContext])
if err != nil {
return fmt.Errorf("failed to read condition context on line %d: %w", index, err)
}

condition = &openfga.RelationshipCondition{
Name: tuple[ConditionName],
Context: conditionContext,
}
condition, err := parseConditionColumnsForRow(columns, tuple, index)
if err != nil {
return err
}

tupleKey := client.ClientTupleKey{
User: tupleUserKey,
Relation: tuple[Relation],
Object: tuple[ObjectType] + ":" + tuple[ObjectID],
Relation: tuple[columns.Relation],
Object: tuple[columns.ObjectType] + ":" + tuple[columns.ObjectID],
Condition: condition,
}

Expand All @@ -240,44 +219,120 @@ func parseTuplesFromCSV(data []byte, tuples *[]client.ClientTupleKey) error {
return nil
}

func guardAgainstInvalidHeaderWithinCSV(reader *csv.Reader) error {
headers, err := reader.Read()
if err != nil {
return fmt.Errorf("failed to read csv headers: %w", err)
func parseConditionColumnsForRow(columns *csvColumns, tuple []string, index int) (*openfga.RelationshipCondition, error) {
var condition *openfga.RelationshipCondition

if columns.ConditionName != -1 && tuple[columns.ConditionName] != "" {
conditionContext := &(map[string]interface{}{})

if columns.ConditionContext != -1 {
var err error

conditionContext, err = cmdutils.ParseQueryContextInner(tuple[columns.ConditionContext])
if err != nil {
return nil, fmt.Errorf("failed to read condition context on line %d: %w", index, err)
}
}

condition = &openfga.RelationshipCondition{
Name: tuple[columns.ConditionName],
Context: conditionContext,
}
}

headerMap := make(map[string]bool)
for _, header := range headers {
headerMap[strings.TrimSpace(header)] = true
return condition, nil
}

type csvColumns struct {
UserType int
UserID int
UserRelation int
Relation int
ObjectType int
ObjectID int
ConditionName int
ConditionContext int
}

func (columns *csvColumns) setHeaderIndex(headerName string, index int) error {
switch headerName {
case "user_type":
columns.UserType = index
case "user_id":
columns.UserID = index
case "user_relation":
columns.UserRelation = index
case "relation":
columns.Relation = index
case "object_type":
columns.ObjectType = index
case "object_id":
columns.ObjectID = index
case "condition_name":
columns.ConditionName = index
case "condition_context":
columns.ConditionContext = index
default:
return fmt.Errorf("invalid header %q, valid headers are user_type,user_id,user_relation,relation,object_type,object_id,condition_name,condition_context", headerName) //nolint:goerr113
}

requiredHeaders := []string{
"user_type",
"user_id",
"user_relation",
"relation",
"object_type",
"object_id",
"condition_name",
"condition_context",
return nil
}

func (columns *csvColumns) validate() error {
if columns.UserType == -1 {
return clierrors.MissingRequiredCsvHeaderError("user_type") //nolint:wrapcheck
}

if len(headerMap) != len(requiredHeaders) {
return fmt.Errorf( //nolint:goerr113
"csv file must have exactly these headers in order: %q",
strings.Join(requiredHeaders, ","),
)
if columns.UserID == -1 {
return clierrors.MissingRequiredCsvHeaderError("user_id") //nolint:wrapcheck
}

for _, header := range requiredHeaders {
if _, ok := headerMap[header]; !ok {
return fmt.Errorf("required csv header %q not found", header) //nolint:goerr113
}
if columns.Relation == -1 {
return clierrors.MissingRequiredCsvHeaderError("relation") //nolint:wrapcheck
}

if columns.ObjectType == -1 {
return clierrors.MissingRequiredCsvHeaderError("object_type") //nolint:wrapcheck
}

if columns.ObjectID == -1 {
return clierrors.MissingRequiredCsvHeaderError("object_id") //nolint:wrapcheck
}

if columns.ConditionContext != -1 && columns.ConditionName == -1 {
return errors.New("missing \"condition_name\" header which is required when \"condition_context\" is present") //nolint:goerr113
}

return nil
}

func readHeaders(reader *csv.Reader) (*csvColumns, error) {
headers, err := reader.Read()
if err != nil {
return nil, fmt.Errorf("failed to read csv headers: %w", err)
}

columns := &csvColumns{
UserType: -1,
UserID: -1,
UserRelation: -1,
Relation: -1,
ObjectType: -1,
ObjectID: -1,
ConditionName: -1,
ConditionContext: -1,
}
for index, header := range headers {
err = columns.setHeaderIndex(strings.TrimSpace(header), index)
if err != nil {
return nil, err
}
}

return columns, columns.validate()
}

func init() {
writeCmd.Flags().String("model-id", "", "Model ID")
writeCmd.Flags().String("file", "", "Tuples file")
Expand Down
85 changes: 83 additions & 2 deletions cmd/tuple/write_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,82 @@ func TestParseTuplesFileData(t *testing.T) { //nolint:funlen
},
},
},
{
name: "it can correctly parse a csv file regardless of columns order",
file: "testdata/tuples_other_columns_order.csv",
expectedTuples: []client.ClientTupleKey{
{
User: "user:anne",
Relation: "owner",
Object: "folder:product",
Condition: &openfga.RelationshipCondition{
Name: "inOfficeIP",
Context: &map[string]interface{}{},
},
},
{
User: "folder:product",
Relation: "parent",
Object: "folder:product-2021",
Condition: &openfga.RelationshipCondition{
Name: "inOfficeIP",
Context: &map[string]interface{}{
"ip_addr": "10.0.0.1",
},
},
},
{
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: []client.ClientTupleKey{
{
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: []client.ClientTupleKey{
{
User: "user:anne",
Relation: "owner",
Object: "folder:product",
Condition: &openfga.RelationshipCondition{
Name: "inOfficeIP",
Context: &map[string]interface{}{},
},
},
{
User: "folder:product",
Relation: "parent",
Object: "folder:product-2021",
Condition: &openfga.RelationshipCondition{
Name: "inOfficeIP",
Context: &map[string]interface{}{},
},
},
{
User: "team:fga#member",
Relation: "viewer",
Object: "folder:product-2021",
},
},
},
{
name: "it can correctly parse a json file",
file: "testdata/tuples.json",
Expand Down Expand Up @@ -104,12 +180,17 @@ func TestParseTuplesFileData(t *testing.T) { //nolint:funlen
{
name: "it fails to parse a csv file with wrong headers",
file: "testdata/tuples_wrong_headers.csv",
expectedError: "failed to parse input tuples: csv file must have exactly these headers in order: \"user_type,user_id,user_relation,relation,object_type,object_id,condition_name,condition_context\"",
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: required csv header \"object_id\" not found",
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",
Expand Down
5 changes: 5 additions & 0 deletions internal/clierrors/clierrors.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,13 @@ var (
ErrStoreNotFound = errors.New("store not found")
ErrAuthorizationModelNotFound = errors.New("authorization model not found")
ErrModelInputMissing = errors.New("model input not provided")
ErrRequiredCsvHeaderMissing = errors.New("csv header missing")
)

func ValidationError(op string, details string) error {
return fmt.Errorf("%w - %s: %s", ErrValidation, op, details)
}

func MissingRequiredCsvHeaderError(headerName string) error {
return fmt.Errorf("%w (\"%s\")", ErrRequiredCsvHeaderMissing, headerName)
}

0 comments on commit 43481a8

Please sign in to comment.