diff --git a/internal/search/platform.go b/internal/search/platform.go new file mode 100644 index 0000000..c29acf6 --- /dev/null +++ b/internal/search/platform.go @@ -0,0 +1,72 @@ +package search + +import ( + "context" + "fmt" + "time" + + glean "github.com/gleanwork/api-client-go" + "github.com/gleanwork/api-client-go/models/components" +) + +// datasourceField is the facet filter field that both API surfaces route to +// a dedicated datasource filter instead of the generic filter mechanism. +const datasourceField = "datasource" + +// BuildPlatformSearchRequest converts Options into the platform API's +// components.PlatformSearchRequest. The platform surface is deliberately +// narrower than the classic SearchRequest: flags without a platform +// equivalent are simply not mapped (cmd/search reports those to the user via +// flags.Changed, which knows what was explicitly set). +func BuildPlatformSearchRequest(opts *Options) components.PlatformSearchRequest { + req := components.PlatformSearchRequest{ + Query: opts.Query, + } + + if opts.PageSize > 0 { + pageSize := int64(opts.PageSize) + req.PageSize = &pageSize + } + if opts.Cursor != "" { + cursor := opts.Cursor + req.Cursor = &cursor + } + + if opts.RequestOptions != nil { + for _, ff := range opts.RequestOptions.FacetFilters { + // Mirror the classic builder: datasource filtering uses the + // dedicated field, not the generic filter mechanism. + if ff.FieldName == datasourceField { + for _, v := range ff.Values { + req.Datasources = append(req.Datasources, v.Value) + } + continue + } + filter := components.PlatformFilter{Field: ff.FieldName} + for _, v := range ff.Values { + filter.Values = append(filter.Values, v.Value) + } + req.Filters = append(req.Filters, filter) + } + } + + return req +} + +// RunPlatformSearch executes a search against the platform API +// (POST /api/search) and returns the raw platform response. The classic +// request carries timeoutMillis in the body; the platform API does not, so +// --timeout is honored via a context deadline instead. +func RunPlatformSearch(ctx context.Context, opts *Options, sdk *glean.Glean) (*components.PlatformSearchResponse, error) { + if opts.TimeoutMillis > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(opts.TimeoutMillis)*time.Millisecond) + defer cancel() + } + + result, err := sdk.Search.Query(ctx, BuildPlatformSearchRequest(opts)) + if err != nil { + return nil, fmt.Errorf("search request failed: %w", err) + } + return result.PlatformSearchResponse, nil +} diff --git a/internal/search/platform_test.go b/internal/search/platform_test.go new file mode 100644 index 0000000..074778f --- /dev/null +++ b/internal/search/platform_test.go @@ -0,0 +1,88 @@ +package search + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" + + glean "github.com/gleanwork/api-client-go" + "github.com/gleanwork/glean-cli/internal/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildPlatformSearchRequest_Basic(t *testing.T) { + opts := &Options{Query: "vacation policy", PageSize: 25} + req := BuildPlatformSearchRequest(opts) + + assert.Equal(t, "vacation policy", req.Query) + require.NotNil(t, req.PageSize) + assert.Equal(t, int64(25), *req.PageSize) + assert.Nil(t, req.Cursor) + assert.Empty(t, req.Datasources) + assert.Empty(t, req.Filters) +} + +func TestBuildPlatformSearchRequest_ZeroPageSizeOmitted(t *testing.T) { + req := BuildPlatformSearchRequest(&Options{Query: "q"}) + assert.Nil(t, req.PageSize, "unset page size defers to the API default") +} + +func TestBuildPlatformSearchRequest_Cursor(t *testing.T) { + req := BuildPlatformSearchRequest(&Options{Query: "q", Cursor: "next-page-token"}) + require.NotNil(t, req.Cursor) + assert.Equal(t, "next-page-token", *req.Cursor) +} + +func TestBuildPlatformSearchRequest_DatasourceUsesDedicatedField(t *testing.T) { + opts := &Options{Query: "q", RequestOptions: &RequestOptions{}} + AddFacetFilter(opts, "datasource", []string{"confluence", "slack"}) + AddFacetFilter(opts, "type", []string{"document"}) + + req := BuildPlatformSearchRequest(opts) + + assert.Equal(t, []string{"confluence", "slack"}, req.Datasources) + require.Len(t, req.Filters, 1) + assert.Equal(t, "type", req.Filters[0].Field) + assert.Equal(t, []string{"document"}, req.Filters[0].Values) +} + +func TestRunPlatformSearch_HitsPlatformEndpoint(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "results": []map[string]any{{ + "url": "https://example.com/doc", + "title": "Example Doc", + "datasource": "confluence", + "snippets": []string{"an excerpt"}, + }}, + "has_more": false, + "request_id": "req-123", + }) + require.NoError(t, err) + + mock := &testutils.MockTransport{Body: body, ContentType: "application/json"} + sdk := glean.New( + glean.WithServerURL("https://test-company-be.glean.com"), + glean.WithSecurity("test-token"), + glean.WithClient(mock), + ) + + resp, err := RunPlatformSearch(context.Background(), &Options{Query: "example", TimeoutMillis: 5000}, sdk) + require.NoError(t, err) + + require.Len(t, mock.Requests, 1) + sent := mock.Requests[0] + assert.Equal(t, "/api/search", sent.URL.Path) + assert.Equal(t, http.MethodPost, sent.Method) + + sentBody, err := io.ReadAll(sent.Body) + require.NoError(t, err) + assert.Contains(t, string(sentBody), `"query":"example"`) + + require.NotNil(t, resp) + require.Len(t, resp.Results, 1) + assert.Equal(t, "Example Doc", resp.Results[0].Title) + assert.Equal(t, "req-123", resp.RequestID) +} diff --git a/internal/search/utils.go b/internal/search/utils.go index 2ed281f..8c50963 100644 --- a/internal/search/utils.go +++ b/internal/search/utils.go @@ -65,7 +65,7 @@ func BuildSearchRequest(opts *Options) components.SearchRequest { // The Glean API has a dedicated DatasourcesFilter field that must // be used for datasource filtering — the generic FacetFilters // mechanism does not filter by datasource correctly. - if ff.FieldName == "datasource" { + if ff.FieldName == datasourceField { for _, v := range ff.Values { sdkOpts.DatasourcesFilter = append(sdkOpts.DatasourcesFilter, v.Value) }