From 9b280ad60622a6c81363008ac52329f200e5f69f Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 01:06:25 +0100 Subject: [PATCH] feat: add /autocompleter endpoint for search suggestions Proxies to upstream SearXNG /autocompleter if configured, otherwise falls back to Wikipedia OpenSearch API. Returns a JSON array of suggestion strings. Co-Authored-By: Claude Opus 4.6 --- cmd/searxng-go/main.go | 6 +- internal/autocomplete/service.go | 124 +++++++++++++++++++++++++++++++ internal/httpapi/handlers.go | 31 +++++++- 3 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 internal/autocomplete/service.go diff --git a/cmd/searxng-go/main.go b/cmd/searxng-go/main.go index de3b98c..dac6258 100644 --- a/cmd/searxng-go/main.go +++ b/cmd/searxng-go/main.go @@ -9,6 +9,7 @@ import ( "net/http" "os" + "github.com/metamorphosis-dev/kafka/internal/autocomplete" "github.com/metamorphosis-dev/kafka/internal/cache" "github.com/metamorphosis-dev/kafka/internal/config" "github.com/metamorphosis-dev/kafka/internal/httpapi" @@ -57,12 +58,15 @@ func main() { Cache: searchCache, }) - h := httpapi.NewHandler(svc) + acSvc := autocomplete.NewService(cfg.Upstream.URL, cfg.HTTPTimeout()) + + h := httpapi.NewHandler(svc, acSvc.Suggestions) mux := http.NewServeMux() mux.HandleFunc("/", h.Index) mux.HandleFunc("/healthz", h.Healthz) mux.HandleFunc("/search", h.Search) + mux.HandleFunc("/autocompleter", h.Autocompleter) mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL)) // Serve embedded static files (CSS, JS, images). diff --git a/internal/autocomplete/service.go b/internal/autocomplete/service.go new file mode 100644 index 0000000..3892d63 --- /dev/null +++ b/internal/autocomplete/service.go @@ -0,0 +1,124 @@ +package autocomplete + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Service fetches search suggestions from an upstream SearXNG instance +// or falls back to Wikipedia's OpenSearch API. +type Service struct { + upstreamURL string + http *http.Client +} + +func NewService(upstreamURL string, timeout time.Duration) *Service { + if timeout <= 0 { + timeout = 5 * time.Second + } + return &Service{ + upstreamURL: strings.TrimRight(upstreamURL, "/"), + http: &http.Client{Timeout: timeout}, + } +} + +// Suggestions returns search suggestions for the given query. +func (s *Service) Suggestions(ctx context.Context, query string) ([]string, error) { + if strings.TrimSpace(query) == "" { + return nil, nil + } + + if s.upstreamURL != "" { + return s.upstreamSuggestions(ctx, query) + } + return s.wikipediaSuggestions(ctx, query) +} + +// upstreamSuggestions proxies to an upstream SearXNG /autocompleter endpoint. +func (s *Service) upstreamSuggestions(ctx context.Context, query string) ([]string, error) { + u := s.upstreamURL + "/autocompleter?" + url.Values{"q": {query}}.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + + resp, err := s.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errors.New("upstream autocompleter failed") + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if err != nil { + return nil, err + } + + // SearXNG /autocompleter returns a plain JSON array of strings. + var out []string + if err := json.Unmarshal(body, &out); err != nil { + return nil, err + } + return out, nil +} + +// wikipediaSuggestions fetches suggestions from Wikipedia's OpenSearch API. +func (s *Service) wikipediaSuggestions(ctx context.Context, query string) ([]string, error) { + u := "https://en.wikipedia.org/w/api.php?" + url.Values{ + "action": {"opensearch"}, + "format": {"json"}, + "formatversion": {"2"}, + "search": {query}, + "namespace": {"0"}, + "limit": {"10"}, + }.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header.Set( + "User-Agent", + "gosearch-go/0.1 (compatible; +https://github.com/metamorphosis-dev/kafka)", + ) + + resp, err := s.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errors.New("wikipedia opensearch failed") + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if err != nil { + return nil, err + } + + // Wikipedia OpenSearch returns: [query, [suggestions], ...] + var data []json.RawMessage + if err := json.Unmarshal(body, &data); err != nil { + return nil, err + } + if len(data) < 2 { + return nil, nil + } + + var suggestions []string + if err := json.Unmarshal(data[1], &suggestions); err != nil { + return nil, err + } + return suggestions, nil +} diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index 21ad335..837a336 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -1,7 +1,10 @@ package httpapi import ( + "context" + "encoding/json" "net/http" + "strings" "github.com/metamorphosis-dev/kafka/internal/contracts" "github.com/metamorphosis-dev/kafka/internal/search" @@ -9,11 +12,15 @@ import ( ) type Handler struct { - searchSvc *search.Service + searchSvc *search.Service + autocompleteSvc func(ctx context.Context, query string) ([]string, error) } -func NewHandler(searchSvc *search.Service) *Handler { - return &Handler{searchSvc: searchSvc} +func NewHandler(searchSvc *search.Service, autocompleteSuggestions func(ctx context.Context, query string) ([]string, error)) *Handler { + return &Handler{ + searchSvc: searchSvc, + autocompleteSvc: autocompleteSuggestions, + } } func (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) { @@ -96,3 +103,21 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) } } + +// Autocompleter returns search suggestions for the given query. +func (h *Handler) Autocompleter(w http.ResponseWriter, r *http.Request) { + query := strings.TrimSpace(r.FormValue("q")) + if query == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + + suggestions, err := h.autocompleteSvc(r.Context(), query) + if err != nil { + // Return empty list on error rather than an error status. + suggestions = []string{} + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(suggestions) +}