Repository: openfga/cli (go)

Issue:
Add support for contextual tuples
The query requests should support contextual tuples as documented in the README

<img width="1708" alt="Screenshot 2023-07-05 at 1 36 03 PM" src="https://github.com/openfga/cli/assets/536147/0cd3cbbe-8237-4eec-b219-90360b89894d">



Pull request description:
feat(query): support contextual tuples
<!-- Thanks for opening a PR! Here are some quick tips:
If this is your first time contributing, [read our Contributing Guidelines](https://github.com/openfga/.github/blob/main/CONTRIBUTING.md) to learn how to create an acceptable PR for this repo.
By submitting a PR to this repository, you agree to the terms within the [OpenFGA Code of Conduct](https://github.com/openfga/.github/blob/main/CODE_OF_CONDUCT.md)

If your PR is under active development, please submit it as a "draft". Once it's ready, open it up for review.
-->

<!-- Provide a brief summary of the changes -->

## Description
<!-- Provide a detailed description of the changes -->

## References
<!-- Provide a list of any applicable references here (Github Issue, [OpenFGA RFC](https://github.com/openfga/rfcs), other PRs, etc..) -->
- Closes #5 

## Review Checklist
- [x] I have clicked on ["allow edits by maintainers"](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork).
- [x] I have added documentation for new/changed functionality in this PR or in a PR to [openfga.dev](https://github.com/openfga/openfga.dev) [Provide a link to any relevant PRs in the references section above]
- [x] The correct base branch is being used, if not `main`
- [x] I have added tests to validate that the change in functionality is working as expected


Patch:
diff --git a/README.md b/README.md
index 6ce8c5d..595e777 100644
--- a/README.md
+++ b/README.md
@@ -376,7 +376,7 @@ fga tuples **changes** --type <type> --store-id=<store-id>
 ##### Check
 
 ###### Command
-fga query **check** <user> <relation> <object> [--contextual-tuple <user> <relation> <object>]* --store-id=<store-id> [--model-id=<model-id>]
+fga query **check** <user> <relation> <object> [--contextual-tuple "<user> <relation> <object>"]* --store-id=<store-id> [--model-id=<model-id>]
 
 ###### Parameters
 * `--store-id`: Specifies the store id
@@ -384,7 +384,7 @@ fga query **check** <user> <relation> <object> [--contextual-tuple <user> <relat
 * `--contextual-tuple`: Contextual tuples
 
 ###### Example
-`fga query check --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap --contextual-tuple user:anne can_view folder:product --contextual-tuple folder:product parent document:roadmap`
+`fga query check --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document:roadmap --contextual-tuple "user:anne can_view folder:product" --contextual-tuple "folder:product parent document:roadmap"`
 
 ###### JSON Response
 ```json5
@@ -396,7 +396,7 @@ fga query **check** <user> <relation> <object> [--contextual-tuple <user> <relat
 ##### List Objects
 
 ###### Command
-fga query **list-objects** <user> <relation> <object_type> [--contextual-tuple <user> <relation> <object>]* --store-id=<store-id> [--model-id=<model-id>]
+fga query **list-objects** <user> <relation> <object_type> [--contextual-tuple "<user> <relation> <object>"]* --store-id=<store-id> [--model-id=<model-id>]
 
 ###### Parameters
 * `--store-id`: Specifies the store id
@@ -404,7 +404,7 @@ fga query **list-objects** <user> <relation> <object_type> [--contextual-tuple <
 * `--contextual-tuple`: Contextual tuples (optional) (can be multiple)
 
 ###### Example
-`fga query list-objects --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document --contextual-tuple user:anne can_view folder:product --contextual-tuple folder:product parent document:roadmap`
+`fga query list-objects --store-id=01H0H015178Y2V4CX10C2KGHF4 user:anne can_view document --contextual-tuple "user:anne can_view folder:product" --contextual-tuple "folder:product parent document:roadmap"`
 
 ###### JSON Response
 ```json5
@@ -419,7 +419,7 @@ fga query **list-objects** <user> <relation> <object_type> [--contextual-tuple <
 ##### List Relations
 
 ###### Command
-fga query **list-objects** <user> <object> [--relation <relation>]* [--contextual-tuple <user> <relation> <object>]* --store-id=<store-id> [--model-id=<model-id>]
+fga query **list-objects** <user> <object> [--relation <relation>]* [--contextual-tuple "<user> <relation> <object>"]* --store-id=<store-id> [--model-id=<model-id>]
 
 ###### Parameters
 * `--store-id`: Specifies the store id
@@ -443,12 +443,11 @@ fga query **list-objects** <user> <object> [--relation <relation>]* [--contextua
 ##### Expand
 
 ###### Command
-fga query **expand** <relation> <object> [--contextual-tuple <user> <relation> <object>]* --store-id=<store-id> [--model-id=<model-id>]
+fga query **expand** <relation> <object> --store-id=<store-id> [--model-id=<model-id>]
 
 ###### Parameters
 * `--store-id`: Specifies the store id
 * `--model-id`: Specifies the model id to target (optional)
-* `--contextual-tuple`: Contextual tuples (optional) (can be multiple)
 
 ###### Example
 `fga query expand --store-id=01H0H015178Y2V4CX10C2KGHF4 can_view document:roadmap`
diff --git a/cmd/models/models.go b/cmd/models/models.go
index c196ebd..0967763 100644
--- a/cmd/models/models.go
+++ b/cmd/models/models.go
@@ -34,8 +34,7 @@ func init() {
 	ModelsCmd.AddCommand(getCmd)
 
 	ModelsCmd.PersistentFlags().String("store-id", "", "Store ID")
-	ModelsCmd.Flags().String("store-id", "", "Store ID")
-	err := ModelsCmd.MarkFlagRequired("store-id")
+	err := ModelsCmd.MarkPersistentFlagRequired("store-id")
 	if err != nil { //nolint:wsl
 		fmt.Print(err)
 		os.Exit(1)
diff --git a/cmd/query/check.go b/cmd/query/check.go
index a046f11..ee3ec0d 100644
--- a/cmd/query/check.go
+++ b/cmd/query/check.go
@@ -25,11 +25,18 @@ import (
 	"github.com/spf13/cobra"
 )
 
-func check(fgaClient client.SdkClient, user string, relation string, object string) (string, error) {
+func check(
+	fgaClient client.SdkClient,
+	user string,
+	relation string,
+	object string,
+	contextualTuples []client.ClientTupleKey,
+) (string, error) {
 	body := &client.ClientCheckRequest{
-		User:     user,
-		Relation: relation,
-		Object:   object,
+		User:             user,
+		Relation:         relation,
+		Object:           object,
+		ContextualTuples: &contextualTuples,
 	}
 	options := &client.ClientCheckOptions{}
 
@@ -50,7 +57,7 @@ func check(fgaClient client.SdkClient, user string, relation string, object stri
 var checkCmd = &cobra.Command{
 	Use:   "check",
 	Short: "Check",
-	Long:  "Check if a user has a particular relation with an object.",
+	Long:  "Check if a user has a particular relation with an object. E.g. \"check user:anne can_view document:roadmap\"",
 	Args:  cobra.ExactArgs(3), //nolint:gomnd
 	RunE: func(cmd *cobra.Command, args []string) error {
 		clientConfig := cmdutils.GetClientConfig(cmd)
@@ -59,9 +66,14 @@ var checkCmd = &cobra.Command{
 			return fmt.Errorf("failed to initialize FGA Client due to %w", err)
 		}
 
-		output, err := check(fgaClient, args[0], args[1], args[2])
+		contextualTuples, err := cmdutils.ParseContextualTuples(cmd)
 		if err != nil {
-			return err
+			return fmt.Errorf("error parsing contextual tuples for check: %w", err)
+		}
+
+		output, err := check(fgaClient, args[0], args[1], args[2], contextualTuples)
+		if err != nil {
+			return fmt.Errorf("error calling check: %w", err)
 		}
 		fmt.Print(output)
 
diff --git a/cmd/query/list-objects.go b/cmd/query/list-objects.go
index 8c198fe..7c7db7c 100644
--- a/cmd/query/list-objects.go
+++ b/cmd/query/list-objects.go
@@ -26,11 +26,18 @@ import (
 )
 
 // listObjects in the internal function for calling SDK for list objects.
-func listObjects(fgaClient client.SdkClient, user string, relation string, types string) (string, error) {
+func listObjects(
+	fgaClient client.SdkClient,
+	user string,
+	relation string,
+	objectType string,
+	contextualTuples []client.ClientTupleKey,
+) (string, error) {
 	body := &client.ClientListObjectsRequest{
-		User:     user,
-		Relation: relation,
-		Type:     types,
+		User:             user,
+		Relation:         relation,
+		Type:             objectType,
+		ContextualTuples: &contextualTuples,
 	}
 	options := &client.ClientListObjectsOptions{}
 
@@ -61,9 +68,14 @@ var listObjectsCmd = &cobra.Command{
 			return fmt.Errorf("failed to initialize FGA Client due to %w", err)
 		}
 
-		output, err := listObjects(fgaClient, args[0], args[1], args[2])
+		contextualTuples, err := cmdutils.ParseContextualTuples(cmd)
 		if err != nil {
-			return err
+			return fmt.Errorf("error parsing contextual tuples for listObjects: %w", err)
+		}
+
+		output, err := listObjects(fgaClient, args[0], args[1], args[2], contextualTuples)
+		if err != nil {
+			return fmt.Errorf("error listing objects: %w", err)
 		}
 
 		fmt.Print(output)
diff --git a/cmd/query/list-relations.go b/cmd/query/list-relations.go
index 4c71d70..5061e4e 100644
--- a/cmd/query/list-relations.go
+++ b/cmd/query/list-relations.go
@@ -32,6 +32,7 @@ func listRelations(clientConfig fga.ClientConfig,
 	fgaClient client.SdkClient,
 	user string,
 	objects string,
+	contextualTuples []client.ClientTupleKey,
 ) (string, error) {
 	var authorizationModel openfga.AuthorizationModel
 
@@ -69,9 +70,10 @@ func listRelations(clientConfig fga.ClientConfig,
 	}
 
 	body := &client.ClientListRelationsRequest{
-		User:      user,
-		Object:    objects,
-		Relations: relations,
+		User:             user,
+		Object:           objects,
+		Relations:        relations,
+		ContextualTuples: &contextualTuples,
 	}
 	options := &client.ClientListRelationsOptions{}
 
@@ -103,9 +105,14 @@ var listRelationsCmd = &cobra.Command{
 			return fmt.Errorf("failed to initialize FGA Client due to %w", err)
 		}
 
-		output, err := listRelations(clientConfig, fgaClient, args[0], args[1])
+		contextualTuples, err := cmdutils.ParseContextualTuples(cmd)
 		if err != nil {
-			return err
+			return fmt.Errorf("error parsing contextual tuples for listRelations: %w", err)
+		}
+
+		output, err := listRelations(clientConfig, fgaClient, args[0], args[1], contextualTuples)
+		if err != nil {
+			return fmt.Errorf("error listing relations: %w", err)
 		}
 
 		fmt.Print(output)
diff --git a/cmd/query/query.go b/cmd/query/query.go
index 8e2629f..984c70a 100644
--- a/cmd/query/query.go
+++ b/cmd/query/query.go
@@ -35,9 +35,11 @@ func init() {
 	QueryCmd.AddCommand(listRelationsCmd)
 
 	QueryCmd.PersistentFlags().String("store-id", "", "Store ID")
-	QueryCmd.Flags().String("store-id", "", "Store ID")
-	err := QueryCmd.MarkFlagRequired("store-id")
-	if err != nil { //nolint:wsl
+	QueryCmd.PersistentFlags().String("model-id", "", "Model ID")
+	QueryCmd.PersistentFlags().StringArray("contextual-tuple", []string{}, `Contextual Tuple, format: "user relation object"`) //nolint:lll
+
+	err := QueryCmd.MarkPersistentFlagRequired("store-id")
+	if err != nil {
 		fmt.Print(err)
 		os.Exit(1)
 	}
diff --git a/cmd/tuples/tuple.go b/cmd/tuples/tuple.go
index 3e2a5e1..7df3119 100644
--- a/cmd/tuples/tuple.go
+++ b/cmd/tuples/tuple.go
@@ -35,8 +35,7 @@ func init() {
 	TupleCmd.AddCommand(changesCmd)
 
 	TupleCmd.PersistentFlags().String("store-id", "", "Store ID")
-	TupleCmd.Flags().String("store-id", "", "Store ID")
-	err := TupleCmd.MarkFlagRequired("store-id")
+	err := TupleCmd.MarkPersistentFlagRequired("store-id")
 	if err != nil { //nolint:wsl
 		fmt.Print(err)
 		os.Exit(1)
diff --git a/go.mod b/go.mod
index 814126d..0d10376 100644
--- a/go.mod
+++ b/go.mod
@@ -21,9 +21,9 @@ require (
 	github.com/spf13/jwalterweatherman v1.1.0 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
 	github.com/subosito/gotenv v1.4.2 // indirect
-	golang.org/x/sync v0.2.0 // indirect
-	golang.org/x/sys v0.9.0 // indirect
-	golang.org/x/text v0.10.0 // indirect
+	golang.org/x/sync v0.3.0 // indirect
+	golang.org/x/sys v0.10.0 // indirect
+	golang.org/x/text v0.11.0 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/go.sum b/go.sum
index fcc2e72..f2693c1 100644
--- a/go.sum
+++ b/go.sum
@@ -283,8 +283,8 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
-golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -323,8 +323,8 @@ golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
-golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
+golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -334,8 +334,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58=
-golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
+golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
diff --git a/lib/clierrors/clierrors.go b/lib/clierrors/clierrors.go
new file mode 100644
index 0000000..4af97a5
--- /dev/null
+++ b/lib/clierrors/clierrors.go
@@ -0,0 +1,12 @@
+package clierrors
+
+import (
+	"errors"
+	"fmt"
+)
+
+var ErrValidation = errors.New("validation error")
+
+func ValidationError(op string, details string) error {
+	return fmt.Errorf("%w - %s: %s", ErrValidation, op, details)
+}
diff --git a/lib/cmd-utils/get-contextual-tuples.go b/lib/cmd-utils/get-contextual-tuples.go
new file mode 100644
index 0000000..059d25d
--- /dev/null
+++ b/lib/cmd-utils/get-contextual-tuples.go
@@ -0,0 +1,38 @@
+package cmdutils
+
+import (
+	"strings"
+
+	"github.com/openfga/cli/lib/clierrors"
+	"github.com/openfga/go-sdk/client"
+	"github.com/spf13/cobra"
+)
+
+func ParseContextualTuplesInner(contextualTuplesArray []string) ([]client.ClientTupleKey, error) {
+	contextualTuples := []client.ClientTupleKey{}
+
+	if len(contextualTuplesArray) > 0 {
+		for index := 0; index < len(contextualTuplesArray); index++ {
+			tuple := strings.Split(contextualTuplesArray[index], " ")
+			if len(tuple) != 3 { //nolint:gomnd
+				return contextualTuples,
+					clierrors.ValidationError("ParseContextualTuplesInner", "Failed to parse contextual tuples, "+ //nolint:wrapcheck
+						"they must be of the format\"user relation object\" ")
+			}
+
+			contextualTuples = append(contextualTuples, client.ClientTupleKey{
+				User:     tuple[0],
+				Relation: tuple[1],
+				Object:   tuple[2],
+			})
+		}
+	}
+
+	return contextualTuples, nil
+}
+
+func ParseContextualTuples(cmd *cobra.Command) ([]client.ClientTupleKey, error) {
+	contextualTuplesArray, _ := cmd.Flags().GetStringArray("contextual-tuple")
+
+	return ParseContextualTuplesInner(contextualTuplesArray)
+}


Test patch:
diff --git a/cmd/query/check_test.go b/cmd/query/check_test.go
index 389e9ef..038781b 100644
--- a/cmd/query/check_test.go
+++ b/cmd/query/check_test.go
@@ -31,17 +31,21 @@ func TestCheckWithError(t *testing.T) {
 	mockRequest.EXPECT().Options(options).Return(mockExecute)
 
 	mockBody := mock_client.NewMockSdkClientCheckRequestInterface(mockCtrl)
+	contextualTuples := []client.ClientTupleKey{
+		{User: "user:foo", Relation: "admin", Object: "doc:doc1"},
+	}
 
 	body := client.ClientCheckRequest{
-		User:     "user:foo",
-		Relation: "writer",
-		Object:   "doc:doc1",
+		User:             "user:foo",
+		Relation:         "writer",
+		Object:           "doc:doc1",
+		ContextualTuples: &contextualTuples,
 	}
 	mockBody.EXPECT().Body(body).Return(mockRequest)
 
 	mockFgaClient.EXPECT().Check(context.Background()).Return(mockBody)
 
-	_, err := check(mockFgaClient, "user:foo", "writer", "doc:doc1")
+	_, err := check(mockFgaClient, "user:foo", "writer", "doc:doc1", contextualTuples)
 	if err == nil {
 		t.Error("Expect error but there is none")
 	}
@@ -71,16 +75,20 @@ func TestCheckWithNoError(t *testing.T) {
 
 	mockBody := mock_client.NewMockSdkClientCheckRequestInterface(mockCtrl)
 
+	contextualTuples := []client.ClientTupleKey{
+		{User: "user:foo", Relation: "admin", Object: "doc:doc1"},
+	}
 	body := client.ClientCheckRequest{
-		User:     "user:foo",
-		Relation: "writer",
-		Object:   "doc:doc1",
+		User:             "user:foo",
+		Relation:         "writer",
+		Object:           "doc:doc1",
+		ContextualTuples: &contextualTuples,
 	}
 	mockBody.EXPECT().Body(body).Return(mockRequest)
 
 	mockFgaClient.EXPECT().Check(context.Background()).Return(mockBody)
 
-	output, err := check(mockFgaClient, "user:foo", "writer", "doc:doc1")
+	output, err := check(mockFgaClient, "user:foo", "writer", "doc:doc1", contextualTuples)
 	if err != nil {
 		t.Error(err)
 	}
diff --git a/cmd/query/list-objects_test.go b/cmd/query/list-objects_test.go
index b9b7a9f..ba098cc 100644
--- a/cmd/query/list-objects_test.go
+++ b/cmd/query/list-objects_test.go
@@ -31,16 +31,20 @@ func TestListObjectsWithError(t *testing.T) {
 
 	mockBody := mock_client.NewMockSdkClientListObjectsRequestInterface(mockCtrl)
 
+	contextualTuples := []client.ClientTupleKey{
+		{User: "user:foo", Relation: "admin", Object: "doc:doc1"},
+	}
 	body := client.ClientListObjectsRequest{
-		User:     "user:foo",
-		Relation: "writer",
-		Type:     "doc",
+		User:             "user:foo",
+		Relation:         "writer",
+		Type:             "doc",
+		ContextualTuples: &contextualTuples,
 	}
 	mockBody.EXPECT().Body(body).Return(mockRequest)
 
 	mockFgaClient.EXPECT().ListObjects(context.Background()).Return(mockBody)
 
-	_, err := listObjects(mockFgaClient, "user:foo", "writer", "doc")
+	_, err := listObjects(mockFgaClient, "user:foo", "writer", "doc", contextualTuples)
 	if err == nil {
 		t.Error("Expect error but there is none")
 	}
@@ -67,16 +71,20 @@ func TestListObjectsWithNoError(t *testing.T) {
 
 	mockBody := mock_client.NewMockSdkClientListObjectsRequestInterface(mockCtrl)
 
+	contextualTuples := []client.ClientTupleKey{
+		{User: "user:foo", Relation: "admin", Object: "doc:doc1"},
+	}
 	body := client.ClientListObjectsRequest{
-		User:     "user:foo",
-		Relation: "writer",
-		Type:     "doc",
+		User:             "user:foo",
+		Relation:         "writer",
+		Type:             "doc",
+		ContextualTuples: &contextualTuples,
 	}
 	mockBody.EXPECT().Body(body).Return(mockRequest)
 
 	mockFgaClient.EXPECT().ListObjects(context.Background()).Return(mockBody)
 
-	output, err := listObjects(mockFgaClient, "user:foo", "writer", "doc")
+	output, err := listObjects(mockFgaClient, "user:foo", "writer", "doc", contextualTuples)
 	if err != nil {
 		t.Error(err)
 	}
diff --git a/cmd/query/list-relations_test.go b/cmd/query/list-relations_test.go
index a6c5423..1a68d19 100644
--- a/cmd/query/list-relations_test.go
+++ b/cmd/query/list-relations_test.go
@@ -32,7 +32,11 @@ func TestListRelationsLatestAuthModelError(t *testing.T) {
 
 	var clientConfig fga.ClientConfig
 
-	_, err := listRelations(clientConfig, mockFgaClient, "user:foo", "doc:doc1")
+	contextualTuples := []client.ClientTupleKey{
+		{User: "user:foo", Relation: "admin", Object: "doc:doc1"},
+	}
+	_, err := listRelations(clientConfig, mockFgaClient, "user:foo", "doc:doc1", contextualTuples)
+
 	if err == nil {
 		t.Error("Expect error but there is none")
 	}
@@ -56,7 +60,11 @@ func TestListRelationsAuthModelSpecifiedError(t *testing.T) {
 		AuthorizationModelID: "01GXSA8YR785C4FYS3C0RTG7B1",
 	}
 
-	_, err := listRelations(clientConfig, mockFgaClient, "user:foo", "doc:doc1")
+	contextualTuples := []client.ClientTupleKey{
+		{User: "user:foo", Relation: "admin", Object: "doc:doc1"},
+	}
+	_, err := listRelations(clientConfig, mockFgaClient, "user:foo", "doc:doc1", contextualTuples)
+
 	if err == nil {
 		t.Error("Expect error but there is none")
 	}
@@ -94,10 +102,14 @@ func TestListRelationsLatestAuthModelListError(t *testing.T) {
 
 	mockBody := mock_client.NewMockSdkClientListRelationsRequestInterface(mockCtrl)
 
+	contextualTuples := []client.ClientTupleKey{
+		{User: "user:foo", Relation: "admin", Object: "doc:doc1"},
+	}
 	body := client.ClientListRelationsRequest{
-		User:      "user:foo",
-		Relations: []string{"viewer"},
-		Object:    "doc:doc1",
+		User:             "user:foo",
+		Relations:        []string{"viewer"},
+		Object:           "doc:doc1",
+		ContextualTuples: &contextualTuples,
 	}
 	mockBody.EXPECT().Body(body).Return(mockListRelationsRequest)
 	gomock.InOrder(
@@ -107,7 +119,7 @@ func TestListRelationsLatestAuthModelListError(t *testing.T) {
 
 	var clientConfig fga.ClientConfig
 
-	_, err := listRelations(clientConfig, mockFgaClient, "user:foo", "doc:doc1")
+	_, err := listRelations(clientConfig, mockFgaClient, "user:foo", "doc:doc1", contextualTuples)
 	if err == nil {
 		t.Error("Expect error but there is none")
 	}
@@ -147,10 +159,14 @@ func TestListRelationsLatestAuthModelList(t *testing.T) {
 
 	mockBody := mock_client.NewMockSdkClientListRelationsRequestInterface(mockCtrl)
 
+	contextualTuples := []client.ClientTupleKey{
+		{User: "user:foo", Relation: "admin", Object: "doc:doc1"},
+	}
 	body := client.ClientListRelationsRequest{
-		User:      "user:foo",
-		Relations: []string{"viewer"},
-		Object:    "doc:doc1",
+		User:             "user:foo",
+		Relations:        []string{"viewer"},
+		Object:           "doc:doc1",
+		ContextualTuples: &contextualTuples,
 	}
 	mockBody.EXPECT().Body(body).Return(mockListRelationsRequest)
 	gomock.InOrder(
@@ -160,7 +176,7 @@ func TestListRelationsLatestAuthModelList(t *testing.T) {
 
 	var clientConfig fga.ClientConfig
 
-	output, err := listRelations(clientConfig, mockFgaClient, "user:foo", "doc:doc1")
+	output, err := listRelations(clientConfig, mockFgaClient, "user:foo", "doc:doc1", contextualTuples)
 	if err != nil {
 		t.Error(err)
 	}
diff --git a/lib/cmd-utils/get-contextual-tuples_test.go b/lib/cmd-utils/get-contextual-tuples_test.go
new file mode 100644
index 0000000..061bfd7
--- /dev/null
+++ b/lib/cmd-utils/get-contextual-tuples_test.go
@@ -0,0 +1,102 @@
+package cmdutils_test
+
+import (
+	"testing"
+
+	cmdutils "github.com/openfga/cli/lib/cmd-utils"
+	"github.com/openfga/go-sdk/client"
+)
+
+type tupleTestPassing struct {
+	raw    []string
+	parsed []client.ClientTupleKey
+}
+
+type tupleTestFailing struct {
+	raw []string
+}
+
+func TestGetContextualTuplesWithNoError(t *testing.T) {
+	t.Parallel()
+
+	tests := []tupleTestPassing{{
+		raw: []string{"user:anne can_view document:2"},
+		parsed: []client.ClientTupleKey{
+			{User: "user:anne", Relation: "can_view", Object: "document:2"},
+		},
+	}, {
+		raw: []string{
+			"group:product#member owner document:roadmap",
+			"user:beth can_delete folder:marketing",
+			"user:carl can_share repo:openfga/openfga",
+		},
+		parsed: []client.ClientTupleKey{
+			{User: "group:product#member", Relation: "owner", Object: "document:roadmap"},
+			{User: "user:beth", Relation: "can_delete", Object: "folder:marketing"},
+			{User: "user:carl", Relation: "can_share", Object: "repo:openfga/openfga"},
+		},
+	}, {
+		// Note that this is an invalid tuple, but the server will let us know that.
+		// We can validate against it in a future iteration
+		raw: []string{"anne can_view document-2"},
+		parsed: []client.ClientTupleKey{
+			{User: "anne", Relation: "can_view", Object: "document-2"},
+		},
+	}}
+
+	for index := 0; index < len(tests); index++ {
+		test := tests[index]
+		t.Run("TestGetContextualTuplesWithNoError"+string(rune(index)), func(t *testing.T) {
+			t.Parallel()
+			tuples, err := cmdutils.ParseContextualTuplesInner(test.raw)
+			if err != nil {
+				t.Error(err)
+			}
+
+			if len(tuples) != len(test.parsed) {
+				t.Errorf("Expected parsed tuples to have length %v actual %v", len(test.parsed), len(tuples))
+			}
+
+			for index := 0; index < len(tuples); index++ {
+				if tuples[index].User != test.parsed[index].User ||
+					tuples[index].Relation != test.parsed[index].Relation ||
+					tuples[index].Object != test.parsed[index].Object {
+					t.Errorf("Expected parsed tuples to match %v actual %v", test.parsed, tuples)
+				}
+			}
+		})
+	}
+}
+
+func TestGetContextualTuplesWithError(t *testing.T) {
+	t.Parallel()
+
+	tests := []tupleTestFailing{{
+		raw: []string{"user:anne can_view"},
+	}, {
+		raw: []string{"can_view document:2"},
+	}, {
+		raw: []string{"can_view"},
+	}, {
+		raw: []string{"this is not a valid tuple"},
+	}, {
+		raw: []string{
+			"group:product#member owner document:roadmap",
+			"user:beth can_delete folder:marketing",
+			"user:carl can_share repo:openfga/openfga",
+			"user:dan can_share",
+		},
+	}}
+
+	for index := 0; index < len(tests); index++ {
+		test := tests[index]
+		t.Run("TestGetContextualTuplesWithNoError"+string(rune(index)), func(t *testing.T) {
+			t.Parallel()
+			_, err := cmdutils.ParseContextualTuplesInner(test.raw)
+
+			if err == nil {
+				t.Error("Expect error but there is none")
+			}
+		})
+	}
+}