Make the home page's AS name search work (closes #151)
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> and lists the matches
as links to their AS pages. asinfo.Search ignores case, returns the first
matches up to a limit in AS number order, and counts all matches. An
empty q answers 400.

When the database has no record of an AS, its page and /api/v1/as/{asn}
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. AS 0 means no known origin: it always answers 404, and the search
never lists it.

Model: opus-5-5
This commit is contained in:
2026-10-26 21:20:12 +00:00
parent 71ee2aeda7
commit c360a82aa2
16 changed files with 886 additions and 85 deletions
+49 -23
View File
@@ -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,36 @@ 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
func Search(query string) []Info {
// Search finds the AS entries whose handle or description contains query,
// ignoring case. It returns the first limit of them in AS number order, and the
// number of entries that match. An empty query matches nothing.
func Search(query string, limit int) ([]Info, int) {
once.Do(initRegistry)
if query == "" {
return nil, 0
}
query = strings.ToLower(query)
defaultRegistry.mu.RLock()
defer defaultRegistry.mu.RUnlock()
var results []Info
var (
results []Info
total int
)
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) {
if total < limit {
results = append(results, *entry.info)
}
total++
}
}
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
return results, total
}
+103 -15
View File
@@ -1,6 +1,8 @@
package asinfo_test
import (
"cmp"
"slices"
"strings"
"testing"
@@ -140,11 +142,6 @@ func TestSearch(t *testing.T) {
query: "University",
wantMin: 10,
},
{
name: "Empty search",
query: "",
wantMin: 0,
},
{
name: "Non-existent org",
query: "XYZABC123456",
@@ -156,26 +153,115 @@ func TestSearch(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
results := asinfo.Search(tt.query)
results, _ := asinfo.Search(tt.query, asinfo.Total())
if len(results) < tt.wantMin {
t.Errorf("Search(%q) returned %d results, want at least %d",
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, total := asinfo.Search("", asinfo.Total())
if len(results) != 0 || total != 0 {
t.Errorf(`Search("") returned %d results and a total of %d, want none`,
len(results), total)
}
}
// 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."} {
results, _ := asinfo.Search(query, asinfo.Total())
if !slices.ContainsFunc(results, 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, asinfo.Total())
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)
}
}
}
// TestSearchStopsAtLimit searches for a word that matches more entries than
// the limit. It expects exactly limit entries, the first matches in AS number
// order, and the number of all matches, counted here from All.
func TestSearchStopsAtLimit(t *testing.T) {
t.Parallel()
const (
query = "university"
limit = 10
)
var matches []asinfo.Info
for _, info := range asinfo.All() {
if strings.Contains(strings.ToLower(info.Handle), query) ||
strings.Contains(strings.ToLower(info.Description), query) {
matches = append(matches, info)
}
}
if len(matches) <= limit {
t.Fatalf("%q matches %d entries; the test needs more than %d",
query, len(matches), limit)
}
slices.SortFunc(matches, func(a, b asinfo.Info) int {
return cmp.Compare(a.ASN, b.ASN)
})
results, total := asinfo.Search(query, limit)
if total != len(matches) {
t.Errorf("Search(%q, %d): total %d, want %d",
query, limit, total, len(matches))
}
if !slices.Equal(results, matches[:limit]) {
t.Errorf("Search(%q, %d) = %v, want %v",
query, limit, results, matches[:limit])
}
}
func TestDataIntegrity(t *testing.T) {
t.Parallel()
@@ -215,11 +301,13 @@ func BenchmarkGet(b *testing.B) {
}
func BenchmarkSearch(b *testing.B) {
const limit = 50
queries := []string{"Google", "Amazon", "Microsoft", "University"}
b.ResetTimer()
for i := range b.N {
asinfo.Search(queries[i%len(queries)])
asinfo.Search(queries[i%len(queries)], limit)
}
}
+4 -2
View File
@@ -16,8 +16,10 @@ 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, for the first 10
// matches in AS number order and the number of all matches
results, total := asinfo.Search("university", 10)
fmt.Printf("%d matches\n", total)
for _, as := range results {
fmt.Printf("AS%d: %s\n", as.ASN, as.Description)
}
+9 -2
View File
@@ -21,9 +21,16 @@ func ExampleGetDescription() {
}
func ExampleSearch() {
results := asinfo.Search("MIT-GATEWAY")
const limit = 10
results, total := asinfo.Search("mit-gateway", limit)
fmt.Printf("%d of %d matches\n", len(results), total)
for _, info := range results {
fmt.Printf("AS%d: %s - %s\n", info.ASN, info.Handle, info.Description)
}
// Output: AS3: MIT-GATEWAYS - Massachusetts Institute of Technology
// Output:
// 1 of 1 matches
// AS3: MIT-GATEWAYS - Massachusetts Institute of Technology
}