homelabby/internal/api/router.go
Mikkel Georgsen 9db7707a64 feat(07-02): SearchHandler — NL query to NetBox filter with Tier 1 LLM
- internal/api/handlers/search.go: SearchHandler, NewSearchHandler, SearchDevices
- Sanitizes query (non-printable stripped, 200 char max) per T-07-05
- LLM extracts catalog_status/name_contains/tag; falls back to substring on parse failure
- internal/api/handlers/search_test.go: 4 tests covering 400, fallback, status filter, combined
- internal/api/router.go: wires GET /api/search with nil guard (503)
- cmd/hwlab/main.go: constructs searchHandler and passes to NewRouter
2026-04-10 07:55:07 +00:00

109 lines
3.7 KiB
Go

package api
import (
"io/fs"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"git.georgsen.dk/hwlab/internal/advisor"
"git.georgsen.dk/hwlab/internal/api/handlers"
)
// spaHandler serves static files and falls back to index.html for unknown paths,
// enabling client-side routing in the React SPA.
type spaHandler struct {
staticFS fs.FS
}
func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Try to open the requested path in the embedded FS.
f, err := h.staticFS.Open(r.URL.Path)
if err != nil {
// File not found — serve index.html so the SPA router handles it.
r2 := r.Clone(r.Context())
r2.URL.Path = "/"
http.FileServer(http.FS(h.staticFS)).ServeHTTP(w, r2)
return
}
f.Close()
http.FileServer(http.FS(h.staticFS)).ServeHTTP(w, r)
}
// NewRouter creates the chi router. staticFiles is the fs.FS rooted at web/dist,
// passed from main.go where the go:embed directive lives.
// intakeHandler handles POST /api/intake (multipart photo upload).
// inventoryHandler handles GET /api/inventory and GET /api/inventory/{id}.
// labelHandler handles POST /api/labels/:deviceID/print.
// usbEventsHandler handles GET /api/usb/events (SSE stream).
// testHandler handles POST /api/test/cable, GET /api/test/events, GET /api/test/recent.
// advisorHandler handles POST /api/advisor/chat, GET /api/advisor/conversations, GET /api/advisor/conversations/{id}.
// researchHandler handles POST /api/research/trigger.
// searchHandler handles GET /api/search?q=...
func NewRouter(
staticFiles fs.FS,
intakeHandler http.Handler,
inventoryHandler *handlers.InventoryHandler,
labelHandler *handlers.LabelHandler,
usbEventsHandler *handlers.USBEventsHandler,
testHandler *handlers.TestHandler,
advisorHandler *advisor.AdvisorHandler,
researchHandler *handlers.ResearchHandler,
searchHandler *handlers.SearchHandler,
) http.Handler {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RealIP)
r.Route("/api", func(r chi.Router) {
r.Get("/health", handlers.Health)
r.Post("/intake", intakeHandler.ServeHTTP)
r.Get("/inventory", inventoryHandler.ListInventory)
r.Get("/inventory/{id}", inventoryHandler.GetInventoryItem)
r.Post("/labels/{deviceID}/print", labelHandler.PrintLabel)
r.Get("/usb/events", usbEventsHandler.ServeEvents)
r.Post("/test/cable", testHandler.SubmitCableTest)
r.Get("/test/events", testHandler.StreamEvents)
r.Get("/test/recent", testHandler.RecentTests)
r.Route("/advisor", func(r chi.Router) {
if advisorHandler != nil {
r.Post("/chat", advisorHandler.StreamChat)
r.Get("/conversations", advisorHandler.GetConversations)
r.Get("/conversations/{id}", advisorHandler.GetConversation)
} else {
unavailable := func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "advisor unavailable: database not configured", http.StatusServiceUnavailable)
}
r.Post("/chat", unavailable)
r.Get("/conversations", unavailable)
r.Get("/conversations/{id}", unavailable)
}
})
r.Route("/research", func(r chi.Router) {
if researchHandler != nil {
r.Post("/trigger", researchHandler.TriggerResearch)
} else {
r.Post("/trigger", func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "research unavailable", http.StatusServiceUnavailable)
})
}
})
if searchHandler != nil {
r.Get("/search", searchHandler.SearchDevices)
} else {
r.Get("/search", func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "search unavailable", http.StatusServiceUnavailable)
})
}
})
// SPA fallback — serve static files; unknown paths fall back to index.html.
r.Handle("/*", spaHandler{staticFS: staticFiles})
return r
}