cmd/pkgserver: search endpoint
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"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]))]
|
values := index.sorts[sort][i:min(i+limit, len(index.sorts[sort]))]
|
||||||
// TODO(mae): remove count field
|
// TODO(mae): remove count field
|
||||||
writeAPIPayload(w, &struct {
|
writeAPIPayload(w, &struct {
|
||||||
Count int `json:"count"`
|
|
||||||
Values []*metadata `json:"values"`
|
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.
|
// 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) {
|
func (index *packageIndex) registerAPI(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /api/"+apiVersion+"/info", handleInfo)
|
mux.HandleFunc("GET /api/"+apiVersion+"/info", handleInfo)
|
||||||
mux.HandleFunc("GET /api/"+apiVersion+"/get", index.handleGet)
|
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 /api/"+apiVersion+"/status/", index.newStatusHandler(false))
|
||||||
mux.HandleFunc("GET /status/", index.newStatusHandler(true))
|
mux.HandleFunc("GET /status/", index.newStatusHandler(true))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ const (
|
|||||||
|
|
||||||
// packageIndex refers to metadata by name and various sort orders.
|
// packageIndex refers to metadata by name and various sort orders.
|
||||||
type packageIndex struct {
|
type packageIndex struct {
|
||||||
sorts [sortOrderEnd + 1][rosa.PresetUnexportedStart]*metadata
|
sorts [sortOrderEnd + 1][rosa.PresetUnexportedStart]*metadata
|
||||||
names map[string]*metadata
|
names map[string]*metadata
|
||||||
|
search searchCache
|
||||||
// Taken from [rosa.Report] if available.
|
// Taken from [rosa.Report] if available.
|
||||||
handleAccess func(*error) func()
|
handleAccess func(*error) func()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,10 +64,22 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var index packageIndex
|
var index packageIndex
|
||||||
|
index.search = make(searchCache)
|
||||||
if err := index.populate(cache, report); err != nil {
|
if err := index.populate(cache, report); err != nil {
|
||||||
return err
|
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
|
var mux http.ServeMux
|
||||||
uiRoutes(&mux)
|
uiRoutes(&mux)
|
||||||
index.registerAPI(&mux)
|
index.registerAPI(&mux)
|
||||||
|
|||||||
77
cmd/pkgserver/search.go
Normal file
77
cmd/pkgserver/search.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -21,13 +21,10 @@ func serveStaticContent(w http.ResponseWriter, r *http.Request) {
|
|||||||
} else {
|
} else {
|
||||||
http.ServeFileFS(w, r, content, "ui/static/light.css")
|
http.ServeFileFS(w, r, content, "ui/static/light.css")
|
||||||
}
|
}
|
||||||
break
|
|
||||||
case "/favicon.ico":
|
case "/favicon.ico":
|
||||||
http.ServeFileFS(w, r, content, "ui/static/favicon.ico")
|
http.ServeFileFS(w, r, content, "ui/static/favicon.ico")
|
||||||
break
|
|
||||||
case "/static/index.js":
|
case "/static/index.js":
|
||||||
http.ServeFileFS(w, r, content, "ui/static/index.js")
|
http.ServeFileFS(w, r, content, "ui/static/index.js")
|
||||||
break
|
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user