diff --git a/src/anyvlm/auth/token_provider.py b/src/anyvlm/auth/token_provider.py index 8cf95ae..679e271 100644 --- a/src/anyvlm/auth/token_provider.py +++ b/src/anyvlm/auth/token_provider.py @@ -7,16 +7,16 @@ import requests from requests.models import Response +from anyvlm.utils.constants import ( + VLM_NETWORK_AUDIENCE_URL, + VLM_NETWORK_TOKEN_REQUEST_URL, +) from anyvlm.utils.exceptions import TokenRetrievalError class TokenProvider: """Handles retrieving/caching valid bearer tokens""" - BASE_VLM_AUTH_URL: str = "https://vlm-auth.us.auth0.com" - AUDIENCE_URL: str = BASE_VLM_AUTH_URL + "/api/v2/" - TOKEN_REQUEST_URL: str = BASE_VLM_AUTH_URL + "/oauth/token" - # used to add margin to the token expiry time, to account for the time # it takes for the token provider to send its response back to AnyVLM TOKEN_EXPIRY_MARGIN_SECONDS = 30 @@ -54,11 +54,11 @@ def _refresh_token(self) -> None: """Retrieves and sets a new JWT token and updates the token expiry time""" now: datetime = datetime.now(tz=UTC) response: Response = requests.post( - url=self.TOKEN_REQUEST_URL, + url=VLM_NETWORK_TOKEN_REQUEST_URL, json={ "client_id": self.client_id, "client_secret": self.client_secret, - "audience": self.AUDIENCE_URL, + "audience": VLM_NETWORK_AUDIENCE_URL, "grant_type": "client_credentials", }, timeout=10, diff --git a/src/anyvlm/outbound/node_registry.py b/src/anyvlm/outbound/node_registry.py new file mode 100644 index 0000000..eb5eea5 --- /dev/null +++ b/src/anyvlm/outbound/node_registry.py @@ -0,0 +1,94 @@ +"""Retrieves information about other nodes on the VLM Network""" + +import os +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from http import HTTPMethod, HTTPStatus + +from requests import Response + +from anyvlm.outbound.vlm_client import VlmClient +from anyvlm.utils.constants import VLM_NETWORK_NODES_REQUEST_URL +from anyvlm.utils.exceptions import NodeRetrievalError + + +@dataclass +class VlmNode: + """Houses metadata about a VLM Network node""" + + client_id: str + client_name: str + match_url: str | None + + +class NodeRegistry: + """Manages the list of other known nodes on the VLM Network""" + + NODES_REFRESH_INTERVAL: timedelta = timedelta(hours=24) + NODES_RESULTS_PAGE_SIZE: int = ( + 100 # Auth0's node list is paginated w/ a max of 100 results per page + ) + + _nodes: list[VlmNode] + _nodes_expiry: datetime + _vlm_client: VlmClient + + def __init__(self, vlm_client: VlmClient) -> None: + """Initializes a list of all other nodes currently on the VLM Network + + :param vlm_client: A 'VlmClient' instance used to send outbound HTTP requests to the VLM Network + """ + self._vlm_client = vlm_client + self.refresh_nodes() + + def get_nodes(self) -> list[VlmNode]: + """Get a list of known nodes on the VLM network. Uses the cached list if cache is set & unexpired. + See 'self.refresh_nodes()' for list of possible Exceptions that may be raised. + """ + now: datetime = datetime.now(tz=UTC) + if (not self._nodes_expiry) or (now >= self._nodes_expiry): + self.refresh_nodes() + return self._nodes + + def refresh_nodes(self) -> None: + """Retrieve an updated list of known nodes on the VLM Network + reset the expiry time for the next check + + :raises: `NodeRetrievalError` if request to the VLM Network to obtain the list of registered nodes fails + :raises: `TokenRetrievalError` if unable to obtain a JWT token to send the request to the VLM Network + """ + refreshed_nodes: list[VlmNode] = [] + page = 0 + while True: + response: Response = self._vlm_client.request( + method_type=HTTPMethod.GET, + url=( + f"{VLM_NETWORK_NODES_REQUEST_URL}?page={page}" + f"&per_page={self.NODES_RESULTS_PAGE_SIZE}" + ), + ) + + if response.status_code != HTTPStatus.OK: + error_message: str = f"Unable to fetch VLM Network nodes from Auth0 - received {response.status_code} response" + raise NodeRetrievalError(error_message) + + data = response.json() + refreshed_nodes.extend( + VlmNode( + client_id=entry.get("client_id", ""), + client_name=entry.get("name", ""), + match_url=entry.get("client_metadata", {}).get("match_url"), + ) + for entry in data + if entry.get("client_id") + != os.environ.get("VLM_CLIENT_ID") # exclude our own node + ) + + # we know we've hit the last page if the number of results is less than our requested limit + if len(data) < self.NODES_RESULTS_PAGE_SIZE: + break + page += 1 + + # Update the cache only after all pages have been retrieved successfully. + self._nodes = refreshed_nodes + + self._nodes_expiry = datetime.now(tz=UTC) + self.NODES_REFRESH_INTERVAL diff --git a/src/anyvlm/utils/constants.py b/src/anyvlm/utils/constants.py new file mode 100644 index 0000000..7fbc354 --- /dev/null +++ b/src/anyvlm/utils/constants.py @@ -0,0 +1,7 @@ +"""Global constants""" + +# VLM Network URLs +VLM_NETWORK_ROOT_URL: str = "https://vlm-auth.us.auth0.com/" +VLM_NETWORK_AUDIENCE_URL: str = VLM_NETWORK_ROOT_URL + "api/v2/" +VLM_NETWORK_TOKEN_REQUEST_URL: str = VLM_NETWORK_ROOT_URL + "oauth/token/" +VLM_NETWORK_NODES_REQUEST_URL: str = VLM_NETWORK_ROOT_URL + "api/v2/clients/" diff --git a/src/anyvlm/utils/exceptions.py b/src/anyvlm/utils/exceptions.py index a65365f..079fbc1 100644 --- a/src/anyvlm/utils/exceptions.py +++ b/src/anyvlm/utils/exceptions.py @@ -23,3 +23,7 @@ class VariantLookupError(Exception): class TokenRetrievalError(Exception): """Raised when a token cannot be retrieved""" + + +class NodeRetrievalError(Exception): + """Raised when the list of nodes on the VLM network cannot be retrieved""" diff --git a/tests/unit/test_node_registry.py b/tests/unit/test_node_registry.py new file mode 100644 index 0000000..00e05a0 --- /dev/null +++ b/tests/unit/test_node_registry.py @@ -0,0 +1,162 @@ +from datetime import UTC, datetime, timedelta +from http import HTTPMethod, HTTPStatus +from typing import Any +from unittest.mock import MagicMock, call + +import pytest + +from anyvlm.outbound.node_registry import NodeRegistry +from anyvlm.utils.constants import VLM_NETWORK_NODES_REQUEST_URL +from anyvlm.utils.exceptions import NodeRetrievalError + + +def _response( + status_code: HTTPStatus = HTTPStatus.OK, data: list[Any] | None = None +) -> MagicMock: + response: MagicMock = MagicMock() + response.status_code = status_code + response.json.return_value = data or [] + return response + + +def test_loads_nodes_and_exposes_client_ids() -> None: + client: MagicMock = MagicMock() + client.request.return_value = _response( + data=[ + { + "client_id": "client-one", + "name": "One", + "client_metadata": {"match_url": "https://one.example"}, + }, + {"client_id": "client-two", "name": "Two"}, + ] + ) + + registry: NodeRegistry = NodeRegistry(vlm_client=client) + + assert [node.client_id for node in registry.get_nodes()] == [ + "client-one", + "client-two", + ] + client.request.assert_called_once_with( + method_type=HTTPMethod.GET, + url=VLM_NETWORK_NODES_REQUEST_URL + "?page=0&per_page=100", + ) + + +def test_loads_nodes_from_multiple_pages() -> None: + client: MagicMock = MagicMock() + client.request.side_effect = [ + _response(data=[{"client_id": "client-one"}] * 100), + _response(data=[{"client_id": "client-two"}]), + ] + + registry: NodeRegistry = NodeRegistry(vlm_client=client) + + assert [node.client_id for node in registry.get_nodes()] == [ + "client-one", + ] * 100 + ["client-two"] + assert client.request.call_args_list == [ + call( + method_type=HTTPMethod.GET, + url=VLM_NETWORK_NODES_REQUEST_URL + "?page=0&per_page=100", + ), + call( + method_type=HTTPMethod.GET, + url=VLM_NETWORK_NODES_REQUEST_URL + "?page=1&per_page=100", + ), + ] + + +def test_refresh_nodes_replaces_cached_nodes() -> None: + client: MagicMock = MagicMock() + client.request.side_effect = [ + _response(data=[{"client_id": "old"}]), + _response(data=[{"client_id": "new"}]), + ] + + registry: NodeRegistry = NodeRegistry(vlm_client=client) + registry.refresh_nodes() + + assert [node.client_id for node in registry.get_nodes()] == ["new"] + + +def test_refreshes_expired_cache_on_get_nodes() -> None: + client: MagicMock = MagicMock() + client.request.side_effect = [ + _response(data=[{"client_id": "initial"}]), + _response(data=[{"client_id": "refreshed"}]), + ] + + registry: NodeRegistry = NodeRegistry(vlm_client=client) + registry._nodes_expiry = datetime.now(tz=UTC) - timedelta(seconds=1) # pyright: ignore[reportPrivateUsage] + assert [node.client_id for node in registry.get_nodes()] == ["refreshed"] + + assert client.request.call_count == 2 + + +def test_raises_when_node_retrieval_fails() -> None: + client: MagicMock = MagicMock() + client.request.return_value = _response(status_code=HTTPStatus.SERVICE_UNAVAILABLE) + + with pytest.raises(NodeRetrievalError, match="Unable to fetch"): + NodeRegistry(vlm_client=client) + + +def test_registry_excludes_self_when_other_nodes_exist( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # this is our own node's client_id + self_vlm_client_id = "self-client" + monkeypatch.setenv("VLM_CLIENT_ID", self_vlm_client_id) + + client: MagicMock = MagicMock() + client.request.return_value = _response( + data=[ + { + "client_id": "client-one", + "name": "One", + "client_metadata": {"match_url": "https://one.example"}, + }, + { + "client_id": self_vlm_client_id, + "name": "GenomicMedLabs' AnyVLM instance", + }, + ] + ) + + registry: NodeRegistry = NodeRegistry(vlm_client=client) + + assert [node.client_id for node in registry.get_nodes()] == [ + "client-one", + ] + client.request.assert_called_once_with( + method_type=HTTPMethod.GET, + url=VLM_NETWORK_NODES_REQUEST_URL + "?page=0&per_page=100", + ) + + +def test_registry_returns_empty_list_when_only_self_exists( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # this is our own node's client_id + self_vlm_client_id = "self-client" + monkeypatch.setenv("VLM_CLIENT_ID", self_vlm_client_id) + + client: MagicMock = MagicMock() + client.request.return_value = _response( + data=[ + { + "client_id": self_vlm_client_id, + "name": "GenomicMedLabs' AnyVLM instance", + }, + ] + ) + + registry: NodeRegistry = NodeRegistry(vlm_client=client) + + assert [node.client_id for node in registry.get_nodes()] == [] + client.request.assert_called_once_with( + method_type=HTTPMethod.GET, + url=VLM_NETWORK_NODES_REQUEST_URL + "?page=0&per_page=100", + )