mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-15 18:19:57 +00:00
release: v2.0.4 (#217)
* perf(routes): batch cross-IATA and hash route search (#216) One search fired a /routes/cross request per IATA pair (138k on 2026-10-08). Route search now takes an optional iatas list and runs a fixed number of index-backed queries. /info advertises built-in minimum client versions. * chore: bump version to 2.0.4
This commit is contained in:
+4
-2
@@ -45,7 +45,7 @@ import (
|
||||
var version = "dev"
|
||||
|
||||
// @title MeshCore Beacon API
|
||||
// @version 2.0.3
|
||||
// @version 2.0.4
|
||||
// @description MeshCore network observation backend. Ingests LoRa packets from MQTT brokers, stores in PostgreSQL, and streams live events via WebSocket.
|
||||
// @description REST requests share a configurable per-client rate limit (default 300/minute and a 300-request one-second burst cap). Exceeded limits return HTTP 429 with error.code=rate_limited and a Retry-After header in seconds. CORS preflights and WebSocket upgrades do not consume this API budget.
|
||||
// @description WebSocket upgrade attempts at /ws have a configurable per-client limit (default 10/minute, including failed handshakes). Rate exhaustion returns HTTP 429 with Retry-After before upgrade. An accepted socket exceeding the concurrent cap closes with code 1013 before hello; established connections remain open.
|
||||
@@ -434,7 +434,9 @@ func main() {
|
||||
WSAllowedOrigins: cfg.WebSocket.AllowedOrigins,
|
||||
CORS: cfg.CORS, Server: cfg.Server, Auth: cfg.Auth, RateLimit: resolved.RateLimit,
|
||||
Scopes: scopes, RollupRetention: resolved.RollupRetention,
|
||||
MinAppVersion: cfg.Mobile.MinAppVersion, ServerVersion: docs.SwaggerInfo.Version,
|
||||
MinAppVersion: api.HigherVersion(api.MinAppVersion, cfg.Mobile.MinAppVersion),
|
||||
MinWebVersion: api.HigherVersion(api.MinWebVersion, cfg.Web.MinWebVersion),
|
||||
ServerVersion: docs.SwaggerInfo.Version,
|
||||
AdminRoutes: map[string]http.Handler{
|
||||
"/accounts": handlers.AccountsRouter(store),
|
||||
"/backup": handlers.BackupRouter(backupOpts, ctx),
|
||||
|
||||
+7
-4
@@ -320,8 +320,11 @@ cache:
|
||||
# reconfirm: 1h # default: 1h
|
||||
# cleanup: 1h # default: 1h
|
||||
|
||||
# Minimum BEACON Mobile version, served at GET /api/v1/info. Older apps show
|
||||
# "update required" for this server. Strict X.Y.Z (no v, no suffix); anything
|
||||
# else prevents startup. Default: empty, no requirement.
|
||||
# Minimum client versions, served at GET /api/v1/info. Older clients show
|
||||
# "update required" for this server. Each release has a built-in floor; these
|
||||
# can raise it, never lower it. Strict X.Y.Z (no v, no suffix); anything else
|
||||
# prevents startup. Default: empty, the built-in floor only.
|
||||
#mobile:
|
||||
# min_app_version: "0.1.1"
|
||||
# min_app_version: "0.1.1" # BEACON Mobile
|
||||
#web:
|
||||
# min_web_version: "2.0.3" # Beacon Web
|
||||
|
||||
@@ -6,7 +6,9 @@ package db
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -42,6 +44,20 @@ func applyBaseline(t *testing.T, ctx context.Context, tx pgx.Tx) {
|
||||
}
|
||||
}
|
||||
|
||||
// embeddedMigrations lists the migration files RunMigrations applies, in order.
|
||||
func embeddedMigrations(t *testing.T) []string {
|
||||
t.Helper()
|
||||
entries, err := fs.ReadDir(migrationFiles, "migrations")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// schemaPool returns a pool whose connections all use a fresh, dropped-on-cleanup schema.
|
||||
func schemaPool(t *testing.T) (context.Context, *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
@@ -92,7 +108,7 @@ func TestRunMigrationsBaselinePostgres(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ledger, err := pgx.CollectRows(rows, pgx.RowTo[string])
|
||||
if err != nil || len(ledger) != 1 || ledger[0] != baselineMigration {
|
||||
if err != nil || !slices.Equal(ledger, embeddedMigrations(t)) {
|
||||
t.Fatalf("ledger %v, %v", ledger, err)
|
||||
}
|
||||
// Stats refresh uses CONCURRENTLY, which needs populated views.
|
||||
|
||||
+16
-2
@@ -13,6 +13,7 @@ import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
@@ -130,8 +131,21 @@ func RunMigrations(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
return fmt.Errorf("failed to acquire migration connection: %w", err)
|
||||
}
|
||||
defer conn.Release()
|
||||
if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock($1)", migrationLockKey); err != nil {
|
||||
return fmt.Errorf("failed to take migration lock: %w", err)
|
||||
// Poll, don't block: a waiter in pg_advisory_lock holds a snapshot that the
|
||||
// holder's CREATE INDEX CONCURRENTLY waits on, which deadlocks.
|
||||
for {
|
||||
var locked bool
|
||||
if err := conn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", migrationLockKey).Scan(&locked); err != nil {
|
||||
return fmt.Errorf("failed to take migration lock: %w", err)
|
||||
}
|
||||
if locked {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("failed to take migration lock: %w", ctx.Err())
|
||||
case <-time.After(250 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
// A session lock outlives Release; drop the connection if unlock fails.
|
||||
|
||||
@@ -26,8 +26,8 @@ func TestRunMigrationsConcurrentStartPostgres(t *testing.T) {
|
||||
}
|
||||
}
|
||||
var n int
|
||||
if err := pool.QueryRow(ctx, "SELECT count(*) FROM schema_migrations").Scan(&n); err != nil || n != 1 {
|
||||
t.Errorf("ledger rows = %d, %v; want 1", n, err)
|
||||
if want := len(embeddedMigrations(t)); pool.QueryRow(ctx, "SELECT count(*) FROM schema_migrations").Scan(&n) != nil || n != want {
|
||||
t.Errorf("ledger rows = %d; want %d", n, want)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ package db
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
@@ -75,3 +76,23 @@ func TestIsDuplicateRelation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var ifNotExistsRe = regexp.MustCompile(`(?i)\bIF\s+NOT\s+EXISTS\b`)
|
||||
|
||||
// IF NOT EXISTS turns a retry over an interrupted build's invalid index into a silent
|
||||
// success, skipping applyMigration's drop-and-rebuild.
|
||||
func TestConcurrentMigrationsAvoidIfNotExists(t *testing.T) {
|
||||
entries, err := migrationFiles.ReadDir("migrations")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
sql, err := migrationFiles.ReadFile("migrations/" + e.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := concurrentIndexName(string(sql)); ok && ifNotExistsRe.MatchString(lineCommentRe.ReplaceAllString(string(sql), "")) {
|
||||
t.Errorf("%s: CREATE INDEX CONCURRENTLY must not use IF NOT EXISTS", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Copyright 2026 Beacon Contributors
|
||||
-- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-- Cross-IATA route search looks routes up by member node.
|
||||
CREATE INDEX CONCURRENTLY idx_known_routes_node_ids ON known_routes USING gin (node_ids);
|
||||
+62
-24
@@ -1486,23 +1486,28 @@ LIMIT $4
|
||||
ORDER BY date_trunc('milliseconds', r.last_seen, 'UTC') DESC, r.id DESC
|
||||
LIMIT $4;
|
||||
|
||||
-- name: SearchKnownRoutes :many
|
||||
-- Returns known routes containing a subsequence from source to destination hash prefix.
|
||||
-- Verifies source appears before destination in the route.
|
||||
-- name: GetKnownRoutesByNodes :many
|
||||
-- Routes containing @nodes[i] in IATA @iatas[i] (aligned, one node per IATA). The &&
|
||||
-- drives the node_ids GIN index; the indexed check keeps LIMIT counting real matches.
|
||||
SELECT id, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen, observation_count
|
||||
FROM known_routes
|
||||
WHERE iata = $1
|
||||
AND array_position(hash_prefix, $2::bytea) IS NOT NULL
|
||||
AND array_position(hash_prefix, $3::bytea) IS NOT NULL
|
||||
AND array_position(hash_prefix, $2::bytea) < array_position(hash_prefix, $3::bytea)
|
||||
ORDER BY hop_count ASC, last_seen DESC;
|
||||
FROM known_routes kr
|
||||
WHERE kr.node_ids && @nodes::uuid[]
|
||||
AND kr.iata = ANY(@iatas::bpchar[])
|
||||
AND (@nodes::uuid[])[array_position(@iatas::bpchar[], kr.iata)] = ANY(kr.node_ids)
|
||||
ORDER BY hop_count ASC, last_seen DESC
|
||||
LIMIT @row_limit;
|
||||
|
||||
-- name: GetKnownRoutesByNode :many
|
||||
-- name: SearchKnownRoutesByNodes :many
|
||||
-- Routes holding a source and a destination candidate; callers check order and IATA.
|
||||
-- A route's hops resolve in its own IATA, so candidates from other IATAs can't match.
|
||||
-- Both && predicates use the node_ids GIN index.
|
||||
SELECT id, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen, observation_count
|
||||
FROM known_routes
|
||||
WHERE iata = $1
|
||||
AND $2::uuid = ANY(node_ids)
|
||||
ORDER BY hop_count ASC, last_seen DESC;
|
||||
FROM known_routes kr
|
||||
WHERE kr.node_ids && @from_nodes::uuid[]
|
||||
AND kr.node_ids && @to_nodes::uuid[]
|
||||
AND kr.iata = ANY(@iatas::bpchar[])
|
||||
ORDER BY hop_count ASC, last_seen DESC
|
||||
LIMIT @row_limit;
|
||||
|
||||
-- ============================================================
|
||||
-- NEIGHBORS
|
||||
@@ -1541,16 +1546,13 @@ JOIN nodes n ON n.id = nn.neighbor_id
|
||||
WHERE nn.node_id = $1
|
||||
ORDER BY nn.last_seen DESC;
|
||||
|
||||
-- name: GetCrossIATANeighbors :many
|
||||
-- Returns neighbors of a node that are in a different IATA.
|
||||
SELECT
|
||||
n.id, n.name, n.node_type, n.latitude, n.longitude,
|
||||
nn.iata AS neighbor_iata, nn.observation_count, nn.last_seen, nn.snr
|
||||
FROM node_neighbors nn
|
||||
JOIN nodes n ON n.id = nn.neighbor_id
|
||||
WHERE nn.node_id = $1
|
||||
AND nn.iata != $2
|
||||
ORDER BY nn.last_seen DESC;
|
||||
-- name: GetNeighborLinks :many
|
||||
-- Neighbor rows between @a_nodes and @b_nodes in either orientation: ingest writes
|
||||
-- adverts as (sender, forwarder) but traces as (receiver, sender). Callers normalise.
|
||||
SELECT node_id, neighbor_id, last_seen
|
||||
FROM node_neighbors
|
||||
WHERE (node_id = ANY(@a_nodes::uuid[]) AND neighbor_id = ANY(@b_nodes::uuid[]))
|
||||
OR (node_id = ANY(@b_nodes::uuid[]) AND neighbor_id = ANY(@a_nodes::uuid[]));
|
||||
|
||||
-- ============================================================
|
||||
-- HELPERS
|
||||
@@ -1620,6 +1622,42 @@ WHERE ns.iata = $1
|
||||
AND n.node_type IN (2, 3)
|
||||
AND ns.prefix_4 = ANY($2::bytea[]);
|
||||
|
||||
-- name: ResolveRelayHashPairsP1 :many
|
||||
-- Batch form of ResolvePathHashesP1 across IATAs; callers check uniqueness per IATA.
|
||||
SELECT ns.iata, ns.prefix_4 AS hash, ns.node_id
|
||||
FROM node_short_ids ns
|
||||
JOIN nodes n ON n.id = ns.node_id
|
||||
WHERE ns.iata = ANY(@iatas::bpchar[])
|
||||
AND n.node_type IN (2, 3)
|
||||
AND ns.prefix_1 = ANY(@hashes::bytea[]);
|
||||
|
||||
-- name: ResolveRelayHashPairsP2 :many
|
||||
-- Batch form of ResolvePathHashesP2 across IATAs; callers check uniqueness per IATA.
|
||||
SELECT ns.iata, ns.prefix_4 AS hash, ns.node_id
|
||||
FROM node_short_ids ns
|
||||
JOIN nodes n ON n.id = ns.node_id
|
||||
WHERE ns.iata = ANY(@iatas::bpchar[])
|
||||
AND n.node_type IN (2, 3)
|
||||
AND ns.prefix_2 = ANY(@hashes::bytea[]);
|
||||
|
||||
-- name: ResolveRelayHashPairsP3 :many
|
||||
-- Batch form of ResolvePathHashesP3 across IATAs; callers check uniqueness per IATA.
|
||||
SELECT ns.iata, ns.prefix_4 AS hash, ns.node_id
|
||||
FROM node_short_ids ns
|
||||
JOIN nodes n ON n.id = ns.node_id
|
||||
WHERE ns.iata = ANY(@iatas::bpchar[])
|
||||
AND n.node_type IN (2, 3)
|
||||
AND ns.prefix_3 = ANY(@hashes::bytea[]);
|
||||
|
||||
-- name: ResolveRelayHashPairsP4 :many
|
||||
-- Batch form of ResolvePathHashesP4 across IATAs; callers check uniqueness per IATA.
|
||||
SELECT ns.iata, ns.prefix_4 AS hash, ns.node_id
|
||||
FROM node_short_ids ns
|
||||
JOIN nodes n ON n.id = ns.node_id
|
||||
WHERE ns.iata = ANY(@iatas::bpchar[])
|
||||
AND n.node_type IN (2, 3)
|
||||
AND ns.prefix_4 = ANY(@hashes::bytea[]);
|
||||
|
||||
-- name: RefreshRadioPresets :exec
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_radio_presets;
|
||||
|
||||
|
||||
+343
-174
@@ -4,9 +4,11 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -64,57 +66,77 @@ func (s *Store) ListKnownRoutes(ctx context.Context, iata string, hopCount int32
|
||||
return toKnownRoutes(rows, nodes), nil
|
||||
}
|
||||
|
||||
func (s *Store) SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash string) ([]api.KnownRoute, error) {
|
||||
fromBytes, err := hex.DecodeString(fromHash)
|
||||
// Work bounds for one route search; the query count is fixed, these cap row volume.
|
||||
const (
|
||||
routeSearchLimit = 500 // routes either search returns
|
||||
crossMaxEndpointIATAs = 50 // IATAs a cross endpoint may resolve uniquely in
|
||||
searchMaxCandidates = 2000 // relays a search endpoint may resolve to
|
||||
crossRouteRowLimit = 500 // routes loaded per cross endpoint
|
||||
crossMaxHops = 2000 // hops per side fed to the neighbor query
|
||||
crossMaxStitched = 5000 // cross routes collected before sort and truncate
|
||||
)
|
||||
|
||||
func (s *Store) SearchKnownRoutes(ctx context.Context, iatas []string, fromHash, toHash string) ([]api.KnownRoute, error) {
|
||||
fromBytes, toBytes, err := decodeHashPair(fromHash, toHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toBytes, err := hex.DecodeString(toHash)
|
||||
if iatas, err = s.scopeIATAs(ctx, iatas); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
from, err := s.resolveRelays(ctx, iatas, fromBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sqlRows, err := s.q.SearchKnownRoutes(ctx, sqlc.SearchKnownRoutesParams{
|
||||
Iata: iata,
|
||||
Column2: fromBytes,
|
||||
Column3: toBytes,
|
||||
to, err := s.resolveRelays(ctx, iatas, toBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if from.count() > searchMaxCandidates || to.count() > searchMaxCandidates {
|
||||
return nil, api.ErrRouteSearchTooBroad
|
||||
}
|
||||
var shared []string
|
||||
for iata := range from {
|
||||
if _, ok := to[iata]; ok {
|
||||
shared = append(shared, iata)
|
||||
}
|
||||
}
|
||||
items := []api.KnownRoute{}
|
||||
if len(shared) == 0 {
|
||||
return items, nil
|
||||
}
|
||||
sqlRows, err := s.q.SearchKnownRoutesByNodes(ctx, sqlc.SearchKnownRoutesByNodesParams{
|
||||
FromNodes: from.nodes(), ToNodes: to.nodes(), Iatas: shared, RowLimit: routeSearchLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]knownRouteRow, len(sqlRows))
|
||||
for i, r := range sqlRows {
|
||||
rows[i] = knownRouteRow{ID: r.ID, NodeIds: r.NodeIds, HashPrefix: r.HashPrefix, Iata: r.Iata, HopCount: r.HopCount, FirstSeen: r.FirstSeen, LastSeen: r.LastSeen, ObservationCount: r.ObservationCount}
|
||||
rows[i] = knownRouteRow(r)
|
||||
}
|
||||
ids := collectNodeIDs(rows)
|
||||
nodes, err := s.GetNodesByIDs(ctx, ids)
|
||||
nodes, err := s.GetNodesByIDs(ctx, collectNodeIDs(rows))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]api.KnownRoute, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
fromPos, toPos := -1, -1
|
||||
for i, h := range r.HashPrefix {
|
||||
if fromPos == -1 && hex.EncodeToString(h) == fromHash {
|
||||
for i, id := range r.NodeIds {
|
||||
if fromPos == -1 && slices.Contains(from[r.Iata], id) {
|
||||
fromPos = i
|
||||
}
|
||||
if fromPos != -1 && hex.EncodeToString(h) == toHash {
|
||||
} else if fromPos != -1 && slices.Contains(to[r.Iata], id) {
|
||||
toPos = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if fromPos == -1 || toPos == -1 {
|
||||
if toPos == -1 {
|
||||
continue
|
||||
}
|
||||
nodeIDs := r.NodeIds[fromPos : toPos+1]
|
||||
hashPrefix := r.HashPrefix[fromPos : toPos+1]
|
||||
hops := make([]api.RouteHop, 0, len(nodeIDs))
|
||||
for i, nodeID := range nodeIDs {
|
||||
hop := api.RouteHop{
|
||||
NodeID: nodeID,
|
||||
Node: nodes[nodeID],
|
||||
}
|
||||
if i < len(hashPrefix) {
|
||||
hop.HashBytes = hex.EncodeToString(hashPrefix[i])
|
||||
hops := make([]api.RouteHop, 0, toPos-fromPos+1)
|
||||
for i := fromPos; i <= toPos; i++ {
|
||||
hop := api.RouteHop{NodeID: r.NodeIds[i], Node: nodes[r.NodeIds[i]]}
|
||||
if i < len(r.HashPrefix) {
|
||||
hop.HashBytes = hex.EncodeToString(r.HashPrefix[i])
|
||||
}
|
||||
hops = append(hops, hop)
|
||||
}
|
||||
@@ -132,160 +154,317 @@ func (s *Store) SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash st
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetKnownRoutesByNode(ctx context.Context, iata string, nodeID uuid.UUID) ([]api.KnownRoute, error) {
|
||||
sqlRows, err := s.q.GetKnownRoutesByNode(ctx, sqlc.GetKnownRoutesByNodeParams{
|
||||
Iata: iata,
|
||||
Column2: nodeID,
|
||||
// crossRoute is a route containing a searched endpoint at index end.
|
||||
type crossRoute struct {
|
||||
api.KnownRoute
|
||||
end int
|
||||
}
|
||||
|
||||
func (s *Store) SearchCrossIATARoutes(ctx context.Context, q api.CrossRouteSearch) ([]api.CrossIATARoute, error) {
|
||||
fromBytes, toBytes, err := decodeHashPair(q.FromHash, q.ToHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 1. resolve each hash per IATA, keeping only IATAs where it names exactly one relay
|
||||
fromIATAs, err := s.scopeIATAs(ctx, q.FromIATAs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toIATAs, err := s.scopeIATAs(ctx, q.ToIATAs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fromCands, err := s.resolveRelays(ctx, fromIATAs, fromBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toCands, err := s.resolveRelays(ctx, toIATAs, toBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fromNodes, toNodes := fromCands.unique(), toCands.unique()
|
||||
if len(fromNodes) > crossMaxEndpointIATAs || len(toNodes) > crossMaxEndpointIATAs {
|
||||
return nil, api.ErrRouteSearchTooBroad
|
||||
}
|
||||
if len(fromNodes) == 0 || len(toNodes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 2. routes through each endpoint, in the IATA it resolved in
|
||||
sourceRows, err := s.routesThrough(ctx, fromNodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetRows, err := s.routesThrough(ctx, toNodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(sourceRows) == 0 || len(targetRows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
nodes, err := s.GetNodesByIDs(ctx, collectNodeIDs(slices.Concat(sourceRows, targetRows)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sources := toCrossRoutes(sourceRows, nodes, fromNodes)
|
||||
targets := toCrossRoutes(targetRows, nodes, toNodes)
|
||||
|
||||
// 3. one neighbor query linking source hops from the source endpoint on
|
||||
// to target hops up to the destination endpoint
|
||||
type targetRef struct{ route, hop int }
|
||||
targetsByNode := make(map[uuid.UUID][]targetRef)
|
||||
var tgtHops []uuid.UUID
|
||||
for ti, tr := range targets {
|
||||
for j := 0; j <= tr.end; j++ {
|
||||
id := tr.Hops[j].NodeID
|
||||
if _, ok := targetsByNode[id]; !ok {
|
||||
if len(tgtHops) >= crossMaxHops {
|
||||
continue
|
||||
}
|
||||
tgtHops = append(tgtHops, id)
|
||||
}
|
||||
targetsByNode[id] = append(targetsByNode[id], targetRef{ti, j})
|
||||
}
|
||||
}
|
||||
srcSet := make(map[uuid.UUID]struct{})
|
||||
var srcHops []uuid.UUID
|
||||
for _, sr := range sources {
|
||||
for _, hop := range sr.Hops[sr.end:] {
|
||||
if _, ok := srcSet[hop.NodeID]; !ok && len(srcHops) < crossMaxHops {
|
||||
srcSet[hop.NodeID] = struct{}{}
|
||||
srcHops = append(srcHops, hop.NodeID)
|
||||
}
|
||||
}
|
||||
}
|
||||
links, err := s.q.GetNeighborLinks(ctx, sqlc.GetNeighborLinksParams{ANodes: srcHops, BNodes: tgtHops})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// adjacency source hop → target hop → newest last_seen, whichever way the row was written
|
||||
adj := make(map[uuid.UUID]map[uuid.UUID]int64)
|
||||
link := func(src, tgt uuid.UUID, seen int64) {
|
||||
_, isSrc := srcSet[src]
|
||||
_, isTgt := targetsByNode[tgt]
|
||||
if !isSrc || !isTgt {
|
||||
return
|
||||
}
|
||||
if adj[src] == nil {
|
||||
adj[src] = make(map[uuid.UUID]int64)
|
||||
}
|
||||
adj[src][tgt] = max(adj[src][tgt], seen)
|
||||
}
|
||||
for _, l := range links {
|
||||
seen := l.LastSeen.Time.UnixMilli()
|
||||
link(l.NodeID, l.NeighborID, seen)
|
||||
link(l.NeighborID, l.NodeID, seen)
|
||||
}
|
||||
|
||||
// 4. stitch source segment → cross hop → target segment
|
||||
type stitched struct {
|
||||
route api.CrossIATARoute
|
||||
key string
|
||||
}
|
||||
var found []stitched
|
||||
dedupe := make(map[string]struct{})
|
||||
stitch:
|
||||
for _, sr := range sources {
|
||||
for i := sr.end; i < len(sr.Hops); i++ {
|
||||
hop := sr.Hops[i]
|
||||
for tgt, seen := range adj[hop.NodeID] {
|
||||
for _, ref := range targetsByNode[tgt] {
|
||||
tr := targets[ref.route]
|
||||
if tr.IATA == sr.IATA {
|
||||
continue
|
||||
}
|
||||
source := sr.Hops[sr.end : i+1]
|
||||
target := tr.Hops[ref.hop : tr.end+1]
|
||||
key := crossRouteKey(sr.IATA, tr.IATA, source, target)
|
||||
if _, dup := dedupe[key]; dup {
|
||||
continue
|
||||
}
|
||||
dedupe[key] = struct{}{}
|
||||
found = append(found, stitched{key: key, route: api.CrossIATARoute{
|
||||
SourceSegment: source,
|
||||
CrossHop: api.CrossIATAHop{
|
||||
FromNode: resolvedOrID(hop.Node, hop.NodeID),
|
||||
ToNode: resolvedOrID(nodes[tgt], tgt),
|
||||
FromIATA: sr.IATA,
|
||||
ToIATA: tr.IATA,
|
||||
LastSeen: seen,
|
||||
},
|
||||
TargetSegment: target,
|
||||
TotalHops: len(source) + len(target),
|
||||
}})
|
||||
if len(found) >= crossMaxStitched {
|
||||
break stitch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.SortFunc(found, func(a, b stitched) int {
|
||||
return cmp.Or(
|
||||
cmp.Compare(a.route.TotalHops, b.route.TotalHops),
|
||||
cmp.Compare(b.route.CrossHop.LastSeen, a.route.CrossHop.LastSeen),
|
||||
strings.Compare(a.key, b.key),
|
||||
)
|
||||
})
|
||||
results := make([]api.CrossIATARoute, 0, min(len(found), routeSearchLimit))
|
||||
for _, f := range found[:min(len(found), routeSearchLimit)] {
|
||||
results = append(results, f.route)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// relayCandidates maps each IATA to the relays a hash prefix names there.
|
||||
type relayCandidates map[string][]uuid.UUID
|
||||
|
||||
func (c relayCandidates) count() int {
|
||||
n := 0
|
||||
for _, ids := range c {
|
||||
n += len(ids)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// unique keeps the IATAs where the hash names exactly one relay.
|
||||
func (c relayCandidates) unique() map[string]uuid.UUID {
|
||||
out := make(map[string]uuid.UUID)
|
||||
for iata, ids := range c {
|
||||
if len(ids) == 1 {
|
||||
out[iata] = ids[0]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c relayCandidates) nodes() []uuid.UUID {
|
||||
var nodes []uuid.UUID
|
||||
for _, ids := range c {
|
||||
nodes = append(nodes, ids...)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
func decodeHashPair(from, to string) ([]byte, []byte, error) {
|
||||
f, err := hex.DecodeString(from)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
t, err := hex.DecodeString(to)
|
||||
return f, t, err
|
||||
}
|
||||
|
||||
// scopeIATAs returns iatas, or every known IATA when none are given.
|
||||
func (s *Store) scopeIATAs(ctx context.Context, iatas []string) ([]string, error) {
|
||||
if len(iatas) > 0 {
|
||||
return iatas, nil
|
||||
}
|
||||
return s.ListKnownIATAs(ctx)
|
||||
}
|
||||
|
||||
// resolveRelays maps each IATA to the relays whose hash starts with hash there.
|
||||
func (s *Store) resolveRelays(ctx context.Context, iatas []string, hash []byte) (relayCandidates, error) {
|
||||
type pair struct {
|
||||
iata string
|
||||
node uuid.UUID
|
||||
}
|
||||
var rows []pair
|
||||
hashes := [][]byte{hash}
|
||||
switch len(hash) {
|
||||
case 1:
|
||||
rs, err := s.q.ResolveRelayHashPairsP1(ctx, sqlc.ResolveRelayHashPairsP1Params{Iatas: iatas, Hashes: hashes})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rs {
|
||||
rows = append(rows, pair{r.Iata, r.NodeID})
|
||||
}
|
||||
case 2:
|
||||
rs, err := s.q.ResolveRelayHashPairsP2(ctx, sqlc.ResolveRelayHashPairsP2Params{Iatas: iatas, Hashes: hashes})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rs {
|
||||
rows = append(rows, pair{r.Iata, r.NodeID})
|
||||
}
|
||||
case 3:
|
||||
rs, err := s.q.ResolveRelayHashPairsP3(ctx, sqlc.ResolveRelayHashPairsP3Params{Iatas: iatas, Hashes: hashes})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rs {
|
||||
rows = append(rows, pair{r.Iata, r.NodeID})
|
||||
}
|
||||
case 4:
|
||||
rs, err := s.q.ResolveRelayHashPairsP4(ctx, sqlc.ResolveRelayHashPairsP4Params{Iatas: iatas, Hashes: hashes})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rs {
|
||||
rows = append(rows, pair{r.Iata, r.NodeID})
|
||||
}
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
c := make(relayCandidates)
|
||||
for _, r := range rows {
|
||||
if !slices.Contains(c[r.iata], r.node) {
|
||||
c[r.iata] = append(c[r.iata], r.node)
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// routesThrough returns routes containing endpoints[iata] in that same IATA.
|
||||
func (s *Store) routesThrough(ctx context.Context, endpoints map[string]uuid.UUID) ([]knownRouteRow, error) {
|
||||
iatas := make([]string, 0, len(endpoints))
|
||||
nodes := make([]uuid.UUID, 0, len(endpoints))
|
||||
for iata, id := range endpoints {
|
||||
iatas = append(iatas, iata)
|
||||
nodes = append(nodes, id)
|
||||
}
|
||||
sqlRows, err := s.q.GetKnownRoutesByNodes(ctx, sqlc.GetKnownRoutesByNodesParams{
|
||||
Nodes: nodes, Iatas: iatas, RowLimit: crossRouteRowLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]knownRouteRow, len(sqlRows))
|
||||
for i, r := range sqlRows {
|
||||
rows[i] = knownRouteRow{ID: r.ID, NodeIds: r.NodeIds, HashPrefix: r.HashPrefix, Iata: r.Iata, HopCount: r.HopCount, FirstSeen: r.FirstSeen, LastSeen: r.LastSeen, ObservationCount: r.ObservationCount}
|
||||
rows[i] = knownRouteRow(r)
|
||||
}
|
||||
ids := collectNodeIDs(rows)
|
||||
nodes, err := s.GetNodesByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toKnownRoutes(rows, nodes), nil
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetCrossIATANeighbors(ctx context.Context, nodeID uuid.UUID, iata string) ([]api.NodeNeighbor, error) {
|
||||
rows, err := s.q.GetCrossIATANeighbors(ctx, sqlc.GetCrossIATANeighborsParams{
|
||||
NodeID: nodeID,
|
||||
Iata: iata,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func toCrossRoutes(rows []knownRouteRow, nodes map[uuid.UUID]*api.ResolvedNode, endpoints map[string]uuid.UUID) []crossRoute {
|
||||
routes := toKnownRoutes(rows, nodes)
|
||||
out := make([]crossRoute, len(routes))
|
||||
for i, r := range routes {
|
||||
out[i] = crossRoute{KnownRoute: r, end: slices.Index(rows[i].NodeIds, endpoints[r.IATA])}
|
||||
}
|
||||
items := make([]api.NodeNeighbor, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, api.NodeNeighbor{
|
||||
ID: r.ID,
|
||||
Name: r.Name,
|
||||
NodeType: r.NodeType,
|
||||
NodeTypeName: api.NodeTypeName(r.NodeType),
|
||||
Latitude: r.Latitude,
|
||||
Longitude: r.Longitude,
|
||||
IATA: r.NeighborIata,
|
||||
ObservationCount: r.ObservationCount,
|
||||
LastSeen: r.LastSeen.Time.UnixMilli(),
|
||||
SNR: r.Snr,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) SearchCrossIATARoutes(ctx context.Context, fromHash, fromIATA, toHash, toIATA string) ([]api.CrossIATARoute, error) {
|
||||
// 1. resolve fromHash in fromIATA
|
||||
fromBytes, err := hex.DecodeString(fromHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func crossRouteKey(fromIATA, toIATA string, source, target []api.RouteHop) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fromIATA + toIATA)
|
||||
for _, h := range source {
|
||||
b.WriteString(h.NodeID.String())
|
||||
}
|
||||
fromResolved, err := s.ResolvePathHashes(ctx, fromIATA, [][]byte{fromBytes})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
b.WriteByte('|')
|
||||
for _, h := range target {
|
||||
b.WriteString(h.NodeID.String())
|
||||
}
|
||||
fromEntries := fromResolved[fromHash]
|
||||
if len(fromEntries) != 1 {
|
||||
return nil, nil // not found or ambiguous
|
||||
}
|
||||
fromNodeID := fromEntries[0].NodeID
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// 2. resolve toHash in toIATA
|
||||
toBytes, err := hex.DecodeString(toHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func resolvedOrID(n *api.ResolvedNode, id uuid.UUID) api.ResolvedNode {
|
||||
if n == nil {
|
||||
return api.ResolvedNode{ID: id}
|
||||
}
|
||||
toResolved, err := s.ResolvePathHashes(ctx, toIATA, [][]byte{toBytes})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toEntries := toResolved[toHash]
|
||||
if len(toEntries) != 1 {
|
||||
return nil, nil // not found or ambiguous
|
||||
}
|
||||
toNodeID := toEntries[0].NodeID
|
||||
|
||||
// 3. find routes in source IATA containing fromNode
|
||||
sourceRoutes, err := s.GetKnownRoutesByNode(ctx, fromIATA, fromNodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. find routes in target IATA containing toNode
|
||||
targetRoutes, err := s.GetKnownRoutesByNode(ctx, toIATA, toNodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(sourceRoutes) == 0 || len(targetRoutes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 5. find cross-IATA links — nodes at the boundary of source routes
|
||||
// that have neighbors in the target IATA at the start of target routes
|
||||
var results []api.CrossIATARoute
|
||||
|
||||
// build a set of node IDs that appear in target routes
|
||||
targetNodeSet := make(map[uuid.UUID][]api.RouteHop)
|
||||
for _, tr := range targetRoutes {
|
||||
for _, hop := range tr.Hops {
|
||||
if _, ok := targetNodeSet[hop.NodeID]; !ok {
|
||||
targetNodeSet[hop.NodeID] = tr.Hops
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// for each source route, check if any node has a cross-IATA neighbor in targetNodeSet
|
||||
for _, sr := range sourceRoutes {
|
||||
for i, hop := range sr.Hops {
|
||||
crossNeighbors, err := s.GetCrossIATANeighbors(ctx, hop.NodeID, fromIATA)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, neighbor := range crossNeighbors {
|
||||
if neighbor.IATA != toIATA {
|
||||
continue
|
||||
}
|
||||
if targetHops, ok := targetNodeSet[neighbor.ID]; ok {
|
||||
// found a cross-IATA link — build the route
|
||||
sourceSegment := sr.Hops[:i+1]
|
||||
targetSegment := extractFromNode(targetHops, neighbor.ID)
|
||||
|
||||
fromNode := api.ResolvedNode{
|
||||
ID: hop.NodeID,
|
||||
Latitude: fromEntries[0].Latitude,
|
||||
Longitude: fromEntries[0].Longitude,
|
||||
PublicKey: hex.EncodeToString(fromEntries[0].PublicKey),
|
||||
}
|
||||
toNode := api.ResolvedNode{
|
||||
ID: neighbor.ID,
|
||||
Name: neighbor.Name,
|
||||
Latitude: neighbor.Latitude,
|
||||
Longitude: neighbor.Longitude,
|
||||
}
|
||||
|
||||
results = append(results, api.CrossIATARoute{
|
||||
SourceSegment: sourceSegment,
|
||||
CrossHop: api.CrossIATAHop{
|
||||
FromNode: fromNode,
|
||||
ToNode: toNode,
|
||||
FromIATA: fromIATA,
|
||||
ToIATA: toIATA,
|
||||
LastSeen: neighbor.LastSeen,
|
||||
},
|
||||
TargetSegment: targetSegment,
|
||||
TotalHops: len(sourceSegment) + 1 + len(targetSegment),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
return *n
|
||||
}
|
||||
|
||||
// AmbiguousPrefixes is the per-IATA set of hop prefixes that resolve to more than one node.
|
||||
@@ -336,16 +515,6 @@ func (s *Store) DeleteOldRoutes(ctx context.Context, retentionCutoff time.Time,
|
||||
})
|
||||
}
|
||||
|
||||
// extractFromNode returns the portion of a route starting at the given node.
|
||||
func extractFromNode(hops []api.RouteHop, nodeID uuid.UUID) []api.RouteHop {
|
||||
for i, hop := range hops {
|
||||
if hop.NodeID == nodeID {
|
||||
return hops[i:]
|
||||
}
|
||||
}
|
||||
return hops
|
||||
}
|
||||
|
||||
// knownRouteRow normalizes the per-query sqlc row structs (identical
|
||||
// columns, distinct generated types) so the helpers below share one body.
|
||||
type knownRouteRow struct {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func TestSearchCrossIATARoutesPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("BEACON_TEST_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set BEACON_TEST_POSTGRES_DSN for the PostgreSQL regression test")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
conn, err := pgx.Connect(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close(context.Background())
|
||||
tx, err := conn.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
applyBaseline(t, ctx, tx)
|
||||
|
||||
// YVR route P→S→A→B, B heard by X in YYJ, YYJ route X→C→T→Z. S is aa, T is bb.
|
||||
// P also links to X (before the source, must be ignored); YYJ route T→X has X after T.
|
||||
ids := map[string]uuid.UUID{}
|
||||
for _, n := range []string{"P", "S", "A", "B", "Q", "X", "C", "T", "Z", "D1", "D2"} {
|
||||
ids[n] = uuid.New()
|
||||
}
|
||||
order := []string{"P", "S", "A", "B", "Q", "X", "C", "T", "Z", "D1", "D2"}
|
||||
nodeIDs, lats := make([]uuid.UUID, len(order)), make([]float64, len(order))
|
||||
for i, n := range order {
|
||||
nodeIDs[i], lats[i] = ids[n], float64(i+1)
|
||||
}
|
||||
id := func(n string) uuid.UUID { return ids[n] }
|
||||
for _, st := range []struct {
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{`INSERT INTO nodes (id, public_key, node_type, name, latitude, longitude)
|
||||
SELECT id, decode(md5(id::text), 'hex'), 2, name, lat, 0
|
||||
FROM unnest($1::uuid[], $2::text[], $3::float8[]) AS t(id, name, lat)`, []any{nodeIDs, order, lats}},
|
||||
{`INSERT INTO node_short_ids (node_id, iata, prefix_4)
|
||||
SELECT id, iata, prefix FROM unnest($1::uuid[], $2::bpchar[], $3::bytea[]) AS t(id, iata, prefix)`, []any{nodeIDs,
|
||||
[]string{"YVR", "YVR", "YVR", "YVR", "YVR", "YYJ", "YYJ", "YYJ", "YYJ", "YYZ", "YYZ"},
|
||||
[][]byte{{1, 0, 0, 1}, {0xaa, 0, 0, 1}, {2, 0, 0, 1}, {3, 0, 0, 1}, {4, 0, 0, 1}, {5, 0, 0, 1}, {6, 0, 0, 1}, {0xbb, 0, 0, 1}, {7, 0, 0, 1}, {0xaa, 0, 0, 2}, {0xaa, 0, 0, 3}}}},
|
||||
{`INSERT INTO node_neighbors (node_id, neighbor_id, iata, last_seen) VALUES
|
||||
($1, $2, 'YYJ', '2026-01-01 00:00:00+00'), ($3, $2, 'YYJ', '2026-01-01 00:00:00+00')`, []any{id("B"), id("X"), id("P")}},
|
||||
{`INSERT INTO known_routes (path_key, node_ids, hash_prefix, iata, hop_count) VALUES
|
||||
('\x01', ARRAY[$1, $2, $3, $4]::uuid[], ARRAY['\x01','\xaa','\x02','\x03']::bytea[], 'YVR', 4),
|
||||
('\x02', ARRAY[$2, $3, $4, $5]::uuid[], ARRAY['\xaa','\x02','\x03','\x04']::bytea[], 'YVR', 4),
|
||||
('\x03', ARRAY[$6, $7, $8, $9]::uuid[], ARRAY['\x05','\x06','\xbb','\x07']::bytea[], 'YYJ', 4),
|
||||
('\x04', ARRAY[$8, $6]::uuid[], ARRAY['\xbb','\x05']::bytea[], 'YYJ', 2)`,
|
||||
[]any{id("P"), id("S"), id("A"), id("B"), id("Q"), id("X"), id("C"), id("T"), id("Z")}},
|
||||
} {
|
||||
if _, err := tx.Exec(ctx, st.sql, st.args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
store := &Store{q: sqlc.New(tx)}
|
||||
|
||||
names := func(hops []api.RouteHop) []string {
|
||||
out := make([]string, len(hops))
|
||||
for i, h := range hops {
|
||||
for n, id := range ids {
|
||||
if id == h.NodeID {
|
||||
out[i] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
want := func(t *testing.T, got []api.CrossIATARoute) {
|
||||
t.Helper()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d routes, want 1: %+v", len(got), got)
|
||||
}
|
||||
r := got[0]
|
||||
if s, d := names(r.SourceSegment), names(r.TargetSegment); len(s) != 3 || s[0] != "S" || s[2] != "B" || len(d) != 3 || d[0] != "X" || d[2] != "T" {
|
||||
t.Errorf("segments = %v → %v, want [S A B] → [X C T]", s, d)
|
||||
}
|
||||
h := r.CrossHop
|
||||
if h.FromNode.ID != ids["B"] || h.FromNode.Latitude == nil || *h.FromNode.Latitude != 4 || h.ToNode.ID != ids["X"] ||
|
||||
h.FromIATA != "YVR" || h.ToIATA != "YYJ" || r.TotalHops != 6 {
|
||||
t.Errorf("cross hop = %+v total %d", h, r.TotalHops)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
q api.CrossRouteSearch
|
||||
}{
|
||||
{"legacy pair", api.CrossRouteSearch{FromHash: "aa", ToHash: "bb", FromIATAs: []string{"YVR"}, ToIATAs: []string{"YYJ"}}},
|
||||
{"iatas", api.CrossRouteSearch{FromHash: "aa", ToHash: "bb", FromIATAs: []string{"YVR", "YYJ", "YYZ"}, ToIATAs: []string{"YVR", "YYJ", "YYZ"}}},
|
||||
{"all iatas", api.CrossRouteSearch{FromHash: "aa", ToHash: "bb"}},
|
||||
{"wider hash", api.CrossRouteSearch{FromHash: "aa000001", ToHash: "bb00"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := store.SearchCrossIATARoutes(ctx, tc.q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want(t, got)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("within-IATA search", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, from, to string
|
||||
iatas []string
|
||||
want int
|
||||
iata string
|
||||
}{
|
||||
{"two iatas", "aa", "03", []string{"YVR", "YYJ"}, 2, "YVR"},
|
||||
{"all iatas", "aa", "03", nil, 2, "YVR"},
|
||||
{"mixed widths", "aa00", "0300", nil, 2, "YVR"},
|
||||
{"destination before source", "05", "bb", nil, 1, "YYJ"},
|
||||
{"wrong order", "03", "aa", nil, 0, ""},
|
||||
{"other iata only", "aa", "03", []string{"YYJ"}, 0, ""},
|
||||
} {
|
||||
got, err := store.SearchKnownRoutes(ctx, tc.iatas, tc.from, tc.to)
|
||||
if err != nil || len(got) != tc.want {
|
||||
t.Fatalf("%s: %d routes, err %v; want %d", tc.name, len(got), err, tc.want)
|
||||
}
|
||||
for _, r := range got {
|
||||
if h := names(r.Hops); r.IATA != tc.iata || len(h) != 3 || int(r.HopCount) != len(h) {
|
||||
t.Errorf("%s: route %s %v hopCount %d", tc.name, r.IATA, h, r.HopCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("neighbor row written receiver-first", func(t *testing.T) {
|
||||
if _, err := tx.Exec(ctx, `UPDATE node_neighbors SET node_id = neighbor_id, neighbor_id = node_id, iata = 'YVR' WHERE node_id = $1`, ids["B"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := store.SearchCrossIATARoutes(ctx, api.CrossRouteSearch{FromHash: "aa", ToHash: "bb"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want(t, got)
|
||||
})
|
||||
|
||||
t.Run("same path in two target iatas", func(t *testing.T) {
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO node_short_ids (node_id, iata, prefix_4)
|
||||
SELECT node_id, 'YOW', prefix_4 FROM node_short_ids WHERE iata = 'YYJ'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO known_routes (path_key, node_ids, hash_prefix, iata, hop_count)
|
||||
SELECT path_key, node_ids, hash_prefix, 'YOW', hop_count FROM known_routes WHERE iata = 'YYJ'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := store.SearchCrossIATARoutes(ctx, api.CrossRouteSearch{FromHash: "aa", ToHash: "bb"})
|
||||
if err != nil || len(got) != 2 || got[0].CrossHop.ToIATA == got[1].CrossHop.ToIATA {
|
||||
t.Fatalf("got %d routes, err %v; want one each to YYJ and YOW", len(got), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reverse direction", func(t *testing.T) {
|
||||
got, err := store.SearchCrossIATARoutes(ctx, api.CrossRouteSearch{FromHash: "aa", ToHash: "bb", FromIATAs: []string{"YYJ"}, ToIATAs: []string{"YVR"}})
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("got %d routes, err %v; want none", len(got), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("too broad", func(t *testing.T) {
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO iata_codes (iata) SELECT 'Q' || lpad(i::text, 2, '0') FROM generate_series(1, 60) i`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO node_short_ids (node_id, iata, prefix_4)
|
||||
SELECT $1, 'Q' || lpad(i::text, 2, '0'), '\xaa000001'::bytea FROM generate_series(1, 60) i`, ids["S"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.SearchCrossIATARoutes(ctx, api.CrossRouteSearch{FromHash: "aa", ToHash: "bb"}); !errors.Is(err, api.ErrRouteSearchTooBroad) {
|
||||
t.Fatalf("err = %v, want ErrRouteSearchTooBroad", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
|
||||
mockdb "github.com/MeshCore-Beacon/beacon-server/db/sqlc/mock"
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
@@ -68,48 +67,3 @@ func TestDeleteOldRoutes_PassesCutoffs(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFromNode_Found(t *testing.T) {
|
||||
a, b, c := uuid.New(), uuid.New(), uuid.New()
|
||||
hops := []api.RouteHop{
|
||||
{NodeID: a},
|
||||
{NodeID: b},
|
||||
{NodeID: c},
|
||||
}
|
||||
result := extractFromNode(hops, b)
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 hops, got %d", len(result))
|
||||
}
|
||||
if result[0].NodeID != b {
|
||||
t.Errorf("expected first hop to be b, got %s", result[0].NodeID)
|
||||
}
|
||||
if result[1].NodeID != c {
|
||||
t.Errorf("expected second hop to be c, got %s", result[1].NodeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFromNode_FirstNode(t *testing.T) {
|
||||
a, b := uuid.New(), uuid.New()
|
||||
hops := []api.RouteHop{{NodeID: a}, {NodeID: b}}
|
||||
result := extractFromNode(hops, a)
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 hops, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFromNode_NotFound(t *testing.T) {
|
||||
a, b := uuid.New(), uuid.New()
|
||||
hops := []api.RouteHop{{NodeID: a}}
|
||||
result := extractFromNode(hops, b)
|
||||
// not found returns full slice
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected full slice returned, got %d hops", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFromNode_Empty(t *testing.T) {
|
||||
result := extractFromNode(nil, uuid.New())
|
||||
if len(result) != 0 {
|
||||
t.Errorf("expected empty result for nil hops")
|
||||
}
|
||||
}
|
||||
|
||||
+89
-29
@@ -432,21 +432,6 @@ func (mr *MockQuerierMockRecorder) GetChannelByID(ctx, id any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannelByID", reflect.TypeOf((*MockQuerier)(nil).GetChannelByID), ctx, id)
|
||||
}
|
||||
|
||||
// GetCrossIATANeighbors mocks base method.
|
||||
func (m *MockQuerier) GetCrossIATANeighbors(ctx context.Context, arg db.GetCrossIATANeighborsParams) ([]db.GetCrossIATANeighborsRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetCrossIATANeighbors", ctx, arg)
|
||||
ret0, _ := ret[0].([]db.GetCrossIATANeighborsRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetCrossIATANeighbors indicates an expected call of GetCrossIATANeighbors.
|
||||
func (mr *MockQuerierMockRecorder) GetCrossIATANeighbors(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCrossIATANeighbors", reflect.TypeOf((*MockQuerier)(nil).GetCrossIATANeighbors), ctx, arg)
|
||||
}
|
||||
|
||||
// GetEarliestCompleteRollupHour mocks base method.
|
||||
func (m *MockQuerier) GetEarliestCompleteRollupHour(ctx context.Context) (pgtype.Timestamptz, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -507,19 +492,19 @@ func (mr *MockQuerierMockRecorder) GetIATABorder(ctx, iata any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetIATABorder", reflect.TypeOf((*MockQuerier)(nil).GetIATABorder), ctx, iata)
|
||||
}
|
||||
|
||||
// GetKnownRoutesByNode mocks base method.
|
||||
func (m *MockQuerier) GetKnownRoutesByNode(ctx context.Context, arg db.GetKnownRoutesByNodeParams) ([]db.GetKnownRoutesByNodeRow, error) {
|
||||
// GetKnownRoutesByNodes mocks base method.
|
||||
func (m *MockQuerier) GetKnownRoutesByNodes(ctx context.Context, arg db.GetKnownRoutesByNodesParams) ([]db.GetKnownRoutesByNodesRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetKnownRoutesByNode", ctx, arg)
|
||||
ret0, _ := ret[0].([]db.GetKnownRoutesByNodeRow)
|
||||
ret := m.ctrl.Call(m, "GetKnownRoutesByNodes", ctx, arg)
|
||||
ret0, _ := ret[0].([]db.GetKnownRoutesByNodesRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetKnownRoutesByNode indicates an expected call of GetKnownRoutesByNode.
|
||||
func (mr *MockQuerierMockRecorder) GetKnownRoutesByNode(ctx, arg any) *gomock.Call {
|
||||
// GetKnownRoutesByNodes indicates an expected call of GetKnownRoutesByNodes.
|
||||
func (mr *MockQuerierMockRecorder) GetKnownRoutesByNodes(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetKnownRoutesByNode", reflect.TypeOf((*MockQuerier)(nil).GetKnownRoutesByNode), ctx, arg)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetKnownRoutesByNodes", reflect.TypeOf((*MockQuerier)(nil).GetKnownRoutesByNodes), ctx, arg)
|
||||
}
|
||||
|
||||
// GetLatestCompleteRollupHour mocks base method.
|
||||
@@ -537,6 +522,21 @@ func (mr *MockQuerierMockRecorder) GetLatestCompleteRollupHour(ctx any) *gomock.
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestCompleteRollupHour", reflect.TypeOf((*MockQuerier)(nil).GetLatestCompleteRollupHour), ctx)
|
||||
}
|
||||
|
||||
// GetNeighborLinks mocks base method.
|
||||
func (m *MockQuerier) GetNeighborLinks(ctx context.Context, arg db.GetNeighborLinksParams) ([]db.GetNeighborLinksRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetNeighborLinks", ctx, arg)
|
||||
ret0, _ := ret[0].([]db.GetNeighborLinksRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetNeighborLinks indicates an expected call of GetNeighborLinks.
|
||||
func (mr *MockQuerierMockRecorder) GetNeighborLinks(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNeighborLinks", reflect.TypeOf((*MockQuerier)(nil).GetNeighborLinks), ctx, arg)
|
||||
}
|
||||
|
||||
// GetNodeByID mocks base method.
|
||||
func (m *MockQuerier) GetNodeByID(ctx context.Context, id uuid.UUID) (db.GetNodeByIDRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -1911,6 +1911,66 @@ func (mr *MockQuerierMockRecorder) ResolvePathHashesP4(ctx, arg any) *gomock.Cal
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolvePathHashesP4", reflect.TypeOf((*MockQuerier)(nil).ResolvePathHashesP4), ctx, arg)
|
||||
}
|
||||
|
||||
// ResolveRelayHashPairsP1 mocks base method.
|
||||
func (m *MockQuerier) ResolveRelayHashPairsP1(ctx context.Context, arg db.ResolveRelayHashPairsP1Params) ([]db.ResolveRelayHashPairsP1Row, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ResolveRelayHashPairsP1", ctx, arg)
|
||||
ret0, _ := ret[0].([]db.ResolveRelayHashPairsP1Row)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ResolveRelayHashPairsP1 indicates an expected call of ResolveRelayHashPairsP1.
|
||||
func (mr *MockQuerierMockRecorder) ResolveRelayHashPairsP1(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveRelayHashPairsP1", reflect.TypeOf((*MockQuerier)(nil).ResolveRelayHashPairsP1), ctx, arg)
|
||||
}
|
||||
|
||||
// ResolveRelayHashPairsP2 mocks base method.
|
||||
func (m *MockQuerier) ResolveRelayHashPairsP2(ctx context.Context, arg db.ResolveRelayHashPairsP2Params) ([]db.ResolveRelayHashPairsP2Row, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ResolveRelayHashPairsP2", ctx, arg)
|
||||
ret0, _ := ret[0].([]db.ResolveRelayHashPairsP2Row)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ResolveRelayHashPairsP2 indicates an expected call of ResolveRelayHashPairsP2.
|
||||
func (mr *MockQuerierMockRecorder) ResolveRelayHashPairsP2(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveRelayHashPairsP2", reflect.TypeOf((*MockQuerier)(nil).ResolveRelayHashPairsP2), ctx, arg)
|
||||
}
|
||||
|
||||
// ResolveRelayHashPairsP3 mocks base method.
|
||||
func (m *MockQuerier) ResolveRelayHashPairsP3(ctx context.Context, arg db.ResolveRelayHashPairsP3Params) ([]db.ResolveRelayHashPairsP3Row, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ResolveRelayHashPairsP3", ctx, arg)
|
||||
ret0, _ := ret[0].([]db.ResolveRelayHashPairsP3Row)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ResolveRelayHashPairsP3 indicates an expected call of ResolveRelayHashPairsP3.
|
||||
func (mr *MockQuerierMockRecorder) ResolveRelayHashPairsP3(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveRelayHashPairsP3", reflect.TypeOf((*MockQuerier)(nil).ResolveRelayHashPairsP3), ctx, arg)
|
||||
}
|
||||
|
||||
// ResolveRelayHashPairsP4 mocks base method.
|
||||
func (m *MockQuerier) ResolveRelayHashPairsP4(ctx context.Context, arg db.ResolveRelayHashPairsP4Params) ([]db.ResolveRelayHashPairsP4Row, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ResolveRelayHashPairsP4", ctx, arg)
|
||||
ret0, _ := ret[0].([]db.ResolveRelayHashPairsP4Row)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ResolveRelayHashPairsP4 indicates an expected call of ResolveRelayHashPairsP4.
|
||||
func (mr *MockQuerierMockRecorder) ResolveRelayHashPairsP4(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveRelayHashPairsP4", reflect.TypeOf((*MockQuerier)(nil).ResolveRelayHashPairsP4), ctx, arg)
|
||||
}
|
||||
|
||||
// RollAdvertHearings mocks base method.
|
||||
func (m *MockQuerier) RollAdvertHearings(ctx context.Context, hour pgtype.Timestamptz) error {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -2178,19 +2238,19 @@ func (mr *MockQuerierMockRecorder) SaveZoneList(ctx, arg any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveZoneList", reflect.TypeOf((*MockQuerier)(nil).SaveZoneList), ctx, arg)
|
||||
}
|
||||
|
||||
// SearchKnownRoutes mocks base method.
|
||||
func (m *MockQuerier) SearchKnownRoutes(ctx context.Context, arg db.SearchKnownRoutesParams) ([]db.SearchKnownRoutesRow, error) {
|
||||
// SearchKnownRoutesByNodes mocks base method.
|
||||
func (m *MockQuerier) SearchKnownRoutesByNodes(ctx context.Context, arg db.SearchKnownRoutesByNodesParams) ([]db.SearchKnownRoutesByNodesRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SearchKnownRoutes", ctx, arg)
|
||||
ret0, _ := ret[0].([]db.SearchKnownRoutesRow)
|
||||
ret := m.ctrl.Call(m, "SearchKnownRoutesByNodes", ctx, arg)
|
||||
ret0, _ := ret[0].([]db.SearchKnownRoutesByNodesRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// SearchKnownRoutes indicates an expected call of SearchKnownRoutes.
|
||||
func (mr *MockQuerierMockRecorder) SearchKnownRoutes(ctx, arg any) *gomock.Call {
|
||||
// SearchKnownRoutesByNodes indicates an expected call of SearchKnownRoutesByNodes.
|
||||
func (mr *MockQuerierMockRecorder) SearchKnownRoutesByNodes(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchKnownRoutes", reflect.TypeOf((*MockQuerier)(nil).SearchKnownRoutes), ctx, arg)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchKnownRoutesByNodes", reflect.TypeOf((*MockQuerier)(nil).SearchKnownRoutesByNodes), ctx, arg)
|
||||
}
|
||||
|
||||
// SetNodeDefaultScope mocks base method.
|
||||
|
||||
+18
-6
@@ -64,8 +64,6 @@ type Querier interface {
|
||||
// Both counters only grow, so their sum changes whenever rolled content or coverage does.
|
||||
GetAnalyticsRevision(ctx context.Context) (int64, error)
|
||||
GetChannelByID(ctx context.Context, id int32) (Channel, error)
|
||||
// Returns neighbors of a node that are in a different IATA.
|
||||
GetCrossIATANeighbors(ctx context.Context, arg GetCrossIATANeighborsParams) ([]GetCrossIATANeighborsRow, error)
|
||||
GetEarliestCompleteRollupHour(ctx context.Context) (pgtype.Timestamptz, error)
|
||||
// ============================================================
|
||||
// STATS
|
||||
@@ -76,8 +74,13 @@ type Querier interface {
|
||||
// when neither exists; a missing row (unknown IATA) is sql.ErrNoRows, same
|
||||
// not-found distinction GetIATA already makes.
|
||||
GetIATABorder(ctx context.Context, iata string) (json.RawMessage, error)
|
||||
GetKnownRoutesByNode(ctx context.Context, arg GetKnownRoutesByNodeParams) ([]GetKnownRoutesByNodeRow, error)
|
||||
// Routes containing @nodes[i] in IATA @iatas[i] (aligned, one node per IATA). The &&
|
||||
// drives the node_ids GIN index; the indexed check keeps LIMIT counting real matches.
|
||||
GetKnownRoutesByNodes(ctx context.Context, arg GetKnownRoutesByNodesParams) ([]GetKnownRoutesByNodesRow, error)
|
||||
GetLatestCompleteRollupHour(ctx context.Context) (pgtype.Timestamptz, error)
|
||||
// Neighbor rows between @a_nodes and @b_nodes in either orientation: ingest writes
|
||||
// adverts as (sender, forwarder) but traces as (receiver, sender). Callers normalise.
|
||||
GetNeighborLinks(ctx context.Context, arg GetNeighborLinksParams) ([]GetNeighborLinksRow, error)
|
||||
GetNodeByID(ctx context.Context, id uuid.UUID) (GetNodeByIDRow, error)
|
||||
GetNodeByPubkey(ctx context.Context, publicKey []byte) (uuid.UUID, error)
|
||||
// Returns the neighbors of a node with details, ordered by most recently seen.
|
||||
@@ -317,6 +320,14 @@ type Querier interface {
|
||||
ResolvePathHashesP2(ctx context.Context, arg ResolvePathHashesP2Params) ([]ResolvePathHashesP2Row, error)
|
||||
ResolvePathHashesP3(ctx context.Context, arg ResolvePathHashesP3Params) ([]ResolvePathHashesP3Row, error)
|
||||
ResolvePathHashesP4(ctx context.Context, arg ResolvePathHashesP4Params) ([]ResolvePathHashesP4Row, error)
|
||||
// Batch form of ResolvePathHashesP1 across IATAs; callers check uniqueness per IATA.
|
||||
ResolveRelayHashPairsP1(ctx context.Context, arg ResolveRelayHashPairsP1Params) ([]ResolveRelayHashPairsP1Row, error)
|
||||
// Batch form of ResolvePathHashesP2 across IATAs; callers check uniqueness per IATA.
|
||||
ResolveRelayHashPairsP2(ctx context.Context, arg ResolveRelayHashPairsP2Params) ([]ResolveRelayHashPairsP2Row, error)
|
||||
// Batch form of ResolvePathHashesP3 across IATAs; callers check uniqueness per IATA.
|
||||
ResolveRelayHashPairsP3(ctx context.Context, arg ResolveRelayHashPairsP3Params) ([]ResolveRelayHashPairsP3Row, error)
|
||||
// Batch form of ResolvePathHashesP4 across IATAs; callers check uniqueness per IATA.
|
||||
ResolveRelayHashPairsP4(ctx context.Context, arg ResolveRelayHashPairsP4Params) ([]ResolveRelayHashPairsP4Row, error)
|
||||
RollAdvertHearings(ctx context.Context, hour pgtype.Timestamptz) error
|
||||
// Each distinct ADVERT packet counts once, under the exact set of IATAs that heard it this hour.
|
||||
RollAdvertSets(ctx context.Context, hour pgtype.Timestamptz) error
|
||||
@@ -347,9 +358,10 @@ type Querier interface {
|
||||
SaveZoneBoundary(ctx context.Context, arg SaveZoneBoundaryParams) error
|
||||
// NULL payload/etag/fetched_at retain the last good list after an error or 304.
|
||||
SaveZoneList(ctx context.Context, arg SaveZoneListParams) error
|
||||
// Returns known routes containing a subsequence from source to destination hash prefix.
|
||||
// Verifies source appears before destination in the route.
|
||||
SearchKnownRoutes(ctx context.Context, arg SearchKnownRoutesParams) ([]SearchKnownRoutesRow, error)
|
||||
// Routes holding a source and a destination candidate; callers check order and IATA.
|
||||
// A route's hops resolve in its own IATA, so candidates from other IATAs can't match.
|
||||
// Both && predicates use the node_ids GIN index.
|
||||
SearchKnownRoutesByNodes(ctx context.Context, arg SearchKnownRoutesByNodesParams) ([]SearchKnownRoutesByNodesRow, error)
|
||||
SetNodeDefaultScope(ctx context.Context, arg SetNodeDefaultScopeParams) error
|
||||
SetNodeMultibytePaths(ctx context.Context, id uuid.UUID) error
|
||||
SetNodeMultibyteTraces(ctx context.Context, id uuid.UUID) error
|
||||
|
||||
+245
-88
@@ -403,65 +403,6 @@ func (q *Queries) GetChannelByID(ctx context.Context, id int32) (Channel, error)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getCrossIATANeighbors = `-- name: GetCrossIATANeighbors :many
|
||||
SELECT
|
||||
n.id, n.name, n.node_type, n.latitude, n.longitude,
|
||||
nn.iata AS neighbor_iata, nn.observation_count, nn.last_seen, nn.snr
|
||||
FROM node_neighbors nn
|
||||
JOIN nodes n ON n.id = nn.neighbor_id
|
||||
WHERE nn.node_id = $1
|
||||
AND nn.iata != $2
|
||||
ORDER BY nn.last_seen DESC
|
||||
`
|
||||
|
||||
type GetCrossIATANeighborsParams struct {
|
||||
NodeID uuid.UUID `json:"node_id"`
|
||||
Iata string `json:"iata"`
|
||||
}
|
||||
|
||||
type GetCrossIATANeighborsRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name *string `json:"name"`
|
||||
NodeType int16 `json:"node_type"`
|
||||
Latitude *float64 `json:"latitude"`
|
||||
Longitude *float64 `json:"longitude"`
|
||||
NeighborIata string `json:"neighbor_iata"`
|
||||
ObservationCount int64 `json:"observation_count"`
|
||||
LastSeen pgtype.Timestamptz `json:"last_seen"`
|
||||
Snr *float32 `json:"snr"`
|
||||
}
|
||||
|
||||
// Returns neighbors of a node that are in a different IATA.
|
||||
func (q *Queries) GetCrossIATANeighbors(ctx context.Context, arg GetCrossIATANeighborsParams) ([]GetCrossIATANeighborsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getCrossIATANeighbors, arg.NodeID, arg.Iata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []GetCrossIATANeighborsRow{}
|
||||
for rows.Next() {
|
||||
var i GetCrossIATANeighborsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.NodeType,
|
||||
&i.Latitude,
|
||||
&i.Longitude,
|
||||
&i.NeighborIata,
|
||||
&i.ObservationCount,
|
||||
&i.LastSeen,
|
||||
&i.Snr,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getHourlyStats = `-- name: GetHourlyStats :many
|
||||
|
||||
SELECT iata, hour, observation_count
|
||||
@@ -540,20 +481,23 @@ func (q *Queries) GetIATABorder(ctx context.Context, iata string) (json.RawMessa
|
||||
return border, err
|
||||
}
|
||||
|
||||
const getKnownRoutesByNode = `-- name: GetKnownRoutesByNode :many
|
||||
const getKnownRoutesByNodes = `-- name: GetKnownRoutesByNodes :many
|
||||
SELECT id, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen, observation_count
|
||||
FROM known_routes
|
||||
WHERE iata = $1
|
||||
AND $2::uuid = ANY(node_ids)
|
||||
FROM known_routes kr
|
||||
WHERE kr.node_ids && $1::uuid[]
|
||||
AND kr.iata = ANY($2::bpchar[])
|
||||
AND ($1::uuid[])[array_position($2::bpchar[], kr.iata)] = ANY(kr.node_ids)
|
||||
ORDER BY hop_count ASC, last_seen DESC
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type GetKnownRoutesByNodeParams struct {
|
||||
Iata string `json:"iata"`
|
||||
Column2 uuid.UUID `json:"column_2"`
|
||||
type GetKnownRoutesByNodesParams struct {
|
||||
Nodes []uuid.UUID `json:"nodes"`
|
||||
Iatas []string `json:"iatas"`
|
||||
RowLimit int32 `json:"row_limit"`
|
||||
}
|
||||
|
||||
type GetKnownRoutesByNodeRow struct {
|
||||
type GetKnownRoutesByNodesRow struct {
|
||||
ID int64 `json:"id"`
|
||||
NodeIds []uuid.UUID `json:"node_ids"`
|
||||
HashPrefix [][]byte `json:"hash_prefix"`
|
||||
@@ -564,15 +508,17 @@ type GetKnownRoutesByNodeRow struct {
|
||||
ObservationCount int64 `json:"observation_count"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetKnownRoutesByNode(ctx context.Context, arg GetKnownRoutesByNodeParams) ([]GetKnownRoutesByNodeRow, error) {
|
||||
rows, err := q.db.Query(ctx, getKnownRoutesByNode, arg.Iata, arg.Column2)
|
||||
// Routes containing @nodes[i] in IATA @iatas[i] (aligned, one node per IATA). The &&
|
||||
// drives the node_ids GIN index; the indexed check keeps LIMIT counting real matches.
|
||||
func (q *Queries) GetKnownRoutesByNodes(ctx context.Context, arg GetKnownRoutesByNodesParams) ([]GetKnownRoutesByNodesRow, error) {
|
||||
rows, err := q.db.Query(ctx, getKnownRoutesByNodes, arg.Nodes, arg.Iatas, arg.RowLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []GetKnownRoutesByNodeRow{}
|
||||
items := []GetKnownRoutesByNodesRow{}
|
||||
for rows.Next() {
|
||||
var i GetKnownRoutesByNodeRow
|
||||
var i GetKnownRoutesByNodesRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.NodeIds,
|
||||
@@ -593,6 +539,46 @@ func (q *Queries) GetKnownRoutesByNode(ctx context.Context, arg GetKnownRoutesBy
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getNeighborLinks = `-- name: GetNeighborLinks :many
|
||||
SELECT node_id, neighbor_id, last_seen
|
||||
FROM node_neighbors
|
||||
WHERE (node_id = ANY($1::uuid[]) AND neighbor_id = ANY($2::uuid[]))
|
||||
OR (node_id = ANY($2::uuid[]) AND neighbor_id = ANY($1::uuid[]))
|
||||
`
|
||||
|
||||
type GetNeighborLinksParams struct {
|
||||
ANodes []uuid.UUID `json:"a_nodes"`
|
||||
BNodes []uuid.UUID `json:"b_nodes"`
|
||||
}
|
||||
|
||||
type GetNeighborLinksRow struct {
|
||||
NodeID uuid.UUID `json:"node_id"`
|
||||
NeighborID uuid.UUID `json:"neighbor_id"`
|
||||
LastSeen pgtype.Timestamptz `json:"last_seen"`
|
||||
}
|
||||
|
||||
// Neighbor rows between @a_nodes and @b_nodes in either orientation: ingest writes
|
||||
// adverts as (sender, forwarder) but traces as (receiver, sender). Callers normalise.
|
||||
func (q *Queries) GetNeighborLinks(ctx context.Context, arg GetNeighborLinksParams) ([]GetNeighborLinksRow, error) {
|
||||
rows, err := q.db.Query(ctx, getNeighborLinks, arg.ANodes, arg.BNodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []GetNeighborLinksRow{}
|
||||
for rows.Next() {
|
||||
var i GetNeighborLinksRow
|
||||
if err := rows.Scan(&i.NodeID, &i.NeighborID, &i.LastSeen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getNodeByID = `-- name: GetNodeByID :one
|
||||
SELECT n.id, n.public_key, n.node_type, n.name, n.latitude, n.longitude, n.location_source, n.last_advert_at, n.supports_multibyte_paths, n.supports_multibyte_traces, n.default_scope_id, n.min_firmware_version, n.first_seen, n.last_seen, n.radio_freq_mhz, n.radio_sf, n.radio_bw_khz, n.metadata, n.device_clock_drift_seconds, ts.name AS default_scope_name,
|
||||
EXISTS (SELECT 1 FROM observers o WHERE o.public_key = n.public_key) AS is_observer,
|
||||
@@ -4856,6 +4842,170 @@ func (q *Queries) ResolvePathHashesP4(ctx context.Context, arg ResolvePathHashes
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const resolveRelayHashPairsP1 = `-- name: ResolveRelayHashPairsP1 :many
|
||||
SELECT ns.iata, ns.prefix_4 AS hash, ns.node_id
|
||||
FROM node_short_ids ns
|
||||
JOIN nodes n ON n.id = ns.node_id
|
||||
WHERE ns.iata = ANY($1::bpchar[])
|
||||
AND n.node_type IN (2, 3)
|
||||
AND ns.prefix_1 = ANY($2::bytea[])
|
||||
`
|
||||
|
||||
type ResolveRelayHashPairsP1Params struct {
|
||||
Iatas []string `json:"iatas"`
|
||||
Hashes [][]byte `json:"hashes"`
|
||||
}
|
||||
|
||||
type ResolveRelayHashPairsP1Row struct {
|
||||
Iata string `json:"iata"`
|
||||
Hash []byte `json:"hash"`
|
||||
NodeID uuid.UUID `json:"node_id"`
|
||||
}
|
||||
|
||||
// Batch form of ResolvePathHashesP1 across IATAs; callers check uniqueness per IATA.
|
||||
func (q *Queries) ResolveRelayHashPairsP1(ctx context.Context, arg ResolveRelayHashPairsP1Params) ([]ResolveRelayHashPairsP1Row, error) {
|
||||
rows, err := q.db.Query(ctx, resolveRelayHashPairsP1, arg.Iatas, arg.Hashes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ResolveRelayHashPairsP1Row{}
|
||||
for rows.Next() {
|
||||
var i ResolveRelayHashPairsP1Row
|
||||
if err := rows.Scan(&i.Iata, &i.Hash, &i.NodeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const resolveRelayHashPairsP2 = `-- name: ResolveRelayHashPairsP2 :many
|
||||
SELECT ns.iata, ns.prefix_4 AS hash, ns.node_id
|
||||
FROM node_short_ids ns
|
||||
JOIN nodes n ON n.id = ns.node_id
|
||||
WHERE ns.iata = ANY($1::bpchar[])
|
||||
AND n.node_type IN (2, 3)
|
||||
AND ns.prefix_2 = ANY($2::bytea[])
|
||||
`
|
||||
|
||||
type ResolveRelayHashPairsP2Params struct {
|
||||
Iatas []string `json:"iatas"`
|
||||
Hashes [][]byte `json:"hashes"`
|
||||
}
|
||||
|
||||
type ResolveRelayHashPairsP2Row struct {
|
||||
Iata string `json:"iata"`
|
||||
Hash []byte `json:"hash"`
|
||||
NodeID uuid.UUID `json:"node_id"`
|
||||
}
|
||||
|
||||
// Batch form of ResolvePathHashesP2 across IATAs; callers check uniqueness per IATA.
|
||||
func (q *Queries) ResolveRelayHashPairsP2(ctx context.Context, arg ResolveRelayHashPairsP2Params) ([]ResolveRelayHashPairsP2Row, error) {
|
||||
rows, err := q.db.Query(ctx, resolveRelayHashPairsP2, arg.Iatas, arg.Hashes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ResolveRelayHashPairsP2Row{}
|
||||
for rows.Next() {
|
||||
var i ResolveRelayHashPairsP2Row
|
||||
if err := rows.Scan(&i.Iata, &i.Hash, &i.NodeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const resolveRelayHashPairsP3 = `-- name: ResolveRelayHashPairsP3 :many
|
||||
SELECT ns.iata, ns.prefix_4 AS hash, ns.node_id
|
||||
FROM node_short_ids ns
|
||||
JOIN nodes n ON n.id = ns.node_id
|
||||
WHERE ns.iata = ANY($1::bpchar[])
|
||||
AND n.node_type IN (2, 3)
|
||||
AND ns.prefix_3 = ANY($2::bytea[])
|
||||
`
|
||||
|
||||
type ResolveRelayHashPairsP3Params struct {
|
||||
Iatas []string `json:"iatas"`
|
||||
Hashes [][]byte `json:"hashes"`
|
||||
}
|
||||
|
||||
type ResolveRelayHashPairsP3Row struct {
|
||||
Iata string `json:"iata"`
|
||||
Hash []byte `json:"hash"`
|
||||
NodeID uuid.UUID `json:"node_id"`
|
||||
}
|
||||
|
||||
// Batch form of ResolvePathHashesP3 across IATAs; callers check uniqueness per IATA.
|
||||
func (q *Queries) ResolveRelayHashPairsP3(ctx context.Context, arg ResolveRelayHashPairsP3Params) ([]ResolveRelayHashPairsP3Row, error) {
|
||||
rows, err := q.db.Query(ctx, resolveRelayHashPairsP3, arg.Iatas, arg.Hashes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ResolveRelayHashPairsP3Row{}
|
||||
for rows.Next() {
|
||||
var i ResolveRelayHashPairsP3Row
|
||||
if err := rows.Scan(&i.Iata, &i.Hash, &i.NodeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const resolveRelayHashPairsP4 = `-- name: ResolveRelayHashPairsP4 :many
|
||||
SELECT ns.iata, ns.prefix_4 AS hash, ns.node_id
|
||||
FROM node_short_ids ns
|
||||
JOIN nodes n ON n.id = ns.node_id
|
||||
WHERE ns.iata = ANY($1::bpchar[])
|
||||
AND n.node_type IN (2, 3)
|
||||
AND ns.prefix_4 = ANY($2::bytea[])
|
||||
`
|
||||
|
||||
type ResolveRelayHashPairsP4Params struct {
|
||||
Iatas []string `json:"iatas"`
|
||||
Hashes [][]byte `json:"hashes"`
|
||||
}
|
||||
|
||||
type ResolveRelayHashPairsP4Row struct {
|
||||
Iata string `json:"iata"`
|
||||
Hash []byte `json:"hash"`
|
||||
NodeID uuid.UUID `json:"node_id"`
|
||||
}
|
||||
|
||||
// Batch form of ResolvePathHashesP4 across IATAs; callers check uniqueness per IATA.
|
||||
func (q *Queries) ResolveRelayHashPairsP4(ctx context.Context, arg ResolveRelayHashPairsP4Params) ([]ResolveRelayHashPairsP4Row, error) {
|
||||
rows, err := q.db.Query(ctx, resolveRelayHashPairsP4, arg.Iatas, arg.Hashes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ResolveRelayHashPairsP4Row{}
|
||||
for rows.Next() {
|
||||
var i ResolveRelayHashPairsP4Row
|
||||
if err := rows.Scan(&i.Iata, &i.Hash, &i.NodeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const saveChannelCatalogue = `-- name: SaveChannelCatalogue :exec
|
||||
INSERT INTO meshmapper_channel_catalogues (iata, url, payload, etag, checked_at, attempted_at, next_attempt, last_error)
|
||||
VALUES ($1, $2, $3::jsonb, $4::text,
|
||||
@@ -5026,23 +5176,24 @@ func (q *Queries) SaveZoneList(ctx context.Context, arg SaveZoneListParams) erro
|
||||
return err
|
||||
}
|
||||
|
||||
const searchKnownRoutes = `-- name: SearchKnownRoutes :many
|
||||
const searchKnownRoutesByNodes = `-- name: SearchKnownRoutesByNodes :many
|
||||
SELECT id, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen, observation_count
|
||||
FROM known_routes
|
||||
WHERE iata = $1
|
||||
AND array_position(hash_prefix, $2::bytea) IS NOT NULL
|
||||
AND array_position(hash_prefix, $3::bytea) IS NOT NULL
|
||||
AND array_position(hash_prefix, $2::bytea) < array_position(hash_prefix, $3::bytea)
|
||||
FROM known_routes kr
|
||||
WHERE kr.node_ids && $1::uuid[]
|
||||
AND kr.node_ids && $2::uuid[]
|
||||
AND kr.iata = ANY($3::bpchar[])
|
||||
ORDER BY hop_count ASC, last_seen DESC
|
||||
LIMIT $4
|
||||
`
|
||||
|
||||
type SearchKnownRoutesParams struct {
|
||||
Iata string `json:"iata"`
|
||||
Column2 []byte `json:"column_2"`
|
||||
Column3 []byte `json:"column_3"`
|
||||
type SearchKnownRoutesByNodesParams struct {
|
||||
FromNodes []uuid.UUID `json:"from_nodes"`
|
||||
ToNodes []uuid.UUID `json:"to_nodes"`
|
||||
Iatas []string `json:"iatas"`
|
||||
RowLimit int32 `json:"row_limit"`
|
||||
}
|
||||
|
||||
type SearchKnownRoutesRow struct {
|
||||
type SearchKnownRoutesByNodesRow struct {
|
||||
ID int64 `json:"id"`
|
||||
NodeIds []uuid.UUID `json:"node_ids"`
|
||||
HashPrefix [][]byte `json:"hash_prefix"`
|
||||
@@ -5053,17 +5204,23 @@ type SearchKnownRoutesRow struct {
|
||||
ObservationCount int64 `json:"observation_count"`
|
||||
}
|
||||
|
||||
// Returns known routes containing a subsequence from source to destination hash prefix.
|
||||
// Verifies source appears before destination in the route.
|
||||
func (q *Queries) SearchKnownRoutes(ctx context.Context, arg SearchKnownRoutesParams) ([]SearchKnownRoutesRow, error) {
|
||||
rows, err := q.db.Query(ctx, searchKnownRoutes, arg.Iata, arg.Column2, arg.Column3)
|
||||
// Routes holding a source and a destination candidate; callers check order and IATA.
|
||||
// A route's hops resolve in its own IATA, so candidates from other IATAs can't match.
|
||||
// Both && predicates use the node_ids GIN index.
|
||||
func (q *Queries) SearchKnownRoutesByNodes(ctx context.Context, arg SearchKnownRoutesByNodesParams) ([]SearchKnownRoutesByNodesRow, error) {
|
||||
rows, err := q.db.Query(ctx, searchKnownRoutesByNodes,
|
||||
arg.FromNodes,
|
||||
arg.ToNodes,
|
||||
arg.Iatas,
|
||||
arg.RowLimit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []SearchKnownRoutesRow{}
|
||||
items := []SearchKnownRoutesByNodesRow{}
|
||||
for rows.Next() {
|
||||
var i SearchKnownRoutesRow
|
||||
var i SearchKnownRoutesByNodesRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.NodeIds,
|
||||
|
||||
+62
-22
@@ -899,14 +899,14 @@ const docTemplate = `{
|
||||
},
|
||||
"/info": {
|
||||
"get": {
|
||||
"description": "minAppVersion is null when the server sets no requirement.",
|
||||
"description": "minAppVersion (BEACON Mobile) and minWebVersion (Beacon Web) are null when the\nserver sets no requirement. Each is the higher of the built-in floor and config.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Info"
|
||||
],
|
||||
"summary": "Server info and minimum mobile app version",
|
||||
"summary": "Server info and minimum client versions",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -2201,6 +2201,7 @@ const docTemplate = `{
|
||||
},
|
||||
"/routes/cross": {
|
||||
"get": {
|
||||
"description": "Pass iatas (comma-separated, optional; omitted searches every IATA) or the\nsingle directed pair fromIata/toIata, not both.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -2211,31 +2212,35 @@ const docTemplate = `{
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source node hash prefix (hex)",
|
||||
"description": "Source node hash prefix (hex, 1-4 bytes)",
|
||||
"name": "fromHash",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source IATA code",
|
||||
"name": "fromIata",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Destination node hash prefix (hex)",
|
||||
"description": "Destination node hash prefix (hex, 1-4 bytes)",
|
||||
"name": "toHash",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Destination IATA code",
|
||||
"description": "Comma-separated IATA codes to search (2-100)",
|
||||
"name": "iatas",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source IATA code (with toIata)",
|
||||
"name": "fromIata",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Destination IATA code (with fromIata)",
|
||||
"name": "toIata",
|
||||
"in": "query",
|
||||
"required": true
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -2259,12 +2264,19 @@ const docTemplate = `{
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/routes/search": {
|
||||
"get": {
|
||||
"description": "Routes within one IATA that run from the source hash to the destination hash,\ntrimmed to that span. iatas is optional (omitted searches every IATA); the single\niata is still accepted. At most 500, shortest first.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -2275,21 +2287,26 @@ const docTemplate = `{
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "IATA code to search within",
|
||||
"name": "iata",
|
||||
"in": "query",
|
||||
"required": true
|
||||
"description": "Comma-separated IATA codes to search (max 100)",
|
||||
"name": "iatas",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source node hash prefix (hex)",
|
||||
"description": "Single IATA code (when iatas is omitted)",
|
||||
"name": "iata",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source node hash prefix (hex, 1-4 bytes)",
|
||||
"name": "from",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Destination node hash prefix (hex)",
|
||||
"description": "Destination node hash prefix (hex, 1-4 bytes)",
|
||||
"name": "to",
|
||||
"in": "query",
|
||||
"required": true
|
||||
@@ -2316,6 +2333,12 @@ const docTemplate = `{
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3662,6 +3685,9 @@ const docTemplate = `{
|
||||
},
|
||||
"mobile": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminMobileConfig"
|
||||
},
|
||||
"web": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminWebConfig"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3678,7 +3704,16 @@ const docTemplate = `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min_app_version": {
|
||||
"description": "empty means no requirement",
|
||||
"description": "effective floor; empty means no requirement",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.AdminWebConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min_web_version": {
|
||||
"description": "effective floor; empty means no requirement",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
@@ -3987,6 +4022,11 @@ const docTemplate = `{
|
||||
"type": "string",
|
||||
"example": "0.1.1"
|
||||
},
|
||||
"minWebVersion": {
|
||||
"description": "MinWebVersion is the oldest allowed Beacon Web version (X.Y.Z); null means no requirement.",
|
||||
"type": "string",
|
||||
"example": "2.0.3"
|
||||
},
|
||||
"serverVersion": {
|
||||
"type": "string",
|
||||
"example": "2.0.2"
|
||||
@@ -6086,7 +6126,7 @@ const docTemplate = `{
|
||||
|
||||
// SwaggerInfo holds exported Swagger Info so clients can modify it
|
||||
var SwaggerInfo = &swag.Spec{
|
||||
Version: "2.0.3",
|
||||
Version: "2.0.4",
|
||||
Host: "localhost:8080",
|
||||
BasePath: "/api/v1",
|
||||
Schemes: []string{"http", "https"},
|
||||
|
||||
+62
-22
@@ -15,7 +15,7 @@
|
||||
"license": {
|
||||
"name": "AGPL-3-or-later"
|
||||
},
|
||||
"version": "2.0.3"
|
||||
"version": "2.0.4"
|
||||
},
|
||||
"host": "localhost:8080",
|
||||
"basePath": "/api/v1",
|
||||
@@ -897,14 +897,14 @@
|
||||
},
|
||||
"/info": {
|
||||
"get": {
|
||||
"description": "minAppVersion is null when the server sets no requirement.",
|
||||
"description": "minAppVersion (BEACON Mobile) and minWebVersion (Beacon Web) are null when the\nserver sets no requirement. Each is the higher of the built-in floor and config.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Info"
|
||||
],
|
||||
"summary": "Server info and minimum mobile app version",
|
||||
"summary": "Server info and minimum client versions",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -2199,6 +2199,7 @@
|
||||
},
|
||||
"/routes/cross": {
|
||||
"get": {
|
||||
"description": "Pass iatas (comma-separated, optional; omitted searches every IATA) or the\nsingle directed pair fromIata/toIata, not both.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -2209,31 +2210,35 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source node hash prefix (hex)",
|
||||
"description": "Source node hash prefix (hex, 1-4 bytes)",
|
||||
"name": "fromHash",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source IATA code",
|
||||
"name": "fromIata",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Destination node hash prefix (hex)",
|
||||
"description": "Destination node hash prefix (hex, 1-4 bytes)",
|
||||
"name": "toHash",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Destination IATA code",
|
||||
"description": "Comma-separated IATA codes to search (2-100)",
|
||||
"name": "iatas",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source IATA code (with toIata)",
|
||||
"name": "fromIata",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Destination IATA code (with fromIata)",
|
||||
"name": "toIata",
|
||||
"in": "query",
|
||||
"required": true
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -2257,12 +2262,19 @@
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/routes/search": {
|
||||
"get": {
|
||||
"description": "Routes within one IATA that run from the source hash to the destination hash,\ntrimmed to that span. iatas is optional (omitted searches every IATA); the single\niata is still accepted. At most 500, shortest first.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -2273,21 +2285,26 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "IATA code to search within",
|
||||
"name": "iata",
|
||||
"in": "query",
|
||||
"required": true
|
||||
"description": "Comma-separated IATA codes to search (max 100)",
|
||||
"name": "iatas",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source node hash prefix (hex)",
|
||||
"description": "Single IATA code (when iatas is omitted)",
|
||||
"name": "iata",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source node hash prefix (hex, 1-4 bytes)",
|
||||
"name": "from",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Destination node hash prefix (hex)",
|
||||
"description": "Destination node hash prefix (hex, 1-4 bytes)",
|
||||
"name": "to",
|
||||
"in": "query",
|
||||
"required": true
|
||||
@@ -2314,6 +2331,12 @@
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3660,6 +3683,9 @@
|
||||
},
|
||||
"mobile": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminMobileConfig"
|
||||
},
|
||||
"web": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminWebConfig"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3676,7 +3702,16 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min_app_version": {
|
||||
"description": "empty means no requirement",
|
||||
"description": "effective floor; empty means no requirement",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.AdminWebConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min_web_version": {
|
||||
"description": "effective floor; empty means no requirement",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
@@ -3985,6 +4020,11 @@
|
||||
"type": "string",
|
||||
"example": "0.1.1"
|
||||
},
|
||||
"minWebVersion": {
|
||||
"description": "MinWebVersion is the oldest allowed Beacon Web version (X.Y.Z); null means no requirement.",
|
||||
"type": "string",
|
||||
"example": "2.0.3"
|
||||
},
|
||||
"serverVersion": {
|
||||
"type": "string",
|
||||
"example": "2.0.2"
|
||||
|
||||
+52
-17
@@ -54,6 +54,8 @@ definitions:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminIngestConfig'
|
||||
mobile:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminMobileConfig'
|
||||
web:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminWebConfig'
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.AdminIngestConfig:
|
||||
properties:
|
||||
@@ -66,7 +68,13 @@ definitions:
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.AdminMobileConfig:
|
||||
properties:
|
||||
min_app_version:
|
||||
description: empty means no requirement
|
||||
description: effective floor; empty means no requirement
|
||||
type: string
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.AdminWebConfig:
|
||||
properties:
|
||||
min_web_version:
|
||||
description: effective floor; empty means no requirement
|
||||
type: string
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.AdvertObservation:
|
||||
@@ -279,6 +287,11 @@ definitions:
|
||||
means no requirement.
|
||||
example: 0.1.1
|
||||
type: string
|
||||
minWebVersion:
|
||||
description: MinWebVersion is the oldest allowed Beacon Web version (X.Y.Z);
|
||||
null means no requirement.
|
||||
example: 2.0.3
|
||||
type: string
|
||||
serverVersion:
|
||||
example: 2.0.2
|
||||
type: string
|
||||
@@ -1727,7 +1740,7 @@ info:
|
||||
name: AGPL-3-or-later
|
||||
termsOfService: https://github.com/MeshCore-Beacon/beacon-server
|
||||
title: MeshCore Beacon API
|
||||
version: 2.0.3
|
||||
version: 2.0.4
|
||||
paths:
|
||||
/admin/accounts:
|
||||
get:
|
||||
@@ -2322,7 +2335,9 @@ paths:
|
||||
- IATAs
|
||||
/info:
|
||||
get:
|
||||
description: minAppVersion is null when the server sets no requirement.
|
||||
description: |-
|
||||
minAppVersion (BEACON Mobile) and minWebVersion (Beacon Web) are null when the
|
||||
server sets no requirement. Each is the higher of the built-in floor and config.
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -2330,7 +2345,7 @@ paths:
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Info'
|
||||
summary: Server info and minimum mobile app version
|
||||
summary: Server info and minimum client versions
|
||||
tags:
|
||||
- Info
|
||||
/messages:
|
||||
@@ -3275,26 +3290,31 @@ paths:
|
||||
- Routes
|
||||
/routes/cross:
|
||||
get:
|
||||
description: |-
|
||||
Pass iatas (comma-separated, optional; omitted searches every IATA) or the
|
||||
single directed pair fromIata/toIata, not both.
|
||||
parameters:
|
||||
- description: Source node hash prefix (hex)
|
||||
- description: Source node hash prefix (hex, 1-4 bytes)
|
||||
in: query
|
||||
name: fromHash
|
||||
required: true
|
||||
type: string
|
||||
- description: Source IATA code
|
||||
in: query
|
||||
name: fromIata
|
||||
required: true
|
||||
type: string
|
||||
- description: Destination node hash prefix (hex)
|
||||
- description: Destination node hash prefix (hex, 1-4 bytes)
|
||||
in: query
|
||||
name: toHash
|
||||
required: true
|
||||
type: string
|
||||
- description: Destination IATA code
|
||||
- description: Comma-separated IATA codes to search (2-100)
|
||||
in: query
|
||||
name: iatas
|
||||
type: string
|
||||
- description: Source IATA code (with toIata)
|
||||
in: query
|
||||
name: fromIata
|
||||
type: string
|
||||
- description: Destination IATA code (with fromIata)
|
||||
in: query
|
||||
name: toIata
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
@@ -3313,23 +3333,34 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
"503":
|
||||
description: Service Unavailable
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
summary: Search for routes that cross IATA boundaries
|
||||
tags:
|
||||
- Routes
|
||||
/routes/search:
|
||||
get:
|
||||
description: |-
|
||||
Routes within one IATA that run from the source hash to the destination hash,
|
||||
trimmed to that span. iatas is optional (omitted searches every IATA); the single
|
||||
iata is still accepted. At most 500, shortest first.
|
||||
parameters:
|
||||
- description: IATA code to search within
|
||||
- description: Comma-separated IATA codes to search (max 100)
|
||||
in: query
|
||||
name: iatas
|
||||
type: string
|
||||
- description: Single IATA code (when iatas is omitted)
|
||||
in: query
|
||||
name: iata
|
||||
required: true
|
||||
type: string
|
||||
- description: Source node hash prefix (hex)
|
||||
- description: Source node hash prefix (hex, 1-4 bytes)
|
||||
in: query
|
||||
name: from
|
||||
required: true
|
||||
type: string
|
||||
- description: Destination node hash prefix (hex)
|
||||
- description: Destination node hash prefix (hex, 1-4 bytes)
|
||||
in: query
|
||||
name: to
|
||||
required: true
|
||||
@@ -3351,6 +3382,10 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
"503":
|
||||
description: Service Unavailable
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
summary: Search known routes by source and destination hash
|
||||
tags:
|
||||
- Routes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/MeshCore-Beacon/beacon-server
|
||||
|
||||
go 1.26.6
|
||||
go 1.26.9
|
||||
|
||||
require (
|
||||
github.com/alicebob/miniredis/v2 v2.38.0
|
||||
@@ -18,7 +18,7 @@ require (
|
||||
github.com/swaggo/http-swagger v1.3.4
|
||||
github.com/swaggo/swag v1.16.6
|
||||
go.uber.org/mock v0.6.0
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/sync v0.24.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -43,11 +43,11 @@ require (
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.39.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
golang.org/x/crypto v0.58.0 // indirect
|
||||
golang.org/x/mod v0.41.0 // indirect
|
||||
golang.org/x/net v0.61.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/text v0.43.0 // indirect
|
||||
golang.org/x/tools v0.51.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
@@ -100,26 +100,26 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
|
||||
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
|
||||
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -10,10 +10,15 @@ type AdminConfig struct {
|
||||
CORS AdminCORSConfig `json:"cors"`
|
||||
Ingest AdminIngestConfig `json:"ingest"`
|
||||
Mobile AdminMobileConfig `json:"mobile"`
|
||||
Web AdminWebConfig `json:"web"`
|
||||
}
|
||||
|
||||
type AdminMobileConfig struct {
|
||||
MinAppVersion string `json:"min_app_version"` // empty means no requirement
|
||||
MinAppVersion string `json:"min_app_version"` // effective floor; empty means no requirement
|
||||
}
|
||||
|
||||
type AdminWebConfig struct {
|
||||
MinWebVersion string `json:"min_web_version"` // effective floor; empty means no requirement
|
||||
}
|
||||
|
||||
type AdminAuthConfig struct {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// InfoRouter mounts GET /info. The mobile app treats a 404 as "no requirement",
|
||||
// InfoRouter mounts GET /info. Clients treat a 404 as "no requirement",
|
||||
// so this route must stay mounted once shipped.
|
||||
func InfoRouter(info api.Info) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
@@ -20,8 +20,9 @@ func InfoRouter(info api.Info) http.Handler {
|
||||
|
||||
// getInfo godoc
|
||||
//
|
||||
// @Summary Server info and minimum mobile app version
|
||||
// @Description minAppVersion is null when the server sets no requirement.
|
||||
// @Summary Server info and minimum client versions
|
||||
// @Description minAppVersion (BEACON Mobile) and minWebVersion (Beacon Web) are null when the
|
||||
// @Description server sets no requirement. Each is the higher of the built-in floor and config.
|
||||
// @Tags Info
|
||||
// @Produce json
|
||||
// @Success 200 {object} api.Info
|
||||
|
||||
@@ -13,14 +13,14 @@ import (
|
||||
)
|
||||
|
||||
func TestGetInfo(t *testing.T) {
|
||||
version := "0.1.1"
|
||||
version, web := "0.1.1", "2.0.3"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
info api.Info
|
||||
want string
|
||||
}{
|
||||
{"unset", api.Info{ServerVersion: "2.0.2"}, `{"minAppVersion":null,"serverVersion":"2.0.2"}`},
|
||||
{"set", api.Info{MinAppVersion: &version, ServerVersion: "2.0.2"}, `{"minAppVersion":"0.1.1","serverVersion":"2.0.2"}`},
|
||||
{"unset", api.Info{ServerVersion: "2.0.2"}, `{"minAppVersion":null,"minWebVersion":null,"serverVersion":"2.0.2"}`},
|
||||
{"set", api.Info{MinAppVersion: &version, MinWebVersion: &web, ServerVersion: "2.0.2"}, `{"minAppVersion":"0.1.1","minWebVersion":"2.0.3","serverVersion":"2.0.2"}`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
+142
-36
@@ -4,6 +4,10 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -83,30 +87,47 @@ func listKnownRoutes(reader api.Reader) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// maxRouteSearchIATAs bounds the iatas list; the searches themselves are bounded by row limits.
|
||||
const maxRouteSearchIATAs = 100
|
||||
|
||||
// searchKnownRoutes godoc
|
||||
//
|
||||
// @Summary Search known routes by source and destination hash
|
||||
// @Tags Routes
|
||||
// @Produce json
|
||||
// @Param iata query string true "IATA code to search within"
|
||||
// @Param from query string true "Source node hash prefix (hex)"
|
||||
// @Param to query string true "Destination node hash prefix (hex)"
|
||||
// @Success 200 {object} []api.KnownRoute
|
||||
// @Failure 400 {object} handlers.APIError
|
||||
// @Failure 500 {object} handlers.APIError
|
||||
// @Router /routes/search [get]
|
||||
// @Summary Search known routes by source and destination hash
|
||||
// @Description Routes within one IATA that run from the source hash to the destination hash,
|
||||
// @Description trimmed to that span. iatas is optional (omitted searches every IATA); the single
|
||||
// @Description iata is still accepted. At most 500, shortest first.
|
||||
// @Tags Routes
|
||||
// @Produce json
|
||||
// @Param iatas query string false "Comma-separated IATA codes to search (max 100)"
|
||||
// @Param iata query string false "Single IATA code (when iatas is omitted)"
|
||||
// @Param from query string true "Source node hash prefix (hex, 1-4 bytes)"
|
||||
// @Param to query string true "Destination node hash prefix (hex, 1-4 bytes)"
|
||||
// @Success 200 {object} []api.KnownRoute
|
||||
// @Failure 400 {object} handlers.APIError
|
||||
// @Failure 500 {object} handlers.APIError
|
||||
// @Failure 503 {object} handlers.APIError
|
||||
// @Router /routes/search [get]
|
||||
func searchKnownRoutes(reader api.Reader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
iata := strings.ToUpper(r.URL.Query().Get("iata"))
|
||||
iatas, iataErr := routeIATAs(r)
|
||||
from := strings.ToLower(r.URL.Query().Get("from"))
|
||||
to := strings.ToLower(r.URL.Query().Get("to"))
|
||||
if iata == "" || from == "" || to == "" {
|
||||
respondError(w, http.StatusBadRequest, "iata, from and to are required")
|
||||
if from == "" || to == "" {
|
||||
respondError(w, http.StatusBadRequest, "from and to are required")
|
||||
return
|
||||
}
|
||||
routes, err := reader.SearchKnownRoutes(r.Context(), iata, from, to)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
if !validHopHash(from) || !validHopHash(to) {
|
||||
respondError(w, http.StatusBadRequest, "from and to must be 1-4 bytes of hex")
|
||||
return
|
||||
}
|
||||
if iataErr != nil {
|
||||
respondError(w, http.StatusBadRequest, iataErr.Error())
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
routes, err := reader.SearchKnownRoutes(ctx, iatas, from, to)
|
||||
if respondRouteSearchError(w, ctx, err) {
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, routes)
|
||||
@@ -115,30 +136,32 @@ func searchKnownRoutes(reader api.Reader) http.HandlerFunc {
|
||||
|
||||
// searchCrossIATARoutes godoc
|
||||
//
|
||||
// @Summary Search for routes that cross IATA boundaries
|
||||
// @Tags Routes
|
||||
// @Produce json
|
||||
// @Param fromHash query string true "Source node hash prefix (hex)"
|
||||
// @Param fromIata query string true "Source IATA code"
|
||||
// @Param toHash query string true "Destination node hash prefix (hex)"
|
||||
// @Param toIata query string true "Destination IATA code"
|
||||
// @Success 200 {object} []api.CrossIATARoute
|
||||
// @Failure 400 {object} handlers.APIError
|
||||
// @Failure 500 {object} handlers.APIError
|
||||
// @Router /routes/cross [get]
|
||||
// @Summary Search for routes that cross IATA boundaries
|
||||
// @Description Pass iatas (comma-separated, optional; omitted searches every IATA) or the
|
||||
// @Description single directed pair fromIata/toIata, not both.
|
||||
// @Tags Routes
|
||||
// @Produce json
|
||||
// @Param fromHash query string true "Source node hash prefix (hex, 1-4 bytes)"
|
||||
// @Param toHash query string true "Destination node hash prefix (hex, 1-4 bytes)"
|
||||
// @Param iatas query string false "Comma-separated IATA codes to search (2-100)"
|
||||
// @Param fromIata query string false "Source IATA code (with toIata)"
|
||||
// @Param toIata query string false "Destination IATA code (with fromIata)"
|
||||
// @Success 200 {object} []api.CrossIATARoute
|
||||
// @Failure 400 {object} handlers.APIError
|
||||
// @Failure 500 {object} handlers.APIError
|
||||
// @Failure 503 {object} handlers.APIError
|
||||
// @Router /routes/cross [get]
|
||||
func searchCrossIATARoutes(reader api.Reader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
fromHash := strings.ToLower(r.URL.Query().Get("fromHash"))
|
||||
fromIATA := strings.ToUpper(r.URL.Query().Get("fromIata"))
|
||||
toHash := strings.ToLower(r.URL.Query().Get("toHash"))
|
||||
toIATA := strings.ToUpper(r.URL.Query().Get("toIata"))
|
||||
if fromHash == "" || fromIATA == "" || toHash == "" || toIATA == "" {
|
||||
respondError(w, http.StatusBadRequest, "fromHash, fromIata, toHash and toIata are required")
|
||||
q, err := parseCrossRouteSearch(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
routes, err := reader.SearchCrossIATARoutes(r.Context(), fromHash, fromIATA, toHash, toIATA)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
routes, err := reader.SearchCrossIATARoutes(ctx, q)
|
||||
if respondRouteSearchError(w, ctx, err) {
|
||||
return
|
||||
}
|
||||
if routes == nil {
|
||||
@@ -147,3 +170,86 @@ func searchCrossIATARoutes(reader api.Reader) http.HandlerFunc {
|
||||
respond(w, http.StatusOK, routes)
|
||||
}
|
||||
}
|
||||
|
||||
func parseCrossRouteSearch(r *http.Request) (api.CrossRouteSearch, error) {
|
||||
v := r.URL.Query()
|
||||
q := api.CrossRouteSearch{
|
||||
FromHash: strings.ToLower(v.Get("fromHash")),
|
||||
ToHash: strings.ToLower(v.Get("toHash")),
|
||||
}
|
||||
if q.FromHash == "" || q.ToHash == "" {
|
||||
return q, errors.New("fromHash and toHash are required")
|
||||
}
|
||||
if !validHopHash(q.FromHash) || !validHopHash(q.ToHash) {
|
||||
return q, errors.New("fromHash and toHash must be 1-4 bytes of hex")
|
||||
}
|
||||
fromIATA, toIATA := strings.ToUpper(v.Get("fromIata")), strings.ToUpper(v.Get("toIata"))
|
||||
if fromIATA != "" || toIATA != "" {
|
||||
if fromIATA == "" || toIATA == "" {
|
||||
return q, errors.New("fromIata and toIata must be given together")
|
||||
}
|
||||
if v.Has("iatas") {
|
||||
return q, errors.New("use iatas or fromIata/toIata, not both")
|
||||
}
|
||||
q.FromIATAs, q.ToIATAs = []string{fromIATA}, []string{toIATA}
|
||||
return q, nil
|
||||
}
|
||||
if !v.Has("iatas") {
|
||||
return q, nil
|
||||
}
|
||||
iatas, err := routeIATAs(r)
|
||||
if err != nil {
|
||||
return q, err
|
||||
}
|
||||
if len(iatas) < 2 {
|
||||
return q, fmt.Errorf("iatas must list 2-%d distinct codes", maxRouteSearchIATAs)
|
||||
}
|
||||
q.FromIATAs, q.ToIATAs = iatas, iatas
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// respondRouteSearchError writes the response for a failed route search and reports whether it did.
|
||||
func respondRouteSearchError(w http.ResponseWriter, ctx context.Context, err error) bool {
|
||||
switch {
|
||||
case err == nil:
|
||||
return false
|
||||
case errors.Is(err, api.ErrRouteSearchTooBroad):
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||
respondError(w, http.StatusServiceUnavailable, "route search timed out; use a longer hash or fewer iatas")
|
||||
case errors.Is(err, context.Canceled):
|
||||
// client went away; nothing to write
|
||||
default:
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// routeIATAs dedupes the iatas (or iata) list, failing as soon as it passes the cap so a
|
||||
// huge list costs no more than parsing it.
|
||||
func routeIATAs(r *http.Request) ([]string, error) {
|
||||
var out []string
|
||||
seen := make(map[string]struct{})
|
||||
for _, c := range parseIATAs(r) {
|
||||
if len(c) != 3 {
|
||||
return nil, errors.New("iatas must be 3-letter codes")
|
||||
}
|
||||
if _, dup := seen[c]; dup {
|
||||
continue
|
||||
}
|
||||
if len(out) == maxRouteSearchIATAs {
|
||||
return nil, fmt.Errorf("iatas must list at most %d codes", maxRouteSearchIATAs)
|
||||
}
|
||||
seen[c] = struct{}{}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validHopHash(h string) bool {
|
||||
if len(h) < 2 || len(h) > 8 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(h)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -25,7 +28,10 @@ func TestSearchKnownRoutes_MissingParams(t *testing.T) {
|
||||
{"missing all", ""},
|
||||
{"missing from and to", "?iata=YVR"},
|
||||
{"missing to", "?iata=YVR&from=aa"},
|
||||
{"missing iata", "?from=aa&to=bb"},
|
||||
{"bad hex", "?iata=YVR&from=zz&to=bb"},
|
||||
{"too many iatas", "?from=aa&to=bb&iatas=" + manyIATAs(maxRouteSearchIATAs+1)},
|
||||
{"long code", "?from=aa&to=bb&iatas=YVRX"},
|
||||
{"huge list", "?from=aa&to=bb&iatas=" + manyIATAs(200000)},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
req := httptest.NewRequest(http.MethodGet, "/routes/search"+tt.query, nil)
|
||||
@@ -37,18 +43,25 @@ func TestSearchKnownRoutes_MissingParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchCrossIATARoutes_MissingParams(t *testing.T) {
|
||||
func TestSearchCrossIATARoutes_BadParams(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/routes/cross", searchCrossIATARoutes(stubReader{}))
|
||||
|
||||
many := manyIATAs(maxRouteSearchIATAs + 1)
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
}{
|
||||
{"missing all", ""},
|
||||
{"missing toHash and toIata", "?fromHash=aa&fromIata=YVR"},
|
||||
{"missing toHash", "?fromHash=aa&fromIata=YVR&toIata=YYJ"},
|
||||
{"missing fromIata", "?fromHash=aa&toHash=bb&toIata=YYJ"},
|
||||
{"missing fromHash", "?fromIata=YVR&toHash=bb&toIata=YYJ"},
|
||||
{"bad hex", "?fromHash=zz&toHash=bb"},
|
||||
{"odd hex", "?fromHash=aab&toHash=bb"},
|
||||
{"hash too long", "?fromHash=aabbccddee&toHash=bb"},
|
||||
{"iatas and pair", "?fromHash=aa&toHash=bb&fromIata=YVR&toIata=YYJ&iatas=YVR,YYJ"},
|
||||
{"one iata", "?fromHash=aa&toHash=bb&iatas=YVR,yvr"},
|
||||
{"too many iatas", "?fromHash=aa&toHash=bb&iatas=" + many},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
req := httptest.NewRequest(http.MethodGet, "/routes/cross"+tt.query, nil)
|
||||
@@ -110,31 +123,91 @@ func TestListKnownRoutes_OK(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSearchKnownRoutes_OK(t *testing.T) {
|
||||
var got []string
|
||||
r := chi.NewRouter()
|
||||
r.Get("/routes/search", searchKnownRoutes(stubReader{
|
||||
searchKnownRoutes: func(_ context.Context, _, _, _ string) ([]api.KnownRoute, error) {
|
||||
searchKnownRoutes: func(_ context.Context, iatas []string, _, _ string) ([]api.KnownRoute, error) {
|
||||
got = iatas
|
||||
return []api.KnownRoute{{IATA: "YVR"}}, nil
|
||||
},
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/routes/search?iata=YVR&from=aa&to=bb", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
for _, tt := range []struct {
|
||||
query string
|
||||
want []string
|
||||
}{
|
||||
{"?iata=yvr&from=aa&to=bb", []string{"YVR"}},
|
||||
{"?iatas=yvr,YYJ,YVR&from=aa&to=bbcc", []string{"YVR", "YYJ"}},
|
||||
{"?from=aa&to=bb", nil},
|
||||
} {
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/routes/search"+tt.query, nil))
|
||||
if w.Code != http.StatusOK || !slices.Equal(got, tt.want) {
|
||||
t.Errorf("%s: got %d, iatas %v", tt.query, w.Code, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchCrossIATARoutes_OK(t *testing.T) {
|
||||
func TestSearchCrossIATARoutes_Query(t *testing.T) {
|
||||
var got api.CrossRouteSearch
|
||||
r := chi.NewRouter()
|
||||
r.Get("/routes/cross", searchCrossIATARoutes(stubReader{
|
||||
searchCrossIATARoutes: func(_ context.Context, _, _, _, _ string) ([]api.CrossIATARoute, error) {
|
||||
return []api.CrossIATARoute{}, nil
|
||||
searchCrossIATARoutes: func(_ context.Context, q api.CrossRouteSearch) ([]api.CrossIATARoute, error) {
|
||||
got = q
|
||||
return nil, nil
|
||||
},
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/routes/cross?fromHash=aa&fromIata=YVR&toHash=bb&toIata=YYJ", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
for _, tt := range []struct {
|
||||
query string
|
||||
from, to []string
|
||||
}{
|
||||
{"?fromHash=AA&fromIata=yvr&toHash=bb&toIata=YYJ", []string{"YVR"}, []string{"YYJ"}},
|
||||
{"?fromHash=aa&toHash=bb&iatas=yvr,YYJ,YVR", []string{"YVR", "YYJ"}, []string{"YVR", "YYJ"}},
|
||||
{"?fromHash=aa&toHash=bbcc", nil, nil},
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/routes/cross"+tt.query, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK || strings.TrimSpace(w.Body.String()) != "[]" {
|
||||
t.Errorf("%s: got %d %q", tt.query, w.Code, w.Body.String())
|
||||
}
|
||||
if got.FromHash != "aa" || !slices.Equal(got.FromIATAs, tt.from) || !slices.Equal(got.ToIATAs, tt.to) {
|
||||
t.Errorf("%s: reader got %+v", tt.query, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchCrossIATARoutes_TooBroad(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/routes/cross", searchCrossIATARoutes(stubReader{
|
||||
searchCrossIATARoutes: func(context.Context, api.CrossRouteSearch) ([]api.CrossIATARoute, error) {
|
||||
return nil, api.ErrRouteSearchTooBroad
|
||||
},
|
||||
}))
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/routes/cross?fromHash=aa&toHash=bb", nil))
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func manyIATAs(n int) string {
|
||||
codes := make([]string, n)
|
||||
for i := range codes {
|
||||
codes[i] = fmt.Sprintf("%c%c%c", 'A'+i/676%26, 'A'+i/26%26, 'A'+i%26)
|
||||
}
|
||||
return strings.Join(codes, ",")
|
||||
}
|
||||
|
||||
func TestRouteSearch_ClientGoneWritesNothing(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/routes/search", searchKnownRoutes(stubReader{
|
||||
searchKnownRoutes: func(context.Context, []string, string, string) ([]api.KnownRoute, error) {
|
||||
return nil, context.Canceled
|
||||
},
|
||||
}))
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/routes/search?from=aa&to=bb", nil))
|
||||
if w.Body.Len() != 0 {
|
||||
t.Errorf("expected no body, got %d %q", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,10 +61,8 @@ type stubReader struct {
|
||||
listTraceTags func(ctx context.Context, iatas []string, scope, traceType string, since, until time.Time, cursor time.Time, cursorTag string, limit int32) ([]api.TraceTagSummary, error)
|
||||
getTraceByTag func(ctx context.Context, tag string) (*api.TraceDetail, error)
|
||||
listKnownRoutes func(ctx context.Context, iata string, hopCount int32, cursor time.Time, cursorID int64, limit int32) ([]api.KnownRoute, error)
|
||||
searchKnownRoutes func(ctx context.Context, iata, fromHash, toHash string) ([]api.KnownRoute, error)
|
||||
getKnownRoutesByNode func(ctx context.Context, iata string, nodeID uuid.UUID) ([]api.KnownRoute, error)
|
||||
getCrossIATANeighbors func(ctx context.Context, nodeID uuid.UUID, iata string) ([]api.NodeNeighbor, error)
|
||||
searchCrossIATARoutes func(ctx context.Context, fromHash, fromIATA, toHash, toIATA string) ([]api.CrossIATARoute, error)
|
||||
searchKnownRoutes func(ctx context.Context, iatas []string, fromHash, toHash string) ([]api.KnownRoute, error)
|
||||
searchCrossIATARoutes func(ctx context.Context, q api.CrossRouteSearch) ([]api.CrossIATARoute, error)
|
||||
getNodesByIDs func(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]*api.ResolvedNode, error)
|
||||
}
|
||||
|
||||
@@ -377,30 +375,16 @@ func (s stubReader) ListKnownRoutes(ctx context.Context, iata string, hopCount i
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s stubReader) SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash string) ([]api.KnownRoute, error) {
|
||||
func (s stubReader) SearchKnownRoutes(ctx context.Context, iatas []string, fromHash, toHash string) ([]api.KnownRoute, error) {
|
||||
if s.searchKnownRoutes != nil {
|
||||
return s.searchKnownRoutes(ctx, iata, fromHash, toHash)
|
||||
return s.searchKnownRoutes(ctx, iatas, fromHash, toHash)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s stubReader) GetKnownRoutesByNode(ctx context.Context, iata string, nodeID uuid.UUID) ([]api.KnownRoute, error) {
|
||||
if s.getKnownRoutesByNode != nil {
|
||||
return s.getKnownRoutesByNode(ctx, iata, nodeID)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s stubReader) GetCrossIATANeighbors(ctx context.Context, nodeID uuid.UUID, iata string) ([]api.NodeNeighbor, error) {
|
||||
if s.getCrossIATANeighbors != nil {
|
||||
return s.getCrossIATANeighbors(ctx, nodeID, iata)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s stubReader) SearchCrossIATARoutes(ctx context.Context, fromHash, fromIATA, toHash, toIATA string) ([]api.CrossIATARoute, error) {
|
||||
func (s stubReader) SearchCrossIATARoutes(ctx context.Context, q api.CrossRouteSearch) ([]api.CrossIATARoute, error) {
|
||||
if s.searchCrossIATARoutes != nil {
|
||||
return s.searchCrossIATARoutes(ctx, fromHash, fromIATA, toHash, toIATA)
|
||||
return s.searchCrossIATARoutes(ctx, q)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
+38
-1
@@ -3,9 +3,46 @@
|
||||
|
||||
package api
|
||||
|
||||
// Info is the public server description the mobile app checks before using a server.
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Client floors this server code needs. Bump the matching one when a release breaks older
|
||||
// clients; config can raise them, never lower them. Empty means no requirement.
|
||||
const (
|
||||
MinAppVersion = "" // BEACON Mobile
|
||||
MinWebVersion = "2.0.3" // Beacon Web: batched route search
|
||||
)
|
||||
|
||||
// Info is the public server description clients check before using a server.
|
||||
type Info struct {
|
||||
// MinAppVersion is the oldest allowed app version (X.Y.Z); null means no requirement.
|
||||
MinAppVersion *string `json:"minAppVersion" example:"0.1.1"`
|
||||
// MinWebVersion is the oldest allowed Beacon Web version (X.Y.Z); null means no requirement.
|
||||
MinWebVersion *string `json:"minWebVersion" example:"2.0.3"`
|
||||
ServerVersion string `json:"serverVersion" example:"2.0.2"`
|
||||
}
|
||||
|
||||
// HigherVersion returns the newer of two X.Y.Z versions; empty sorts oldest.
|
||||
func HigherVersion(a, b string) string {
|
||||
if versionLess(a, b) {
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func versionLess(a, b string) bool {
|
||||
if a == "" || b == "" {
|
||||
return a == "" && b != ""
|
||||
}
|
||||
pa, pb := strings.Split(a, "."), strings.Split(b, ".")
|
||||
for i := 0; i < len(pa) && i < len(pb); i++ {
|
||||
x, _ := strconv.Atoi(pa[i])
|
||||
y, _ := strconv.Atoi(pb[i])
|
||||
if x != y {
|
||||
return x < y
|
||||
}
|
||||
}
|
||||
return len(pa) < len(pb)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHigherVersion(t *testing.T) {
|
||||
for _, tc := range []struct{ a, b, want string }{
|
||||
{"", "", ""},
|
||||
{"2.0.3", "", "2.0.3"},
|
||||
{"", "2.0.3", "2.0.3"},
|
||||
{"2.0.3", "2.0.10", "2.0.10"},
|
||||
{"2.1.0", "2.0.9", "2.1.0"},
|
||||
{"10.0.0", "9.9.9", "10.0.0"},
|
||||
{"2.0.3", "2.0.3", "2.0.3"},
|
||||
} {
|
||||
if got := HigherVersion(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("HigherVersion(%q, %q) = %q, want %q", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clients reject anything but plain X.Y.Z, so a typo here would lock them all out.
|
||||
func TestClientFloorsAreValid(t *testing.T) {
|
||||
re := regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`)
|
||||
for name, v := range map[string]string{"MinAppVersion": MinAppVersion, "MinWebVersion": MinWebVersion} {
|
||||
if v != "" && !re.MatchString(v) {
|
||||
t.Errorf("%s = %q, want X.Y.Z or empty", name, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-12
@@ -229,21 +229,14 @@ type Reader interface {
|
||||
// A nonzero cursorID breaks ties within the cursor's millisecond.
|
||||
ListKnownRoutes(ctx context.Context, iata string, hopCount int32, cursor time.Time, cursorID int64, limit int32) ([]KnownRoute, error)
|
||||
|
||||
// SearchKnownRoutes returns known routes containing a path from source to destination hash.
|
||||
SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash string) ([]KnownRoute, error)
|
||||
// SearchKnownRoutes returns known routes in any of iatas (empty means all) running from a
|
||||
// source to a destination hash prefix, trimmed to that span.
|
||||
SearchKnownRoutes(ctx context.Context, iatas []string, fromHash, toHash string) ([]KnownRoute, error)
|
||||
|
||||
// GetNodeNeighbors returns the neighbors of a node ordered by most recently seen.
|
||||
GetNodeNeighbors(ctx context.Context, nodeID uuid.UUID) ([]NodeNeighbor, error)
|
||||
|
||||
// GetKnownRoutesByNode returns all known routes in a given IATA that contain
|
||||
// the specified node UUID anywhere in their hop sequence.
|
||||
GetKnownRoutesByNode(ctx context.Context, iata string, nodeID uuid.UUID) ([]KnownRoute, error)
|
||||
|
||||
// GetCrossIATANeighbors returns neighbors of a node that were observed in a
|
||||
// different IATA — indicating a potential cross-IATA radio link.
|
||||
GetCrossIATANeighbors(ctx context.Context, nodeID uuid.UUID, iata string) ([]NodeNeighbor, error)
|
||||
|
||||
// SearchCrossIATARoutes finds routes that cross IATA boundaries between
|
||||
// a source node/IATA and a destination node/IATA.
|
||||
SearchCrossIATARoutes(ctx context.Context, fromHash, fromIATA, toHash, toIATA string) ([]CrossIATARoute, error)
|
||||
// a source and destination hash; see CrossRouteSearch.
|
||||
SearchCrossIATARoutes(ctx context.Context, q CrossRouteSearch) ([]CrossIATARoute, error)
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestAdminConfig(t *testing.T) {
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := map[string]any{"cors": want, "auth": map[string]any{"configured": true}, "ingest": map[string]any{"broker_count": float64(2)}, "mobile": map[string]any{"min_app_version": ""}}
|
||||
expected := map[string]any{"cors": want, "auth": map[string]any{"configured": true}, "ingest": map[string]any{"broker_count": float64(2)}, "mobile": map[string]any{"min_app_version": ""}, "web": map[string]any{"min_web_version": ""}}
|
||||
if !reflect.DeepEqual(body, expected) {
|
||||
t.Fatalf("unexpected config shape or values: %v", body)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type Options struct {
|
||||
Scopes handlers.ScopeMembership // region-filtered scope lists; nil lists none
|
||||
RollupRetention time.Duration // longest historical stats window
|
||||
MinAppVersion string // empty means no mobile app requirement
|
||||
MinWebVersion string // empty means no Beacon Web requirement
|
||||
ServerVersion string
|
||||
// AdminRoutes mounts operator-only subrouters at literal paths such as
|
||||
// "/accounts". Each feature owns its dependencies, methods and handlers.
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
//
|
||||
// /ws → WebSocket (public in v1)
|
||||
// /api/v1/ → public group
|
||||
// /info → server info for the mobile app
|
||||
// /info → server info and minimum client versions
|
||||
// /packets → packets subrouter
|
||||
// /nodes → nodes subrouter
|
||||
// /brokers → brokers subrouter
|
||||
@@ -71,11 +71,15 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, opts Options)
|
||||
},
|
||||
Ingest: api.AdminIngestConfig{BrokerCount: len(workers)},
|
||||
Mobile: api.AdminMobileConfig{MinAppVersion: opts.MinAppVersion},
|
||||
Web: api.AdminWebConfig{MinWebVersion: opts.MinWebVersion},
|
||||
}
|
||||
info := api.Info{ServerVersion: opts.ServerVersion}
|
||||
if opts.MinAppVersion != "" {
|
||||
info.MinAppVersion = &opts.MinAppVersion
|
||||
}
|
||||
if opts.MinWebVersion != "" {
|
||||
info.MinWebVersion = &opts.MinWebVersion
|
||||
}
|
||||
runtimeConfig := mw.NewRuntimeConfig(adminConfig, []string{"Retry-After"})
|
||||
r.Use(runtimeConfig.CORS)
|
||||
|
||||
|
||||
@@ -93,10 +93,11 @@ func checkWebSocketLimit(t *testing.T, cfg config.ServerConfig, secondIP string,
|
||||
|
||||
// Old servers 404 here and the app reads that as "no requirement", so the route must stay public.
|
||||
func TestInfoRoute(t *testing.T) {
|
||||
handler := New(nil, nil, nil, Options{MinAppVersion: "0.1.1", ServerVersion: "2.0.2"})
|
||||
handler := New(nil, nil, nil, Options{MinAppVersion: "0.1.1", MinWebVersion: "2.0.3", ServerVersion: "2.0.2"})
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/info", nil))
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"minAppVersion":"0.1.1"`) {
|
||||
if body := response.Body.String(); response.Code != http.StatusOK || !strings.Contains(body, `"minAppVersion":"0.1.1"`) ||
|
||||
!strings.Contains(body, `"minWebVersion":"2.0.3"`) {
|
||||
t.Fatalf("info: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
+17
-1
@@ -3,7 +3,23 @@
|
||||
|
||||
package api
|
||||
|
||||
import "github.com/google/uuid"
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ErrRouteSearchTooBroad means an endpoint hash matches too widely to search.
|
||||
var ErrRouteSearchTooBroad = errors.New("hash matches too many areas; use a longer hash or fewer iatas")
|
||||
|
||||
// CrossRouteSearch finds routes from FromHash (resolved in FromIATAs) to ToHash
|
||||
// (resolved in ToIATAs) that cross between two different IATAs. Empty lists mean every IATA.
|
||||
type CrossRouteSearch struct {
|
||||
FromHash string
|
||||
ToHash string
|
||||
FromIATAs []string
|
||||
ToIATAs []string
|
||||
}
|
||||
|
||||
// RouteHop is a single resolved hop in a known route.
|
||||
type RouteHop struct {
|
||||
|
||||
Vendored
+2
-10
@@ -160,14 +160,6 @@ func (s *stubReader) GetTraceByTag(_ context.Context, _ string) (*api.TraceDetai
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubReader) GetKnownRoutesByNode(_ context.Context, _ string, _ uuid.UUID) ([]api.KnownRoute, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubReader) GetCrossIATANeighbors(_ context.Context, _ uuid.UUID, _ string) ([]api.NodeNeighbor, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubReader) ListChannels(_ context.Context, _ int32, _ []byte, _ []string, _ *bool, _ int64, _ *api.ChannelCursor) (api.ChannelPage, error) {
|
||||
return api.ChannelPage{}, nil
|
||||
}
|
||||
@@ -212,11 +204,11 @@ func (s *stubReader) ListKnownRoutes(_ context.Context, _ string, _ int32, _ tim
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubReader) SearchKnownRoutes(_ context.Context, _, _, _ string) ([]api.KnownRoute, error) {
|
||||
func (s *stubReader) SearchKnownRoutes(_ context.Context, _ []string, _, _ string) ([]api.KnownRoute, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubReader) SearchCrossIATARoutes(_ context.Context, _, _, _, _ string) ([]api.CrossIATARoute, error) {
|
||||
func (s *stubReader) SearchCrossIATARoutes(_ context.Context, _ api.CrossRouteSearch) ([]api.CrossIATARoute, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
||||
Vendored
+4
-14
@@ -294,16 +294,6 @@ func (cr *CachedReader) GetTraceByTag(ctx context.Context, tag string) (*api.Tra
|
||||
return cr.inner.GetTraceByTag(ctx, tag)
|
||||
}
|
||||
|
||||
// GetKnownRoutesByNode implements [api.Reader].
|
||||
func (cr *CachedReader) GetKnownRoutesByNode(ctx context.Context, iata string, nodeID uuid.UUID) ([]api.KnownRoute, error) {
|
||||
return cr.inner.GetKnownRoutesByNode(ctx, iata, nodeID)
|
||||
}
|
||||
|
||||
// GetCrossIATANeighbors implements [api.Reader].
|
||||
func (cr *CachedReader) GetCrossIATANeighbors(ctx context.Context, nodeID uuid.UUID, iata string) ([]api.NodeNeighbor, error) {
|
||||
return cr.inner.GetCrossIATANeighbors(ctx, nodeID, iata)
|
||||
}
|
||||
|
||||
// ListChannels implements [api.Reader].
|
||||
func (cr *CachedReader) ListChannels(ctx context.Context, limit int32, hash []byte, iatas []string, keyKnown *bool, cursor int64, pageCursor *api.ChannelCursor) (api.ChannelPage, error) {
|
||||
return cr.inner.ListChannels(ctx, limit, hash, iatas, keyKnown, cursor, pageCursor)
|
||||
@@ -360,8 +350,8 @@ func (cr *CachedReader) ListKnownRoutes(ctx context.Context, iata string, hopCou
|
||||
}
|
||||
|
||||
// SearchKnownRoutes implements [api.Reader].
|
||||
func (cr *CachedReader) SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash string) ([]api.KnownRoute, error) {
|
||||
return cr.inner.SearchKnownRoutes(ctx, iata, fromHash, toHash)
|
||||
func (cr *CachedReader) SearchKnownRoutes(ctx context.Context, iatas []string, fromHash, toHash string) ([]api.KnownRoute, error) {
|
||||
return cr.inner.SearchKnownRoutes(ctx, iatas, fromHash, toHash)
|
||||
}
|
||||
|
||||
// Precise cursor/window combinations are intentionally passed through, like route lists.
|
||||
@@ -370,8 +360,8 @@ func (cr *CachedReader) GetRouteEvidence(ctx context.Context, iata, key string,
|
||||
}
|
||||
|
||||
// SearchCrossIATARoutes implements [api.Reader].
|
||||
func (cr *CachedReader) SearchCrossIATARoutes(ctx context.Context, fromHash, fromIATA, toHash, toIATA string) ([]api.CrossIATARoute, error) {
|
||||
return cr.inner.SearchCrossIATARoutes(ctx, fromHash, fromIATA, toHash, toIATA)
|
||||
func (cr *CachedReader) SearchCrossIATARoutes(ctx context.Context, q api.CrossRouteSearch) ([]api.CrossIATARoute, error) {
|
||||
return cr.inner.SearchCrossIATARoutes(ctx, q)
|
||||
}
|
||||
|
||||
// ListTraceTags implements [api.Reader].
|
||||
|
||||
@@ -45,6 +45,7 @@ type Config struct {
|
||||
Observers ObserversConfig `yaml:"observers"`
|
||||
Analytics AnalyticsConfig `yaml:"analytics"`
|
||||
Mobile MobileConfig `yaml:"mobile"`
|
||||
Web WebConfig `yaml:"web"`
|
||||
}
|
||||
|
||||
// MobileConfig holds settings the BEACON Mobile app reads from /info.
|
||||
@@ -53,6 +54,12 @@ type MobileConfig struct {
|
||||
MinAppVersion string `yaml:"min_app_version"`
|
||||
}
|
||||
|
||||
// WebConfig holds settings Beacon Web reads from /info.
|
||||
type WebConfig struct {
|
||||
// MinWebVersion raises the built-in Beacon Web floor (X.Y.Z); it can't lower it.
|
||||
MinWebVersion string `yaml:"min_web_version"`
|
||||
}
|
||||
|
||||
// BackupConfig enables protected downloads. Disabled by default.
|
||||
type BackupConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
@@ -458,7 +465,7 @@ func Load(path string) (*Config, error) {
|
||||
if err := cfg.validateScopes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.validateMobile(); err != nil {
|
||||
if err := cfg.validateClientVersions(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.Nodes.MarkForeign && !cfg.MeshMapper.Zones.Enabled && !cfg.hasBorderFile() {
|
||||
@@ -492,11 +499,16 @@ func (c *Config) validateAPIKey() error {
|
||||
|
||||
var appVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`)
|
||||
|
||||
// validateMobile keeps min_app_version to plain X.Y.Z so every app can compare it.
|
||||
func (c *Config) validateMobile() error {
|
||||
c.Mobile.MinAppVersion = strings.TrimSpace(c.Mobile.MinAppVersion)
|
||||
if v := c.Mobile.MinAppVersion; v != "" && !appVersionPattern.MatchString(v) {
|
||||
return fmt.Errorf("mobile.min_app_version must be X.Y.Z (digits only), got %q", v)
|
||||
// validateClientVersions keeps minimum client versions to plain X.Y.Z so every client can compare them.
|
||||
func (c *Config) validateClientVersions() error {
|
||||
for _, f := range []struct {
|
||||
key string
|
||||
v *string
|
||||
}{{"mobile.min_app_version", &c.Mobile.MinAppVersion}, {"web.min_web_version", &c.Web.MinWebVersion}} {
|
||||
*f.v = strings.TrimSpace(*f.v)
|
||||
if *f.v != "" && !appVersionPattern.MatchString(*f.v) {
|
||||
return fmt.Errorf("%s must be X.Y.Z (digits only), got %q", f.key, *f.v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -332,3 +332,29 @@ func TestLoadMinAppVersion(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMinWebVersion(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, yaml, want string
|
||||
wantError bool
|
||||
}{
|
||||
{"omitted", "{}", "", false},
|
||||
{"valid", "web: {min_web_version: ' 2.0.4 '}", "2.0.4", false},
|
||||
{"v prefix", "web: {min_web_version: 'v2.0.4'}", "", true},
|
||||
{"two parts", "web: {min_web_version: '2.0'}", "", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(path, []byte(tc.yaml), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := Load(path)
|
||||
if (err != nil) != tc.wantError {
|
||||
t.Fatalf("Load error = %v, want error = %v", err, tc.wantError)
|
||||
}
|
||||
if err == nil && cfg.Web.MinWebVersion != tc.want {
|
||||
t.Fatalf("min_web_version = %q, want %q", cfg.Web.MinWebVersion, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user