Separate API prefix search from exact match endpoint

- Add /api/v1/nodes/prefix/{prefix} for prefix-based node lookup
- Change /api/v1/nodes/{public_key} to exact match only
- /n/{prefix} now simply redirects to /nodes/{prefix}
- /nodes/{key} resolves prefixes via API and redirects to full key
This commit is contained in:
Louis King
2026-01-26 22:27:15 +00:00
parent 9661b22390
commit 345ffd219b
5 changed files with 73 additions and 42 deletions
+26 -11
View File
@@ -77,25 +77,20 @@ async def list_nodes(
)
@router.get("/{public_key}", response_model=NodeRead)
async def get_node(
@router.get("/prefix/{prefix}", response_model=NodeRead)
async def get_node_by_prefix(
_: RequireRead,
session: DbSession,
public_key: str = Path(
description="Full public key or prefix. If multiple nodes match the prefix, "
"the first one (alphabetically) is returned."
),
prefix: str = Path(description="Public key prefix to search for"),
) -> NodeRead:
"""Get a single node by public key or prefix.
"""Get a single node by public key 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.
Returns the first node (alphabetically by public_key) that matches the prefix.
"""
query = (
select(Node)
.options(selectinload(Node.tags))
.where(Node.public_key.startswith(public_key))
.where(Node.public_key.startswith(prefix))
.order_by(Node.public_key)
.limit(1)
)
@@ -105,3 +100,23 @@ async def get_node(
raise HTTPException(status_code=404, detail="Node not found")
return NodeRead.model_validate(node)
@router.get("/{public_key}", response_model=NodeRead)
async def get_node(
_: RequireRead,
session: DbSession,
public_key: str = Path(description="Full 64-character public key"),
) -> NodeRead:
"""Get a single node by exact public key match."""
query = (
select(Node)
.options(selectinload(Node.tags))
.where(Node.public_key == public_key)
)
node = session.execute(query).scalar_one_or_none()
if not node:
raise HTTPException(status_code=404, detail="Node not found")
return NodeRead.model_validate(node)
+19 -16
View File
@@ -82,35 +82,38 @@ async def nodes_list(
@router.get("/n/{prefix}")
async def node_short_link(request: Request, prefix: str) -> RedirectResponse:
"""Redirect short link to full node detail page.
async def node_short_link(prefix: str) -> RedirectResponse:
"""Redirect short link to nodes page."""
return RedirectResponse(url=f"/nodes/{prefix}", status_code=302)
Looks up the node by prefix and redirects to the canonical URL
with the full public key.
@router.get("/nodes/{public_key}")
async def node_detail(
request: Request, public_key: str
) -> HTMLResponse | RedirectResponse:
"""Render the node detail page.
If the key is not a full 64-character public key, uses the prefix API
to resolve it and redirects to the canonical URL.
"""
try:
response = await request.app.state.http_client.get(f"/api/v1/nodes/{prefix}")
# If not a full public key, resolve via prefix API and redirect
if len(public_key) != 64:
response = await request.app.state.http_client.get(
f"/api/v1/nodes/prefix/{public_key}"
)
if response.status_code == 200:
node = response.json()
return RedirectResponse(url=f"/nodes/{node['public_key']}", status_code=302)
except Exception as e:
logger.warning(f"Failed to look up node for short link: {e}")
raise HTTPException(status_code=404, detail="Node not found")
raise HTTPException(status_code=404, detail="Node not found")
@router.get("/nodes/{public_key}", response_class=HTMLResponse)
async def node_detail(request: Request, public_key: str) -> HTMLResponse:
"""Render the node detail page."""
templates = get_templates(request)
context = get_network_context(request)
context["request"] = request
node = None
advertisements = []
telemetry = []
# Fetch node details
# Fetch node details (exact match)
response = await request.app.state.http_client.get(f"/api/v1/nodes/{public_key}")
if response.status_code != 200:
raise HTTPException(status_code=404, detail="Node not found")
+3 -3
View File
@@ -149,7 +149,7 @@ class TestGetNode:
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}")
response = client_no_auth.get(f"/api/v1/nodes/prefix/{prefix}")
assert response.status_code == 200
data = response.json()
assert data["public_key"] == sample_node.public_key
@@ -157,7 +157,7 @@ class TestGetNode:
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}")
response = client_no_auth.get(f"/api/v1/nodes/prefix/{prefix}")
assert response.status_code == 200
data = response.json()
assert data["public_key"] == sample_node.public_key
@@ -189,7 +189,7 @@ class TestGetNode:
api_db_session.commit()
# Request with prefix should return first alphabetically
response = client_no_auth.get("/api/v1/nodes/abc")
response = client_no_auth.get("/api/v1/nodes/prefix/abc")
assert response.status_code == 200
data = response.json()
assert data["public_key"] == node_a.public_key
+9 -7
View File
@@ -40,7 +40,7 @@ class MockHttpClient:
"items": [
{
"id": "node-1",
"public_key": "abc123def456abc123def456abc123de",
"public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
"name": "Node One",
"adv_type": "REPEATER",
"last_seen": "2024-01-01T12:00:00Z",
@@ -48,7 +48,7 @@ class MockHttpClient:
},
{
"id": "node-2",
"public_key": "def456abc123def456abc123def456ab",
"public_key": "def456abc123def456abc123def456abc123def456abc123def456abc123def4",
"name": "Node Two",
"adv_type": "CLIENT",
"last_seen": "2024-01-01T11:00:00Z",
@@ -62,12 +62,14 @@ class MockHttpClient:
},
}
# Default single node response
self._responses["GET:/api/v1/nodes/abc123def456abc123def456abc123de"] = {
# Default single node response (exact match)
self._responses[
"GET:/api/v1/nodes/abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
] = {
"status_code": 200,
"json": {
"id": "node-1",
"public_key": "abc123def456abc123def456abc123de",
"public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
"name": "Node One",
"adv_type": "REPEATER",
"last_seen": "2024-01-01T12:00:00Z",
@@ -110,7 +112,7 @@ class MockHttpClient:
"items": [
{
"id": "adv-1",
"public_key": "abc123def456abc123def456abc123de",
"public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
"name": "Node One",
"adv_type": "REPEATER",
"received_at": "2024-01-01T12:00:00Z",
@@ -127,7 +129,7 @@ class MockHttpClient:
"items": [
{
"id": "tel-1",
"node_public_key": "abc123def456abc123def456abc123de",
"node_public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
"parsed_data": {"battery_level": 85.5},
"received_at": "2024-01-01T12:00:00Z",
},
+16 -5
View File
@@ -73,21 +73,27 @@ class TestNodeDetailPage:
self, client: TestClient, mock_http_client: MockHttpClient
) -> None:
"""Test that node detail page returns 200 status code."""
response = client.get("/nodes/abc123def456abc123def456abc123de")
response = client.get(
"/nodes/abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
)
assert response.status_code == 200
def test_node_detail_returns_html(
self, client: TestClient, mock_http_client: MockHttpClient
) -> None:
"""Test that node detail page returns HTML content."""
response = client.get("/nodes/abc123def456abc123def456abc123de")
response = client.get(
"/nodes/abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
)
assert "text/html" in response.headers["content-type"]
def test_node_detail_displays_node_info(
self, client: TestClient, mock_http_client: MockHttpClient
) -> None:
"""Test that node detail page displays node information."""
response = client.get("/nodes/abc123def456abc123def456abc123de")
response = client.get(
"/nodes/abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
)
assert response.status_code == 200
# Should display node details
assert "Node One" in response.text
@@ -98,8 +104,13 @@ class TestNodeDetailPage:
self, client: TestClient, mock_http_client: MockHttpClient
) -> None:
"""Test that node detail page displays the full public key."""
response = client.get("/nodes/abc123def456abc123def456abc123de")
assert "abc123def456abc123def456abc123de" in response.text
response = client.get(
"/nodes/abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
)
assert (
"abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
in response.text
)
class TestNodesPageAPIErrors: