Make the home page's AS name search work (closes #151)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
The AS Name Search card on the home page said the search was coming
soon. It now asks the new GET /api/v1/search/as?q=<text>, which
searches the AS list built into asinfo, and lists the matches as links
to their AS pages. An empty q answers 400. asinfo.Search now ignores
case and returns its matches in AS number order. asinfo puts each
handle and description in lower case once, when the list loads.
A match can be an AS that the database has no record of. Its AS page
and /api/v1/as/{asn} then take its handle and description from asinfo
and still list its prefixes. They answer 404 only for an AS with no
record, no asinfo entry and no prefix. Empty prefix lists in that JSON
answer are [] rather than null.
Model: opus-5-5
This commit is contained in:
+38
-20
@@ -2,10 +2,13 @@ package asinfo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"compress/gzip"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
@@ -23,6 +26,17 @@ type Info struct {
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
byASN map[int]*Info
|
||||
// searchEntries holds every entry in AS number order, for Search.
|
||||
searchEntries []searchEntry
|
||||
}
|
||||
|
||||
// searchEntry is an entry with its handle and description in lower case. They
|
||||
// are put in lower case once, when the data loads, so that Search only
|
||||
// compares.
|
||||
type searchEntry struct {
|
||||
info *Info
|
||||
lowerHandle string
|
||||
lowerDescription string
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -59,9 +73,21 @@ func initRegistry() {
|
||||
panic("failed to unmarshal embedded AS data: " + err.Error())
|
||||
}
|
||||
|
||||
slices.SortFunc(asInfos, func(a, b Info) int {
|
||||
return cmp.Compare(a.ASN, b.ASN)
|
||||
})
|
||||
|
||||
defaultRegistry.searchEntries = make([]searchEntry, 0, len(asInfos))
|
||||
|
||||
for i := range asInfos {
|
||||
info := &asInfos[i]
|
||||
defaultRegistry.byASN[info.ASN] = info
|
||||
defaultRegistry.searchEntries = append(defaultRegistry.searchEntries,
|
||||
searchEntry{
|
||||
info: info,
|
||||
lowerHandle: strings.ToLower(info.Handle),
|
||||
lowerDescription: strings.ToLower(info.Description),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,36 +155,28 @@ func All() []Info {
|
||||
return result
|
||||
}
|
||||
|
||||
// Search searches for AS entries where the handle or description contains the query
|
||||
// This is a simple case-sensitive substring search
|
||||
// Search returns the AS entries whose handle or description contains query,
|
||||
// ignoring case, in AS number order. An empty query matches nothing.
|
||||
func Search(query string) []Info {
|
||||
once.Do(initRegistry)
|
||||
|
||||
if query == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
query = strings.ToLower(query)
|
||||
|
||||
defaultRegistry.mu.RLock()
|
||||
defer defaultRegistry.mu.RUnlock()
|
||||
|
||||
var results []Info
|
||||
|
||||
for _, info := range defaultRegistry.byASN {
|
||||
if contains(info.Handle, query) || contains(info.Description, query) {
|
||||
results = append(results, *info)
|
||||
for _, entry := range defaultRegistry.searchEntries {
|
||||
if strings.Contains(entry.lowerHandle, query) ||
|
||||
strings.Contains(entry.lowerDescription, query) {
|
||||
results = append(results, *entry.info)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// contains is a simple substring search
|
||||
func contains(s, substr string) bool {
|
||||
return len(substr) > 0 && len(s) >= len(substr) && containsImpl(s, substr)
|
||||
}
|
||||
|
||||
func containsImpl(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
+54
-13
@@ -1,6 +1,7 @@
|
||||
package asinfo_test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -140,11 +141,6 @@ func TestSearch(t *testing.T) {
|
||||
query: "University",
|
||||
wantMin: 10,
|
||||
},
|
||||
{
|
||||
name: "Empty search",
|
||||
query: "",
|
||||
wantMin: 0,
|
||||
},
|
||||
{
|
||||
name: "Non-existent org",
|
||||
query: "XYZABC123456",
|
||||
@@ -162,20 +158,65 @@ func TestSearch(t *testing.T) {
|
||||
tt.query, len(results), tt.wantMin)
|
||||
}
|
||||
|
||||
// Verify all results contain the query
|
||||
if tt.query != "" {
|
||||
for _, r := range results {
|
||||
if !strings.Contains(r.Handle, tt.query) &&
|
||||
!strings.Contains(r.Description, tt.query) {
|
||||
t.Errorf("Search result ASN %d doesn't contain query %q",
|
||||
r.ASN, tt.query)
|
||||
}
|
||||
// Verify all results contain the query, ignoring case
|
||||
query := strings.ToLower(tt.query)
|
||||
for _, r := range results {
|
||||
if !strings.Contains(strings.ToLower(r.Handle), query) &&
|
||||
!strings.Contains(strings.ToLower(r.Description), query) {
|
||||
t.Errorf("Search result ASN %d doesn't contain query %q",
|
||||
r.ASN, tt.query)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchEmptyQueryMatchesNothing expects an empty query to match no AS,
|
||||
// although every handle contains the empty string.
|
||||
func TestSearchEmptyQueryMatchesNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
results := asinfo.Search("")
|
||||
if len(results) != 0 {
|
||||
t.Errorf(`Search("") returned %d results, want none`, len(results))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchIgnoresCase searches for Cloudflare's handle in lower case and its
|
||||
// description in upper case, and expects both searches to find AS13335.
|
||||
func TestSearchIgnoresCase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const cloudflareASN = 13335 // handle CLOUDFLARENET, "Cloudflare, Inc."
|
||||
|
||||
isCloudflare := func(info asinfo.Info) bool {
|
||||
return info.ASN == cloudflareASN
|
||||
}
|
||||
|
||||
for _, query := range []string{"cloudflarenet", "CLOUDFLARE, INC."} {
|
||||
if !slices.ContainsFunc(asinfo.Search(query), isCloudflare) {
|
||||
t.Errorf("Search(%q) did not find AS%d", query, cloudflareASN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchOrdersByASN expects the matches of a search with many of them to
|
||||
// come in AS number order.
|
||||
func TestSearchOrdersByASN(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const query = "University"
|
||||
|
||||
results := asinfo.Search(query)
|
||||
|
||||
for i := 1; i < len(results); i++ {
|
||||
if results[i-1].ASN >= results[i].ASN {
|
||||
t.Fatalf("Search(%q): AS%d comes after AS%d",
|
||||
query, results[i].ASN, results[i-1].ASN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataIntegrity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+3
-2
@@ -16,8 +16,9 @@ Basic usage:
|
||||
// Get just the description
|
||||
desc := asinfo.GetDescription(15169) // "Google LLC"
|
||||
|
||||
// Search for AS entries
|
||||
results := asinfo.Search("University")
|
||||
// Search the handles and descriptions, ignoring case; the matches come
|
||||
// in AS number order
|
||||
results := asinfo.Search("university")
|
||||
for _, as := range results {
|
||||
fmt.Printf("AS%d: %s\n", as.ASN, as.Description)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func ExampleGetDescription() {
|
||||
}
|
||||
|
||||
func ExampleSearch() {
|
||||
results := asinfo.Search("MIT-GATEWAY")
|
||||
results := asinfo.Search("mit-gateway")
|
||||
for _, info := range results {
|
||||
fmt.Printf("AS%d: %s - %s\n", info.ASN, info.Handle, info.Description)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user