Skip to content

Commit

Permalink
feat: Create an --output/-o flag for JSON or plain text responses (#195)
Browse files Browse the repository at this point in the history
Create an --output/-o flag for JSON or plain text responses

* accepts either "json" or "plaintext
* defaults to plaintext
  • Loading branch information
dbolson authored Apr 23, 2024
1 parent fd98b42 commit 96474cd
Show file tree
Hide file tree
Showing 14 changed files with 369 additions and 35 deletions.
1 change: 1 addition & 0 deletions cmd/cliflags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const (
EmailsFlag = "emails"
EnvironmentFlag = "environment"
FlagFlag = "flag"
OutputFlag = "output"
ProjectFlag = "project"
RoleFlag = "role"
)
1 change: 1 addition & 0 deletions cmd/config/testdata/help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ Flags:
Global Flags:
--access-token string LaunchDarkly API token with write-level access
--base-uri string LaunchDarkly base URI (default "https://app.launchdarkly.com")
-o, --output string Command response output format in either JSON or plain text (default "plaintext")
11 changes: 10 additions & 1 deletion cmd/environments/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"ldcli/cmd/cliflags"
"ldcli/cmd/validators"
"ldcli/internal/environments"
"ldcli/internal/output"
)

func NewGetCmd(
Expand Down Expand Up @@ -65,7 +66,15 @@ func runGet(
return err
}

fmt.Fprintf(cmd.OutOrStdout(), string(response)+"\n")
output, err := output.CmdOutput(
viper.GetString(cliflags.OutputFlag),
output.NewSingularOutputterFn(response),
)
if err != nil {
return err
}

fmt.Fprintf(cmd.OutOrStdout(), string(output)+"\n")

return nil
}
Expand Down
33 changes: 27 additions & 6 deletions cmd/environments/get_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,27 +20,29 @@ func TestGet(t *testing.T) {
"test-env",
"test-proj",
}
stubbedResponse := `{"key": "test-key", "name": "test-name"}`

