feat: add /autocompleter endpoint for search suggestions
Some checks failed
Mirror to GitHub / mirror (push) Waiting to run
Tests / test (push) Waiting to run
Build and Push Docker Image / build-and-push (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
ashisgreat22 2026-03-22 01:06:25 +01:00
parent 90810cb934
commit 9b280ad606
3 changed files with 157 additions and 4 deletions

View file

@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"os" "os"
"github.com/metamorphosis-dev/kafka/internal/autocomplete"
"github.com/metamorphosis-dev/kafka/internal/cache" "github.com/metamorphosis-dev/kafka/internal/cache"
"github.com/metamorphosis-dev/kafka/internal/config" "github.com/metamorphosis-dev/kafka/internal/config"
"github.com/metamorphosis-dev/kafka/internal/httpapi" "github.com/metamorphosis-dev/kafka/internal/httpapi"
@ -57,12 +58,15 @@ func main() {
Cache: searchCache, Cache: searchCache,
}) })
h := httpapi.NewHandler(svc) acSvc := autocomplete.NewService(cfg.Upstream.URL, cfg.HTTPTimeout())
h := httpapi.NewHandler(svc, acSvc.Suggestions)
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/", h.Index) mux.HandleFunc("/", h.Index)
mux.HandleFunc("/healthz", h.Healthz) mux.HandleFunc("/healthz", h.Healthz)
mux.HandleFunc("/search", h.Search) mux.HandleFunc("/search", h.Search)
mux.HandleFunc("/autocompleter", h.Autocompleter)
mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL)) mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL))
// Serve embedded static files (CSS, JS, images). // Serve embedded static files (CSS, JS, images).

View file

@ -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
}

View file

@ -1,7 +1,10 @@
package httpapi package httpapi
import ( import (
"context"
"encoding/json"
"net/http" "net/http"
"strings"
"github.com/metamorphosis-dev/kafka/internal/contracts" "github.com/metamorphosis-dev/kafka/internal/contracts"
"github.com/metamorphosis-dev/kafka/internal/search" "github.com/metamorphosis-dev/kafka/internal/search"
@ -9,11 +12,15 @@ import (
) )
type Handler struct { type Handler struct {
searchSvc *search.Service searchSvc *search.Service
autocompleteSvc func(ctx context.Context, query string) ([]string, error)
} }
func NewHandler(searchSvc *search.Service) *Handler { func NewHandler(searchSvc *search.Service, autocompleteSuggestions func(ctx context.Context, query string) ([]string, error)) *Handler {
return &Handler{searchSvc: searchSvc} return &Handler{
searchSvc: searchSvc,
autocompleteSvc: autocompleteSuggestions,
}
} }
func (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) { 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) 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)
}