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. asinfo lowers each
handle and description once, when the list loads.
A match can be an AS that the database has no record of. Its AS page
and /api/v1/as/{asn} then 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. Empty prefix lists in that JSON
answer are [] rather than null.
Model: opus-5-5
182 lines
3.8 KiB
Go
182 lines
3.8 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
|
|
// 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 lowered once, when the data loads, so that Search only compares.
|
|
type searchEntry struct {
|
|
info *Info
|
|
lowerHandle string
|
|
lowerDescription string
|
|
}
|
|
|
|
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())
|
|
}
|
|
|
|
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),
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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 _, entry := range defaultRegistry.searchEntries {
|
|
if strings.Contains(entry.lowerHandle, query) ||
|
|
strings.Contains(entry.lowerDescription, query) {
|
|
results = append(results, *entry.info)
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|