Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ linters:
- github.com/spf13/cobra
- github.com/spf13/pflag
- github.com/spf13/viper
- golang.org/x/term
- golang.org/x/time/rate
- google.golang.org/protobuf/encoding/protojson
- google.golang.org/protobuf/proto
Expand Down
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1228,7 +1228,9 @@ No store credentials or network access required.
##### Validate Mapping

###### Command
fga mapping **validate** \<mapping-file\>
fga mapping **validate** [mapping-file]

The mapping file is optional: omit it in an interactive terminal to choose one from a `.yaml`/`.yml` file picker.

###### Parameters
* `--format`: Output format — `text` (default) or `json`
Expand Down Expand Up @@ -1260,7 +1262,9 @@ JSON response:
##### Test Mapping

###### Command
fga mapping **test** \<mapping-file\>
fga mapping **test** [mapping-file]

The mapping file is optional: omit it in an interactive terminal to choose one from a `.yaml`/`.yml` file picker.

###### Parameters
* `--format`: Output format — `text` (default), `json`, or `junit`
Expand Down Expand Up @@ -1305,16 +1309,19 @@ Created mapping.yaml
##### Run Mapping

###### Command
fga mapping **run** \<mapping-file\>
fga mapping **run** [mapping-file]

Reads JSONL (one JSON object per line) from stdin (or `--input`) and emits tuple operations as JSONL (default) or a JSON batch. Runs entirely offline. Rules using `tuple_filters` cannot be expanded without a store; they are reported as warnings on stderr, or under `tuple_filter_operations` with `--format json`.

The mapping file is optional: omit it in an interactive terminal to choose one from a `.yaml`/`.yml` file picker.

###### Parameters
* `--input`: Path to a JSONL input file, one JSON object per line (default: stdin)
* `--format`: Output format — `jsonl` (default) or `json`
* `--writes-only`: Emit only write-action tuples in `ClientTupleKey` format, consumable directly by `fga tuple write --file`
* `--aggregate`: Buffer all records and collapse them (dedup tuples and filters, detect write/delete conflicts) before emitting
* `--continue-on-error`: Skip input records that fail to parse or evaluate (warn to stderr) and exit non-zero if any were skipped
* `--interactive` / `-i`: Explore the mapping in a terminal loop — type or paste a JSON document and see the tuple operations it produces. The document is evaluated as soon as it forms a complete JSON value, so a single-line object is evaluated on Enter and a multi-line one when its closing brace is typed. Supports line editing (arrow keys, history). Requires an interactive terminal (both stdin and stdout must be a TTY) and cannot be combined with `--input`, `--writes-only`, `--format`, `--aggregate`, or `--continue-on-error`.

###### Example
`echo '{"id":"anne","org":"acme"}' | fga mapping run mapping.yaml`
Expand All @@ -1326,6 +1333,25 @@ Reads JSONL (one JSON object per line) from stdin (or `--input`) and emits tuple
{"op":"write","user":"user:anne","relation":"member","object":"org:acme"}
```

###### Interactive mode
In a terminal, `-i` starts an explorer loop. Type or paste a JSON document and the resulting tuple operations are printed as an aligned table. The document is evaluated as soon as it parses as complete JSON — a single-line object on Enter, a multi-line one when its closing brace is typed. While more input is expected the prompt shows `...` and a one-time hint notes that the document is not yet valid JSON; pressing Enter on a blank line evaluates whatever is buffered. Invalid JSON is reported with the line, column, and a caret under the offending character. A rule with `tuple_filters` cannot be resolved offline, so its filter conditions are shown as `filter:patch` or `filter:delete` rows (the action distinguishes how the store is reconciled), with the desired-state tuples it reconciles toward shown as indented `desired` rows (they drive a read-diff-write against a store rather than being written directly). Filter fields left unset match any value and render as `*`, and a conditioned tuple shows its condition name and rendered context in brackets. Line editing (arrow keys, history) is available. Available commands: `:reload` re-reads and recompiles the mapping from disk, `:trace on|off` toggles the per-rule trace, and `:quit` exits.

```
$ fga mapping run mapping.yaml -i
mapping loaded: 2 rules. Type or paste a JSON document; it is evaluated once complete. commands: :reload :trace on|off :quit