t.Run("with valid environments calls API", func(t *testing.T) {
t.Run("with valid flags calls API", func(t *testing.T) {
client := environments.MockClient{}
client.
On("Get", mockArgs...).
Return([]byte(cmd.ValidResponse), nil)
Return([]byte(stubbedResponse), nil)
clients := cmd.APIClients{
EnvironmentsClient: &client,
}
args := []string{
"environments", "get",
"--access-token", "testAccessToken",
"--base-uri", "http://test.com",
"--output", "json",
"--environment", "test-env",
"--project", "test-proj",
}

output, err := cmd.CallCmd(t, clients, &analytics.NoopClient{}, args)

require.NoError(t, err)
assert.JSONEq(t, `{"valid": true}`, string(output))
assert.JSONEq(t, stubbedResponse, string(output))
})

t.Run("with valid flags from environment variables calls API", func(t *testing.T) {
Expand All @@ -49,20 +51,21 @@ func TestGet(t *testing.T) {
client := environments.MockClient{}
client.
On("Get", mockArgs...).
Return([]byte(cmd.ValidResponse), nil)
Return([]byte(stubbedResponse), nil)
clients := cmd.APIClients{
EnvironmentsClient: &client,
}
args := []string{
"environments", "get",
"--output", "json",
"--environment", "test-env",
"--project", "test-proj",
}

output, err := cmd.CallCmd(t, clients, &analytics.NoopClient{}, args)

require.NoError(t, err)
assert.JSONEq(t, `{"valid": true}`, string(output))
assert.JSONEq(t, stubbedResponse, string(output))
})

t.Run("with an error response is an error", func(t *testing.T) {
Expand All @@ -77,6 +80,7 @@ func TestGet(t *testing.T) {
"environments", "get",
"--access-token", "testAccessToken",
"--base-uri", "http://test.com",
"--output", "json",
"--environment", "test-env",
"--project", "test-proj",
}
Expand Down Expand Up @@ -144,6 +148,23 @@ func TestGet(t *testing.T) {
assert.EqualError(t, err, "base-uri is invalid"+errorHelp)
})

t.Run("with invalid output is an error", func(t *testing.T) {
clients := cmd.APIClients{
EnvironmentsClient: &environments.MockClient{},
}
args := []string{
"environments", "get",
"--access-token", "testAccessToken",
"--output", "invalid",
"--environment", "test-env",
"--project", "test-proj",
}

_, err := cmd.CallCmd(t, clients, &analytics.NoopClient{}, args)

assert.EqualError(t, err, "output is invalid"+errorHelp)
})

t.Run("will track analytics for CLI Command Run event", func(t *testing.T) {
tracker := analytics.MockedTracker(
"environments",
Expand All @@ -154,7 +175,6 @@ func TestGet(t *testing.T) {
"environment",
"project",
})

client := environments.MockClient{}
client.
On("Get", mockArgs...).
Expand All @@ -172,6 +192,7 @@ func TestGet(t *testing.T) {
}

_, err := cmd.CallCmd(t, clients, tracker, args)

require.NoError(t, err)
})
}
11 changes: 10 additions & 1 deletion cmd/projects/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"ldcli/cmd/cliflags"
"ldcli/cmd/validators"
"ldcli/internal/output"
"ldcli/internal/projects"
)

Expand All @@ -35,7 +36,15 @@ func runList(client projects.Client) func(*cobra.Command, []string) error {
return err
}

fmt.Fprintf(cmd.OutOrStdout(), string(response)+"\n")
output, err := output.CmdOutput(
viper.GetString(cliflags.OutputFlag),
output.NewMultipleOutputterFn(response),
)
if err != nil {
return err
}

fmt.Fprintf(cmd.OutOrStdout(), string(output)+"\n")

return nil
}
Expand Down
18 changes: 14 additions & 4 deletions cmd/projects/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,34 @@ func TestList(t *testing.T) {
"testAccessToken",
"http://test.com",
}
stubbedResponse := `{
"items": [
{
"key": "test-key",
"name": "test-name"
}
]
}`

t.Run("with valid flags calls API", func(t *testing.T) {
client := projects.MockClient{}
client.
On("List", mockArgs...).
Return([]byte(cmd.ValidResponse), nil)
Return([]byte(stubbedResponse), nil)
clients := cmd.APIClients{
ProjectsClient: &client,
}
args := []string{
"projects", "list",
"--access-token", "testAccessToken",
"--base-uri", "http://test.com",
"--output", "json",
}

output, err := cmd.CallCmd(t, clients, &analytics.NoopClient{}, args)

require.NoError(t, err)
assert.JSONEq(t, `{"valid": true}`, string(output))
assert.JSONEq(t, stubbedResponse, string(output))
})

t.Run("with valid flags from environment variables calls API", func(t *testing.T) {
Expand All @@ -45,19 +54,20 @@ func TestList(t *testing.T) {
client := projects.MockClient{}
client.
On("List", mockArgs...).
Return([]byte(cmd.ValidResponse), nil)
Return([]byte(stubbedResponse), nil)
clients := cmd.APIClients{
ProjectsClient: &client,
}
args := []string{
"projects",
"list",
"--output", "json",
}

output, err := cmd.CallCmd(t, clients, &analytics.NoopClient{}, args)

require.NoError(t, err)
assert.JSONEq(t, `{"valid": true}`, string(output))
assert.JSONEq(t, stubbedResponse, string(output))
})

t.Run("with an error response is an error", func(t *testing.T) {
Expand Down
11 changes: 11 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,17 @@ func NewRootCommand(
return nil, err
}

cmd.PersistentFlags().StringP(
cliflags.OutputFlag,
"o",
"plaintext",
"Command response output format in either JSON or plain text",
)
err = viper.BindPFlag(cliflags.OutputFlag, cmd.PersistentFlags().Lookup(cliflags.OutputFlag))
if err != nil {
return nil, err
}

environmentsCmd, err := envscmd.NewEnvironmentsCmd(analyticsTracker, clients.EnvironmentsClient)
if err != nil {
return nil, err
Expand Down
46 changes: 26 additions & 20 deletions cmd/validators/validators.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,48 +11,54 @@ import (

"ldcli/cmd/cliflags"
errs "ldcli/internal/errors"
"ldcli/internal/output"
)

// Validate is a validator for commands to print an error when the user input is invalid.
func Validate() cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
rebindFlags(cmd, cmd.ValidArgs) // rebind flags before validating them below
commandPath := getCommandPath(cmd)

_, err := url.ParseRequestURI(viper.GetString(cliflags.BaseURIFlag))
if err != nil {
errorMessage := fmt.Sprintf(
"%s. See `%s --help` for supported flags and usage.",
errs.ErrInvalidBaseURI,
commandPath,
)
return errors.New(errorMessage)
return CmdError(errs.ErrInvalidBaseURI, cmd.CommandPath())
}

err = cmd.ValidateRequiredFlags()
if err != nil {
errorMessage := fmt.Sprintf(
"%s. See `%s --help` for supported flags and usage.",
err.Error(),
commandPath,
)
return CmdError(err, cmd.CommandPath())
}

return errors.New(errorMessage)
err = validateOutput(viper.GetString(cliflags.OutputFlag))
if err != nil {
return CmdError(err, cmd.CommandPath())
}

return nil
}
}

func getCommandPath(cmd *cobra.Command) string {
var commandPath string
if cmd.Annotations["scope"] == "plugin" {
commandPath = fmt.Sprintf("stripe %s", cmd.CommandPath())
} else {
commandPath = cmd.CommandPath()
func CmdError(err error, commandPath string) error {
errorMessage := fmt.Sprintf(
"%s. See `%s --help` for supported flags and usage.",
err.Error(),
commandPath,
)

return errors.New(errorMessage)
}

func validateOutput(outputFlag string) error {
validKinds := map[string]struct{}{
"json": {},
"plaintext": {},
}
_, ok := validKinds[outputFlag]
if !ok {
return output.ErrInvalidOutputKind
}

return commandPath
return nil
}

// rebindFlags sets the command's flags based on the values stored in viper because they may not
Expand Down
7 changes: 4 additions & 3 deletions internal/environments/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,11 @@ func (c EnvironmentsClient) Get(

}

responseJSON, err := json.Marshal(environment)
output, err := json.Marshal(environment)
if err != nil {
return nil, err
return nil, errors.NewLDAPIError(err)

}

return responseJSON, nil
return output, nil
}
49 changes: 49 additions & 0 deletions internal/output/multiple_outputter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package output

import (
"encoding/json"
"fmt"
)

var multiplePlaintextOutputFn = func(r resource) string {
return fmt.Sprintf("* %s (%s)", r.Name, r.Key)
}

// TODO: rename this to be "cleaner"? -- NewMultipleOutput()
func NewMultipleOutputterFn(input []byte) multipleOutputterFn {
return multipleOutputterFn{
input: input,
}
}

type multipleOutputterFn struct {
input []byte
}

func (o multipleOutputterFn) New() (Outputter, error) {
var r resources
err := json.Unmarshal(o.input, &r)
if err != nil {
return MultipleOutputter{}, err
}

return MultipleOutputter{
outputFn: multiplePlaintextOutputFn,
resources: r,
resourceJSON: o.input,
}, nil
}

type MultipleOutputter struct {
outputFn PlaintextOutputFn
resources resources
resourceJSON []byte
}

func (o MultipleOutputter) JSON() string {
return string(o.resourceJSON)
}

func (o MultipleOutputter) String() string {
return formatColl(o.resources.Items, o.outputFn)
}
Loading

0 comments on commit 96474cd

Please sign in to comment.