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. 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:
@@ -56,8 +56,8 @@ Every route answers `HEAD` as it answers `GET`, without the body.
|
||||
|
||||
### Web Interface
|
||||
|
||||
- `GET /` - Home page (HTML): headline figures, and lookups by AS number and by
|
||||
IP address
|
||||
- `GET /` - Home page (HTML): headline figures, lookups by AS number and by IP
|
||||
address, and a search of AS names
|
||||
- `GET /status` - HTML status dashboard, which reads `/api/v1/stats`
|
||||
- `GET /status.json` - JSON statistics: feed, memory, database and route figures
|
||||
- `GET /.well-known/healthcheck.json` - Health check (JSON): 200 when the
|
||||
@@ -96,6 +96,18 @@ Every route answers `HEAD` as it answers `GET`, without the body.
|
||||
apart, each with when its newest route was updated
|
||||
- `GET /api/v1/prefix/{prefix}/{length}` - Every live route for the prefix, one
|
||||
per peer, and the same routes grouped by origin AS in `origins`
|
||||
- `GET /api/v1/search/as?q={text}` - Finds the ASes whose handle or description
|
||||
contains `{text}`, ignoring case. `data` holds:
|
||||
- `query`: `{text}` without its leading and trailing spaces
|
||||
- `total`: the number of ASes that match
|
||||
- `results`: the first 50 of them in AS number order, each with `asn`,
|
||||
`handle` and `description`
|
||||
|
||||
The search reads the AS list built into RouteWatch, taken from
|
||||
[ipverse/asn-info](https://github.com/ipverse/asn-info), not the database. A
|
||||
match can therefore be an AS that the database does not hold. Its
|
||||
`/as/{asn}` and `/api/v1/as/{asn}` then answer 404. A `q` that is missing,
|
||||
empty or only spaces answers 400.
|
||||
|
||||
A JSON answer is `{"status": "ok", "data": ...}`, or for an error
|
||||
`{"status": "error", "error": {"msg": ..., "code": ...}}`, and carries an
|
||||
|
||||
@@ -33,6 +33,9 @@ tested.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-26: the home page's AS name search lists the ASes whose handle or
|
||||
description holds the text, as links to their pages, from the new
|
||||
`/api/v1/search/as`; the search ignores case (closes #151)
|
||||
- 2026-10-19: README "HTTP Endpoints" lists every route with what it answers,
|
||||
"Database Schema" matches `schema.sql`, and the rest of the README matches the
|
||||
code; this file brought up to date (closes #72)
|
||||
|
||||
@@ -56,10 +56,18 @@ const (
|
||||
// Error messages an answer sends, in place of the error behind it where there
|
||||
// is one.
|
||||
const (
|
||||
invalidPrefixMessage = "Invalid prefix"
|
||||
hostNotResolvedMessage = "could not resolve host name"
|
||||
hostNotPublicMessage = "host name has no public address"
|
||||
tooManyTargetsMessage = "Too many addresses or host names"
|
||||
invalidPrefixMessage = "Invalid prefix"
|
||||
hostNotResolvedMessage = "could not resolve host name"
|
||||
hostNotPublicMessage = "host name has no public address"
|
||||
tooManyTargetsMessage = "Too many addresses or host names"
|
||||
searchQueryMissingMessage = "Missing search query"
|
||||
)
|
||||
|
||||
// The AS name search reads the text it looks for from the URL query parameter
|
||||
// asSearchQueryParam, and answers with at most maxASSearchResults matches.
|
||||
const (
|
||||
asSearchQueryParam = "q"
|
||||
maxASSearchResults = 50
|
||||
)
|
||||
|
||||
// IP versions as database.LiveRoute.IPVersion records them.
|
||||
@@ -1105,6 +1113,46 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// ASSearchResponse is the answer to an AS name search: the text searched for,
|
||||
// the number of ASes that match it, and the first maxASSearchResults of them in
|
||||
// AS number order.
|
||||
type ASSearchResponse struct {
|
||||
Query string `json:"query"`
|
||||
Total int `json:"total"`
|
||||
Results []asinfo.Info `json:"results"`
|
||||
}
|
||||
|
||||
// handleASSearch returns a handler that searches the AS list built into
|
||||
// asinfo for the text in the q parameter, without its leading and trailing
|
||||
// spaces. It answers 400 when no text is left.
|
||||
func (s *Server) handleASSearch() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
query := strings.TrimSpace(r.URL.Query().Get(asSearchQueryParam))
|
||||
if query == "" {
|
||||
s.writeJSONError(w, http.StatusBadRequest, searchQueryMissingMessage)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
matches := asinfo.Search(query)
|
||||
|
||||
// results is an empty list rather than nil when nothing matches, so
|
||||
// that the answer holds [] and not null.
|
||||
shown := min(len(matches), maxASSearchResults)
|
||||
results := make([]asinfo.Info, 0, shown)
|
||||
results = append(results, matches[:shown]...)
|
||||
|
||||
err := writeJSONSuccess(w, ASSearchResponse{
|
||||
Query: query,
|
||||
Total: len(matches),
|
||||
Results: results,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to encode AS search results", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleASDetail returns a handler that serves the AS detail HTML page
|
||||
func (s *Server) handleASDetail() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -1166,6 +1166,125 @@ func TestIPLookupHidesPrivateAddresses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// asSearchPath is the path of the AS name search.
|
||||
const asSearchPath = "/api/v1/search/as"
|
||||
|
||||
// searchAS sends an AS name search with the URL query rawQuery through s's
|
||||
// router, expects a 200 answer, and returns its data.
|
||||
func searchAS(t *testing.T, s *Server, rawQuery string) ASSearchResponse {
|
||||
t.Helper()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, asSearchPath+"?"+rawQuery, nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s: status %d, want %d; body %s",
|
||||
rawQuery, rec.Code, http.StatusOK, rec.Body)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Data ASSearchResponse `json:"data"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: decoding the answer %q: %v", rawQuery, rec.Body, err)
|
||||
}
|
||||
|
||||
return body.Data
|
||||
}
|
||||
|
||||
// TestASSearchAnswersMatches searches for Cloudflare's handle in lower case,
|
||||
// with spaces around it, and expects AS13335 among the matches. Before the fix
|
||||
// there was no AS name search, and the home page's search card said it was
|
||||
// coming soon (https://git.eeqj.de/sneak/routewatch/issues/151).
|
||||
func TestASSearchAnswersMatches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const query = "cloudflarenet"
|
||||
|
||||
data := searchAS(t, newServerWithTestRoutes(t), "q=%20"+query+"%20")
|
||||
|
||||
if data.Query != query {
|
||||
t.Errorf("query %q, want %q", data.Query, query)
|
||||
}
|
||||
|
||||
if data.Total != len(data.Results) {
|
||||
t.Errorf("total %d, but %d results", data.Total, len(data.Results))
|
||||
}
|
||||
|
||||
cloudflare := asinfo.Info{
|
||||
ASN: 13335, Handle: "CLOUDFLARENET", Description: "Cloudflare, Inc.",
|
||||
}
|
||||
if !slices.Contains(data.Results, cloudflare) {
|
||||
t.Errorf("results %v do not hold %v", data.Results, cloudflare)
|
||||
}
|
||||
}
|
||||
|
||||
// TestASSearchAnswersEmptyListWhenNothingMatches expects a search that matches
|
||||
// nothing to answer 200 with a total of 0 and an empty list, not null.
|
||||
func TestASSearchAnswersEmptyListWhenNothingMatches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := searchAS(t, newServerWithTestRoutes(t), "q=XYZABC123456")
|
||||
|
||||
if data.Total != 0 || data.Results == nil || len(data.Results) != 0 {
|
||||
t.Errorf("total %d and results %#v, want 0 and an empty list",
|
||||
data.Total, data.Results)
|
||||
}
|
||||
}
|
||||
|
||||
// TestASSearchAnswers400ForEmptyQuery expects a search with no q, an empty q
|
||||
// or a q of only spaces to answer 400 with the JSON error body.
|
||||
func TestASSearchAnswers400ForEmptyQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newServerWithTestRoutes(t)
|
||||
|
||||
for _, path := range []string{
|
||||
asSearchPath, asSearchPath + "?q=", asSearchPath + "?q=%20%20",
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, path, nil))
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s: status %d, want %d; body %s",
|
||||
path, rec.Code, http.StatusBadRequest, rec.Body)
|
||||
}
|
||||
|
||||
checkJSONErrorCode(t, path, rec, http.StatusBadRequest)
|
||||
checkJSONErrorMessage(t, path, rec, searchQueryMissingMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestASSearchShowsFirstMatchesAndTotal searches for a word with more matches
|
||||
// than an answer holds, and expects the first maxASSearchResults of them, in
|
||||
// AS number order, and the number of all of them.
|
||||
func TestASSearchShowsFirstMatchesAndTotal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const query = "university"
|
||||
|
||||
all := asinfo.Search(query)
|
||||
if len(all) <= maxASSearchResults {
|
||||
t.Fatalf("Search(%q) has %d matches; the test needs more than %d",
|
||||
query, len(all), maxASSearchResults)
|
||||
}
|
||||
|
||||
data := searchAS(t, newServerWithTestRoutes(t), "q="+query)
|
||||
|
||||
if data.Total != len(all) {
|
||||
t.Errorf("total %d, want %d", data.Total, len(all))
|
||||
}
|
||||
|
||||
if !slices.Equal(data.Results, all[:maxASSearchResults]) {
|
||||
t.Errorf("results %v, want the first %d matches %v",
|
||||
data.Results, maxASSearchResults, all[:maxASSearchResults])
|
||||
}
|
||||
}
|
||||
|
||||
// settledGoroutineCount lets transient goroutines finish, then reports the
|
||||
// current count.
|
||||
func settledGoroutineCount() int {
|
||||
|
||||
@@ -87,6 +87,7 @@ func (s *Server) setupRoutes() {
|
||||
r.Get("/ip/{ip}", s.handleIPLookup())
|
||||
r.Get("/as/{asn}", s.handleASDetailJSON())
|
||||
r.Get("/prefix/{prefix}/{len}", s.handlePrefixDetailJSON())
|
||||
r.Get("/search/as", s.handleASSearch())
|
||||
})
|
||||
|
||||
s.router = r
|
||||
|
||||
+101
-11
@@ -174,6 +174,38 @@
|
||||
#asname-results .search-message {
|
||||
font-size: 13px;
|
||||
}
|
||||
#asname-results .error {
|
||||
color: #c00;
|
||||
}
|
||||
.asname-list {
|
||||
list-style: none;
|
||||
margin: 12px 0 0 0;
|
||||
padding: 0;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.asname-list li {
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.asname-list a {
|
||||
display: block;
|
||||
padding: 8px 4px;
|
||||
color: #2c3e50;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
.asname-list a:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.asname-list .asn {
|
||||
font-weight: 600;
|
||||
color: #3498db;
|
||||
}
|
||||
.asname-list .description {
|
||||
display: block;
|
||||
color: #7f8c8d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* IP Lookup result */
|
||||
.ip-result {
|
||||
@@ -306,7 +338,7 @@
|
||||
<input type="text" id="asname-input" placeholder="e.g., Google, Cloudflare" autocomplete="off">
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
<p class="search-hint">Search for autonomous systems by organization name</p>
|
||||
<p class="search-hint">Search for autonomous systems by organization name or handle</p>
|
||||
<div id="asname-results"></div>
|
||||
</div>
|
||||
|
||||
@@ -387,24 +419,82 @@
|
||||
}
|
||||
});
|
||||
|
||||
// AS name search
|
||||
// AS name search. The handles and descriptions come from outside data,
|
||||
// so the results are built with textContent, never innerHTML.
|
||||
const asNameResults = document.getElementById('asname-results');
|
||||
|
||||
// Replace the search results with one line of text.
|
||||
function showASNameMessage(text, className) {
|
||||
const line = document.createElement('p');
|
||||
line.className = className;
|
||||
line.textContent = text;
|
||||
asNameResults.replaceChildren(line);
|
||||
}
|
||||
|
||||
// List the matches of a search answer, each as a link to its AS page,
|
||||
// and say how many more matched than the answer holds.
|
||||
function showASNameMatches(data) {
|
||||
if (data.total === 0) {
|
||||
showASNameMessage('No AS matches "' + data.query + '".', 'search-message');
|
||||
return;
|
||||
}
|
||||
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'asname-list';
|
||||
for (const match of data.results) {
|
||||
const asn = document.createElement('span');
|
||||
asn.className = 'asn';
|
||||
asn.textContent = 'AS' + match.asn;
|
||||
|
||||
const description = document.createElement('span');
|
||||
description.className = 'description';
|
||||
description.textContent = match.description;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = '/as/' + match.asn;
|
||||
link.append(asn, ' ' + match.handle, description);
|
||||
|
||||
const item = document.createElement('li');
|
||||
item.append(link);
|
||||
list.append(item);
|
||||
}
|
||||
asNameResults.replaceChildren(list);
|
||||
|
||||
const more = data.total - data.results.length;
|
||||
if (more > 0) {
|
||||
const line = document.createElement('p');
|
||||
line.className = 'search-message';
|
||||
line.textContent = more.toLocaleString() + ' more matched. Narrow the search to see them.';
|
||||
asNameResults.append(line);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('asname-form').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const query = document.getElementById('asname-input').value.trim();
|
||||
if (!query) return;
|
||||
|
||||
const resultsDiv = document.getElementById('asname-results');
|
||||
resultsDiv.innerHTML = '<p class="loading">Searching...</p>';
|
||||
|
||||
// Use a simple client-side search against the asinfo data
|
||||
// For now, redirect to AS page if it looks like an ASN
|
||||
if (/^\d+$/.test(query)) {
|
||||
window.location.href = '/as/' + query;
|
||||
// A number, or AS and a number, opens that AS's page
|
||||
const asn = query.replace(/^AS/i, '');
|
||||
if (/^\d+$/.test(asn)) {
|
||||
window.location.href = '/as/' + asn;
|
||||
return;
|
||||
}
|
||||
|
||||
// Show a message that server-side search is coming
|
||||
resultsDiv.innerHTML = '<p class="search-message">AS name search coming soon. For now, try an AS number.</p>';
|
||||
showASNameMessage('Searching...', 'loading');
|
||||
|
||||
fetch('/api/v1/search/as?q=' + encodeURIComponent(query))
|
||||
.then(response => response.json())
|
||||
.then(response => {
|
||||
if (response.status === 'error') {
|
||||
showASNameMessage('Error: ' + response.error.msg, 'error');
|
||||
} else {
|
||||
showASNameMatches(response.data);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showASNameMessage('Error: ' + error.message, 'error');
|
||||
});
|
||||
});
|
||||
|
||||
// IP lookup
|
||||
|
||||
+17
-18
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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