Skip to content

Commit

Permalink
feat: relationtuple parse command (#490)
Browse files Browse the repository at this point in the history
This command parses the relation tuple format used in the docs. It greatly improves the experience when copying something from the documentation. It can especially be used to pipe relation tuples into other commands, e.g.:

```shell
echo "messages:02y_15_4w350m3#decypher@john" | \
  keto relation-tuple parse - --format json | \
  keto relation-tuple create -
```
  • Loading branch information
zepatrik authored Mar 17, 2021
1 parent af9512d commit 91a3cf4
Show file tree
Hide file tree
Showing 7 changed files with 334 additions and 14 deletions.
19 changes: 17 additions & 2 deletions cmd/relationtuple/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,24 @@ func readTuplesFromArg(cmd *cobra.Command, arg string) ([]*relationtuple.Interna
}
}

var r relationtuple.InternalRelationTuple
err := json.NewDecoder(f).Decode(&r)
fc, err := io.ReadAll(f)
if err != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could read file %s: %s\n", arg, err)
return nil, cmdx.FailSilently(cmd)
}

// it is ok to not validate beforehand because json.Unmarshal will report errors
if fc[0] == '[' {
var rts []*relationtuple.InternalRelationTuple
if err := json.Unmarshal(fc, &rts); err != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not decode: %s\n", err)
return nil, cmdx.FailSilently(cmd)
}
return rts, nil
}

var r relationtuple.InternalRelationTuple
if err := json.Unmarshal(fc, &r); err != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not decode: %s\n", err)
return nil, cmdx.FailSilently(cmd)
}
Expand Down
85 changes: 85 additions & 0 deletions cmd/relationtuple/parse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package relationtuple

import (
"fmt"
"io"
"os"
"strings"

"github.com/ory/x/cmdx"
"github.com/spf13/cobra"

"github.com/ory/keto/internal/relationtuple"
)

func newParseCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "parse",
Short: "Parse human readable relation tuples.",
Long: "Parse human readable relation tuples as used in the documentation. Supports various output formats. Especially useful for piping into other commands by using `--format json`.",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var rts []*relationtuple.InternalRelationTuple
for _, fn := range args {
rtss, err := parseFile(cmd, fn)
if err != nil {
return err
}
rts = append(rts, rtss...)
}

if len(rts) == 1 {
cmdx.PrintRow(cmd, rts[0])
return nil
}
cmdx.PrintTable(cmd, relationtuple.NewRelationCollection(rts))
return nil
},
}

cmdx.RegisterFormatFlags(cmd.Flags())

return cmd
}

func parseFile(cmd *cobra.Command, fn string) ([]*relationtuple.InternalRelationTuple, error) {
var f io.Reader
if fn == "-" {
// set human readable filename here for debug and error messages
fn = "stdin"
f = cmd.InOrStdin()
} else {
ff, err := os.Open(fn)
if err != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not open file %s: %v\n", fn, err)
return nil, cmdx.FailSilently(cmd)
}
defer ff.Close()
f = ff
}

fc, err := io.ReadAll(f)
if err != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could read file %s: %v\n", fn, err)
return nil, cmdx.FailSilently(cmd)
}

parts := strings.Split(string(fc), "\n")
rts := make([]*relationtuple.InternalRelationTuple, 0, len(parts))
for i, row := range parts {
row = strings.TrimSpace(row)
// ignore comments and empty lines
if row == "" || strings.HasPrefix(row, "//") {
continue
}

rt, err := (&relationtuple.InternalRelationTuple{}).FromString(row)
if err != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not decode %s:%d\n %s\n\n%v\n", fn, i+1, row, err)
return nil, cmdx.FailSilently(cmd)
}
rts = append(rts, rt)
}

return rts, nil
}
120 changes: 120 additions & 0 deletions cmd/relationtuple/parse_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package relationtuple

import (
"bytes"
"os"
"path/filepath"
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/ory/keto/internal/relationtuple"
)

