Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 29 additions & 10 deletions cmd/docs/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,27 @@ import (
"net/url"
"strings"

"github.com/slackapi/slack-cli/internal/api"
"github.com/slackapi/slack-cli/internal/shared"
"github.com/slackapi/slack-cli/internal/slackerror"
"github.com/slackapi/slack-cli/internal/slacktrace"
"github.com/slackapi/slack-cli/internal/style"
"github.com/spf13/cobra"
)

func buildDocsSearchURL(query string) string {
encodedQuery := url.QueryEscape(query)
return fmt.Sprintf("%s/search/?q=%s", docsURL, encodedQuery)
func buildDocsSearchURL(query, category string) string {
params := url.Values{}
params.Set("q", query)
if category != "" {
params.Set("filter", category)
}
return fmt.Sprintf("%s/search/?%s", docsURL, params.Encode())
}

type searchConfig struct {
output string
limit int
output string
limit int
category string
}

func makeAbsoluteURL(relativeURL string) string {
Expand All @@ -53,6 +59,9 @@ func NewSearchCommand(clients *shared.ClientFactory) *cobra.Command {
Long: strings.Join([]string{
"Search the Slack developer docs and return results in text, JSON, or browser",
"format.",
"",
"Results can be filtered to a single category with the --category flag. Available",
"categories: " + strings.Join(api.DocsSearchCategories, ", ") + ".",
}, "\n"),
Example: style.ExampleCommandsf([]style.ExampleCommand{
{
Expand All @@ -67,6 +76,10 @@ func NewSearchCommand(clients *shared.ClientFactory) *cobra.Command {
Meaning: "Search docs with limited JSON results",
Command: "docs search \"api\" --output=json --limit=5",
},
{
Meaning: "Search only the API reference docs",
Command: "docs search \"chat.postMessage\" --category=reference",
},
}),
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -76,6 +89,7 @@ func NewSearchCommand(clients *shared.ClientFactory) *cobra.Command {

cmd.Flags().StringVar(&cfg.output, "output", "text", "output format: text, json, browser")
cmd.Flags().IntVar(&cfg.limit, "limit", 20, "maximum number of text or json search results to return")
cmd.Flags().StringVar(&cfg.category, "category", "", fmt.Sprintf("filter results by category: %s", strings.Join(api.DocsSearchCategories, ", ")))

return cmd
}
Expand All @@ -87,7 +101,7 @@ func runDocsSearchCommand(clients *shared.ClientFactory, cmd *cobra.Command, arg

switch cfg.output {
case "json":
searchResponse, err := clients.API().DocsSearch(ctx, query, cfg.limit)
searchResponse, err := clients.API().DocsSearch(ctx, query, cfg.limit, cfg.category)
if err != nil {
return err
}
Expand All @@ -106,17 +120,22 @@ func runDocsSearchCommand(clients *shared.ClientFactory, cmd *cobra.Command, arg

return nil
case "text":
searchResponse, err := clients.API().DocsSearch(ctx, query, cfg.limit)
searchResponse, err := clients.API().DocsSearch(ctx, query, cfg.limit, cfg.category)
if err != nil {
return err
}

categorySuffix := ""
if cfg.category != "" {
categorySuffix = fmt.Sprintf(" in category \"%s\"", cfg.category)
}

if len(searchResponse.Results) == 0 {
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
Emoji: "books",
Text: "Docs Search",
Secondary: []string{
fmt.Sprintf("Found zero results for \"%s\"", query),
fmt.Sprintf("Found zero results for \"%s\"%s", query, categorySuffix),
},
}))
clients.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, query)
Expand All @@ -127,7 +146,7 @@ func runDocsSearchCommand(clients *shared.ClientFactory, cmd *cobra.Command, arg
Emoji: "books",
Text: "Docs Search",
Secondary: []string{
fmt.Sprintf("Displaying first %d of %d results for \"%s\"", len(searchResponse.Results), searchResponse.TotalResults, query),
fmt.Sprintf("Displaying first %d of %d results for \"%s\"%s", len(searchResponse.Results), searchResponse.TotalResults, query, categorySuffix),
},
}))

Expand All @@ -144,7 +163,7 @@ func runDocsSearchCommand(clients *shared.ClientFactory, cmd *cobra.Command, arg

return nil
case "browser":
docsSearchURL := buildDocsSearchURL(query)
docsSearchURL := buildDocsSearchURL(query, cfg.category)

clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
Emoji: "books",
Expand Down
87 changes: 77 additions & 10 deletions cmd/docs/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func Test_Docs_SearchCommand(t *testing.T) {
"returns text results": {
CmdArgs: []string{"search", "Block Kit"},
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
cm.API.On("DocsSearch", mock.Anything, "Block Kit", 20).Return(&api.DocsSearchResponse{
cm.API.On("DocsSearch", mock.Anything, "Block Kit", 20, "").Return(&api.DocsSearchResponse{
TotalResults: 2,
Limit: 20,
Results: []api.DocsSearchItem{
Expand All @@ -52,7 +52,7 @@ func Test_Docs_SearchCommand(t *testing.T) {
"returns JSON results": {
CmdArgs: []string{"search", "Block Kit", "--output=json"},
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
cm.API.On("DocsSearch", mock.Anything, "Block Kit", 20).Return(&api.DocsSearchResponse{
cm.API.On("DocsSearch", mock.Anything, "Block Kit", 20, "").Return(&api.DocsSearchResponse{
TotalResults: 2,
Limit: 20,
Results: []api.DocsSearchItem{
Expand Down Expand Up @@ -85,7 +85,7 @@ func Test_Docs_SearchCommand(t *testing.T) {
"returns JSON results with absolute URLs": {
CmdArgs: []string{"search", "test", "--output=json"},
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
cm.API.On("DocsSearch", mock.Anything, "test", 20).Return(&api.DocsSearchResponse{
cm.API.On("DocsSearch", mock.Anything, "test", 20, "").Return(&api.DocsSearchResponse{
TotalResults: 1,
Limit: 20,
Results: []api.DocsSearchItem{
Expand All @@ -103,7 +103,7 @@ func Test_Docs_SearchCommand(t *testing.T) {
"returns empty results": {
CmdArgs: []string{"search", "nonexistent"},
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
cm.API.On("DocsSearch", mock.Anything, "nonexistent", 20).Return(&api.DocsSearchResponse{
cm.API.On("DocsSearch", mock.Anything, "nonexistent", 20, "").Return(&api.DocsSearchResponse{
TotalResults: 0,
Results: []api.DocsSearchItem{},
Limit: 20,
Expand All @@ -116,41 +116,108 @@ func Test_Docs_SearchCommand(t *testing.T) {
"returns error on API failure": {
CmdArgs: []string{"search", "test"},
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
cm.API.On("DocsSearch", mock.Anything, "test", 20).Return(nil, slackerror.New(slackerror.ErrHTTPRequestFailed))
cm.API.On("DocsSearch", mock.Anything, "test", 20, "").Return(nil, slackerror.New(slackerror.ErrHTTPRequestFailed))
},
ExpectedErrorStrings: []string{slackerror.ErrHTTPRequestFailed},
},
"returns error on API failure for JSON output": {
CmdArgs: []string{"search", "test", "--output=json"},
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
cm.API.On("DocsSearch", mock.Anything, "test", 20).Return(nil, slackerror.New(slackerror.ErrHTTPRequestFailed))
cm.API.On("DocsSearch", mock.Anything, "test", 20, "").Return(nil, slackerror.New(slackerror.ErrHTTPRequestFailed))
},
ExpectedErrorStrings: []string{slackerror.ErrHTTPRequestFailed},
},
"passes custom limit": {
CmdArgs: []string{"search", "test", "--limit=5"},
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
cm.API.On("DocsSearch", mock.Anything, "test", 5).Return(&api.DocsSearchResponse{
cm.API.On("DocsSearch", mock.Anything, "test", 5, "").Return(&api.DocsSearchResponse{
TotalResults: 0,
Results: []api.DocsSearchItem{},
Limit: 5,
}, nil)
},
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "test", 5)
cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "test", 5, "")
},
},
"joins multiple arguments into query": {
CmdArgs: []string{"search", "Block", "Kit", "Element"},
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
cm.API.On("DocsSearch", mock.Anything, "Block Kit Element", 20).Return(&api.DocsSearchResponse{
cm.API.On("DocsSearch", mock.Anything, "Block Kit Element", 20, "").Return(&api.DocsSearchResponse{
TotalResults: 0,
Results: []api.DocsSearchItem{},
Limit: 20,
}, nil)
},
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "Block Kit Element", 20)
cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "Block Kit Element", 20, "")
},
},
"passes category to API for text output": {
CmdArgs: []string{"search", "chat.postMessage", "--category=reference"},
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
cm.API.On("DocsSearch", mock.Anything, "chat.postMessage", 20, "reference").Return(&api.DocsSearchResponse{
TotalResults: 1,
Limit: 20,
Results: []api.DocsSearchItem{
{Title: "chat.postMessage", URL: "/reference/methods/chat.postMessage"},
},
}, nil)
},
ExpectedStdoutOutputs: []string{
`Displaying first 1 of 1 results for "chat.postMessage" in category "reference"`,
},
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "chat.postMessage", 20, "reference")
},
},
"includes category in zero results message": {
CmdArgs: []string{"search", "nonexistent", "--category=reference"},
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
cm.API.On("DocsSearch", mock.Anything, "nonexistent", 20, "reference").Return(&api.DocsSearchResponse{
TotalResults: 0,
Results: []api.DocsSearchItem{},
Limit: 20,
}, nil)
},
ExpectedStdoutOutputs: []string{
`Found zero results for "nonexistent" in category "reference"`,
},
},
"passes category to API for json output": {
CmdArgs: []string{"search", "events", "--category=reference", "--output=json"},
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
cm.API.On("DocsSearch", mock.Anything, "events", 20, "reference").Return(&api.DocsSearchResponse{
TotalResults: 0,
Results: []api.DocsSearchItem{},
Limit: 20,
}, nil)
},
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "events", 20, "reference")
},
},
"passes unknown category through to API": {
CmdArgs: []string{"search", "test", "--category=bogus"},
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
cm.API.On("DocsSearch", mock.Anything, "test", 20, "bogus").Return(&api.DocsSearchResponse{
TotalResults: 0,
Results: []api.DocsSearchItem{},
Limit: 20,
}, nil)
},
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "test", 20, "bogus")
},
},
Comment on lines +200 to +212

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌟 praise: Thanks for keeping this open as ongoing changes might happen! I find this error meaningful enough to move forward with, but might find revisiting it useful if feedback arrives:


$ slack docs search --category software terminal

🚫 HTTP request failed (http_request_failed)
   unexpected status code 400 returned from url https://docs.slack.dev/api/v1/search?query=terminal&limit=20&category=software

"opens browser with category filter": {
CmdArgs: []string{"search", "webhooks", "--category=reference", "--output=browser"},
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
cm.Browser.AssertCalled(t, "OpenURL", "https://docs.slack.dev/search/?filter=reference&q=webhooks")
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.DocsSearchSuccess, mock.Anything)
},
ExpectedOutputs: []string{
"https://docs.slack.dev/search/?filter=reference&q=webhooks",
},
},
"rejects invalid output format": {
Expand Down
13 changes: 10 additions & 3 deletions docs/reference/commands/slack_docs_search.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,20 @@ Search Slack developer docs
Search the Slack developer docs and return results in text, JSON, or browser
format.

Results can be filtered to a single category with the --category flag. Available
categories: guides, reference, changelog, python, javascript, java, slack_cli, slack_github_action, deno_slack_sdk, legacy.

```
slack docs search [query] [flags]
```

## Flags

```
-h, --help help for search
--limit int maximum number of text or json search results to return (default 20)
--output string output format: text, json, browser (default "text")
--category string filter results by category: guides, reference, changelog, python, javascript, java, slack_cli, slack_github_action, deno_slack_sdk, legacy
-h, --help help for search
--limit int maximum number of text or json search results to return (default 20)
--output string output format: text, json, browser (default "text")
```

## Global flags
Expand Down Expand Up @@ -45,6 +49,9 @@ $ slack docs search "webhooks" --output=browser

# Search docs with limited JSON results
$ slack docs search "api" --output=json --limit=5

# Search only the API reference docs
$ slack docs search "chat.postMessage" --category=reference
```

## See also
Expand Down
4 changes: 2 additions & 2 deletions internal/api/api_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -384,8 +384,8 @@ func (m *APIMock) DeveloperAppInstall(ctx context.Context, IO iostreams.IOStream

// DocsClient

func (m *APIMock) DocsSearch(ctx context.Context, query string, limit int) (*DocsSearchResponse, error) {
args := m.Called(ctx, query, limit)
func (m *APIMock) DocsSearch(ctx context.Context, query string, limit int, category string) (*DocsSearchResponse, error) {
args := m.Called(ctx, query, limit, category)
if args.Get(0) == nil {
return nil, args.Error(1)
}
Expand Down
27 changes: 23 additions & 4 deletions internal/api/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,24 @@ var docsBaseURL = "https://docs.slack.dev"

const docsSearchMethod = "api/v1/search"

// DocsSearchCategories lists the categories the docs search endpoint accepts,
// mirroring the filters offered by the docs site search modal. An empty
// category searches across all content.
var DocsSearchCategories = []string{
"guides",
"reference",
"changelog",
"python",
"javascript",
"java",
"slack_cli",
"slack_github_action",
"deno_slack_sdk",
"legacy",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new!

}
Comment thread
lukegalbraithrussell marked this conversation as resolved.

type DocsClient interface {
DocsSearch(ctx context.Context, query string, limit int) (*DocsSearchResponse, error)
DocsSearch(ctx context.Context, query string, limit int, category string) (*DocsSearchResponse, error)
}

type DocsSearchResponse struct {
Expand All @@ -45,8 +61,11 @@ type DocsSearchItem struct {
Title string `json:"title"`
}

func buildDocsSearchURL(baseURL, query string, limit int) (string, error) {
func buildDocsSearchURL(baseURL, query string, limit int, category string) (string, error) {
endpoint := fmt.Sprintf("%s?query=%s&limit=%d", docsSearchMethod, url.QueryEscape(query), limit)
if category != "" {
endpoint += fmt.Sprintf("&category=%s", url.QueryEscape(category))
}
sURL, err := url.Parse(baseURL + "/" + endpoint)
if err != nil {
return "", err
Expand All @@ -64,12 +83,12 @@ func buildDocsSearchRequest(ctx context.Context, urlStr, cliVersion string) (*ht
}

// DocsSearch searches the Slack developer docs API
func (c *Client) DocsSearch(ctx context.Context, query string, limit int) (*DocsSearchResponse, error) {
func (c *Client) DocsSearch(ctx context.Context, query string, limit int, category string) (*DocsSearchResponse, error) {
var span opentracing.Span
span, _ = opentracing.StartSpanFromContext(ctx, "apiclient.DocsSearch")
defer span.Finish()

urlStr, err := buildDocsSearchURL(docsBaseURL, query, limit)
urlStr, err := buildDocsSearchURL(docsBaseURL, query, limit, category)
if err != nil {
return nil, errHTTPRequestFailed.WithRootCause(err)
}
Expand Down
Loading
Loading