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. An empty q answers 400. asinfo.Search now ignores
case and returns its matches in AS number order.

A match can be an AS that the database does not hold yet. Its AS page
and /api/v1/as/{asn} now answer with its handle and description from
asinfo and no prefixes, instead of 404. Empty prefix lists in that JSON
answer are [] rather than null.

Model: opus-5-5
This commit is contained in:
2026-10-26 20:39:30 +00:00
parent 71ee2aeda7
commit ca68b8f605
15 changed files with 622 additions and 67 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
}
+54 -13
View File
@@ -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
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)
}