feat(search+docs): trigram fuzzy search, brand/country filters, developer docs
Search: - migration 0009: trigram GIN index on brand.name + btree on country_of_origin - SearchProducts: typo-tolerant word_similarity matching (>=0.42) on top of ILIKE substring + barcode; new brand/country filters; rank by similarity * (0.5 + quality_score). Response gains country_of_origin, quality_score and per-result relevance score. - public search UI: brand/country filter inputs; show country in results Docs: - serve embedded OpenAPI 3 spec at GET /api/v1/openapi.json (not rate limited) - ApiDocs page: auth + rate-limit section, updated search params/response - docs/api.md developer guide Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
@@ -19,6 +20,9 @@ import (
|
||||
"github.com/baicai2026-baicai/goods/api/internal/store"
|
||||
)
|
||||
|
||||
//go:embed openapi.json
|
||||
var openAPISpec []byte
|
||||
|
||||
// APIVersion is the current public API version prefix.
|
||||
const APIVersion = "v1"
|
||||
|
||||
@@ -66,17 +70,22 @@ func (h *Handler) Router() http.Handler {
|
||||
r.Get("/healthz", h.Healthz)
|
||||
|
||||
r.Route("/api/"+APIVersion, func(r chi.Router) {
|
||||
r.Use(h.rateLimit)
|
||||
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)
|
||||
// Machine-readable spec; not rate limited so tooling can always fetch it.
|
||||
r.Get("/openapi.json", h.OpenAPI)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(h.rateLimit)
|
||||
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)
|
||||
})
|
||||
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
|
||||
@@ -113,6 +122,12 @@ func (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// OpenAPI serves the embedded OpenAPI 3 specification for the public API.
|
||||
func (h *Handler) OpenAPI(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write(openAPISpec)
|
||||
}
|
||||
|
||||
// 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"))
|
||||
@@ -131,13 +146,19 @@ func (h *Handler) ProductByID(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
// SearchProducts runs a fuzzy name search with optional category filter + paging.
|
||||
// SearchProducts runs a trigram-fuzzy name search with optional
|
||||
// category/brand/country filters, ranked by relevance, plus paging.
|
||||
func (h *Handler) SearchProducts(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
category := r.URL.Query().Get("category")
|
||||
qv := r.URL.Query()
|
||||
filters := store.SearchFilters{
|
||||
Query: strings.TrimSpace(qv.Get("q")),
|
||||
Category: strings.TrimSpace(qv.Get("category")),
|
||||
Brand: strings.TrimSpace(qv.Get("brand")),
|
||||
Country: strings.TrimSpace(qv.Get("country")),
|
||||
}
|
||||
page, size := pageParams(r)
|
||||
|
||||
items, total, err := h.store.SearchProducts(r.Context(), q, category, size, (page-1)*size)
|
||||
items, total, err := h.store.SearchProducts(r.Context(), filters, size, (page-1)*size)
|
||||
if h.handleErr(w, r, err) {
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user