From 0b3ac64845322f75b20009892354462af08bcf12 Mon Sep 17 00:00:00 2001 From: Louis King Date: Mon, 26 Jan 2026 21:27:36 +0000 Subject: [PATCH] Add prefix matching support to node API endpoint Allow users to navigate to a node using any prefix of its public key instead of requiring the full 64-character key. If multiple nodes match the prefix, the first one alphabetically is returned. Co-Authored-By: Claude Opus 4.5 --- src/meshcore_hub/api/routes/nodes.py | 22 ++++++++++--- tests/test_api/test_nodes.py | 48 ++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/meshcore_hub/api/routes/nodes.py b/src/meshcore_hub/api/routes/nodes.py index 2e895b4..05b8349 100644 --- a/src/meshcore_hub/api/routes/nodes.py +++ b/src/meshcore_hub/api/routes/nodes.py @@ -2,7 +2,7 @@ from typing import Optional -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, HTTPException, Path, Query from sqlalchemy import func, or_, select from sqlalchemy.orm import selectinload @@ -81,10 +81,24 @@ async def list_nodes( async def get_node( _: RequireRead, session: DbSession, - public_key: str, + public_key: str = Path( + description="Full public key or prefix. If multiple nodes match the prefix, " + "the first one (alphabetically) is returned." + ), ) -> NodeRead: - """Get a single node by public key.""" - query = select(Node).where(Node.public_key == public_key) + """Get a single node by public key or prefix. + + Supports prefix matching - you can provide any number of leading characters + of a public key. If multiple nodes match the prefix, the first one + (alphabetically by public_key) is returned. + """ + query = ( + select(Node) + .options(selectinload(Node.tags)) + .where(Node.public_key.startswith(public_key)) + .order_by(Node.public_key) + .limit(1) + ) node = session.execute(query).scalar_one_or_none() if not node: diff --git a/tests/test_api/test_nodes.py b/tests/test_api/test_nodes.py index d4f6240..0c98bf0 100644 --- a/tests/test_api/test_nodes.py +++ b/tests/test_api/test_nodes.py @@ -146,6 +146,54 @@ class TestGetNode: response = client_no_auth.get("/api/v1/nodes/nonexistent123") assert response.status_code == 404 + def test_get_node_by_prefix(self, client_no_auth, sample_node): + """Test getting a node by public key prefix.""" + prefix = sample_node.public_key[:8] # First 8 chars + response = client_no_auth.get(f"/api/v1/nodes/{prefix}") + assert response.status_code == 200 + data = response.json() + assert data["public_key"] == sample_node.public_key + + def test_get_node_by_single_char_prefix(self, client_no_auth, sample_node): + """Test getting a node by single character prefix.""" + prefix = sample_node.public_key[0] + response = client_no_auth.get(f"/api/v1/nodes/{prefix}") + assert response.status_code == 200 + data = response.json() + assert data["public_key"] == sample_node.public_key + + def test_get_node_prefix_returns_first_alphabetically( + self, client_no_auth, api_db_session + ): + """Test that prefix match returns first node alphabetically.""" + from datetime import datetime, timezone + + from meshcore_hub.common.models import Node + + # Create two nodes with same prefix but different suffixes + # abc... should come before abd... + node_a = Node( + public_key="abc0000000000000000000000000000000000000000000000000000000000000", + name="Node A", + adv_type="REPEATER", + first_seen=datetime.now(timezone.utc), + ) + node_b = Node( + public_key="abc1111111111111111111111111111111111111111111111111111111111111", + name="Node B", + adv_type="REPEATER", + first_seen=datetime.now(timezone.utc), + ) + api_db_session.add(node_a) + api_db_session.add(node_b) + api_db_session.commit() + + # Request with prefix should return first alphabetically + response = client_no_auth.get("/api/v1/nodes/abc") + assert response.status_code == 200 + data = response.json() + assert data["public_key"] == node_a.public_key + class TestNodeTags: """Tests for node tag endpoints."""