Files
routewatch/pkg/asinfo/asinfo.go
T
sneak 50283d9e9e
check / check (push) Canceled after 0s
Make the home page's AS name search work (closes #151)
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
2026-10-26 20:21:14 +00:00

164 lines
3.2 KiB
Go

package asinfo
import (
"bytes"
"cmp"
"compress/gzip"
_ "embed"
"encoding/json"
"io"
"slices"
"strings"
"sync"
)
//go:embed asdata.json.gz
var asDataGZ []byte
// Info represents information about an Autonomous System
type Info struct {
ASN int `json:"asn"`
Handle string `json:"handle"`
Description string `json:"description"`
}
// Registry provides access to AS information
type Registry struct {
mu sync.RWMutex
byASN map[int]*Info
}
var (
//nolint:gochecknoglobals // Singleton pattern for embedded data
defaultRegistry *Registry
//nolint:gochecknoglobals // Singleton pattern for embedded data
once sync.Once
)
// initRegistry initializes the default registry with embedded data
func initRegistry() {
defaultRegistry = &Registry{
byASN: make(map[int]*Info),
}
// Decompress the gzipped data
gzReader, err := gzip.NewReader(bytes.NewReader(asDataGZ))
if err != nil {
panic("failed to create gzip reader: " + err.Error())
}
defer func() {
_ = gzReader.Close()
}()
jsonData, err := io.ReadAll(gzReader)
if err != nil {
panic("failed to decompress AS data: " + err.Error())
}
var asInfos []Info
err = json.Unmarshal(jsonData, &asInfos)
if err != nil {
panic("failed to unmarshal embedded AS data: " + err.Error())
}
for i := range asInfos {
info := &asInfos[i]
defaultRegistry.byASN[info.ASN] = info
}
}
// Get returns AS information for the given ASN
func Get(asn int) (*Info, bool) {
once.Do(initRegistry)
defaultRegistry.mu.RLock()
defer defaultRegistry.mu.RUnlock()
info, ok := defaultRegistry.byASN[asn]
if !ok {
return nil, false
}
// Return a copy to prevent mutation
infoCopy := *info
return &infoCopy, true
}
// GetDescription returns just the description for an ASN
func GetDescription(asn int) string {
info, ok := Get(asn)
if !ok {
return ""
}
return info.Description
}
// GetHandle returns just the handle for an ASN
func GetHandle(asn int) string {
info, ok := Get(asn)
if !ok {
return ""
}
return info.Handle
}
// Total returns the total number of AS entries in the registry
func Total() int {
once.Do(initRegistry)
defaultRegistry.mu.RLock()
defer defaultRegistry.mu.RUnlock()
return len(defaultRegistry.byASN)
}
// All returns all AS information as a slice
// Note: This creates a copy of all data, use sparingly
func All() []Info {
once.Do(initRegistry)
defaultRegistry.mu.RLock()
defer defaultRegistry.mu.RUnlock()
result := make([]Info, 0, len(defaultRegistry.byASN))
for _, info := range defaultRegistry.byASN {
result = append(result, *info)
}
return result
}
// 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 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
}