From d5db9add98accde39aa4c1cfb24d790427aec9fb Mon Sep 17 00:00:00 2001 From: mae Date: Fri, 13 Mar 2026 20:32:19 -0500 Subject: [PATCH] cmd/pkgserver: search endpoint --- cmd/pkgserver/api.go | 43 +++++++++++++++++++++-- cmd/pkgserver/index.go | 6 ++-- cmd/pkgserver/main.go | 14 +++++++- cmd/pkgserver/search.go | 77 +++++++++++++++++++++++++++++++++++++++++ cmd/pkgserver/ui.go | 3 -- 5 files changed, 134 insertions(+), 9 deletions(-) create mode 100644 cmd/pkgserver/search.go diff --git a/cmd/pkgserver/api.go b/cmd/pkgserver/api.go index b6dfabc..afa37c2 100644 --- a/cmd/pkgserver/api.go +++ b/cmd/pkgserver/api.go @@ -4,6 +4,7 @@ import ( "encoding/json" "log" "net/http" + "net/url" "path" "strconv" "sync" @@ -104,9 +105,46 @@ func (index *packageIndex) handleGet(w http.ResponseWriter, r *http.Request) { values := index.sorts[sort][i:min(i+limit, len(index.sorts[sort]))] // TODO(mae): remove count field writeAPIPayload(w, &struct { - Count int `json:"count"` Values []*metadata `json:"values"` - }{len(values), values}) + }{values}) +} + +func (index *packageIndex) handleSearch(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + limit, err := strconv.Atoi(q.Get("limit")) + if err != nil || limit > 100 || limit < 1 { + http.Error( + w, "limit must be an integer between 1 and 100", + http.StatusBadRequest, + ) + return + } + i, err := strconv.Atoi(q.Get("index")) + if err != nil || i >= len(index.sorts[0]) || i < 0 { + http.Error( + w, "index must be an integer between 0 and "+ + strconv.Itoa(int(rosa.PresetUnexportedStart-1)), + http.StatusBadRequest, + ) + return + } + search, err := url.PathUnescape(q.Get("search")) + if len(search) > 100 || err != nil { + http.Error( + w, "search must be a string between 0 and 100 characters long", + http.StatusBadRequest, + ) + return + } + desc := q.Get("desc") == "true" + n, res, err := index.performSearchQuery(limit, i, search, desc) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + writeAPIPayload(w, &struct { + Count int `json:"count"` + Results []searchResult `json:"results"` + }{n, res}) } // apiVersion is the name of the current API revision, as part of the pattern. @@ -116,6 +154,7 @@ const apiVersion = "v1" func (index *packageIndex) registerAPI(mux *http.ServeMux) { mux.HandleFunc("GET /api/"+apiVersion+"/info", handleInfo) mux.HandleFunc("GET /api/"+apiVersion+"/get", index.handleGet) + mux.HandleFunc("GET /api/"+apiVersion+"/search", index.handleSearch) mux.HandleFunc("GET /api/"+apiVersion+"/status/", index.newStatusHandler(false)) mux.HandleFunc("GET /status/", index.newStatusHandler(true)) } diff --git a/cmd/pkgserver/index.go b/cmd/pkgserver/index.go index 9d2c5c8..1dcdd27 100644 --- a/cmd/pkgserver/index.go +++ b/cmd/pkgserver/index.go @@ -23,9 +23,9 @@ const ( // packageIndex refers to metadata by name and various sort orders. type packageIndex struct { - sorts [sortOrderEnd + 1][rosa.PresetUnexportedStart]*metadata - names map[string]*metadata - + sorts [sortOrderEnd + 1][rosa.PresetUnexportedStart]*metadata + names map[string]*metadata + search searchCache // Taken from [rosa.Report] if available. handleAccess func(*error) func() } diff --git a/cmd/pkgserver/main.go b/cmd/pkgserver/main.go index 9fa6d01..af59ff2 100644 --- a/cmd/pkgserver/main.go +++ b/cmd/pkgserver/main.go @@ -64,10 +64,22 @@ func main() { } var index packageIndex + index.search = make(searchCache) if err := index.populate(cache, report); err != nil { return err } - + ticker := time.NewTicker(1 * time.Minute) + go func() { + for { + select { + case <-ctx.Done(): + ticker.Stop() + return + case <-ticker.C: + index.search.clean() + } + } + }() var mux http.ServeMux uiRoutes(&mux) index.registerAPI(&mux) diff --git a/cmd/pkgserver/search.go b/cmd/pkgserver/search.go new file mode 100644 index 0000000..ba41628 --- /dev/null +++ b/cmd/pkgserver/search.go @@ -0,0 +1,77 @@ +package main + +import ( + "cmp" + "maps" + "regexp" + "slices" + "time" +) + +type searchCache map[string]searchCacheEntry +type searchResult struct { + NameIndices [][]int `json:"name_matches"` + DescIndices [][]int `json:"desc_matches,omitempty"` + Score float64 `json:"score"` + *metadata +} +type searchCacheEntry struct { + query string + results []searchResult + expiry time.Time +} + +func (index *packageIndex) performSearchQuery(limit int, i int, search string, desc bool) (int, []searchResult, error) { + entry, ok := index.search[search] + if ok { + return len(entry.results), entry.results[i:min(i+limit, len(entry.results))], nil + } + + regex, err := regexp.Compile(search) + if err != nil { + return 0, make([]searchResult, 0), err + } + res := make([]searchResult, 0) + for p := range maps.Values(index.names) { + nameIndices := regex.FindAllIndex([]byte(p.Name), -1) + var descIndices [][]int = nil + if desc { + descIndices = regex.FindAllIndex([]byte(p.Description), -1) + } + if nameIndices == nil && descIndices == nil { + continue + } + score := float64(indexsum(nameIndices)) / (float64(len(nameIndices)) + 1) + if desc { + score += float64(indexsum(descIndices)) / (float64(len(descIndices)) + 1) / 10.0 + } + res = append(res, searchResult{ + NameIndices: nameIndices, + DescIndices: descIndices, + Score: score, + metadata: p, + }) + } + slices.SortFunc(res[:], func(a, b searchResult) int { return -cmp.Compare(a.Score, b.Score) }) + expiry := time.Now().Add(1 * time.Minute) + entry = searchCacheEntry{ + query: search, + results: res, + expiry: expiry, + } + index.search[search] = entry + + return len(res), res[i:min(i+limit, len(entry.results))], nil +} +func (s *searchCache) clean() { + maps.DeleteFunc(*s, func(_ string, v searchCacheEntry) bool { + return v.expiry.Before(time.Now()) + }) +} +func indexsum(in [][]int) int { + sum := 0 + for i := 0; i < len(in); i++ { + sum += in[i][1] - in[i][0] + } + return sum +} diff --git a/cmd/pkgserver/ui.go b/cmd/pkgserver/ui.go index 4c87f22..e4c4283 100644 --- a/cmd/pkgserver/ui.go +++ b/cmd/pkgserver/ui.go @@ -21,13 +21,10 @@ func serveStaticContent(w http.ResponseWriter, r *http.Request) { } else { http.ServeFileFS(w, r, content, "ui/static/light.css") } - break case "/favicon.ico": http.ServeFileFS(w, r, content, "ui/static/favicon.ico") - break case "/static/index.js": http.ServeFileFS(w, r, content, "ui/static/index.js") - break default: http.NotFound(w, r)