managarten/services/mana-search/internal/handler/search.go
Till JS d3d11e661d feat(apps): create Hono compute servers for Traces, Planta, NutriPhi
Add lightweight Hono + Bun servers for server-only compute endpoints.
CRUD is handled by mana-sync, these handle AI + file upload only.

Traces: AI guide generation, location sync (Port 3026)
Planta: Photo upload (S3), AI plant analysis (Port 3022)
NutriPhi: AI meal analysis (photo+text), recommendations (Port 3023)

Each uses @manacore/shared-hono for auth/health/errors. ~100-200 LOC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:16:57 +01:00

160 lines
4.3 KiB
Go

package handler
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/manacore/shared-go/httputil"
"sort"
"time"
"github.com/manacore/mana-search/internal/cache"
"github.com/manacore/mana-search/internal/config"
"github.com/manacore/mana-search/internal/metrics"
"github.com/manacore/mana-search/internal/search"
)
type SearchHandler struct {
provider *search.SearxngProvider
cache *cache.Cache
metrics *metrics.Metrics
cfg *config.Config
}
func NewSearchHandler(provider *search.SearxngProvider, c *cache.Cache, m *metrics.Metrics, cfg *config.Config) *SearchHandler {
return &SearchHandler{
provider: provider,
cache: c,
metrics: m,
cfg: cfg,
}
}
// Search handles POST /api/v1/search
func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) {
start := time.Now()
h.metrics.ActiveSearches.Inc()
defer h.metrics.ActiveSearches.Dec()
var req search.SearchRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
httputil.WriteError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Query == "" {
httputil.WriteError(w, http.StatusBadRequest, "query is required")
return
}
// Validate options
if req.Options != nil {
if req.Options.Limit < 0 || req.Options.Limit > 50 {
httputil.WriteError(w, http.StatusBadRequest, "limit must be between 1 and 50")
return
}
if req.Options.SafeSearch < 0 || req.Options.SafeSearch > 2 {
httputil.WriteError(w, http.StatusBadRequest, "safeSearch must be 0, 1, or 2")
return
}
}
cacheKey := search.BuildCacheKey(&req)
// Check cache
if req.Cache.IsEnabled() {
if data, ok := h.cache.Get(r.Context(), cacheKey); ok {
var cached search.SearchResponse
if err := json.Unmarshal(data, &cached); err == nil {
cached.Meta.Cached = true
cached.Meta.CacheKey = cacheKey
duration := time.Since(start).Seconds()
h.metrics.RecordRequest("search", "200", duration)
httputil.WriteJSON(w, http.StatusOK, cached)
return
}
}
}
// Query SearXNG
results, err := h.provider.Search(r.Context(), &req)
if err != nil {
slog.Error("search failed", "error", err, "query", req.Query)
duration := time.Since(start).Seconds()
h.metrics.RecordRequest("search", "502", duration)
httputil.WriteError(w, http.StatusBadGateway, err.Error())
return
}
// Collect unique engines (sorted for deterministic cache keys)
engineSet := make(map[string]bool)
for _, res := range results {
engineSet[res.Engine] = true
}
engines := make([]string, 0, len(engineSet))
for e := range engineSet {
engines = append(engines, e)
}
sort.Strings(engines)
resp := search.SearchResponse{
Results: results,
Meta: search.SearchMeta{
Query: req.Query,
TotalResults: len(results),
Engines: engines,
Duration: time.Since(start).Milliseconds(),
Cached: false,
CacheKey: cacheKey,
},
}
// Cache result
if req.Cache.IsEnabled() {
ttl := time.Duration(h.cfg.CacheSearchTTL) * time.Second
if req.Cache != nil && req.Cache.TTL > 0 {
ttl = time.Duration(req.Cache.TTL) * time.Second
}
h.cache.Set(r.Context(), cacheKey, resp, ttl)
}
duration := time.Since(start).Seconds()
h.metrics.RecordRequest("search", "200", duration)
httputil.WriteJSON(w, http.StatusOK, resp)
}
// Engines handles GET /api/v1/search/engines
func (h *SearchHandler) Engines(w http.ResponseWriter, r *http.Request) {
engines := h.provider.GetEngines(r.Context())
httputil.WriteJSON(w, http.StatusOK, map[string]any{"engines": engines})
}
// Health handles GET /api/v1/search/health
func (h *SearchHandler) Health(w http.ResponseWriter, r *http.Request) {
sxStatus, sxLatency := h.provider.HealthCheck(r.Context())
redisHealth := h.cache.HealthCheck(r.Context())
cacheStats := h.cache.Stats()
httputil.WriteJSON(w, http.StatusOK, map[string]any{
"searxng": map[string]any{
"status": sxStatus,
"latency": sxLatency,
},
"redis": redisHealth,
"cache": cacheStats,
})
}
// ClearCache handles DELETE /api/v1/search/cache
func (h *SearchHandler) ClearCache(w http.ResponseWriter, r *http.Request) {
deleted, err := h.cache.Clear(r.Context())
if err != nil {
httputil.WriteError(w, http.StatusInternalServerError, "failed to clear cache")
return
}
httputil.WriteJSON(w, http.StatusOK, map[string]any{
"cleared": true,
"keysRemoved": deleted,
})
}