> {"id":"anne","org":"acme"}
write user:anne member org:acme
> {"id": bob}
Error: invalid JSON at line 1, column 8: invalid character 'b' looking for beginning of value
{"id": bob}
^
> {"id":"anne","org":"acme"}
filter:patch user:anne * org:acme
desired user:anne viewer org:acme
> :quit
```

## Contributing

See [CONTRIBUTING](https://github.com/openfga/.github/blob/main/CONTRIBUTING.md).
Expand Down
46 changes: 45 additions & 1 deletion cmd/mapping/mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,15 @@ limitations under the License.
// Package mapping implements the fga mapping command group.
package mapping

import "github.com/spf13/cobra"
import (
"fmt"
"io"
"os"

"github.com/charmbracelet/huh"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
)

// MappingCmd is the root of the fga mapping command group.
var MappingCmd = &cobra.Command{
Expand All @@ -26,6 +34,42 @@ var MappingCmd = &cobra.Command{
Long: "Validate, test, and run JSON-to-tuple mapping files.",
}

// promptMappingFile resolves the mapping file path for a command that accepts an
// optional path argument. An explicit argument is returned unchanged. With no
// argument it prompts with a .yaml/.yml-scoped file picker, which needs a real
// terminal: stdin for keystrokes and stderr for rendering (huh's default output,
// chosen so a piped stdout is never corrupted). If either is not a TTY the picker
// cannot be shown or driven, so a missing path is a usage error rather than an
// invisible hang. A cancelled picker or an empty selection is likewise a usage
// error: all are reported to errOut and exit with status 2.
func promptMappingFile(args []string, errOut io.Writer) string {
if len(args) > 0 {
return args[0]
}

if !isatty.IsTerminal(os.Stdin.Fd()) || !isatty.IsTerminal(os.Stderr.Fd()) {
fmt.Fprintln(errOut, "Error: mapping file path is required")
os.Exit(2)
}

path := ""

if err := huh.NewFilePicker().
Title("Mapping file").
CurrentDirectory(".").
AllowedTypes([]string{".yaml", ".yml"}).
ShowHidden(false).
Picking(true).
Height(15).
Value(&path).
Run(); err != nil || path == "" {
fmt.Fprintln(errOut, "Error: mapping file path is required")
os.Exit(2)
}

return path
}

func init() {
MappingCmd.AddCommand(validateCmd)
MappingCmd.AddCommand(testCmd)
Expand Down
21 changes: 21 additions & 0 deletions cmd/mapping/mapping_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package mapping

import (
"io"
"testing"

"github.com/stretchr/testify/assert"
)

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

// An explicit path argument is returned verbatim without prompting. The
// no-argument branches (TTY picker, non-interactive usage error) call
// os.Exit and are exercised via the built binary, not here.
t.Run("returns an explicit path argument unchanged", func(t *testing.T) {
t.Parallel()

assert.Equal(t, "mapping.yaml", promptMappingFile([]string{"mapping.yaml"}, io.Discard))
})
}
59 changes: 49 additions & 10 deletions cmd/mapping/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"os"
"strings"

"github.com/mattn/go-isatty"
"github.com/openfga/mapper"
"github.com/openfga/mapper/language"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -470,10 +471,11 @@ var (
runInputFile string
runAggregate bool
runContinueOnError bool
runInteractive bool
)

var runCmd = &cobra.Command{
Use: "run <mapping-file>",
Use: "run [mapping-file]",
Short: "Evaluate a mapping against JSON input and emit tuple operations",
Long: `Reads JSONL from stdin (or --input) and evaluates it against the mapping file.
Input is JSON Lines: one JSON object per line. Outputs tuple operations as JSONL (default)
Expand All @@ -495,9 +497,48 @@ before emitting; the default streaming JSONL does not.
continues; the command still exits non-zero if any record was skipped.`,
Example: ` echo '{"id":"anne","org":"acme"}' | fga mapping run mapping.yaml
fga mapping run mapping.yaml --input event.json --format json
fga mapping run --writes-only mapping.yaml > out.jsonl && fga tuple write --store-id $STORE_ID --file out.jsonl`,
Args: cobra.ExactArgs(1),
fga mapping run --writes-only mapping.yaml > out.jsonl && fga tuple write --store-id $STORE_ID --file out.jsonl
fga mapping run mapping.yaml -i`,
Args: cobra.RangeArgs(0, 1),
RunE: func(cmd *cobra.Command, args []string) error {
errStream := cmd.ErrOrStderr()

opts := runMappingOptions{
format: runFormat,
writesOnly: runWritesOnly,
aggregate: runAggregate,
continueOnError: runContinueOnError,
}

// Validate the interactive invocation before any prompt, so a bad flag
// combination or a redirected stream never blocks on asking for a mapping
// path first.
if runInteractive {
if err := checkInteractiveFlags(opts, runInputFile, cmd.Flags().Changed("format")); err != nil {
fmt.Fprintln(errStream, "Error: "+err.Error())
os.Exit(2)
}

// Both streams must be a TTY: the explorer drives a raw-mode terminal,
// so a redirected stdout would send the prompt, echo, and results to a
// file and leave the user staring at a blank screen.
if !isatty.IsTerminal(os.Stdin.Fd()) || !isatty.IsTerminal(os.Stdout.Fd()) {
Comment thread
ewanharris marked this conversation as resolved.
fmt.Fprintln(errStream, "Error: --interactive requires an interactive terminal")
os.Exit(2)
}
}

path := promptMappingFile(args, errStream)
Comment thread
SoulPancake marked this conversation as resolved.

if runInteractive {
err := runMappingInteractive(cmd.Context(), path, cmd.InOrStdin(), cmd.OutOrStdout(), errStream)
if errors.Is(err, errMappingInvalid) {
os.Exit(2)
}

return err
}

inputReader := cmd.InOrStdin()

if runInputFile != "" {
Expand All @@ -512,13 +553,7 @@ continues; the command still exits non-zero if any record was skipped.`,
}

err := runMapping(
cmd.Context(), args[0],
runMappingOptions{
format: runFormat,
writesOnly: runWritesOnly,
aggregate: runAggregate,
continueOnError: runContinueOnError,
},
cmd.Context(), path, opts,
inputReader, cmd.OutOrStdout(), cmd.ErrOrStderr(),
)
if errors.Is(err, errUnknownRunFormat) {
Expand Down Expand Up @@ -550,4 +585,8 @@ func init() {
&runContinueOnError, "continue-on-error", false,
"Skip input records that fail to parse or evaluate (warn to stderr) and exit non-zero if any were skipped",
)
runCmd.Flags().BoolVarP(
&runInteractive, "interactive", "i", false,
"Explore the mapping in a terminal loop: paste JSON documents and see the tuples they produce (requires a TTY)",
)
}
Loading
Loading