From 345ffd219b8c6a9a07de22ef0ec3ca29dd69e052 Mon Sep 17 00:00:00 2001 From: Louis King Date: Mon, 26 Jan 2026 22:27:15 +0000 Subject: [PATCH] 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 --- src/meshcore_hub/api/routes/nodes.py | 37 +++++++++++++++++++--------- src/meshcore_hub/web/routes/nodes.py | 35 ++++++++++++++------------ tests/test_api/test_nodes.py | 6 ++--- tests/test_web/conftest.py | 16 ++++++------ tests/test_web/test_nodes.py | 21 ++++++++++++---- 5 files changed, 73 insertions(+), 42 deletions(-) diff --git a/src/meshcore_hub/api/routes/nodes.py b/src/meshcore_hub/api/routes/nodes.py index 05b8349..a46f211 100644 --- a/src/meshcore_hub/api/routes/nodes.py +++ b/src/meshcore_hub/api/routes/nodes.py @@ -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) diff --git a/src/meshcore_hub/web/routes/nodes.py b/src/meshcore_hub/web/routes/nodes.py index e5098c1..03739ed 100644 --- a/src/meshcore_hub/web/routes/nodes.py +++ b/src/meshcore_hub/web/routes/nodes.py @@ -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") diff --git a/tests/test_api/test_nodes.py b/tests/test_api/test_nodes.py index 0c98bf0..7395a73 100644 --- a/tests/test_api/test_nodes.py +++ b/tests/test_api/test_nodes.py @@ -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 diff --git a/tests/test_web/conftest.py b/tests/test_web/conftest.py index d069ce4..57ee363 100644 --- a/tests/test_web/conftest.py +++ b/tests/test_web/conftest.py @@ -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", }, diff --git a/tests/test_web/test_nodes.py b/tests/test_web/test_nodes.py index da01065..4cd97ce 100644 --- a/tests/test_web/test_nodes.py +++ b/tests/test_web/test_nodes.py @@ -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: