// Package handler wires up the public, read-only OpenGoods HTTP API. // The OpenGoods service is a public-good product information API: it only // collects and serves product facts. It exposes no purchase, checkout, or // commerce endpoints by design. package handler import ( "encoding/json" "errors" "io/fs" "net/http" "strconv" "strings" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/baicai2026-baicai/goods/api/internal/store" ) // APIVersion is the current public API version prefix. const APIVersion = "v1" const ( defaultPageSize = 20 maxPageSize = 100 ) // Handler holds dependencies shared by the HTTP routes. type Handler struct { store *store.Store spa fs.FS } // New constructs a Handler backed by the given store. spa may be nil (JSON-only). func New(s *store.Store, spa fs.FS) *Handler { return &Handler{store: s, spa: spa} } // Router builds the top-level HTTP handler with middleware and routes mounted. func (h *Handler) Router() http.Handler { r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(middleware.Recoverer) r.Get("/healthz", h.Healthz) r.Route("/api/"+APIVersion, func(r chi.Router) { r.Route("/products", func(r chi.Router) { r.Get("/barcode/{gtin}", h.ProductByBarcode) r.Get("/search", h.SearchProducts) r.Get("/{id}", h.ProductByID) r.Get("/{id}/nutriments", h.ProductNutriments) r.Get("/{id}/msrp", h.ProductMSRP) }) r.Get("/brands", h.ListBrands) r.Get("/categories", h.ListCategories) r.Get("/sources/{id}", h.SourceByID) }) // Public SPA (homepage + search + contribute). API routes above take // precedence; everything else falls back to the embedded single-page app. if h.spa != nil { r.Handle("/*", http.HandlerFunc(h.serveSPA)) } return r } func (h *Handler) serveSPA(w http.ResponseWriter, r *http.Request) { rel := strings.TrimPrefix(r.URL.Path, "/") if rel == "" { rel = "index.html" } if f, err := h.spa.Open(rel); err == nil { f.Close() http.FileServer(http.FS(h.spa)).ServeHTTP(w, r) return } // SPA fallback: serve index.html for client-side routes. data, err := fs.ReadFile(h.spa, "index.html") if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(data) } // Healthz reports liveness of the service. func (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } // ProductByBarcode returns a product by its GTIN. func (h *Handler) ProductByBarcode(w http.ResponseWriter, r *http.Request) { p, err := h.store.ProductByGTIN(r.Context(), chi.URLParam(r, "gtin")) if h.handleErr(w, r, err) { return } writeJSON(w, http.StatusOK, p) } // ProductByID returns a product by its UUID. func (h *Handler) ProductByID(w http.ResponseWriter, r *http.Request) { p, err := h.store.ProductByID(r.Context(), chi.URLParam(r, "id")) if h.handleErr(w, r, err) { return } writeJSON(w, http.StatusOK, p) } // SearchProducts runs a fuzzy name search with optional category filter + paging. func (h *Handler) SearchProducts(w http.ResponseWriter, r *http.Request) { q := r.URL.Query().Get("q") category := r.URL.Query().Get("category") page, size := pageParams(r) items, total, err := h.store.SearchProducts(r.Context(), q, category, size, (page-1)*size) if h.handleErr(w, r, err) { return } writeJSON(w, http.StatusOK, map[string]any{ "items": items, "page": page, "size": size, "total": total, }) } // ProductNutriments returns just the nutrition facts of a product. func (h *Handler) ProductNutriments(w http.ResponseWriter, r *http.Request) { n, err := h.store.Nutriments(r.Context(), chi.URLParam(r, "id")) if h.handleErr(w, r, err) { return } writeJSON(w, http.StatusOK, n) } // ProductMSRP returns official suggested retail price snapshots (no purchase link). func (h *Handler) ProductMSRP(w http.ResponseWriter, r *http.Request) { items, err := h.store.ListMSRP(r.Context(), chi.URLParam(r, "id")) if h.handleErr(w, r, err) { return } writeJSON(w, http.StatusOK, map[string]any{ "items": items, "disclaimer": "厂商建议零售价历史快照,仅供参考,不构成购买建议,本服务不提供任何购买入口。", }) } // ListBrands returns a paginated list of brands. func (h *Handler) ListBrands(w http.ResponseWriter, r *http.Request) { page, size := pageParams(r) items, total, err := h.store.ListBrands(r.Context(), size, (page-1)*size) if h.handleErr(w, r, err) { return } writeJSON(w, http.StatusOK, map[string]any{ "items": items, "page": page, "size": size, "total": total, }) } // ListCategories returns the full category tree. func (h *Handler) ListCategories(w http.ResponseWriter, r *http.Request) { items, err := h.store.ListCategories(r.Context()) if h.handleErr(w, r, err) { return } writeJSON(w, http.StatusOK, map[string]any{"items": items}) } // SourceByID returns a single data source. func (h *Handler) SourceByID(w http.ResponseWriter, r *http.Request) { src, err := h.store.SourceByID(r.Context(), chi.URLParam(r, "id")) if h.handleErr(w, r, err) { return } writeJSON(w, http.StatusOK, src) } // handleErr writes an appropriate error response; returns true if it handled one. func (h *Handler) handleErr(w http.ResponseWriter, r *http.Request, err error) bool { if err == nil { return false } if errors.Is(err, store.ErrNotFound) { writeError(w, r, http.StatusNotFound, "not_found", "resource not found") return true } writeError(w, r, http.StatusInternalServerError, "internal_error", "internal server error") return true } func pageParams(r *http.Request) (page, size int) { page = atoiDefault(r.URL.Query().Get("page"), 1) if page < 1 { page = 1 } size = atoiDefault(r.URL.Query().Get("size"), defaultPageSize) if size < 1 { size = defaultPageSize } if size > maxPageSize { size = maxPageSize } return page, size } func atoiDefault(s string, fallback int) int { if s == "" { return fallback } v, err := strconv.Atoi(s) if err != nil { return fallback } return v } func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(body) } func writeError(w http.ResponseWriter, r *http.Request, status int, code, message string) { writeJSON(w, status, map[string]any{ "error": map[string]string{ "code": code, "message": message, "request_id": middleware.GetReqID(r.Context()), }, }) }