diff --git a/go.mod b/go.mod index 9d1549787..3bacec4fb 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.7.1 github.com/stretchr/testify v1.6.1 + github.com/tidwall/gjson v1.6.1 golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 golang.org/x/tools v0.0.0-20200812195022-5ae4c3c160a0 gopkg.in/yaml.v2 v2.3.0 diff --git a/go.sum b/go.sum index b5437cfd1..4bd457004 100644 --- a/go.sum +++ b/go.sum @@ -787,6 +787,12 @@ github.com/tdakkota/asciicheck v0.0.0-20200416190851-d7f85be797a2 h1:Xr9gkxfOP0K github.com/tdakkota/asciicheck v0.0.0-20200416190851-d7f85be797a2/go.mod h1:yHp0ai0Z9gUljN3o0xMhYJnH/IcvkdTBOX2fmJ93JEM= github.com/tetafro/godot v0.4.8 h1:h61+hQraWhdI6WYqMwAwZYCE5yxL6a9/Orw4REbabSU= github.com/tetafro/godot v0.4.8/go.mod h1:/7NLHhv08H1+8DNj0MElpAACw1ajsCuf3TKNQxA5S+0= +github.com/tidwall/gjson v1.6.1 h1:LRbvNuNuvAiISWg6gxLEFuCe72UKy5hDqhxW/8183ws= +github.com/tidwall/gjson v1.6.1/go.mod h1:BaHyNc5bjzYkPqgLq7mdVzeiRtULKULXLgZFKsxEHI0= +github.com/tidwall/match v1.0.1 h1:PnKP62LPNxHKTwvHHZZzdOAOCtsJTjo6dZLCwpKm5xc= +github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E= +github.com/tidwall/pretty v1.0.2 h1:Z7S3cePv9Jwm1KwS0513MRaoUe3S01WPbLNV40pwWZU= +github.com/tidwall/pretty v1.0.2/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/timakin/bodyclose v0.0.0-20190930140734-f7f2e9bca95e h1:RumXZ56IrCj4CL+g1b9OL/oH0QnsF976bC8xQFYUD5Q= github.com/timakin/bodyclose v0.0.0-20190930140734-f7f2e9bca95e/go.mod h1:Qimiffbc6q9tBWlVV6x0P9sat/ao1xEkREYPPj9hphk= github.com/tj/assert v0.0.0-20171129193455-018094318fb0 h1:Rw8kxzWo1mr6FSaYXjQELRe88y2KdfynXdnK72rdjtA= diff --git a/internal/entities/command_tags.go b/internal/entities/command_tags.go index 5bb5257d4..9abe04c98 100644 --- a/internal/entities/command_tags.go +++ b/internal/entities/command_tags.go @@ -9,6 +9,7 @@ import ( "github.com/newrelic/newrelic-cli/internal/client" "github.com/newrelic/newrelic-cli/internal/output" + "github.com/newrelic/newrelic-cli/internal/pipe" "github.com/newrelic/newrelic-cli/internal/utils" "github.com/newrelic/newrelic-client-go/newrelic" "github.com/newrelic/newrelic-client-go/pkg/entities" @@ -40,10 +41,16 @@ The get command returns JSON output of the tags for the requested entity. Example: "newrelic entity tags get --guid ", Run: func(cmd *cobra.Command, args []string) { client.WithClient(func(nrClient *newrelic.NewRelic) { - tags, err := nrClient.Entities.ListTags(entityGUID) - utils.LogIfFatal(err) - - utils.LogIfError(output.Print(tags)) + // Temporary until bulk actions can be build into newrelic-client-go + if value, ok := pipe.Get("guid"); ok { + tags, err := nrClient.Entities.ListTags(value[0]) + utils.LogIfFatal(err) + utils.LogIfError(output.Print(tags)) + } else { + tags, err := nrClient.Entities.ListTags(entityGUID) + utils.LogIfFatal(err) + utils.LogIfError(output.Print(tags)) + } }) }, } @@ -200,8 +207,13 @@ func init() { Command.AddCommand(cmdTags) cmdTags.AddCommand(cmdTagsGet) - cmdTagsGet.Flags().StringVarP(&entityGUID, "guid", "g", "", "the entity GUID to retrieve tags for") - utils.LogIfError(cmdTagsGet.MarkFlagRequired("guid")) + + pipe.GetInput([]string{"guid"}) + + if !pipe.Exists("guid") { + cmdTagsGet.Flags().StringVarP(&entityGUID, "guid", "g", "", "the entity GUID to retrieve tags for") + utils.LogIfError(cmdTagsGet.MarkFlagRequired("guid")) + } cmdTags.AddCommand(cmdTagsDelete) cmdTagsDelete.Flags().StringVarP(&entityGUID, "guid", "g", "", "the entity GUID to delete tags on") diff --git a/internal/pipe/pipe.go b/internal/pipe/pipe.go new file mode 100644 index 000000000..b61aea3a5 --- /dev/null +++ b/internal/pipe/pipe.go @@ -0,0 +1,136 @@ +package pipe + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/tidwall/gjson" + + "github.com/newrelic/newrelic-cli/internal/utils" +) + +type pipeReader interface { + ReadPipe() (string, error) +} + +type stdinPipeReader struct { + input io.Reader +} + +func (spr stdinPipeReader) ReadPipe() (string, error) { + text := "" + var err error = nil + scanner := bufio.NewScanner(spr.input) + + for scanner.Scan() { + text = fmt.Sprintf("%s%s ", text, strings.TrimSpace(scanner.Text())) + } + + if scanErr := scanner.Err(); scanErr != nil { + err = scanErr + } + + return strings.TrimSpace(text), err +} + +func jsonToFilteredMap(r string, selectors []string) ([]map[string]string, error) { + text := r + + if !gjson.Valid(text) { + return nil, errors.New("invalid JSON received by stdin") + } + + // always start with an array of values + if strings.HasPrefix(text, "{") { + text = fmt.Sprintf("[ %s ]", text) + } + + // returns []gjson.Result + jsonArray := gjson.Parse(text).Array() + + resultsArray := make([]map[string]string, len(jsonArray)) + + for index, resultObj := range jsonArray { + resultMap := make(map[string]string) + for _, selector := range selectors { + if value := resultObj.Get(selector); value.Exists() { + // Convert every value to a string + resultMap[selector] = value.String() + } + } + resultsArray[index] = resultMap + } + + return resultsArray, nil +} + +func readStdin(pipe pipeReader, selectorList []string) ([]map[string]string, error) { + jsonString, pipeErr := pipe.ReadPipe() + if pipeErr != nil { + return nil, pipeErr + } + + filteredMap, mapErr := jsonToFilteredMap(jsonString, selectorList) + if mapErr != nil { + return nil, mapErr + } + + return filteredMap, nil +} + +func pipeInputExists() bool { + fi, err := os.Stdin.Stat() + if err != nil { + return false + } + return (fi.Mode() & os.ModeCharDevice) == 0 +} + +func getPipeInputInnerFunc(pipe pipeReader, pipeInputExists bool, acceptedPipeInput []string) map[string][]string { + if pipeInputExists { + pipeInputMap := map[string][]string{} + inputArray, err := readStdin(pipe, acceptedPipeInput) + if err != nil { + utils.LogIfError(err) + return map[string][]string{} + } + for _, key := range acceptedPipeInput { + var collectedItemsForKey []string + for _, value := range inputArray { + collectedItemsForKey = append(collectedItemsForKey, value[key]) + } + pipeInputMap[key] = collectedItemsForKey + } + return pipeInputMap + } + return map[string][]string{} +} + +func getPipeInputFactory(pipe pipeReader, predicate func() bool) func([]string) { + return func(acceptedPipeInput []string) { + if pipeInput == nil { + pipeInput = getPipeInputInnerFunc(pipe, predicate(), acceptedPipeInput) + } + } +} + +var pipeInput map[string][]string + +var GetInput = getPipeInputFactory(stdinPipeReader{input: os.Stdin}, pipeInputExists) + +func Get(inputKey string) ([]string, bool) { + if pipeInput == nil { + return nil, false + } + value, ok := pipeInput[inputKey] + return value, ok +} + +func Exists(inputKey string) bool { + _, ok := Get(inputKey) + return ok +} diff --git a/internal/pipe/pipe_test.go b/internal/pipe/pipe_test.go new file mode 100644 index 000000000..b8c3d6c3a --- /dev/null +++ b/internal/pipe/pipe_test.go @@ -0,0 +1,463 @@ +package pipe + +import ( + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStdinPipeReader(t *testing.T) { + cases := map[string]struct { + Input string + Expected string + }{ + "singleInputReturnsSingleValue": { + Input: `{ + "id": 1, + "name": "Foo", + "price": 123, + "tags": [ + "Bar", + "Eek" + ], + "stock": { + "warehouse": 300, + "retail": 20 + } + }`, + Expected: `{ "id": 1, "name": "Foo", "price": 123, "tags": [ "Bar", "Eek" ], "stock": { "warehouse": 300, "retail": 20 } }`, + }, + "arrayInputReturnsArrayValue": { + Input: `[ + { + "id": 1, + "name": "Foo", + "price": 123, + "tags": [ "Bar", "Eek" ], + "stock": { + "warehouse": 300, + "retail": 20 + } + }, + { + "id": 2, + "name": "Bar", + "price": 456, + "tags": [ "Oop", "Aah" ], + "stock": { + "warehouse": 450, + "retail": 50 + } + }, + { + "id": 3, + "name": "Baz", + "price": 789, + "tags": [ "Syn", "Ack" ], + "stock": { + "warehouse": 100, + "retail": 75 + } + } + ]`, + Expected: `[ { "id": 1, "name": "Foo", "price": 123, "tags": [ "Bar", "Eek" ], "stock": { "warehouse": 300, "retail": 20 } }, { "id": 2, "name": "Bar", "price": 456, "tags": [ "Oop", "Aah" ], "stock": { "warehouse": 450, "retail": 50 } }, { "id": 3, "name": "Baz", "price": 789, "tags": [ "Syn", "Ack" ], "stock": { "warehouse": 100, "retail": 75 } } ]`, + }, + } + + for _, c := range cases { + mockStdin := strings.NewReader(c.Input) + reader := stdinPipeReader{input: mockStdin} + result, err := reader.ReadPipe() + + assert.Equal(t, nil, err) + assert.Equal(t, c.Expected, result) + } + +} + +func TestJsonToFilteredMap(t *testing.T) { + cases := map[string]struct { + Input string + ExpectedErr error + Expected []map[string]string + }{ + "noInputReturnsInvalidJsonError": { + Input: ``, + Expected: nil, + ExpectedErr: errors.New("invalid JSON received by stdin"), + }, + "singleValueReturnsCorrectOutput": { + Input: `{ + "id": 1, + "name": "Foo", + "price": 123, + "tags": [ "Bar", "Eek" ], + "stock": { + "warehouse": 300, + "retail": 20 + } + }`, + Expected: []map[string]string{ + { + "id": "1", + "stock.retail": "20", + }, + }, + ExpectedErr: nil, + }, + "arrayValueReturnsCorrectOutput": { + Input: `[ + { + "id": 1, + "name": "Foo", + "price": 123, + "tags": [ "Bar", "Eek" ], + "stock": { + "warehouse": 300, + "retail": 20 + } + }, + { + "id": 2, + "name": "Bar", + "price": 456, + "tags": [ "Oop", "Aah" ], + "stock": { + "warehouse": 450, + "retail": 50 + } + }, + { + "id": 3, + "name": "Baz", + "price": 789, + "tags": [ "Syn", "Ack" ], + "stock": { + "warehouse": 100, + "retail": 75 + } + } + ]`, + Expected: []map[string]string{ + { + "id": "1", + "stock.retail": "20", + }, + { + "id": "2", + "stock.retail": "50", + }, + { + "id": "3", + "stock.retail": "75", + }, + }, + ExpectedErr: nil, + }, + } + selectors := []string{"id", "stock.retail"} + + for _, c := range cases { + results, err := jsonToFilteredMap(c.Input, selectors) + + assert.Equal(t, c.ExpectedErr, err) + + assert.Equal(t, c.Expected, results) + } +} + +func TestReadStdin(t *testing.T) { + cases := map[string]struct { + Input string + Expected []map[string]string + ExpectedErr error + }{ + "invaildJsonThrowsError": { + Input: `{ + broken = bad + }`, + Expected: nil, + ExpectedErr: errors.New("invalid JSON received by stdin"), + }, + "singlarInputReturnsCorrectValue": { + Input: `{ + "id": 1, + "name": "Foo", + "price": 123, + "tags": [ "Bar", "Eek" ], + "stock": { + "warehouse": 300, + "retail": 20 + } + }`, + Expected: []map[string]string{ + { + "id": "1", + "stock.retail": "20", + }, + }, + ExpectedErr: nil, + }, + "arrayInputReturnsCorrectValue": { + Input: `[ + { + "id": 1, + "name": "Foo", + "price": 123, + "tags": [ "Bar", "Eek" ], + "stock": { + "warehouse": 300, + "retail": 20 + } + }, + { + "id": 2, + "name": "Bar", + "price": 456, + "tags": [ "Oop", "Aah" ], + "stock": { + "warehouse": 450, + "retail": 50 + } + }, + { + "id": 3, + "name": "Baz", + "price": 789, + "tags": [ "Syn", "Ack" ], + "stock": { + "warehouse": 100, + "retail": 75 + } + } + ]`, + Expected: []map[string]string{ + { + "id": "1", + "stock.retail": "20", + }, + { + "id": "2", + "stock.retail": "50", + }, + { + "id": "3", + "stock.retail": "75", + }, + }, + ExpectedErr: nil, + }, + } + selectors := []string{"id", "stock.retail"} + + for _, c := range cases { + mockStdin := strings.NewReader(c.Input) + + results, err := readStdin(stdinPipeReader{input: mockStdin}, selectors) + + assert.Equal(t, c.ExpectedErr, err) + + assert.Equal(t, c.Expected, results) + + } +} + +func TestGetPipeInputInnerFunc(t *testing.T) { + cases := map[string]struct { + Input string + Exists bool + Expected map[string][]string + }{ + "noInputButStdinExists": { + Input: ``, + Exists: true, + Expected: map[string][]string{}, + }, + "invalidJsonFromStdin": { + Input: `{ + broken = bad + }`, + Exists: true, + Expected: map[string][]string{}, + }, + "inputGivenButNoStdinDetected": { + Input: `{ + "id": 1, + "name": "Foo", + "price": 123, + "tags": [ "Bar", "Eek" ], + "stock": { + "warehouse": 300, + "retail": 20 + } + }`, + Exists: false, + Expected: map[string][]string{}, + }, + "singlarInputReturnsCorrectResults": { + Input: `{ + "id": 1, + "name": "Foo", + "price": 123, + "tags": [ "Bar", "Eek" ], + "stock": { + "warehouse": 300, + "retail": 20 + } + }`, + Exists: true, + Expected: map[string][]string{ + "id": {"1"}, + "stock.retail": {"20"}, + }, + }, + "arrayInputReturnsCorrectResults": { + Input: `[ + { + "id": 1, + "name": "Foo", + "price": 123, + "tags": [ "Bar", "Eek" ], + "stock": { + "warehouse": 300, + "retail": 20 + } + }, + { + "id": 2, + "name": "Bar", + "price": 456, + "tags": [ "Oop", "Aah" ], + "stock": { + "warehouse": 450, + "retail": 50 + } + }, + { + "id": 3, + "name": "Baz", + "price": 789, + "tags": [ "Syn", "Ack" ], + "stock": { + "warehouse": 100, + "retail": 75 + } + } + ]`, + Exists: true, + Expected: map[string][]string{ + "id": {"1", "2", "3"}, + "stock.retail": {"20", "50", "75"}, + }, + }, + } + selectors := []string{"id", "stock.retail"} + + for _, c := range cases { + mockStdin := strings.NewReader(c.Input) + + results := getPipeInputInnerFunc(stdinPipeReader{input: mockStdin}, c.Exists, selectors) + + assert.Equal(t, c.Expected, results) + } +} + +func TestGetPipeInput(t *testing.T) { + cases := map[string]struct { + Input string + RunTwice bool + Expected map[string][]string + }{ + "setsInputCorrectlyIfRanOnce": { + Input: `{ + "id": 1, + "name": "Foo", + "price": 123, + "tags": [ "Bar", "Eek" ], + "stock": { + "warehouse": 300, + "retail": 20 + } + }`, + RunTwice: false, + Expected: map[string][]string{ + "id": {"1"}, + "stock.retail": {"20"}, + }, + }, + "setsInputCorrectlyIfRanTwice": { + Input: `{ + "id": 3, + "name": "Foo", + "price": 123, + "tags": [ "Bar", "Eek" ], + "stock": { + "warehouse": 300, + "retail": 30 + } + }`, + RunTwice: true, + Expected: map[string][]string{ + "id": {"3"}, + "stock.retail": {"30"}, + }, + }, + } + + selectors := []string{"id", "stock.retail"} + + for _, c := range cases { + pipeInput = nil + mockStdin := strings.NewReader(c.Input) + + getInput := getPipeInputFactory(stdinPipeReader{input: mockStdin}, func() bool { return true }) + + getInput(selectors) + + assert.Equal(t, c.Expected, pipeInput) + + if c.RunTwice { + getInput(selectors) + assert.Equal(t, c.Expected, pipeInput) + } + } +} + +func TestGet(t *testing.T) { + cases := map[string]struct { + Input map[string][]string + ExpectedOk bool + ExpectedValue []string + }{ + "returnsNotOkIfPipeInputNotSet": { + ExpectedValue: nil, + ExpectedOk: false, + Input: nil, + }, + "returnsNotOkIfKeyNotPresent": { + ExpectedValue: nil, + ExpectedOk: false, + Input: map[string][]string{}, + }, + "setsInputCorrectlyIfRanTwice": { + ExpectedValue: []string{"3"}, + ExpectedOk: true, + Input: map[string][]string{ + "id": {"3"}, + "stock.retail": {"30"}, + }, + }, + } + + for _, c := range cases { + pipeInput = c.Input + + value, ok := Get("id") + + assert.Equal(t, c.ExpectedOk, ok) + + assert.Equal(t, c.ExpectedValue, value) + } +}