Repository: nats-io/nsc
Pull request #269 (2020-04-02 23:20:42)

added jsonpath support to describe to support tools
added `--field` option, which loads the body of the jwt as JSON and then performs a JSON path evaluation.

The argument should be the field path separated by dots: `a.b.c[12].d` Note that you can reference indexed values etc.

kozlovic: @aricart If you have "global" flags that are used in tests, you should make sure that you run the test packages serially with the `-p=1` flag.

Patch:
diff --git a/cmd/describe.go b/cmd/describe.go
index 4840792..b82a246 100644
--- a/cmd/describe.go
+++ b/cmd/describe.go
@@ -29,6 +29,7 @@ var Raw bool
 var WideFlag bool
 var Wide = noopNameFilter
 var Json bool
+var JsonPath string
 
 type WideFun = func(a string) string
 
@@ -72,6 +73,7 @@ func init() {
 	describeCmd.PersistentFlags().BoolVarP(&Json, "json", "J", false, "display JWT body as JSON")
 	describeCmd.PersistentFlags().BoolVarP(&WideFlag, "long-ids", "W", false, "display account ids on imports")
 	describeCmd.PersistentFlags().BoolVarP(&Raw, "raw", "R", false, "output the raw JWT (exclusive of long-ids)")
+	describeCmd.PersistentFlags().StringVarP(&JsonPath, "field", "F", "", "extract value from specified field using json structure")
 }
 
 func bodyAsJson(data []byte) ([]byte, error) {
diff --git a/cmd/describeaccount.go b/cmd/describeaccount.go
index a3a35ba..5c9cd99 100644
--- a/cmd/describeaccount.go
+++ b/cmd/describeaccount.go
@@ -64,16 +64,22 @@ func (p *DescribeAccountParams) Load(ctx ActionCtx) error {
 	if err = p.AccountContextParams.Validate(ctx); err != nil {
 		return err
 	}
-	if Json || Raw {
+	if Json || Raw || JsonPath != "" {
 		p.raw, err = ctx.StoreCtx().Store.ReadRawAccountClaim(p.AccountContextParams.Name)
 		if err != nil {
 			return err
 		}
-		if Json {
+		if Json || JsonPath != "" {
 			p.raw, err = bodyAsJson(p.raw)
 			if err != nil {
 				return err
 			}
+			if JsonPath != "" {
+				p.raw, err = GetField(p.raw, JsonPath)
+				if err != nil {
+					return err
+				}
+			}
 		}
 	} else {
 		ac, err := ctx.StoreCtx().Store.ReadAccountClaim(p.AccountContextParams.Name)
@@ -95,7 +101,7 @@ func (p *DescribeAccountParams) PostInteractive(_ ActionCtx) error {
 }
 
 func (p *DescribeAccountParams) Run(_ ActionCtx) (store.Status, error) {
-	if Raw || Json {
+	if Raw || Json || JsonPath != "" {
 		if !IsStdOut(p.outputFile) {
 			var err error
 			p.raw, err = jwt.DecorateJWT(string(p.raw))
diff --git a/cmd/describeoperator.go b/cmd/describeoperator.go
index 1f42263..0eec4af 100644
--- a/cmd/describeoperator.go
+++ b/cmd/describeoperator.go
@@ -82,16 +82,23 @@ func (p *DescribeOperatorParams) PreInteractive(_ ActionCtx) error {
 
 func (p *DescribeOperatorParams) Load(ctx ActionCtx) error {
 	var err error
-	if Json || Raw {
+	if Json || Raw || JsonPath != "" {
 		p.raw, err = ctx.StoreCtx().Store.ReadRawOperatorClaim()
 		if err != nil {
 			return err
 		}
-		if Json {
+		if Json || JsonPath != "" {
 			p.raw, err = bodyAsJson(p.raw)
 			if err != nil {
 				return err
 			}
+
+			if JsonPath != "" {
+				p.raw, err = GetField(p.raw, JsonPath)
+				if err != nil {
+					return err
+				}
+			}
 		}
 	} else {
 		oc, err := ctx.StoreCtx().Store.ReadOperatorClaim()
@@ -112,7 +119,7 @@ func (p *DescribeOperatorParams) PostInteractive(_ ActionCtx) error {
 }
 
 func (p *DescribeOperatorParams) Run(_ ActionCtx) (store.Status, error) {
-	if Raw || Json {
+	if Raw || Json || JsonPath != "" {
 		if !IsStdOut(p.outputFile) {
 			var err error
 			p.raw, err = jwt.DecorateJWT(string(p.raw))
diff --git a/cmd/describeuser.go b/cmd/describeuser.go
index 559dbbc..dcb1383 100644
--- a/cmd/describeuser.go
+++ b/cmd/describeuser.go
@@ -92,16 +92,22 @@ func (p *DescribeUserParams) Load(ctx ActionCtx) error {
 		return fmt.Errorf("user is required")
 	}
 
-	if Json || Raw {
+	if Json || Raw || JsonPath != "" {
 		p.raw, err = ctx.StoreCtx().Store.ReadRawUserClaim(p.AccountContextParams.Name, p.user)
 		if err != nil {
 			return err
 		}
-		if Json {
+		if Json || JsonPath != "" {
 			p.raw, err = bodyAsJson(p.raw)
 			if err != nil {
 				return err
 			}
+			if JsonPath != "" {
+				p.raw, err = GetField(p.raw, JsonPath)
+				if err != nil {
+					return err
+				}
+			}
 		}
 	} else {
 		uc, err := ctx.StoreCtx().Store.ReadUserClaim(p.AccountContextParams.Name, p.user)
@@ -122,7 +128,7 @@ func (p *DescribeUserParams) PostInteractive(_ ActionCtx) error {
 }
 
 func (p *DescribeUserParams) Run(_ ActionCtx) (store.Status, error) {
-	if Raw || Json {
+	if Raw || Json || JsonPath != "" {
 		if !IsStdOut(p.outputFile) {
 			var err error
 			p.raw, err = jwt.DecorateJWT(string(p.raw))
@@ -131,6 +137,7 @@ func (p *DescribeUserParams) Run(_ ActionCtx) (store.Status, error) {
 			}
 		}
 		p.raw = append(p.raw, '\n')
+
 		if err := Write(p.outputFile, p.raw); err != nil {
 			return nil, err
 		}
diff --git a/cmd/jsonpath.go b/cmd/jsonpath.go
new file mode 100644
index 0000000..8f20288
--- /dev/null
+++ b/cmd/jsonpath.go
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2020 The NATS Authors
+ * 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 cmd
+
+import (
+	"encoding/json"
+	"fmt"
+	"strconv"
+	"strings"
+)
+
+// Extracts a value from a JSON path - keys can be "name" or "person.name" or "persons[0].name"
+func GetField(data []byte, jp string) ([]byte, error) {
+	m := make(map[string]interface{})
+	if err := json.Unmarshal(data, &m); err != nil {
+		return nil, fmt.Errorf("error parsing json: %v", err)
+	}
+	var tokens []string
+	for _, v := range strings.Split(jp, ".") {
+		ob := strings.Index(v, "[")
+		if ob != -1 {
+			cb := strings.Index(v, "]")
+			if cb == -1 {
+				return nil, fmt.Errorf("bad path - unterminated index expression: %q in %q", v, jp)
+			}
+			n := v[:ob]
+			i := v[ob+1 : cb]
+			tokens = append(tokens, n, i)
+		} else {
+			tokens = append(tokens, v)
+		}
+	}
+
+	var d interface{}
+	d = m
+	for _, t := range tokens {
+		switch v := d.(type) {
+		case map[string]interface{}:
+			d = v[t]
+		case []interface{}:
+			idx, err := strconv.Atoi(t)
+			if err != nil {
+				return nil, fmt.Errorf("error parsing index: %q in %q", t, jp)
+			}
+			if idx > len(v) {
+				return nil, fmt.Errorf("index is out of bounds: %d in %q", idx, jp)
+			}
+			d = v[idx]
+		default:
+			return nil, fmt.Errorf("unable to extract %q in %q from %v", t, jp, v)
+		}
+	}
+	return json.Marshal(d)
+}


Test patch:
diff --git a/cmd/describeaccount_test.go b/cmd/describeaccount_test.go
index 33163c8..ff7efc6 100644
--- a/cmd/describeaccount_test.go
+++ b/cmd/describeaccount_test.go
@@ -17,6 +17,7 @@ package cmd
 
 import (
 	"encoding/json"
+	"fmt"
 	"testing"
 
 	"github.com/nats-io/jwt"
@@ -46,14 +47,9 @@ func TestDescribeAccount_Single(t *testing.T) {
 func TestDescribeAccountRaw(t *testing.T) {
 	ts := NewTestStore(t, "operator")
 	defer ts.Done(t)
-	oldRaw := Raw
-	Raw = true
-	defer func() {
-		Raw = oldRaw
-	}()
-
 	ts.AddAccount(t, "A")
 
+	Raw = true
 	stdout, _, err := ExecuteCmd(createDescribeAccountCmd())
 	require.NoError(t, err)
 
@@ -174,8 +170,6 @@ func TestDescribeAccount_Json(t *testing.T) {
 
 	ts.AddAccount(t, "A")
 	out, _, err := ExecuteCmd(rootCmd, "describe", "account", "--json")
-	// reset the global
-	Json = false
 	require.NoError(t, err)
 	m := make(map[string]interface{})
 	err = json.Unmarshal([]byte(out), &m)
@@ -184,3 +178,15 @@ func TestDescribeAccount_Json(t *testing.T) {
 	require.NoError(t, err)
 	require.Equal(t, ac.Subject, m["sub"])
 }
+
+func TestDescribeAccount_JsonPath(t *testing.T) {
+	ts := NewTestStore(t, "O")
+	defer ts.Done(t)
+
+	ts.AddAccount(t, "A")
+
+	out, _, err := ExecuteCmd(rootCmd, "describe", "account", "--field", "sub")
+	ac, err := ts.Store.ReadAccountClaim("A")
+	require.NoError(t, err)
+	require.Equal(t, fmt.Sprintf("\"%s\"\n", ac.Subject), out)
+}
diff --git a/cmd/describeoperator_test.go b/cmd/describeoperator_test.go
index 38b244b..0c8ea4b 100644
--- a/cmd/describeoperator_test.go
+++ b/cmd/describeoperator_test.go
@@ -17,6 +17,7 @@ package cmd
 
 import (
 	"encoding/json"
+	"fmt"
 	"testing"
 
 	"github.com/nats-io/jwt"
@@ -158,8 +159,6 @@ func TestDescribeOperator_Json(t *testing.T) {
 	defer ts.Done(t)
 
 	out, _, err := ExecuteCmd(rootCmd, "describe", "operator", "--json")
-	// reset the global
-	Json = false
 	require.NoError(t, err)
 	m := make(map[string]interface{})
 	err = json.Unmarshal([]byte(out), &m)
@@ -169,3 +168,13 @@ func TestDescribeOperator_Json(t *testing.T) {
 	require.NotNil(t, oc)
 	require.Equal(t, oc.Subject, m["sub"])
 }
+
+func TestDescribeOperator_JsonPath(t *testing.T) {
+	ts := NewTestStore(t, "O")
+	defer ts.Done(t)
+
+	out, _, err := ExecuteCmd(rootCmd, "describe", "operator", "--field", "sub")
+	oc, err := ts.Store.ReadOperatorClaim()
+	require.NoError(t, err)
+	require.Equal(t, fmt.Sprintf("\"%s\"\n", oc.Subject), out)
+}
diff --git a/cmd/describeuser_test.go b/cmd/describeuser_test.go
index c910aec..18b26bd 100644
--- a/cmd/describeuser_test.go
+++ b/cmd/describeuser_test.go
@@ -17,6 +17,7 @@ package cmd
 
 import (
 	"encoding/json"
+	"fmt"
 	"testing"
 
 	"github.com/nats-io/jwt"
@@ -47,15 +48,11 @@ func TestDescribeUser_Single(t *testing.T) {
 func TestDescribeUserRaw(t *testing.T) {
 	ts := NewTestStore(t, "operator")
 	defer ts.Done(t)
-	oldRaw := Raw
-	Raw = true
-	defer func() {
-		Raw = oldRaw
-	}()
 
 	ts.AddAccount(t, "A")
 	ts.AddUser(t, "A", "U")
 
+	Raw = true
 	stdout, _, err := ExecuteCmd(createDescribeUserCmd())
 	require.NoError(t, err)
 
@@ -210,8 +207,6 @@ func TestDescribeUser_Json(t *testing.T) {
 	ts.AddUser(t, "A", "aa")
 
 	out, _, err := ExecuteCmd(rootCmd, "describe", "user", "--json")
-	// reset the global
-	Json = false
 	require.NoError(t, err)
 	m := make(map[string]interface{})
 	err = json.Unmarshal([]byte(out), &m)
@@ -221,3 +216,16 @@ func TestDescribeUser_Json(t *testing.T) {
 	require.NotNil(t, uc)
 	require.Equal(t, uc.Subject, m["sub"])
 }
+
+func TestDescribeUser_JsonPath(t *testing.T) {
+	ts := NewTestStore(t, "O")
+	defer ts.Done(t)
+
+	ts.AddAccount(t, "A")
+	ts.AddUser(t, "A", "aa")
+
+	out, _, err := ExecuteCmd(rootCmd, "describe", "user", "--field", "sub")
+	uc, err := ts.Store.ReadUserClaim("A", "aa")
+	require.NoError(t, err)
+	require.Equal(t, fmt.Sprintf("\"%s\"\n", uc.Subject), out)
+}
diff --git a/cmd/jsonpath_test.go b/cmd/jsonpath_test.go
new file mode 100644
index 0000000..ea955c3
--- /dev/null
+++ b/cmd/jsonpath_test.go
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2020 The NATS Authors
+ * 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 cmd
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestJsonPath_String(t *testing.T) {
+	d, err := json.Marshal("helloworld")
+	require.NoError(t, err)
+	_, err = GetField(d, "hello")
+	require.Error(t, err)
+}
+
+func TestJsonPath_Int(t *testing.T) {
+	d, err := json.Marshal(100)
+	require.NoError(t, err)
+	_, err = GetField(d, "hello")
+	require.Error(t, err)
+}
+
+func TestJsonPath_Bool(t *testing.T) {
+	d, err := json.Marshal(true)
+	require.NoError(t, err)
+	_, err = GetField(d, "hello")
+	require.Error(t, err)
+}
+
+func TestJsonPath_Nil(t *testing.T) {
+	d, err := json.Marshal(nil)
+	require.NoError(t, err)
+	v, err := GetField(d, "hello")
+	require.NoError(t, err)
+	require.Equal(t, "null", string(v))
+}
+
+func TestJsonPath_BadArrayExpression(t *testing.T) {
+	d := []byte(`{"a": [0,1,2]}`)
+	_, err := GetField(d, "a[1")
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "unterminated index expression")
+}
+
+func TestJsonPath_BadArrayIndex(t *testing.T) {
+	d := []byte(`{"a": [0,1,2]}`)
+	_, err := GetField(d, "a[]")
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "error parsing index")
+}
+
+func TestJsonPath_BadArrayBadIndex(t *testing.T) {
+	d := []byte(`{"a": [0,1,2]}`)
+	_, err := GetField(d, "a[ab]")
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "error parsing index")
+}
+
+func TestJsonPath_OutOfBoundsIndex(t *testing.T) {
+	d := []byte(`{"a": [0,1,2]}`)
+	_, err := GetField(d, "a[4]")
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "index is out of bounds")
+}
+
+func TestJsonPath_PrimitiveCannotBeInspected(t *testing.T) {
+	d := []byte(`{"a": [0,1,2]}`)
+	_, err := GetField(d, "a[1].hello")
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "unable to extract")
+}
+
+func TestSimple(t *testing.T) {
+	d := `{"a":[1,"hello",true],"b": "hello","c": {"key": "one","value": "two"}}`
+	ba := []byte(d)
+	v, err := GetField(ba, "a")
+	require.NoError(t, err)
+	require.Equal(t, `[1,"hello",true]`, string(v))
+
+	v, err = GetField(ba, "a[1]")
+	require.NoError(t, err)
+	require.Equal(t, `"hello"`, string(v))
+
+	v, err = GetField(ba, "a[0]")
+	require.NoError(t, err)
+	require.Equal(t, "1", string(v))
+
+	v, err = GetField(ba, "a[2]")
+	require.NoError(t, err)
+	require.Equal(t, "true", string(v))
+
+	v, err = GetField(ba, "b")
+	require.NoError(t, err)
+	require.Equal(t, `"hello"`, string(v))
+
+	v, err = GetField(ba, "c")
+	require.NoError(t, err)
+	require.Equal(t, `{"key":"one","value":"two"}`, string(v))
+
+	v, err = GetField(ba, "c.key")
+	require.NoError(t, err)
+	require.Equal(t, `"one"`, string(v))
+}
diff --git a/cmd/util_test.go b/cmd/util_test.go
index f4ec163..26765cb 100644
--- a/cmd/util_test.go
+++ b/cmd/util_test.go
@@ -1,5 +1,5 @@
 /*
- * Copyright 2018-2019 The NATS Authors
+ * Copyright 2018-2020 The NATS Authors
  * 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
@@ -61,6 +61,9 @@ func ResetForTests() {
 
 func ResetSharedFlags() {
 	KeyPathFlag = ""
+	Json = false
+	Raw = false
+	JsonPath = ""
 }
 
 func NewEmptyStore(t *testing.T) *TestStore {