// the command delegates most of the functionality to the parseFile helper, so we test that
func TestParseCmdParseFile(t *testing.T) {
for _, tc := range []struct {
input, name string
expected []*relationtuple.InternalRelationTuple
}{
{
name: "single basic tuple",
input: "nspace:obj#rel@sub\n",
expected: []*relationtuple.InternalRelationTuple{{
Namespace: "nspace",
Object: "obj",
Relation: "rel",
Subject: &relationtuple.SubjectID{ID: "sub"},
}},
},
{
name: "multiple tuples",
input: `nspace:obj1#rel@sub1
nspace:obj2#rel@sub2
nspace:obj2#rel@(nspace:obj2#rel)`,
expected: []*relationtuple.InternalRelationTuple{
{
Namespace: "nspace",
Object: "obj1",
Relation: "rel",
Subject: &relationtuple.SubjectID{ID: "sub1"},
},
{
Namespace: "nspace",
Object: "obj2",
Relation: "rel",
Subject: &relationtuple.SubjectID{ID: "sub2"},
},
{
Namespace: "nspace",
Object: "obj2",
Relation: "rel",
Subject: &relationtuple.SubjectSet{
Namespace: "nspace",
Object: "obj2",
Relation: "rel",
},
},
},
},
{
name: "crap around tuples",
input: `// foo comment
nspace:obj#rel@sub
// also indentation and trailing spaces
nspace:indent#rel@sub `,
expected: []*relationtuple.InternalRelationTuple{
{
Namespace: "nspace",
Object: "obj",
Relation: "rel",
Subject: &relationtuple.SubjectID{ID: "sub"},
},
{
Namespace: "nspace",
Object: "indent",
Relation: "rel",
Subject: &relationtuple.SubjectID{ID: "sub"},
},
},
},
} {
t.Run("case="+tc.name, func(t *testing.T) {
cmd := &cobra.Command{}
cmd.SetIn(bytes.NewBufferString(tc.input))

actual, err := parseFile(cmd, "-")
require.NoError(t, err)
assert.Equal(t, tc.expected, actual)
})
}

t.Run("case=reads from fs", func(t *testing.T) {
dir := t.TempDir()
fn := filepath.Join(dir, "test.tuples")
require.NoError(t, os.WriteFile(fn, []byte(`
nspace:obj1#rel@sub1
nspace:obj2#rel@sub2`), 0600))

actual, err := parseFile(&cobra.Command{}, fn)
require.NoError(t, err)
assert.Equal(t, []*relationtuple.InternalRelationTuple{
{
Namespace: "nspace",
Object: "obj1",
Relation: "rel",
Subject: &relationtuple.SubjectID{ID: "sub1"},
},
{
Namespace: "nspace",
Object: "obj2",
Relation: "rel",
Subject: &relationtuple.SubjectID{ID: "sub2"},
},
}, actual)
})
}
2 changes: 1 addition & 1 deletion cmd/relationtuple/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func RegisterCommandsRecursive(parent *cobra.Command) {

parent.AddCommand(relationCmd)

relationCmd.AddCommand(newGetCmd(), newCreateCmd(), newDeleteCmd())
relationCmd.AddCommand(newGetCmd(), newCreateCmd(), newDeleteCmd(), newParseCmd())
}

func init() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
#!/bin/bash
set -euo pipefail

relationtuple='
{
"namespace": "messages",
"object": "02y_15_4w350m3",
"relation": "decypher",
"subject": "john"
}'

keto relation-tuple create <(echo "$relationtuple") >/dev/null \
&& echo "Successfully created tuple" \
|| echo "Encountered error"
echo "messages:02y_15_4w350m3#decypher@john" | \
keto relation-tuple parse - --format json | \
keto relation-tuple create - >/dev/null \
&& echo "Successfully created tuple" \
|| echo "Encountered error"
31 changes: 31 additions & 0 deletions internal/relationtuple/definitions.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,37 @@ func (r *InternalRelationTuple) String() string {
return fmt.Sprintf("%s:%s#%s@%s", r.Namespace, r.Object, r.Relation, r.Subject)
}

func (r *InternalRelationTuple) FromString(s string) (*InternalRelationTuple, error) {
parts := strings.SplitN(s, ":", 2)
if len(parts) != 2 {
return nil, errors.Wrap(ErrMalformedInput, "expected input to contain ':'")
}
r.Namespace = parts[0]

parts = strings.SplitN(parts[1], "#", 2)
if len(parts) != 2 {
return nil, errors.Wrap(ErrMalformedInput, "expected input to contain '#'")
}
r.Object = parts[0]

parts = strings.SplitN(parts[1], "@", 2)
if len(parts) != 2 {
return nil, errors.Wrap(ErrMalformedInput, "expected input to contain '@'")
}
r.Relation = parts[0]

// remove optional brackets around the subject set
sub := strings.Trim(parts[1], "()")

var err error
r.Subject, err = SubjectFromString(sub)
if err != nil {
return nil, err
}

return r, nil
}

func (r *InternalRelationTuple) DeriveSubject() *SubjectSet {
return &SubjectSet{
Namespace: r.Namespace,
Expand Down
75 changes: 75 additions & 0 deletions internal/relationtuple/definitions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,81 @@ func TestInternalRelationTuple(t *testing.T) {
}).String())
})

t.Run("method=string decoding", func(t *testing.T) {
for i, tc := range []struct {
enc string
err error
expected *InternalRelationTuple
}{
{
enc: "n:o#r@s",
expected: &InternalRelationTuple{
Namespace: "n",
Object: "o",
Relation: "r",
Subject: &SubjectID{ID: "s"},
},
},
{
enc: "n:o#r@n:o#r",
expected: &InternalRelationTuple{
Namespace: "n",
Object: "o",
Relation: "r",
Subject: &SubjectSet{
Namespace: "n",
Object: "o",
Relation: "r",
},
},
},
{
enc: "n:o#r@(n:o#r)",
expected: &InternalRelationTuple{
Namespace: "n",
Object: "o",
Relation: "r",
Subject: &SubjectSet{
Namespace: "n",
Object: "o",
Relation: "r",
},
},
},
{
enc: "#dev:@ory#:working:@projects:keto#awesome",
expected: &InternalRelationTuple{
Namespace: "#dev",
Object: "@ory",
Relation: ":working:",
Subject: &SubjectSet{
Namespace: "projects",
Object: "keto",
Relation: "awesome",
},
},
},
{
enc: "no-colon#in@this",
err: ErrMalformedInput,
},
{
enc: "no:hash-in@this",
err: ErrMalformedInput,
},
{
enc: "no:at#in-this",
err: ErrMalformedInput,
},
} {
t.Run(fmt.Sprintf("case=%d", i), func(t *testing.T) {
actual, err := (&InternalRelationTuple{}).FromString(tc.enc)
assert.True(t, errors.Is(err, tc.err), "%+v", err)
assert.Equal(t, tc.expected, actual)
})
}
})

t.Run("case=url encoding-decoding", func(t *testing.T) {
for i, r := range []*InternalRelationTuple{
{
Expand Down

0 comments on commit 91a3cf4

Please sign in to comment.