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 }