diff --git a/cmd/kosli/listFlows.go b/cmd/kosli/listFlows.go index 006d04ecb..79ce70b3a 100644 --- a/cmd/kosli/listFlows.go +++ b/cmd/kosli/listFlows.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "github.com/kosli-dev/cli/internal/output" @@ -13,35 +14,73 @@ import ( "github.com/spf13/cobra" ) -const listFlowsDesc = `List flows for an org.` +const listFlowsShortDesc = `List flows for an org. ` + +const listFlowsLongDesc = listFlowsShortDesc + `By default, all flows for the org are returned. +Pass --page-limit and/or --page to paginate the results; when pagination is requested the output +includes pagination metadata and a page footer (table) or a "data"/"pagination" envelope (JSON). +The list can be filtered by name with --name (and --ignore-case for case-insensitive matching).` + +const listFlowsExample = ` +# list all flows for an org: +kosli list flows \ + --api-token yourAPIToken \ + --org yourOrgName + +# list the first 30 flows (paginated): +kosli list flows \ + --page-limit 30 \ + --api-token yourAPIToken \ + --org yourOrgName + +# show the second page of flows (30 per page): +kosli list flows \ + --page-limit 30 \ + --page 2 \ + --api-token yourAPIToken \ + --org yourOrgName + +# list flows whose name contains "backend" (in JSON): +kosli list flows \ + --name backend \ + --api-token yourAPIToken \ + --org yourOrgName \ + --output json +` type listFlowsOptions struct { - output string + listOptions name string ignoreCase bool + paginate bool } func newListFlowsCmd(out io.Writer) *cobra.Command { o := new(listFlowsOptions) cmd := &cobra.Command{ - Use: "flows", - Short: listFlowsDesc, - Long: listFlowsDesc, - Args: cobra.NoArgs, + Use: "flows", + Short: listFlowsShortDesc, + Long: listFlowsLongDesc, + Example: listFlowsExample, + Args: cobra.NoArgs, PreRunE: func(cmd *cobra.Command, args []string) error { err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) if err != nil { return ErrorBeforePrintingUsage(cmd, err.Error()) } - return nil + return o.validate(cmd) }, RunE: func(cmd *cobra.Command, args []string) error { + // pagination is opt-in: only when the user explicitly sets --page or + // --page-limit do we paginate. Otherwise all flows are returned (the + // historical behaviour), keeping output backwards compatible. + o.paginate = cmd.Flags().Changed("page") || cmd.Flags().Changed("page-limit") return o.run(out) }, } - cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag) - cmd.Flags().StringVarP(&o.name, "name", "n", "", searchByNameFlag) + addListFlags(cmd, &o.listOptions, 20) + cmd.Flags().StringVarP(&o.name, "name", "N", "", searchByNameFlag) cmd.Flags().BoolVarP(&o.ignoreCase, "ignore-case", "i", false, ignoreCaseFlag) return cmd @@ -54,6 +93,12 @@ func (o *listFlowsOptions) run(out io.Writer) error { } params := url.Values{} + if o.paginate { + // sending per_page switches the endpoint to the paginated {data, pagination} + // envelope; omitting it returns all flows as a plain array (the default) + params.Set("page", strconv.Itoa(o.pageNumber)) + params.Set("per_page", strconv.Itoa(o.pageLimit)) + } if o.name != "" { params.Set("search_by_name", o.name) // case_sensitive only affects search, so only send it alongside a search term @@ -76,22 +121,43 @@ func (o *listFlowsOptions) run(out io.Writer) error { return err } - return output.FormattedPrint(response.Body, o.output, out, 0, + return output.FormattedPrint(response.Body, o.output, out, o.pageNumber, map[string]output.FormatOutputFunc{ "table": printFlowsListAsTable, "json": output.PrintJson, }) } +type listFlowsResponse struct { + Data []map[string]interface{} `json:"data"` + Pagination Pagination `json:"pagination"` +} + func printFlowsListAsTable(raw string, out io.Writer, page int) error { var flows []map[string]interface{} - err := json.Unmarshal([]byte(raw), &flows) - if err != nil { - return err + var pagination *Pagination + + // The endpoint returns a plain array when unpaginated and a {data, pagination} + // envelope when paginated; handle both so the default behaviour is unchanged. + if strings.HasPrefix(strings.TrimSpace(raw), "[") { + if err := json.Unmarshal([]byte(raw), &flows); err != nil { + return err + } + } else { + response := &listFlowsResponse{} + if err := json.Unmarshal([]byte(raw), response); err != nil { + return err + } + flows = response.Data + pagination = &response.Pagination } if len(flows) == 0 { - logger.Info("No flows were found.") + msg := "No flows were found" + if page != 1 { + msg = fmt.Sprintf("%s at page number %d", msg, page) + } + logger.Info(msg + ".") return nil } @@ -107,6 +173,11 @@ func printFlowsListAsTable(raw string, out io.Writer, page int) error { row := fmt.Sprintf("%s\t%s\t%s\t%s", flow["name"], flow["description"], flow["visibility"], tagsOutput) rows = append(rows, row) } + if pagination != nil { + paginationInfo := fmt.Sprintf("\nShowing page %.0f of %.0f, total %.0f items", pagination.Page, pagination.PageCount, pagination.Total) + rows = append(rows, paginationInfo) + } + tabFormattedPrint(out, header, rows) return nil diff --git a/cmd/kosli/listFlows_test.go b/cmd/kosli/listFlows_test.go index 7135c8d94..2612060ce 100644 --- a/cmd/kosli/listFlows_test.go +++ b/cmd/kosli/listFlows_test.go @@ -48,7 +48,7 @@ func (suite *ListFlowsCommandTestSuite) TestListFlowsCmd() { { name: "listing flows with --output json works when there are flows", cmd: fmt.Sprintf(`list flows --output json %s`, suite.defaultKosliArguments), - goldenJson: []jsonCheck{{"", "non-empty"}}, + goldenJson: []jsonCheck{{"", "non-empty"}}, // unpaginated default returns a plain array }, { name: "listing flows with --output json works when there are no flows", @@ -76,10 +76,49 @@ func (suite *ListFlowsCommandTestSuite) TestListFlowsCmd() { goldenJson: []jsonCheck{{"", "non-empty"}, {"[0].name", "list-flows-search-target"}}, }, { - name: "short flags -n and -i work like --name and --ignore-case", - cmd: fmt.Sprintf(`list flows -n LIST-FLOWS-SEARCH-TARGET -i --output json %s`, suite.defaultKosliArguments), + name: "short flags -N and -i work like --name and --ignore-case", + cmd: fmt.Sprintf(`list flows -N LIST-FLOWS-SEARCH-TARGET -i --output json %s`, suite.defaultKosliArguments), goldenJson: []jsonCheck{{"", "non-empty"}, {"[0].name", "list-flows-search-target"}}, }, + { + name: "pagination metadata is returned on page 1", + cmd: fmt.Sprintf(`list flows --page-limit 1 --page 1 --output json %s`, suite.defaultKosliArguments), + goldenJson: []jsonCheck{{"data", "non-empty"}, {"pagination.page", float64(1)}}, + }, + { + name: "can page through flows with --page", + cmd: fmt.Sprintf(`list flows --page-limit 1 --page 2 --output json %s`, suite.defaultKosliArguments), + goldenJson: []jsonCheck{{"pagination.page", float64(2)}}, + }, + { + name: "an empty page reports the page number", + cmd: fmt.Sprintf(`list flows --page 99 %s`, suite.defaultKosliArguments), + golden: "No flows were found at page number 99.\n", + }, + { + wantError: true, + name: "negative page limit causes an error", + cmd: fmt.Sprintf(`list flows --page-limit -1 %s`, suite.defaultKosliArguments), + golden: "Error: flag '--page-limit' has value '-1' which is illegal\n", + }, + { + wantError: true, + name: "negative page number causes an error", + cmd: fmt.Sprintf(`list flows --page -1 %s`, suite.defaultKosliArguments), + golden: "Error: flag '--page' has value '-1' which is illegal\n", + }, + { + wantError: true, + name: "zero page limit causes an error", + cmd: fmt.Sprintf(`list flows --page-limit 0 %s`, suite.defaultKosliArguments), + golden: "Error: page limit must be a positive integer\nUsage: kosli list flows [flags]\n", + }, + { + wantError: true, + name: "zero page number causes an error", + cmd: fmt.Sprintf(`list flows --page 0 %s`, suite.defaultKosliArguments), + golden: "Error: page number must be a positive integer\nUsage: kosli list flows [flags]\n", + }, { wantError: true, name: "providing an argument causes an error",