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
30 changes: 27 additions & 3 deletions cmd/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"encoding/json"
"fmt"
"strings"

"github.com/gleanwork/api-client-go/models/components"
gleanClient "github.com/gleanwork/glean-cli/internal/client"
Expand All @@ -23,6 +24,7 @@ func NewCmdSearch() *cobra.Command {
var outputFormat string
var dryRun bool
var fields string
var raw bool

cmd := &cobra.Command{
Use: "search [query]",
Expand Down Expand Up @@ -61,7 +63,14 @@ Example:
if err != nil {
return fmt.Errorf("search request failed: %w", err)
}
return output.WriteFormatted(cmd.OutOrStdout(), resp.SearchResponse, outputFormat, nil)
var result any = resp.SearchResponse
if !raw {
result, err = output.CleanseSearchResponse(result)
if err != nil {
return err
}
}
return output.WriteFormatted(cmd.OutOrStdout(), result, outputFormat, nil)
}

// flag-based path
Expand Down Expand Up @@ -111,17 +120,32 @@ Example:
if err != nil {
return err
}
var result any = resp
if !raw {
result, err = output.CleanseSearchResponse(result)
if err != nil {
return err
}
}
if fields != "" {
return output.ProjectFields(cmd.OutOrStdout(), resp, fields)
if !raw {
if stripped := output.WarnStrippedFields(fields); len(stripped) > 0 {
fmt.Fprintf(cmd.ErrOrStderr(),
"Warning: field(s) %s not available in cleansed output (use --raw for the full response)\n",
strings.Join(stripped, ", "))
}
}
return output.ProjectFields(cmd.OutOrStdout(), result, fields)
}
return output.WriteFormatted(cmd.OutOrStdout(), resp, outputFormat, nil)
return output.WriteFormatted(cmd.OutOrStdout(), result, outputFormat, nil)
},
}

cmd.Flags().StringVar(&jsonPayload, "json", "", "Complete JSON request body (overrides all other flags)")
cmd.Flags().StringVar(&outputFormat, "output", "json", "Output format: json, ndjson, or text")
cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated dot-path fields to include (e.g. results.document.title,results.document.url). Results where all projected fields are missing appear as {}")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Print the request body without sending it")
cmd.Flags().BoolVar(&raw, "raw", false, "Output the full SDK response without cleansing")
cmd.Flags().IntVar(&opts.PageSize, "page-size", 10, "Number of results per page")
cmd.Flags().IntVar(&opts.MaxSnippetSize, "max-snippet-size", 0, "Maximum size of snippets")
cmd.Flags().IntVar(&opts.TimeoutMillis, "timeout", 30000, "Request timeout in milliseconds")
Expand Down
185 changes: 185 additions & 0 deletions internal/output/cleanse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package output

import (
"encoding/json"
"fmt"
"strings"
)

// CleanseSearchResponse strips UI-specific fields from a search response,
// keeping only the fields relevant to programmatic consumers.
//
// This is a stopgap until POST /api/search ships (see RFC: Search Data
// Retrieval API). Delete this file and its call sites once the new API
// is available.
func CleanseSearchResponse(resp any) (any, error) {
data, err := json.Marshal(resp)
if err != nil {
return nil, fmt.Errorf("cleanse marshal: %w", err)
}

var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("cleanse unmarshal: %w", err)
}

result := filterMap(raw, responseAllowlist)

if results, ok := result["results"].([]any); ok {
result["results"] = filterEmptyResults(results)
}

return result, nil
}

type allowlist map[string]allowlist

var responseAllowlist = allowlist{
"results": resultAllowlist,
"cursor": nil,
"hasMoreResults": nil,
"requestID": nil,
}

var resultAllowlist = allowlist{
"title": nil,
"url": nil,
"snippets": snippetAllowlist,
"document": documentAllowlist,
}

var documentAllowlist = allowlist{
"title": nil,
"url": nil,
"datasource": nil,
"docType": nil,
"metadata": metadataAllowlist,
}

var metadataAllowlist = allowlist{
"datasource": nil,
"objectType": nil,
"author": personAllowlist,
"updateTime": nil,
"createTime": nil,
}

var personAllowlist = allowlist{
"name": nil,
"email": nil,
}

var snippetAllowlist = allowlist{
"snippet": nil,
"mimeType": nil,
}

// WarnStrippedFields checks whether any of the requested --fields paths
// were removed by cleansing. Returns a list of field paths that don't
// exist in the allowlist.
//
// Stopgap — delete with cleanse.go when POST /api/search ships.
func WarnStrippedFields(fields string) []string {
if fields == "" {
return nil
}
var stripped []string
for _, f := range strings.Split(fields, ",") {
f = strings.TrimSpace(f)
if f == "" {
continue
}
if !isAllowedPath(f, responseAllowlist) {
stripped = append(stripped, f)
}
}
return stripped
}

// isAllowedPath checks whether a dot-separated field path exists in the allowlist tree.
// A path is allowed if every segment resolves in the allowlist. A nil allowlist value
// at any point means "keep everything below here", so all deeper paths are allowed.
func isAllowedPath(path string, al allowlist) bool {
parts := strings.SplitN(path, ".", 2)
key := parts[0]

childAL, ok := al[key]
if !ok {
return false
}
if len(parts) == 1 {
return true
}
// nil allowlist = keep everything below → any sub-path is valid
if childAL == nil {
return true
}
return isAllowedPath(parts[1], childAL)
}

// filterMap recursively keeps only keys present in the allowlist.
// If the allowlist value for a key is nil, the entire value is kept as-is.
// If the allowlist value is a nested allowlist, the value is filtered recursively.
func filterMap(m map[string]any, al allowlist) map[string]any {
out := make(map[string]any, len(al))
for key, childAL := range al {
val, ok := m[key]
if !ok {
continue
}
if childAL == nil {
out[key] = val
continue
}
switch v := val.(type) {
case map[string]any:
out[key] = filterMap(v, childAL)
case []any:
out[key] = filterSlice(v, childAL)
default:
out[key] = val
}
}
return out
}

// filterEmptyResults removes results that have no meaningful content after cleansing.
// Structured results from the SDK (e.g. knowledge cards) cleanse down to just {"url": ""}
// since they have no document, title, or snippets.
func filterEmptyResults(results []any) []any {
out := make([]any, 0, len(results))
for _, r := range results {
m, ok := r.(map[string]any)
if !ok {
out = append(out, r)
continue
}
if _, hasDoc := m["document"]; hasDoc {
out = append(out, r)
continue
}
if title, _ := m["title"].(*string); title != nil {
out = append(out, r)
continue
}
if title, ok := m["title"].(string); ok && title != "" {
out = append(out, r)
continue
}
// No document and no title — skip this empty result
}
return out
}

// filterSlice applies the allowlist to each element in a slice.
func filterSlice(s []any, al allowlist) []any {
out := make([]any, len(s))
for i, elem := range s {
if m, ok := elem.(map[string]any); ok {
out[i] = filterMap(m, al)
} else {
out[i] = elem
}
}
return out
}
Loading
Loading