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>, which
searches the AS list built into asinfo, and lists the matches as links
to their AS pages. The answer holds the query, the number of matches
and the first 50 of them; an empty q answers 400. asinfo.Search now
ignores case, uses strings.Contains instead of its own substring loop,
and returns its matches in AS number order. A number, or AS and a
number, typed into the card still opens that AS's page. The card builds
the list with textContent, since the names come from outside data.

Model: opus-5-5
This commit is contained in:
2026-10-26 20:21:14 +00:00
parent 71ee2aeda7
commit 50283d9e9e
10 changed files with 354 additions and 46 deletions
+17 -18
View File
@@ -2,10 +2,13 @@ package asinfo
import (
"bytes"
"cmp"
"compress/gzip"
_ "embed"
"encoding/json"
"io"
"slices"
"strings"
"sync"
)
@@ -129,36 +132,32 @@ 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) {
if strings.Contains(strings.ToLower(info.Handle), query) ||
strings.Contains(strings.ToLower(info.Description), query) {
results = append(results, *info)
}
}
slices.SortFunc(results, func(a, b Info) int {
return cmp.Compare(a.ASN, b.ASN)
})
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
}
+43 -8
View File
@@ -1,6 +1,7 @@
package asinfo_test
import (
"slices"
"strings"
"testing"
@@ -162,20 +163,54 @@ 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)
}
}
})
}
}
// 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
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}