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 <noreply@anthropic.com>
This commit is contained in:
parent
90810cb934
commit
9b280ad606
3 changed files with 157 additions and 4 deletions
|
|
@ -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).
|
||||||
|
|
|
||||||
124
internal/autocomplete/service.go
Normal file
124
internal/autocomplete/service.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -10,10 +13,14 @@ 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)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue