From 9dca347c7db3c0d1da3e9e351d28480cd4f41ee4 Mon Sep 17 00:00:00 2001 From: Srinivas Chippagiri Date: Fri, 6 Nov 2026 20:52:54 -0800 Subject: [PATCH 1/3] feat: add a --jq flag to filter JSON output Signed-off-by: Srinivas Chippagiri --- .golangci.yaml | 1 + README.md | 12 +++- cmd/root.go | 6 ++ go.mod | 5 +- go.sum | 11 +++- internal/output/jq.go | 112 +++++++++++++++++++++++++++++++++++++ internal/output/jq_test.go | 87 ++++++++++++++++++++++++++++ internal/output/marshal.go | 19 ++++++- 8 files changed, 247 insertions(+), 6 deletions(-) create mode 100644 internal/output/jq.go create mode 100644 internal/output/jq_test.go diff --git a/.golangci.yaml b/.golangci.yaml index 91416d5d..13e65911 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -45,6 +45,7 @@ linters: - github.com/charmbracelet/lipgloss - github.com/gocarina/gocsv - github.com/hashicorp/go-multierror + - github.com/itchyny/gojq - github.com/mattn/go-isatty - github.com/muesli/mango-cobra - github.com/muesli/roff diff --git a/README.md b/README.md index 888c3388..70f0ff46 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ A cross-platform CLI to interact with an OpenFGA server - [Usage](#usage) - [Configuration](#configuration) - [Custom Headers](#custom-headers) + - [Filtering Output](#filtering-output) - [Commands](#commands) - [Stores](#stores) - [List All Stores](#list-stores) @@ -205,6 +206,15 @@ custom-headers: - "X-Request-ID: abc123" ``` +#### Filtering Output + +Commands that print JSON accept a `--jq` flag with a [jq](https://jqlang.org/manual/) expression, so you can pick fields out of the output without installing `jq`. String results are printed without quotes, and other values are printed as JSON. The expression is applied to the output of the command, after `--output-format` and `--simple-output`. + +```shell +export FGA_STORE_ID=$(fga store create --model model.fga --jq .store.id) +fga tuple read --simple-output --jq '.[].object' +``` + ### Commands #### Stores @@ -260,7 +270,7 @@ fga store **create** To automatically set the created store id as an environment variable that will then be used by the CLI, you can use the following command: ```bash -export FGA_STORE_ID=$(fga store create --model model.fga | jq -r .store.id) +export FGA_STORE_ID=$(fga store create --model model.fga --jq .store.id) ``` ##### Import Store diff --git a/cmd/root.go b/cmd/root.go index 3bbc7d91..06e02af7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -31,6 +31,7 @@ import ( "github.com/openfga/cli/cmd/store" "github.com/openfga/cli/cmd/tuple" "github.com/openfga/cli/internal/cmdutils" + "github.com/openfga/cli/internal/output" ) var cfgFile string @@ -71,6 +72,7 @@ func init() { rootCmd.PersistentFlags().StringArray("api-scopes", []string{}, "API Scopes (repeat option for multiple values). Used in the Client Credentials flow") //nolint:lll rootCmd.PersistentFlags().StringArray("custom-headers", []string{}, "Custom HTTP headers in 'Header: value' format (repeat option for multiple values)") //nolint:lll rootCmd.PersistentFlags().Bool("debug", false, "Enable debug mode - can print more detailed information for debugging") + rootCmd.PersistentFlags().String("jq", "", "Filter JSON output using a jq expression, e.g. --jq '.store.id'") _ = rootCmd.Flags().MarkHidden("debug") rootCmd.MarkFlagsRequiredTogether( @@ -130,4 +132,8 @@ func initConfig() { viperInstance.AutomaticEnv() // read in environment variables that match cmdutils.BindViperToFlags(rootCmd, viperInstance) + + jqExpression, err := rootCmd.PersistentFlags().GetString("jq") + cobra.CheckErr(err) + cobra.CheckErr(output.SetJQFilter(jqExpression)) } diff --git a/go.mod b/go.mod index 9c5b4085..118dbae3 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.27.3 require ( github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 + github.com/itchyny/gojq v0.12.20 github.com/mattn/go-isatty v0.0.24 github.com/muesli/mango-cobra v1.3.0 github.com/muesli/roff v0.1.0 @@ -46,6 +47,7 @@ require ( github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect @@ -63,11 +65,12 @@ require ( github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.4 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/timefmt-go v0.1.9 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.31 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect diff --git a/go.sum b/go.sum index 1ec3f343..ec8e9521 100644 --- a/go.sum +++ b/go.sum @@ -65,6 +65,8 @@ github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJn github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= @@ -148,6 +150,10 @@ github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVU github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.20 h1:mvxzKjEJDfeLlPc/15q5Kf6wLtEW2T399BL8xmOd9EQ= +github.com/itchyny/gojq v0.12.20/go.mod h1:cgT47OXUC/BWCBh9V4LuuTRSwFIJNR5loUyqlzbIJbw= +github.com/itchyny/timefmt-go v0.1.9 h1:J4j1qMqZbg5jycwT6zIKfRgZ9aqqMxqanaI6Rr9Ezs8= +github.com/itchyny/timefmt-go v0.1.9/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -187,8 +193,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5IBgwo= +github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= @@ -258,7 +264,6 @@ github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0o github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= diff --git a/internal/output/jq.go b/internal/output/jq.go new file mode 100644 index 00000000..ad76aa9a --- /dev/null +++ b/internal/output/jq.go @@ -0,0 +1,112 @@ +/* +Copyright © 2023 OpenFGA + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package output + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/itchyny/gojq" +) + +// jqFilter is the jq expression set with the --jq flag, applied to the JSON output of every command. +var jqFilter *gojq.Code + +// SetJQFilter compiles a jq expression that filters the output of Display and UniPrinter.Display. +// An empty expression turns filtering off. +func SetJQFilter(expression string) error { + if expression == "" { + jqFilter = nil + + return nil + } + + query, err := gojq.Parse(expression) + if err != nil { + return fmt.Errorf("invalid jq expression %q: %w", expression, err) + } + + code, err := gojq.Compile(query) + if err != nil { + return fmt.Errorf("invalid jq expression %q: %w", expression, err) + } + + jqFilter = code + + return nil +} + +// applyJQFilter runs the jq filter on the JSON form of data and returns its results. +func applyJQFilter(code *gojq.Code, data any) ([]any, error) { + encoded, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("unable to marshal json with error %w", err) + } + + var input any + if err := json.Unmarshal(encoded, &input); err != nil { + return nil, fmt.Errorf("unable to unmarshal json with error %w", err) + } + + var results []any + + iter := code.Run(input) + + for { + result, ok := iter.Next() + if !ok { + break + } + + if err, isErr := result.(error); isErr { + var haltErr *gojq.HaltError + if errors.As(err, &haltErr) && haltErr.Value() == nil { + break + } + + return nil, fmt.Errorf("failed to apply jq expression: %w", err) + } + + results = append(results, result) + } + + return results, nil +} + +// displayFiltered applies the jq filter to data and prints each result: strings as raw text, like +// `jq -r`, so they can be used directly in scripts, and any other value with display. +func displayFiltered(code *gojq.Code, data any, display func(any) error) error { + results, err := applyJQFilter(code, data) + if err != nil { + return err + } + + for _, result := range results { + if text, isString := result.(string); isString { + fmt.Println(text) + + continue + } + + if err := display(result); err != nil { + return err + } + } + + return nil +} diff --git a/internal/output/jq_test.go b/internal/output/jq_test.go new file mode 100644 index 00000000..b9c8f64e --- /dev/null +++ b/internal/output/jq_test.go @@ -0,0 +1,87 @@ +package output + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type jqTestStore struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type jqTestResponse struct { + Stores []jqTestStore `json:"stores"` +} + +func TestSetJQFilter(t *testing.T) { + t.Cleanup(func() { _ = SetJQFilter("") }) + + require.NoError(t, SetJQFilter(".stores")) + assert.NotNil(t, jqFilter) + + require.NoError(t, SetJQFilter("")) + assert.Nil(t, jqFilter) + + err := SetJQFilter(".[") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid jq expression") +} + +func TestApplyJQFilter(t *testing.T) { + t.Cleanup(func() { _ = SetJQFilter("") }) + + data := jqTestResponse{Stores: []jqTestStore{{ID: "01A", Name: "one"}, {ID: "01B", Name: "two"}}} + + tests := []struct { + name string + expression string + expected []any + }{ + {name: "field of a struct", expression: ".stores[0].id", expected: []any{"01A"}}, + {name: "one result per element", expression: ".stores[].name", expected: []any{"one", "two"}}, + {name: "object result", expression: ".stores[1]", expected: []any{map[string]any{"id": "01B", "name": "two"}}}, + {name: "numbers", expression: ".stores | length", expected: []any{2}}, + {name: "no results", expression: "empty", expected: nil}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.NoError(t, SetJQFilter(test.expression)) + + results, err := applyJQFilter(jqFilter, data) + require.NoError(t, err) + assert.Equal(t, test.expected, results) + }) + } +} + +func TestApplyJQFilterRuntimeError(t *testing.T) { + t.Cleanup(func() { _ = SetJQFilter("") }) + + require.NoError(t, SetJQFilter(".stores.id")) + + _, err := applyJQFilter(jqFilter, jqTestResponse{Stores: []jqTestStore{}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to apply jq expression") +} + +func TestDisplayFilteredPrintsStringsRaw(t *testing.T) { + t.Cleanup(func() { _ = SetJQFilter("") }) + + require.NoError(t, SetJQFilter(".stores[] | .id, .")) + + var displayed []any + + err := displayFiltered(jqFilter, jqTestResponse{Stores: []jqTestStore{{ID: "01A", Name: "one"}}}, + func(data any) error { + displayed = append(displayed, data) + + return nil + }) + require.NoError(t, err) + // The string result is printed directly, only the object goes through the display function. + assert.Equal(t, []any{map[string]any{"id": "01A", "name": "one"}}, displayed) +} diff --git a/internal/output/marshal.go b/internal/output/marshal.go index 3875a82d..ddaf9e02 100644 --- a/internal/output/marshal.go +++ b/internal/output/marshal.go @@ -140,8 +140,16 @@ func NewUniPrinter(outputFormat string) *UniPrinter { return &uniPrinter } -// Display prints the data using the configured printer and color settings. +// Display prints the data using the configured printer and color settings, after applying the --jq filter if set. func (prt UniPrinter) Display(data any) error { + if jqFilter != nil { + return displayFiltered(jqFilter, data, prt.display) + } + + return prt.display(data) +} + +func (prt UniPrinter) display(data any) error { if prt.Colorful { err := prt.Printer.DisplayColor(data) if err != nil { @@ -199,7 +207,16 @@ func outputNonTerminal(data any) error { } // Display will decorate the output if possible. Otherwise, will print out the standard JSON. +// If the --jq filter is set, it prints the results of the filter instead. func Display(data any) error { + if jqFilter != nil { + return displayFiltered(jqFilter, data, display) + } + + return display(data) +} + +func display(data any) error { if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) { if os.Getenv("NO_COLOR") != "" { return displayNoColorTerminal(data) From 01de0f734f95a2a8ca11eed8b30144cff5c3a795 Mon Sep 17 00:00:00 2001 From: Srinivas Chippagiri Date: Fri, 6 Nov 2026 21:09:06 -0800 Subject: [PATCH 2/3] fix: address review feedback on --jq Signed-off-by: Srinivas Chippagiri --- README.md | 4 +- cmd/root.go | 24 ++++++++++-- cmd/tuple/read.go | 6 ++- internal/output/jq.go | 75 ++++++++++++++++++++++++-------------- internal/output/jq_test.go | 51 +++++++++++++++++++++++++- 5 files changed, 124 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 70f0ff46..cedd676b 100644 --- a/README.md +++ b/README.md @@ -208,11 +208,11 @@ custom-headers: #### Filtering Output -Commands that print JSON accept a `--jq` flag with a [jq](https://jqlang.org/manual/) expression, so you can pick fields out of the output without installing `jq`. String results are printed without quotes, and other values are printed as JSON. The expression is applied to the output of the command, after `--output-format` and `--simple-output`. +Commands that print JSON or YAML accept a `--jq` flag with a [jq](https://jqlang.org/manual/) expression, so you can pick fields out of the output without installing `jq`. The expression runs on the JSON form of the output. String results are printed without quotes, and other results in the command's output format (JSON, or YAML with `--output-format yaml`). Commands that print something else, like CSV, the model DSL or `version`, fail when `--jq` is set. Filters can read environment variables with `env.NAME`. The flag is only read from the command line, not from `FGA_JQ` or the configuration file. ```shell export FGA_STORE_ID=$(fga store create --model model.fga --jq .store.id) -fga tuple read --simple-output --jq '.[].object' +fga tuple read --output-format simple-json --jq '.[].object' ``` ### Commands diff --git a/cmd/root.go b/cmd/root.go index 06e02af7..c345bb54 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -42,6 +42,10 @@ var rootCmd = &cobra.Command{ Short: "OpenFGA CLI", Long: ``, SilenceUsage: true, + // Commands whose output does not go through output.Display would otherwise ignore --jq. + PersistentPostRunE: func(_ *cobra.Command, _ []string) error { + return output.CheckJQFilterApplied() + }, } // Execute adds all child commands to the root command and sets flags appropriately. @@ -72,7 +76,7 @@ func init() { rootCmd.PersistentFlags().StringArray("api-scopes", []string{}, "API Scopes (repeat option for multiple values). Used in the Client Credentials flow") //nolint:lll rootCmd.PersistentFlags().StringArray("custom-headers", []string{}, "Custom HTTP headers in 'Header: value' format (repeat option for multiple values)") //nolint:lll rootCmd.PersistentFlags().Bool("debug", false, "Enable debug mode - can print more detailed information for debugging") - rootCmd.PersistentFlags().String("jq", "", "Filter JSON output using a jq expression, e.g. --jq '.store.id'") + rootCmd.PersistentFlags().String("jq", "", "Filter the JSON or YAML output of a command using a jq expression, e.g. --jq '.store.id'") _ = rootCmd.Flags().MarkHidden("debug") rootCmd.MarkFlagsRequiredTogether( @@ -131,9 +135,21 @@ func initConfig() { viperInstance.AutomaticEnv() // read in environment variables that match + // Check this before BindViperToFlags, which marks the flags it sets as changed. + jqFromFlag := rootCmd.PersistentFlags().Changed("jq") + cmdutils.BindViperToFlags(rootCmd, viperInstance) - jqExpression, err := rootCmd.PersistentFlags().GetString("jq") - cobra.CheckErr(err) - cobra.CheckErr(output.SetJQFilter(jqExpression)) + cobra.CheckErr(output.SetJQFilter(jqExpression(jqFromFlag))) +} + +// jqExpression returns the --jq flag. Values from FGA_JQ or the config file are ignored: a filter +// set there would apply to every command, and BindViperToFlags splits values on whitespace, which +// would keep only the last part of an expression such as ".a, .b". +func jqExpression(fromFlag bool) string { + if !fromFlag { + return "" + } + + return rootCmd.PersistentFlags().Lookup("jq").Value.String() } diff --git a/cmd/tuple/read.go b/cmd/tuple/read.go index 3360b1ca..75f68db8 100644 --- a/cmd/tuple/read.go +++ b/cmd/tuple/read.go @@ -177,6 +177,11 @@ var readCmd = &cobra.Command{ return fmt.Errorf("failed to initialize FGA Client due to %w", err) } + outputFormat, _ := cmd.Flags().GetString("output-format") + if outputFormat == "csv" && output.JQFilterSet() { + return fmt.Errorf("%w, not with --output-format csv", output.ErrJQNotApplied) + } + user, _ := cmd.Flags().GetString("user") relation, _ := cmd.Flags().GetString("relation") object, _ := cmd.Flags().GetString("object") @@ -221,7 +226,6 @@ var readCmd = &cobra.Command{ } simpleOutput, _ := cmd.Flags().GetBool("simple-output") - outputFormat, _ := cmd.Flags().GetString("output-format") if outputFormat == "csv" { records, err := response.toCsvDTO() diff --git a/internal/output/jq.go b/internal/output/jq.go index ad76aa9a..deeff3f6 100644 --- a/internal/output/jq.go +++ b/internal/output/jq.go @@ -20,16 +20,29 @@ import ( "encoding/json" "errors" "fmt" + "os" "github.com/itchyny/gojq" ) -// jqFilter is the jq expression set with the --jq flag, applied to the JSON output of every command. -var jqFilter *gojq.Code +// ErrJQNotApplied is returned by CheckJQFilterApplied when --jq is set but the command printed +// its output without going through the filter. +var ErrJQNotApplied = errors.New("--jq is only supported by commands that print JSON or YAML output") + +var ( + // jqFilter is the jq expression set with the --jq flag, applied to the output of Display and + // UniPrinter.Display. + jqFilter *gojq.Code + + // jqApplied records whether the filter has been applied to any output. + jqApplied bool +) // SetJQFilter compiles a jq expression that filters the output of Display and UniPrinter.Display. // An empty expression turns filtering off. func SetJQFilter(expression string) error { + jqApplied = false + if expression == "" { jqFilter = nil @@ -41,7 +54,8 @@ func SetJQFilter(expression string) error { return fmt.Errorf("invalid jq expression %q: %w", expression, err) } - code, err := gojq.Compile(query) + // Pass the environment, so filters can use env.NAME and $ENV.NAME like in jq. + code, err := gojq.Compile(query, gojq.WithEnvironLoader(os.Environ)) if err != nil { return fmt.Errorf("invalid jq expression %q: %w", expression, err) } @@ -51,62 +65,69 @@ func SetJQFilter(expression string) error { return nil } -// applyJQFilter runs the jq filter on the JSON form of data and returns its results. -func applyJQFilter(code *gojq.Code, data any) ([]any, error) { +// JQFilterSet reports whether a --jq filter is set. +func JQFilterSet() bool { + return jqFilter != nil +} + +// CheckJQFilterApplied returns ErrJQNotApplied if a --jq filter is set but no output went +// through it, so that commands whose output is not filtered do not silently ignore the flag. +func CheckJQFilterApplied() error { + if jqFilter != nil && !jqApplied { + return ErrJQNotApplied + } + + return nil +} + +// runJQFilter runs the jq filter on the JSON form of data and calls emit with each result as it +// is produced, so results emitted before an error are not lost. +func runJQFilter(code *gojq.Code, data any, emit func(any) error) error { encoded, err := json.Marshal(data) if err != nil { - return nil, fmt.Errorf("unable to marshal json with error %w", err) + return fmt.Errorf("unable to marshal json with error %w", err) } var input any if err := json.Unmarshal(encoded, &input); err != nil { - return nil, fmt.Errorf("unable to unmarshal json with error %w", err) + return fmt.Errorf("unable to unmarshal json with error %w", err) } - var results []any - iter := code.Run(input) for { result, ok := iter.Next() if !ok { - break + return nil } if err, isErr := result.(error); isErr { var haltErr *gojq.HaltError if errors.As(err, &haltErr) && haltErr.Value() == nil { - break + return nil } - return nil, fmt.Errorf("failed to apply jq expression: %w", err) + return fmt.Errorf("failed to apply jq expression: %w", err) } - results = append(results, result) + if err := emit(result); err != nil { + return err + } } - - return results, nil } // displayFiltered applies the jq filter to data and prints each result: strings as raw text, like // `jq -r`, so they can be used directly in scripts, and any other value with display. func displayFiltered(code *gojq.Code, data any, display func(any) error) error { - results, err := applyJQFilter(code, data) - if err != nil { - return err - } + jqApplied = true - for _, result := range results { + return runJQFilter(code, data, func(result any) error { if text, isString := result.(string); isString { fmt.Println(text) - continue + return nil } - if err := display(result); err != nil { - return err - } - } - - return nil + return display(result) + }) } diff --git a/internal/output/jq_test.go b/internal/output/jq_test.go index b9c8f64e..f75cb7bb 100644 --- a/internal/output/jq_test.go +++ b/internal/output/jq_test.go @@ -51,7 +51,7 @@ func TestApplyJQFilter(t *testing.T) { t.Run(test.name, func(t *testing.T) { require.NoError(t, SetJQFilter(test.expression)) - results, err := applyJQFilter(jqFilter, data) + results, err := collectJQResults(data) require.NoError(t, err) assert.Equal(t, test.expected, results) }) @@ -63,7 +63,7 @@ func TestApplyJQFilterRuntimeError(t *testing.T) { require.NoError(t, SetJQFilter(".stores.id")) - _, err := applyJQFilter(jqFilter, jqTestResponse{Stores: []jqTestStore{}}) + _, err := collectJQResults(jqTestResponse{Stores: []jqTestStore{}}) require.Error(t, err) assert.Contains(t, err.Error(), "failed to apply jq expression") } @@ -85,3 +85,50 @@ func TestDisplayFilteredPrintsStringsRaw(t *testing.T) { // The string result is printed directly, only the object goes through the display function. assert.Equal(t, []any{map[string]any{"id": "01A", "name": "one"}}, displayed) } + +func collectJQResults(data any) ([]any, error) { + var results []any + + err := runJQFilter(jqFilter, data, func(result any) error { + results = append(results, result) + + return nil + }) + + return results, err +} + +func TestRunJQFilterEmitsResultsBeforeAnError(t *testing.T) { + t.Cleanup(func() { _ = SetJQFilter("") }) + + require.NoError(t, SetJQFilter(`.stores[].id, error("boom")`)) + + results, err := collectJQResults(jqTestResponse{Stores: []jqTestStore{{ID: "01A"}}}) + require.Error(t, err) + assert.Equal(t, []any{"01A"}, results) +} + +func TestJQFilterReadsEnvironment(t *testing.T) { + t.Cleanup(func() { _ = SetJQFilter("") }) + t.Setenv("FGA_JQ_TEST_STORE", "01B") + + require.NoError(t, SetJQFilter(`.stores[] | select(.id == env.FGA_JQ_TEST_STORE) | .name`)) + + results, err := collectJQResults(jqTestResponse{Stores: []jqTestStore{{ID: "01A", Name: "one"}, {ID: "01B", Name: "two"}}}) + require.NoError(t, err) + assert.Equal(t, []any{"two"}, results) +} + +func TestCheckJQFilterApplied(t *testing.T) { + t.Cleanup(func() { _ = SetJQFilter("") }) + + require.NoError(t, SetJQFilter("")) + require.NoError(t, CheckJQFilterApplied()) + + require.NoError(t, SetJQFilter(".stores")) + assert.True(t, JQFilterSet()) + require.ErrorIs(t, CheckJQFilterApplied(), ErrJQNotApplied) + + require.NoError(t, displayFiltered(jqFilter, jqTestResponse{}, func(any) error { return nil })) + require.NoError(t, CheckJQFilterApplied()) +} From 2a397d1531804fc735efabf802375d6f39120780 Mon Sep 17 00:00:00 2001 From: Srinivas Chippagiri Date: Fri, 6 Nov 2026 21:24:31 -0800 Subject: [PATCH 3/3] fix: reject --jq up front and handle numbers, halt and YAML streams Signed-off-by: Srinivas Chippagiri --- cmd/man.go | 3 ++ cmd/mapping/mapping.go | 4 +++ cmd/model/get.go | 4 +++ cmd/model/test.go | 4 +++ cmd/model/transform.go | 4 +++ cmd/root.go | 28 +++++++++++++++- cmd/store/export.go | 6 +++- cmd/version.go | 8 +++-- internal/output/jq.go | 14 ++++++-- internal/output/jq_test.go | 68 ++++++++++++++++++++++++++++++++++++++ internal/output/marshal.go | 14 +++++++- 11 files changed, 149 insertions(+), 8 deletions(-) diff --git a/cmd/man.go b/cmd/man.go index 5ed57bf2..762eed4e 100644 --- a/cmd/man.go +++ b/cmd/man.go @@ -23,10 +23,13 @@ import ( mcobra "github.com/muesli/mango-cobra" "github.com/muesli/roff" "github.com/spf13/cobra" + + "github.com/openfga/cli/internal/output" ) var manCmd = &cobra.Command{ Use: "man", + Annotations: map[string]string{output.JQUnsupportedAnnotation: "true"}, Short: "Generates manpages", SilenceUsage: true, DisableFlagsInUseLine: true, diff --git a/cmd/mapping/mapping.go b/cmd/mapping/mapping.go index ddc0a1fc..27a07305 100644 --- a/cmd/mapping/mapping.go +++ b/cmd/mapping/mapping.go @@ -25,6 +25,8 @@ import ( "github.com/charmbracelet/huh" "github.com/mattn/go-isatty" "github.com/spf13/cobra" + + "github.com/openfga/cli/internal/output" ) // MappingCmd is the root of the fga mapping command group. @@ -32,6 +34,8 @@ var MappingCmd = &cobra.Command{ Use: "mapping", Short: "Manage JSON-to-tuple mappings", Long: "Validate, test, and run JSON-to-tuple mapping files.", + // The mapping commands print through their own writers, so --jq cannot filter them. + Annotations: map[string]string{output.JQUnsupportedAnnotation: "true"}, } // promptMappingFile resolves the mapping file path for a command that accepts an diff --git a/cmd/model/get.go b/cmd/model/get.go index 5b1676be..a04ea693 100644 --- a/cmd/model/get.go +++ b/cmd/model/get.go @@ -58,6 +58,10 @@ var getCmd = &cobra.Command{ return output.Display(authModel.DisplayAsJSON(fields)) } + if output.JQFilterSet() { + return fmt.Errorf("%w, use --format json", output.ErrJQNotApplied) + } + dslModel, err := authModel.DisplayAsDSL(fields) if err != nil { return fmt.Errorf("failed to display model due to %w", err) diff --git a/cmd/model/test.go b/cmd/model/test.go index 82d5a37f..06b8f60a 100644 --- a/cmd/model/test.go +++ b/cmd/model/test.go @@ -57,6 +57,10 @@ var modelTestCmd = &cobra.Command{ return fmt.Errorf("failed to get verbose flag: %w", err) } + if !verbose && output.JQFilterSet() { + return fmt.Errorf("%w, use --verbose to print the test results as JSON", output.ErrJQNotApplied) + } + suppressSummary, err := cmd.Flags().GetBool("suppress-summary") if err != nil { return fmt.Errorf("failed to get suppress-summary flag: %w", err) diff --git a/cmd/model/transform.go b/cmd/model/transform.go index 96056b5d..7aadb325 100644 --- a/cmd/model/transform.go +++ b/cmd/model/transform.go @@ -80,6 +80,10 @@ fga model transform --file=fga.mod`, transformOutputFormat = determineOutputFormat(transformInputFormat, transformOutputFormat) if transformOutputFormat == authorizationmodel.ModelFormatFGA { + if output.JQFilterSet() { + return fmt.Errorf("%w, not with the DSL output", output.ErrJQNotApplied) + } + dslModel, err := authModel.DisplayAsDSL([]string{"model"}) if err != nil { return fmt.Errorf("failed to transform model due to %w", err) diff --git a/cmd/root.go b/cmd/root.go index c345bb54..8d2e3287 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -42,7 +42,11 @@ var rootCmd = &cobra.Command{ Short: "OpenFGA CLI", Long: ``, SilenceUsage: true, - // Commands whose output does not go through output.Display would otherwise ignore --jq. + // Reject --jq before running commands that never print through output.Display. + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + return checkJQSupported(cmd) + }, + // Fallback for commands that only use output.Display for some of their output formats. PersistentPostRunE: func(_ *cobra.Command, _ []string) error { return output.CheckJQFilterApplied() }, @@ -143,6 +147,28 @@ func initConfig() { cobra.CheckErr(output.SetJQFilter(jqExpression(jqFromFlag))) } +// checkJQSupported returns an error if --jq is set for a command that never prints through +// output.Display: one annotated with output.JQUnsupportedAnnotation, under such a command, or one +// of cobra's help and completion commands. +func checkJQSupported(cmd *cobra.Command) error { + if !output.JQFilterSet() { + return nil + } + + for current := cmd; current != nil; current = current.Parent() { + switch { + case current.Annotations[output.JQUnsupportedAnnotation] == "true", + current.Name() == "help", + current.Name() == "completion", + current.Name() == cobra.ShellCompRequestCmd, + current.Name() == cobra.ShellCompNoDescRequestCmd: + return fmt.Errorf("%w, not by %s", output.ErrJQNotApplied, cmd.CommandPath()) + } + } + + return nil +} + // jqExpression returns the --jq flag. Values from FGA_JQ or the config file are ignored: a filter // set there would apply to every command, and BindViperToFlags splits values on whitespace, which // would keep only the last part of an expression such as ".a, .b". diff --git a/cmd/store/export.go b/cmd/store/export.go index bb7ea594..c88037df 100644 --- a/cmd/store/export.go +++ b/cmd/store/export.go @@ -143,6 +143,11 @@ var exportCmd = &cobra.Command{ return fmt.Errorf("failed to initialize FGA Client due to %w", err) } + fileName, _ := cmd.Flags().GetString("output-file") + if fileName == "" && output.JQFilterSet() { + return fmt.Errorf("%w, not by store export without --output-file", output.ErrJQNotApplied) + } + maxTupleCount, _ := cmd.Flags().GetUint("max-tuples") storeData, err := buildStoreData(cmd.Context(), clientConfig, fgaClient, maxTupleCount) @@ -156,7 +161,6 @@ var exportCmd = &cobra.Command{ return fmt.Errorf("unable to marshal storedata yaml: %w", err) } - fileName, _ := cmd.Flags().GetString("output-file") if fileName == "" { fmt.Println(string(storeYaml)) diff --git a/cmd/version.go b/cmd/version.go index ed368ca0..b1e3ec13 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -22,15 +22,17 @@ import ( "github.com/spf13/cobra" "github.com/openfga/cli/internal/build" + "github.com/openfga/cli/internal/output" ) var versionStr = fmt.Sprintf("v`%s` (commit: `%s`, date: `%s`)", build.Version, build.Commit, build.Date) // versionCmd is the entrypoint for the `fga version“ command. var versionCmd *cobra.Command = &cobra.Command{ - Use: "version", - Short: "Reports the FGA CLI version", - Long: "Reports the FGA CLI version.", + Use: "version", + Annotations: map[string]string{output.JQUnsupportedAnnotation: "true"}, + Short: "Reports the FGA CLI version", + Long: "Reports the FGA CLI version.", RunE: func(_ *cobra.Command, _ []string) error { fmt.Printf("fga version %s\n", versionStr) diff --git a/internal/output/jq.go b/internal/output/jq.go index deeff3f6..2a92f44c 100644 --- a/internal/output/jq.go +++ b/internal/output/jq.go @@ -17,6 +17,7 @@ limitations under the License. package output import ( + "bytes" "encoding/json" "errors" "fmt" @@ -25,6 +26,10 @@ import ( "github.com/itchyny/gojq" ) +// JQUnsupportedAnnotation marks a command, and the commands under it, as never printing through +// Display, so that --jq is rejected before the command runs. +const JQUnsupportedAnnotation = "jq-unsupported" + // ErrJQNotApplied is returned by CheckJQFilterApplied when --jq is set but the command printed // its output without going through the filter. var ErrJQNotApplied = errors.New("--jq is only supported by commands that print JSON or YAML output") @@ -88,8 +93,12 @@ func runJQFilter(code *gojq.Code, data any, emit func(any) error) error { return fmt.Errorf("unable to marshal json with error %w", err) } + // Decode numbers as json.Number, so integers above 2^53 keep their precision in gojq. + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.UseNumber() + var input any - if err := json.Unmarshal(encoded, &input); err != nil { + if err := decoder.Decode(&input); err != nil { return fmt.Errorf("unable to unmarshal json with error %w", err) } @@ -102,8 +111,9 @@ func runJQFilter(code *gojq.Code, data any, emit func(any) error) error { } if err, isErr := result.(error); isErr { + // halt stops the filter successfully, halt_error with a non-zero exit code is an error. var haltErr *gojq.HaltError - if errors.As(err, &haltErr) && haltErr.Value() == nil { + if errors.As(err, &haltErr) && haltErr.Value() == nil && haltErr.ExitCode() == 0 { return nil } diff --git a/internal/output/jq_test.go b/internal/output/jq_test.go index f75cb7bb..c330b7d1 100644 --- a/internal/output/jq_test.go +++ b/internal/output/jq_test.go @@ -1,6 +1,9 @@ package output import ( + "encoding/json" + "io" + "os" "testing" "github.com/stretchr/testify/assert" @@ -132,3 +135,68 @@ func TestCheckJQFilterApplied(t *testing.T) { require.NoError(t, displayFiltered(jqFilter, jqTestResponse{}, func(any) error { return nil })) require.NoError(t, CheckJQFilterApplied()) } + +func TestJQFilterKeepsLargeIntegers(t *testing.T) { + t.Cleanup(func() { _ = SetJQFilter("") }) + + require.NoError(t, SetJQFilter(".n, .n + 1")) + + results, err := collectJQResults(map[string]uint64{"n": 9007199254740993}) + require.NoError(t, err) + require.Len(t, results, 2) + + encoded, err := json.Marshal(results) + require.NoError(t, err) + assert.JSONEq(t, "[9007199254740993, 9007199254740994]", string(encoded)) +} + +func TestJQFilterHalt(t *testing.T) { + t.Cleanup(func() { _ = SetJQFilter("") }) + + require.NoError(t, SetJQFilter(`.stores[].id, halt`)) + + results, err := collectJQResults(jqTestResponse{Stores: []jqTestStore{{ID: "01A"}}}) + require.NoError(t, err) + assert.Equal(t, []any{"01A"}, results) + + require.NoError(t, SetJQFilter(`null | halt_error(7)`)) + + _, err = collectJQResults(jqTestResponse{}) + require.Error(t, err) +} + +func TestUniPrinterSeparatesFilteredYAMLResults(t *testing.T) { + t.Cleanup(func() { _ = SetJQFilter("") }) + + require.NoError(t, SetJQFilter(".stores[]")) + + printed := captureStdout(t, func() { + printer := UniPrinter{Printer: &yamlPrinter{}} + require.NoError(t, printer.Display(jqTestResponse{Stores: []jqTestStore{{ID: "01A", Name: "one"}, {ID: "01B", Name: "two"}}})) + }) + + assert.Equal(t, "id: 01A\nname: one\n\n---\nid: 01B\nname: two\n\n", printed) +} + +func captureStdout(t *testing.T, run func()) string { + t.Helper() + + reader, writer, err := os.Pipe() + require.NoError(t, err) + + stdout := os.Stdout + os.Stdout = writer + + t.Cleanup(func() { os.Stdout = stdout }) + + run() + + require.NoError(t, writer.Close()) + + os.Stdout = stdout + + printed, err := io.ReadAll(reader) + require.NoError(t, err) + + return string(printed) +} diff --git a/internal/output/marshal.go b/internal/output/marshal.go index ddaf9e02..75919f30 100644 --- a/internal/output/marshal.go +++ b/internal/output/marshal.go @@ -143,7 +143,19 @@ func NewUniPrinter(outputFormat string) *UniPrinter { // Display prints the data using the configured printer and color settings, after applying the --jq filter if set. func (prt UniPrinter) Display(data any) error { if jqFilter != nil { - return displayFiltered(jqFilter, data, prt.display) + _, isYAML := prt.Printer.(*yamlPrinter) + first := true + + return displayFiltered(jqFilter, data, func(result any) error { + // Separate successive YAML results into documents, like yq does. + if isYAML && !first { + fmt.Println("---") + } + + first = false + + return prt.display(result) + }) } return prt.display(data)