From 4e2ee62bada1e8c998da61ec929b0b61a5dee262 Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Thu, 2 Jul 2026 09:12:23 -0700 Subject: [PATCH] feat(nodes): add ?neighbors to list nodes inlcudes neighbor IDs in node details list --- db/nodes.go | 22 ++++---- db/nodes_test.go | 63 +++++++++++++++++++++-- db/queries/queries.sql | 7 ++- db/sqlc/queries.sql.go | 29 +++++++---- docs/docs.go | 20 +++++++ docs/swagger.json | 20 +++++++ docs/swagger.yaml | 15 ++++++ internal/api/handlers/nodes.go | 17 +++++- internal/api/handlers/nodes_test.go | 59 ++++++++++++++++++++- internal/api/handlers/stub_reader_test.go | 47 +++++++++++++++-- internal/api/nodes.go | 27 +++++----- internal/api/reader.go | 7 +-- internal/cache/cache_test.go | 2 +- internal/cache/reader.go | 4 +- 14 files changed, 291 insertions(+), 48 deletions(-) diff --git a/db/nodes.go b/db/nodes.go index b100d99..2dcd25a 100644 --- a/db/nodes.go +++ b/db/nodes.go @@ -80,22 +80,23 @@ func (s *Store) SetNodeDefaultScope(ctx context.Context, nodeID uuid.UUID, scope }) } -func (s *Store) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32) (api.Page[api.NodeSummary], error) { +func (s *Store) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) { var cursorTS pgtype.Timestamptz if cursor > 0 { cursorTS = pgtype.Timestamptz{Time: time.UnixMilli(cursor), Valid: true} } iataFilter := strings.Join(iatas, ",") rows, err := s.q.ListNodes(ctx, sqlc.ListNodesParams{ - Column1: nodeType, - Column2: iataFilter, - Column3: tristate(supportsMultibytePaths), - Column4: tristate(supportsMultibyteTraces), - Column5: pubkey, - Column6: name, - Column7: cursorTS, - Limit: limit + 1, - Column9: scope, + Column1: nodeType, + Column2: iataFilter, + Column3: tristate(supportsMultibytePaths), + Column4: tristate(supportsMultibyteTraces), + Column5: pubkey, + Column6: name, + Column7: cursorTS, + Limit: limit + 1, + Column9: scope, + Column10: includeNeighbors, }) if err != nil { return api.Page[api.NodeSummary]{}, err @@ -117,6 +118,7 @@ func (s *Store) ListNodes(ctx context.Context, nodeType int16, iatas []string, s IsObserver: v.IsObserver, ObserverID: nullableUUID(v.ObserverID), KnownNeighborCount: v.KnownNeighborCount, + NeighborIDs: v.NeighborIds, } if len(v.Iatas) > 0 { if err := json.Unmarshal(v.Iatas, &node.IATAs); err != nil { diff --git a/db/nodes_test.go b/db/nodes_test.go index 1460d61..997cadd 100644 --- a/db/nodes_test.go +++ b/db/nodes_test.go @@ -134,7 +134,7 @@ func TestListNodes_Pagination(t *testing.T) { Return(rows, nil) store := &Store{q: mock} - page, err := store.ListNodes(context.Background(), 0, []string{"YVR"}, nil, nil, nil, "", "", 0, 2) + page, err := store.ListNodes(context.Background(), 0, []string{"YVR"}, nil, nil, nil, "", "", 0, 2, false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -167,7 +167,7 @@ func TestListNodes_IATAsUnmarshal(t *testing.T) { }, nil) store := &Store{q: mock} - page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", 0, 10) + page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", 0, 10, false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -201,7 +201,7 @@ func TestListNodes_RadioStringFormatting(t *testing.T) { }, nil) store := &Store{q: mock} - page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", 0, 10) + page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", 0, 10, false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -369,3 +369,60 @@ func TestGetNodeNeighbors_DBError(t *testing.T) { t.Fatal("expected error, got nil") } } + +func TestListNodes_IncludeNeighbors_PassesFlagAndMapsIDs(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + neighborID := uuid.MustParse("00000000-0000-0000-0000-000000000002") + + mock.EXPECT(). + ListNodes(gomock.Any(), gomock.Eq(sqlc.ListNodesParams{ + Column1: int16(0), Column2: "", Column3: "any", Column4: "any", + Column5: nil, Column6: "", Column7: pgtype.Timestamptz{}, + Limit: 11, Column9: "", Column10: true, + })). + Return([]sqlc.ListNodesRow{ + { + ID: nodeID, + PublicKey: []byte{0x01}, + NeighborIds: []uuid.UUID{neighborID}, + }, + }, nil) + + store := &Store{q: mock} + page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", 0, 10, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(page.Items[0].NeighborIDs) != 1 || page.Items[0].NeighborIDs[0] != neighborID { + t.Errorf("expected NeighborIDs [%s], got %v", neighborID, page.Items[0].NeighborIDs) + } +} + +func TestListNodes_ExcludeNeighbors_LeavesIDsNil(t *testing.T) { + ctrl := gomock.NewController(t) + mock := mockdb.NewMockQuerier(ctrl) + + nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + + mock.EXPECT(). + ListNodes(gomock.Any(), gomock.Eq(sqlc.ListNodesParams{ + Column1: int16(0), Column2: "", Column3: "any", Column4: "any", + Column5: nil, Column6: "", Column7: pgtype.Timestamptz{}, + Limit: 11, Column9: "", Column10: false, + })). + Return([]sqlc.ListNodesRow{ + {ID: nodeID, PublicKey: []byte{0x01}, NeighborIds: nil}, + }, nil) + + store := &Store{q: mock} + page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", 0, 10, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if page.Items[0].NeighborIDs != nil { + t.Errorf("expected NeighborIDs to stay nil when includeNeighbors is false, got %v", page.Items[0].NeighborIDs) + } +} diff --git a/db/queries/queries.sql b/db/queries/queries.sql index b843894..a9dabe2 100644 --- a/db/queries/queries.sql +++ b/db/queries/queries.sql @@ -494,7 +494,12 @@ SELECT n.id, n.public_key, n.node_type, n.name, n.latitude, n.longitude, n.last_ json_agg(json_build_object('iata', ni.iata, 'lastHeard', (extract(epoch from ni.last_heard) * 1000)::bigint) ORDER BY ni.last_heard DESC) FILTER (WHERE ni.iata IS NOT NULL) AS iatas, EXISTS (SELECT 1 FROM observers o WHERE o.public_key = n.public_key) AS is_observer, (SELECT o.id FROM observers o WHERE o.public_key = n.public_key LIMIT 1) AS observer_id, - (SELECT COUNT(DISTINCT nn.neighbor_id) FROM node_neighbors nn WHERE nn.node_id = n.id)::bigint AS known_neighbor_count + (SELECT COUNT(DISTINCT nn.neighbor_id) FROM node_neighbors nn WHERE nn.node_id = n.id)::bigint AS known_neighbor_count, + -- CASE short-circuits: the array_agg subquery only runs when $10 is true, + -- so requests that don't ask for neighbor IDs don't pay for it. +(CASE WHEN $10::bool THEN + (SELECT COALESCE(array_agg(DISTINCT nn.neighbor_id), '{}'::uuid[]) FROM node_neighbors nn WHERE nn.node_id = n.id) + ELSE NULL END)::uuid[] AS neighbor_ids FROM nodes n LEFT JOIN node_iatas ni ON ni.node_id = n.id LEFT JOIN transport_scopes ts ON ts.id = n.default_scope_id diff --git a/db/sqlc/queries.sql.go b/db/sqlc/queries.sql.go index 74cadb7..11c5099 100644 --- a/db/sqlc/queries.sql.go +++ b/db/sqlc/queries.sql.go @@ -2039,7 +2039,12 @@ SELECT n.id, n.public_key, n.node_type, n.name, n.latitude, n.longitude, n.last_ json_agg(json_build_object('iata', ni.iata, 'lastHeard', (extract(epoch from ni.last_heard) * 1000)::bigint) ORDER BY ni.last_heard DESC) FILTER (WHERE ni.iata IS NOT NULL) AS iatas, EXISTS (SELECT 1 FROM observers o WHERE o.public_key = n.public_key) AS is_observer, (SELECT o.id FROM observers o WHERE o.public_key = n.public_key LIMIT 1) AS observer_id, - (SELECT COUNT(DISTINCT nn.neighbor_id) FROM node_neighbors nn WHERE nn.node_id = n.id)::bigint AS known_neighbor_count + (SELECT COUNT(DISTINCT nn.neighbor_id) FROM node_neighbors nn WHERE nn.node_id = n.id)::bigint AS known_neighbor_count, + -- CASE short-circuits: the array_agg subquery only runs when $10 is true, + -- so requests that don't ask for neighbor IDs don't pay for it. +(CASE WHEN $10::bool THEN + (SELECT COALESCE(array_agg(DISTINCT nn.neighbor_id), '{}'::uuid[]) FROM node_neighbors nn WHERE nn.node_id = n.id) + ELSE NULL END)::uuid[] AS neighbor_ids FROM nodes n LEFT JOIN node_iatas ni ON ni.node_id = n.id LEFT JOIN transport_scopes ts ON ts.id = n.default_scope_id @@ -2066,15 +2071,16 @@ LIMIT $8 ` type ListNodesParams struct { - Column1 interface{} `json:"column_1"` - Column2 string `json:"column_2"` - Column3 string `json:"column_3"` - Column4 string `json:"column_4"` - Column5 []byte `json:"column_5"` - Column6 interface{} `json:"column_6"` - Column7 pgtype.Timestamptz `json:"column_7"` - Limit int32 `json:"limit"` - Column9 string `json:"column_9"` + Column1 interface{} `json:"column_1"` + Column2 string `json:"column_2"` + Column3 string `json:"column_3"` + Column4 string `json:"column_4"` + Column5 []byte `json:"column_5"` + Column6 interface{} `json:"column_6"` + Column7 pgtype.Timestamptz `json:"column_7"` + Limit int32 `json:"limit"` + Column9 string `json:"column_9"` + Column10 bool `json:"column_10"` } type ListNodesRow struct { @@ -2093,6 +2099,7 @@ type ListNodesRow struct { IsObserver bool `json:"is_observer"` ObserverID uuid.UUID `json:"observer_id"` KnownNeighborCount int64 `json:"known_neighbor_count"` + NeighborIds []uuid.UUID `json:"neighbor_ids"` } func (q *Queries) ListNodes(ctx context.Context, arg ListNodesParams) ([]ListNodesRow, error) { @@ -2106,6 +2113,7 @@ func (q *Queries) ListNodes(ctx context.Context, arg ListNodesParams) ([]ListNod arg.Column7, arg.Limit, arg.Column9, + arg.Column10, ) if err != nil { return nil, err @@ -2130,6 +2138,7 @@ func (q *Queries) ListNodes(ctx context.Context, arg ListNodesParams) ([]ListNod &i.IsObserver, &i.ObserverID, &i.KnownNeighborCount, + &i.NeighborIds, ); err != nil { return nil, err } diff --git a/docs/docs.go b/docs/docs.go index 89b5574..116cb59 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -521,6 +521,12 @@ const docTemplate = `{ "name": "supportsMultibyteTraces", "in": "query" }, + { + "type": "boolean", + "description": "Include each node's known neighbor IDs (neighborIds field). Bare ?neighbors or ?neighbors=true enables it; omit/false for none", + "name": "neighbors", + "in": "query" + }, { "type": "integer", "description": "last_seen epoch ms of last item for pagination", @@ -2341,6 +2347,13 @@ const docTemplate = `{ "name": { "type": "string" }, + "neighborIds": { + "description": "only populated when the list request opts in; see ?neighbors=true", + "type": "array", + "items": { + "type": "string" + } + }, "neighbors": { "type": "array", "items": { @@ -2466,6 +2479,13 @@ const docTemplate = `{ "name": { "type": "string" }, + "neighborIds": { + "description": "only populated when the list request opts in; see ?neighbors=true", + "type": "array", + "items": { + "type": "string" + } + }, "nodeType": { "description": "1=companion, 2=repeater, 3=room_server, 4=sensor", "type": "integer" diff --git a/docs/swagger.json b/docs/swagger.json index c6f0862..b7ba565 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -519,6 +519,12 @@ "name": "supportsMultibyteTraces", "in": "query" }, + { + "type": "boolean", + "description": "Include each node's known neighbor IDs (neighborIds field). Bare ?neighbors or ?neighbors=true enables it; omit/false for none", + "name": "neighbors", + "in": "query" + }, { "type": "integer", "description": "last_seen epoch ms of last item for pagination", @@ -2339,6 +2345,13 @@ "name": { "type": "string" }, + "neighborIds": { + "description": "only populated when the list request opts in; see ?neighbors=true", + "type": "array", + "items": { + "type": "string" + } + }, "neighbors": { "type": "array", "items": { @@ -2464,6 +2477,13 @@ "name": { "type": "string" }, + "neighborIds": { + "description": "only populated when the list request opts in; see ?neighbors=true", + "type": "array", + "items": { + "type": "string" + } + }, "nodeType": { "description": "1=companion, 2=repeater, 3=room_server, 4=sensor", "type": "integer" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 0eda610..b3cdd42 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -212,6 +212,11 @@ definitions: type: string name: type: string + neighborIds: + description: only populated when the list request opts in; see ?neighbors=true + items: + type: string + type: array neighbors: items: $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.NodeNeighbor' @@ -299,6 +304,11 @@ definitions: type: number name: type: string + neighborIds: + description: only populated when the list request opts in; see ?neighbors=true + items: + type: string + type: array nodeType: description: 1=companion, 2=repeater, 3=room_server, 4=sensor type: integer @@ -1339,6 +1349,11 @@ paths: in: query name: supportsMultibyteTraces type: boolean + - description: Include each node's known neighbor IDs (neighborIds field). Bare + ?neighbors or ?neighbors=true enables it; omit/false for none + in: query + name: neighbors + type: boolean - description: last_seen epoch ms of last item for pagination in: query name: cursor diff --git a/internal/api/handlers/nodes.go b/internal/api/handlers/nodes.go index 86b5a59..7e46a78 100644 --- a/internal/api/handlers/nodes.go +++ b/internal/api/handlers/nodes.go @@ -46,6 +46,7 @@ func NodesRouter(reader api.Reader) http.Handler { // @Param pubkey query string false "Exact public key match (hex)" // @Param supportsMultibytePaths query bool false "Filter by multibyte path support (true/false); omit for no filter" // @Param supportsMultibyteTraces query bool false "Filter by multibyte trace support (true/false); omit for no filter" +// @Param neighbors query bool false "Include each node's known neighbor IDs (neighborIds field). Bare ?neighbors or ?neighbors=true enables it; omit/false for none" // @Param cursor query int false "last_seen epoch ms of last item for pagination" // @Param limit query int false "Max results (default 50)" // @Success 200 {object} api.Page[api.NodeSummary] @@ -121,7 +122,21 @@ func listNodes(reader api.Reader) http.HandlerFunc { } supportsMultibyteTraces = &b } - nodes, err := reader.ListNodes(r.Context(), nodeType, iatas, supportsMultibytePaths, supportsMultibyteTraces, pubkey, name, scope, cursor, limit) + var includeNeighbors bool + if vals, ok := r.URL.Query()["neighbors"]; ok { + v := vals[0] // bare `?neighbors` (no `=`) parses as "" + if v == "" { + includeNeighbors = true + } else { + b, err := strconv.ParseBool(v) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid neighbors value") + return + } + includeNeighbors = b + } + } + nodes, err := reader.ListNodes(r.Context(), nodeType, iatas, supportsMultibytePaths, supportsMultibyteTraces, pubkey, name, scope, cursor, limit, includeNeighbors) if err != nil { respondError(w, http.StatusInternalServerError, "internal server error") return diff --git a/internal/api/handlers/nodes_test.go b/internal/api/handlers/nodes_test.go index 46f3618..a7f9b4b 100644 --- a/internal/api/handlers/nodes_test.go +++ b/internal/api/handlers/nodes_test.go @@ -7,6 +7,7 @@ import ( "context" "net/http" "net/http/httptest" + "strings" "testing" "github.com/MeshCore-Beacon/beacon-server/internal/api" @@ -128,7 +129,7 @@ func TestListNodes_OK(t *testing.T) { nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001") r := chi.NewRouter() r.Get("/nodes", listNodes(stubReader{ - listNodes: func(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _ string, _ int64, _ int32) (api.Page[api.NodeSummary], error) { + listNodes: func(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _ string, _ int64, _ int32, _ bool) (api.Page[api.NodeSummary], error) { return api.Page[api.NodeSummary]{Items: []api.NodeSummary{{ID: nodeID}}}, nil }, })) @@ -140,6 +141,62 @@ func TestListNodes_OK(t *testing.T) { } } +func TestListNodes_NeighborsParam_PassedThrough(t *testing.T) { + nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001") + neighborID := uuid.MustParse("00000000-0000-0000-0000-000000000002") + var gotIncludeNeighbors bool + r := chi.NewRouter() + r.Get("/nodes", listNodes(stubReader{ + listNodes: func(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _ string, _ int64, _ int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) { + gotIncludeNeighbors = includeNeighbors + return api.Page[api.NodeSummary]{Items: []api.NodeSummary{{ID: nodeID, NeighborIDs: []uuid.UUID{neighborID}}}}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/nodes?neighbors=true", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + if !gotIncludeNeighbors { + t.Error("expected neighbors=true query param to be passed through as includeNeighbors=true") + } + if !strings.Contains(w.Body.String(), "neighborIds") { + t.Errorf("expected response body to include neighborIds, got %s", w.Body.String()) + } +} + +func TestListNodes_NeighborsParam_InvalidValue(t *testing.T) { + r := chi.NewRouter() + r.Get("/nodes", listNodes(stubReader{})) + req := httptest.NewRequest(http.MethodGet, "/nodes?neighbors=notabool", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestListNodes_NeighborsParam_BareFlagMeansTrue(t *testing.T) { + var gotIncludeNeighbors bool + r := chi.NewRouter() + r.Get("/nodes", listNodes(stubReader{ + listNodes: func(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _ string, _ int64, _ int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) { + gotIncludeNeighbors = includeNeighbors + return api.Page[api.NodeSummary]{}, nil + }, + })) + req := httptest.NewRequest(http.MethodGet, "/nodes?neighbors", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + if !gotIncludeNeighbors { + t.Error("expected bare ?neighbors (no value) to be treated as true") + } +} + func TestGetNode_OK(t *testing.T) { nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001") r := chi.NewRouter() diff --git a/internal/api/handlers/stub_reader_test.go b/internal/api/handlers/stub_reader_test.go index c4f7d0d..2291eca 100644 --- a/internal/api/handlers/stub_reader_test.go +++ b/internal/api/handlers/stub_reader_test.go @@ -31,7 +31,7 @@ type stubReader struct { getObserverTelemetryBucketed func(ctx context.Context, observerID uuid.UUID, since, until time.Time, bucketHours int32) ([]api.ObserverTelemetryPoint, error) getObserverScopes func(ctx context.Context, observerID uuid.UUID) ([]string, error) listObserverAdverts func(ctx context.Context, observerID uuid.UUID, cursor int64, limit int32) (api.Page[api.AdvertObservation], error) - listNodes func(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32) (api.Page[api.NodeSummary], error) + listNodes func(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) getNode func(ctx context.Context, nodeID uuid.UUID) (*api.Node, error) getNodeNeighbors func(ctx context.Context, nodeID uuid.UUID) ([]api.NodeNeighbor, error) listNodeObservations func(ctx context.Context, nodeID uuid.UUID, cursor int64, limit int32) (api.Page[api.PacketObservationSummary], error) @@ -65,246 +65,287 @@ func (s stubReader) ListIATAs(ctx context.Context) ([]api.IATA, error) { } return nil, nil } + func (s stubReader) GetIATA(ctx context.Context, iata string) (*api.IATA, error) { if s.getIATA != nil { return s.getIATA(ctx, iata) } return nil, nil } + func (s stubReader) ListRegions(ctx context.Context) ([]api.RegionSummary, error) { if s.listRegions != nil { return s.listRegions(ctx) } return nil, nil } + func (s stubReader) GetRegion(ctx context.Context, regionID int32) (*api.Region, error) { if s.getRegion != nil { return s.getRegion(ctx, regionID) } return nil, nil } + func (s stubReader) GetRegionBySlug(ctx context.Context, slug string) (*api.Region, error) { if s.getRegionBySlug != nil { return s.getRegionBySlug(ctx, slug) } return nil, nil } + func (s stubReader) ListChannels(ctx context.Context, limit int32, hash []byte, iata string, cursor int64) (api.Page[api.ChannelSummary], error) { if s.listChannels != nil { return s.listChannels(ctx, limit, hash, iata, cursor) } return api.Page[api.ChannelSummary]{}, nil } + func (s stubReader) GetChannel(ctx context.Context, channelID int32) (*api.Channel, error) { if s.getChannel != nil { return s.getChannel(ctx, channelID) } return nil, nil } + func (s stubReader) ListChannelMessages(ctx context.Context, channelID *int32, since time.Time, limit int32, iatas []string, scope string, cursor int64) (api.Page[api.ChannelMessage], error) { if s.listChannelMessages != nil { return s.listChannelMessages(ctx, channelID, since, limit, iatas, scope, cursor) } return api.Page[api.ChannelMessage]{}, nil } + func (s stubReader) ListChannelMessagesByHash(ctx context.Context, hash []byte, since time.Time, limit int32, iatas []string, scope string, cursor int64) (api.Page[api.ChannelMessage], error) { if s.listChannelMessagesByHash != nil { return s.listChannelMessagesByHash(ctx, hash, since, limit, iatas, scope, cursor) } return api.Page[api.ChannelMessage]{}, nil } + func (s stubReader) ListMessagesAfterID(ctx context.Context, afterID int64, iatas []string, scope string, limit int32) ([]api.ChannelMessage, error) { if s.listMessagesAfterID != nil { return s.listMessagesAfterID(ctx, afterID, iatas, scope, limit) } return nil, nil } + func (s stubReader) ListObservers(ctx context.Context, iatas []string, observerType, broker, status, name, scope string, cursor int64, limit int32) (api.Page[api.ObserverSummary], error) { if s.listObservers != nil { return s.listObservers(ctx, iatas, observerType, broker, status, name, scope, cursor, limit) } return api.Page[api.ObserverSummary]{}, nil } + func (s stubReader) GetObserver(ctx context.Context, observerID uuid.UUID) (*api.Observer, error) { if s.getObserver != nil { return s.getObserver(ctx, observerID) } return nil, nil } + func (s stubReader) GetObserverTelemetry(ctx context.Context, observerID uuid.UUID, since, until time.Time, afterID int64) (*api.ObserverTelemetry, error) { if s.getObserverTelemetry != nil { return s.getObserverTelemetry(ctx, observerID, since, until, afterID) } return nil, nil } + func (s stubReader) GetObserverTelemetryBucketed(ctx context.Context, observerID uuid.UUID, since, until time.Time, bucketHours int32) ([]api.ObserverTelemetryPoint, error) { if s.getObserverTelemetryBucketed != nil { return s.getObserverTelemetryBucketed(ctx, observerID, since, until, bucketHours) } return nil, nil } + func (s stubReader) GetObserverScopes(ctx context.Context, observerID uuid.UUID) ([]string, error) { if s.getObserverScopes != nil { return s.getObserverScopes(ctx, observerID) } return nil, nil } + func (s stubReader) ListObserverAdverts(ctx context.Context, observerID uuid.UUID, cursor int64, limit int32) (api.Page[api.AdvertObservation], error) { if s.listObserverAdverts != nil { return s.listObserverAdverts(ctx, observerID, cursor, limit) } return api.Page[api.AdvertObservation]{}, nil } -func (s stubReader) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32) (api.Page[api.NodeSummary], error) { + +func (s stubReader) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) { if s.listNodes != nil { - return s.listNodes(ctx, nodeType, iatas, supportsMultibytePaths, supportsMultibyteTraces, pubkey, name, scope, cursor, limit) + return s.listNodes(ctx, nodeType, iatas, supportsMultibytePaths, supportsMultibyteTraces, pubkey, name, scope, cursor, limit, includeNeighbors) } return api.Page[api.NodeSummary]{}, nil } + func (s stubReader) GetNode(ctx context.Context, nodeID uuid.UUID) (*api.Node, error) { if s.getNode != nil { return s.getNode(ctx, nodeID) } return nil, nil } + func (s stubReader) GetNodeNeighbors(ctx context.Context, nodeID uuid.UUID) ([]api.NodeNeighbor, error) { if s.getNodeNeighbors != nil { return s.getNodeNeighbors(ctx, nodeID) } return nil, nil } + func (s stubReader) ListNodeObservations(ctx context.Context, nodeID uuid.UUID, cursor int64, limit int32) (api.Page[api.PacketObservationSummary], error) { if s.listNodeObservations != nil { return s.listNodeObservations(ctx, nodeID, cursor, limit) } return api.Page[api.PacketObservationSummary]{}, nil } + func (s stubReader) ListPackets(ctx context.Context, payloadType, routeType int16, iatas []string, scope string, since, until time.Time, cursor int64, limit int32) (api.Page[api.PacketSummary], error) { if s.listPackets != nil { return s.listPackets(ctx, payloadType, routeType, iatas, scope, since, until, cursor, limit) } return api.Page[api.PacketSummary]{}, nil } + func (s stubReader) ListPacketsAfterID(ctx context.Context, afterObservationID int64, payloadType, routeType int16, iatas []string, scope string, limit int32) ([]api.PacketSummary, error) { if s.listPacketsAfterID != nil { return s.listPacketsAfterID(ctx, afterObservationID, payloadType, routeType, iatas, scope, limit) } return nil, nil } + func (s stubReader) GetPacket(ctx context.Context, packetHash []byte) (*api.Packet, error) { if s.getPacket != nil { return s.getPacket(ctx, packetHash) } return nil, nil } + func (s stubReader) GetRadioPresets(ctx context.Context, preset string, iatas []string) ([]api.RadioPreset, error) { if s.getRadioPresets != nil { return s.getRadioPresets(ctx, preset, iatas) } return nil, nil } + func (s stubReader) GetStatsOverview(ctx context.Context, iatas []string) (*api.StatsOverview, error) { if s.getStatsOverview != nil { return s.getStatsOverview(ctx, iatas) } return nil, nil } + func (s stubReader) GetStatsObservations(ctx context.Context, iatas []string, since time.Time) ([]api.ObservationPoint, error) { if s.getStatsObservations != nil { return s.getStatsObservations(ctx, iatas, since) } return nil, nil } + func (s stubReader) GetStatsPayloadBreakdown(ctx context.Context, iatas []string, since time.Time) ([]api.PayloadBreakdownItem, error) { if s.getStatsPayloadBreakdown != nil { return s.getStatsPayloadBreakdown(ctx, iatas, since) } return nil, nil } + func (s stubReader) GetStatsTopNodes(ctx context.Context, iatas []string, limit int32) ([]api.TopNode, error) { if s.getStatsTopNodes != nil { return s.getStatsTopNodes(ctx, iatas, limit) } return nil, nil } + func (s stubReader) GetStatsTopObservers(ctx context.Context, iatas []string, since time.Time, limit int32) ([]api.TopObserver, error) { if s.getStatsTopObservers != nil { return s.getStatsTopObservers(ctx, iatas, since, limit) } return nil, nil } + func (s stubReader) GetScopeStats(ctx context.Context) ([]api.ScopeStats, error) { if s.getScopeStats != nil { return s.getScopeStats(ctx) } return nil, nil } + func (s stubReader) GetStatsNodeTypes(ctx context.Context, iatas []string) ([]api.NodeTypeCount, error) { if s.getStatsNodeTypes != nil { return s.getStatsNodeTypes(ctx, iatas) } return nil, nil } + func (s stubReader) GetScopeNames(ctx context.Context) ([]string, error) { if s.getScopeNames != nil { return s.getScopeNames(ctx) } return nil, nil } + func (s stubReader) GetScopesByIATAs(ctx context.Context, iatas []string) ([]api.ScopeSummary, error) { if s.getScopesByIATAs != nil { return s.getScopesByIATAs(ctx, iatas) } return nil, nil } + func (s stubReader) GetScopeByName(ctx context.Context, name string) (*api.ScopeDetail, error) { if s.getScopeByName != nil { return s.getScopeByName(ctx, name) } return nil, nil } + func (s stubReader) ListTraceTags(ctx context.Context, iatas []string, scope, traceType string, since, until time.Time, cursor time.Time, limit int32) ([]api.TraceTagSummary, error) { if s.listTraceTags != nil { return s.listTraceTags(ctx, iatas, scope, traceType, since, until, cursor, limit) } return nil, nil } + func (s stubReader) GetTraceByTag(ctx context.Context, tag string) (*api.TraceDetail, error) { if s.getTraceByTag != nil { return s.getTraceByTag(ctx, tag) } return nil, nil } + func (s stubReader) ListKnownRoutes(ctx context.Context, iata string, hopCount int32, cursor time.Time, limit int32) ([]api.KnownRoute, error) { if s.listKnownRoutes != nil { return s.listKnownRoutes(ctx, iata, hopCount, cursor, limit) } return nil, nil } + func (s stubReader) SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash string) ([]api.KnownRoute, error) { if s.searchKnownRoutes != nil { return s.searchKnownRoutes(ctx, iata, 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) { if s.searchCrossIATARoutes != nil { return s.searchCrossIATARoutes(ctx, fromHash, fromIATA, toHash, toIATA) } return nil, nil } + func (s stubReader) GetNodesByIDs(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]*api.ResolvedNode, error) { if s.getNodesByIDs != nil { return s.getNodesByIDs(ctx, ids) diff --git a/internal/api/nodes.go b/internal/api/nodes.go index 615808a..5a3659e 100644 --- a/internal/api/nodes.go +++ b/internal/api/nodes.go @@ -34,19 +34,20 @@ type NodeIATA struct { // NodeSummary is the minimal node representation used in list responses. type NodeSummary struct { - ID uuid.UUID `json:"id"` - PublicKey string `json:"publicKey"` // hex-encoded Ed25519 public key - NodeType int16 `json:"nodeType"` // 1=companion, 2=repeater, 3=room_server, 4=sensor - NodeTypeName string `json:"nodeTypeName"` - Name *string `json:"name,omitempty"` - IsObserver bool `json:"isObserver"` // true if this node is also a known observer - ObserverID *uuid.UUID `json:"observerId,omitempty"` // UUID of the associated observer row, if any - Latitude *float64 `json:"lat,omitempty"` // decimal degrees, from advert AppData - Longitude *float64 `json:"lng,omitempty"` // decimal degrees, from advert AppData - Radio *string `json:"radio,omitempty"` // shorthand: "freqMhz,bwKhz,sf" e.g. "910.5,62.5,7" - IATAs []NodeIATA `json:"iatas"` // IATAs where this node has been heard, with last heard timestamps - DefaultScope *string `json:"defaultScope,omitempty"` // most recently matched transport scope name e.g. "#bc" - KnownNeighborCount int64 `json:"knownNeighborCount"` + ID uuid.UUID `json:"id"` + PublicKey string `json:"publicKey"` // hex-encoded Ed25519 public key + NodeType int16 `json:"nodeType"` // 1=companion, 2=repeater, 3=room_server, 4=sensor + NodeTypeName string `json:"nodeTypeName"` + Name *string `json:"name,omitempty"` + IsObserver bool `json:"isObserver"` // true if this node is also a known observer + ObserverID *uuid.UUID `json:"observerId,omitempty"` // UUID of the associated observer row, if any + Latitude *float64 `json:"lat,omitempty"` // decimal degrees, from advert AppData + Longitude *float64 `json:"lng,omitempty"` // decimal degrees, from advert AppData + Radio *string `json:"radio,omitempty"` // shorthand: "freqMhz,bwKhz,sf" e.g. "910.5,62.5,7" + IATAs []NodeIATA `json:"iatas"` // IATAs where this node has been heard, with last heard timestamps + DefaultScope *string `json:"defaultScope,omitempty"` // most recently matched transport scope name e.g. "#bc" + KnownNeighborCount int64 `json:"knownNeighborCount"` + NeighborIDs []uuid.UUID `json:"neighborIds,omitempty"` // only populated when the list request opts in; see ?neighbors=true } // Node is the full node representation including firmware capability flags, diff --git a/internal/api/reader.go b/internal/api/reader.go index 0eaaa92..83ca8fb 100644 --- a/internal/api/reader.go +++ b/internal/api/reader.go @@ -97,9 +97,10 @@ type Reader interface { ListObserverAdverts(ctx context.Context, observerID uuid.UUID, cursor int64, limit int32) (Page[AdvertObservation], error) // ListNodes returns a paginated list of nodes with optional filters. - // Pass 0 for nodeType, nil iatas, nil for pubkey to skip those filters. - // cursor is last_seen epoch ms; pass 0 to start from the beginning. - ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32) (Page[NodeSummary], error) + // When includeNeighbors is true, each NodeSummary's NeighborIDs field is + // populated with the distinct set of neighbor node IDs (across all + // IATAs); otherwise it's left nil to avoid the extra aggregation. + ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32, includeNeighbors bool) (Page[NodeSummary], error) // GetNode returns full detail for a single node by UUID. // Returns nil, pgx.ErrNoRows if the node is not found. diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index a342101..eb8d902 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -136,7 +136,7 @@ func (s *stubReader) ListMessagesAfterID(_ context.Context, _ int64, _ []string, return nil, nil } -func (s *stubReader) ListNodes(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _ string, _ int64, _ int32) (api.Page[api.NodeSummary], error) { +func (s *stubReader) ListNodes(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _ string, _ int64, _ int32, _ bool) (api.Page[api.NodeSummary], error) { return api.Page[api.NodeSummary]{}, nil } diff --git a/internal/cache/reader.go b/internal/cache/reader.go index eb98240..5aa984d 100644 --- a/internal/cache/reader.go +++ b/internal/cache/reader.go @@ -344,8 +344,8 @@ func (cr *CachedReader) ListMessagesAfterID(ctx context.Context, afterID int64, } // ListNodes implements [api.Reader]. -func (cr *CachedReader) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32) (api.Page[api.NodeSummary], error) { - return cr.inner.ListNodes(ctx, nodeType, iatas, supportsMultibytePaths, supportsMultibyteTraces, pubkey, name, scope, cursor, limit) +func (cr *CachedReader) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) { + return cr.inner.ListNodes(ctx, nodeType, iatas, supportsMultibytePaths, supportsMultibyteTraces, pubkey, name, scope, cursor, limit, includeNeighbors) } // ListNodeObservations implements [api.Reader].