Skip to content
Open
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
36 changes: 28 additions & 8 deletions cmd/docs/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,30 @@ import (
"encoding/json"
"fmt"
"net/url"
"slices"
"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 Down Expand Up @@ -67,6 +74,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 +87,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 @@ -85,9 +97,17 @@ func runDocsSearchCommand(clients *shared.ClientFactory, cmd *cobra.Command, arg

query := strings.Join(args, " ")

if cfg.category != "" && !slices.Contains(api.DocsSearchCategories, cfg.category) {
return slackerror.New(slackerror.ErrInvalidFlag).WithMessage(
"Invalid category: %s", cfg.category,
).WithRemediation(
"Use one of: %s", strings.Join(api.DocsSearchCategories, ", "),
)
}

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,7 +126,7 @@ 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
}
Expand Down Expand Up @@ -144,7 +164,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
65 changes: 55 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,86 @@ 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)
},
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "chat.postMessage", 20, "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")
},
},
"rejects invalid category": {
CmdArgs: []string{"search", "test", "--category=bogus"},
ExpectedErrorStrings: []string{
"Invalid category",
"reference",
},
},
"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
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
26 changes: 22 additions & 4 deletions internal/api/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,23 @@ 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",
}

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 +60,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 +82,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
37 changes: 35 additions & 2 deletions internal/api/docs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func Test_buildDocsSearchURL(t *testing.T) {
baseURL string
query string
limit int
category string
expectedURL string
expectedErrorContains string
}{
Expand All @@ -43,6 +44,20 @@ func Test_buildDocsSearchURL(t *testing.T) {
limit: 5,
expectedURL: "https://docs.slack.dev/api/v1/search?query=messages+%26+webhooks&limit=5",
},
"appends category when set": {
baseURL: "https://docs.slack.dev",
query: "chat.postMessage",
limit: 20,
category: "reference",
expectedURL: "https://docs.slack.dev/api/v1/search?query=chat.postMessage&limit=20&category=reference",
},
"omits category when empty": {
baseURL: "https://docs.slack.dev",
query: "test",
limit: 20,
category: "",
expectedURL: "https://docs.slack.dev/api/v1/search?query=test&limit=20",
},
"returns error for invalid base URL": {
baseURL: "ht!tp://invalid",
query: "test",
Expand All @@ -52,7 +67,7 @@ func Test_buildDocsSearchURL(t *testing.T) {
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
url, err := buildDocsSearchURL(tc.baseURL, tc.query, tc.limit)
url, err := buildDocsSearchURL(tc.baseURL, tc.query, tc.limit, tc.category)

if tc.expectedErrorContains != "" {
require.Error(t, err)
Expand Down Expand Up @@ -103,6 +118,7 @@ func Test_Client_DocsSearch(t *testing.T) {
tests := map[string]struct {
query string
limit int
category string
response string
statusCode int
expectedQuerystring string
Expand All @@ -129,6 +145,23 @@ func Test_Client_DocsSearch(t *testing.T) {
},
},
},
"includes category in querystring": {
query: "chat.postMessage",
limit: 20,
category: "reference",
response: `{"total_results":1,"limit":20,"results":[{"title":"chat.postMessage","url":"/reference/methods/chat.postMessage"}]}`,
expectedQuerystring: "query=chat.postMessage&limit=20&category=reference",
expectedResponse: &DocsSearchResponse{
TotalResults: 1,
Limit: 20,
Results: []DocsSearchItem{
{
Title: "chat.postMessage",
URL: "/reference/methods/chat.postMessage",
},
},
},
},
"returns empty results": {
query: "nonexistent",
limit: 20,
Expand Down Expand Up @@ -178,7 +211,7 @@ func Test_Client_DocsSearch(t *testing.T) {
docsBaseURL = c.Host()
defer func() { docsBaseURL = originalURL }()

result, err := c.DocsSearch(ctx, tc.query, tc.limit)
result, err := c.DocsSearch(ctx, tc.query, tc.limit, tc.category)

if tc.expectedErrorContains != "" {
require.Error(t, err)
Expand Down
Loading