diff --git a/examples/multimodal/multimodal/room.py b/examples/multimodal/multimodal/room.py index 6049327..233fc0b 100644 --- a/examples/multimodal/multimodal/room.py +++ b/examples/multimodal/multimodal/room.py @@ -6,7 +6,9 @@ from .config import FISHJAM_ID, FISHJAM_TOKEN from .worker import BackgroundWorker -fishjam = FishjamClient(FISHJAM_ID, FISHJAM_TOKEN) +fishjam = FishjamClient.create_and_verify( + fishjam_id=FISHJAM_ID, management_token=FISHJAM_TOKEN +) class RoomService: diff --git a/examples/poet_chat/poet_chat/config.py b/examples/poet_chat/poet_chat/config.py index bb89c0a..a99e11c 100644 --- a/examples/poet_chat/poet_chat/config.py +++ b/examples/poet_chat/poet_chat/config.py @@ -25,4 +25,6 @@ with open(GREET_PATH) as prompt: OPENAI_GREET = prompt.read() -fishjam_client = FishjamClient(FISHJAM_ID, FISHJAM_TOKEN) +fishjam_client = FishjamClient.create_and_verify( + fishjam_id=FISHJAM_ID, management_token=FISHJAM_TOKEN +) diff --git a/examples/room_manager/room_service.py b/examples/room_manager/room_service.py index 92c8be5..1cb66dc 100644 --- a/examples/room_manager/room_service.py +++ b/examples/room_manager/room_service.py @@ -28,7 +28,7 @@ class PeerAccess: class RoomService: def __init__(self, args: Namespace, logger: Logger): - self.fishjam_client = FishjamClient( + self.fishjam_client = FishjamClient.create_and_verify( fishjam_id=args.fishjam_id, management_token=args.management_token, ) diff --git a/examples/selective_subscription/selective_subscription/room_service.py b/examples/selective_subscription/selective_subscription/room_service.py index bab8410..042ef2a 100644 --- a/examples/selective_subscription/selective_subscription/room_service.py +++ b/examples/selective_subscription/selective_subscription/room_service.py @@ -8,7 +8,7 @@ class RoomService: def __init__(self): - self.fishjam = FishjamClient( + self.fishjam = FishjamClient.create_and_verify( fishjam_id=FISHJAM_ID, management_token=FISHJAM_TOKEN ) self.room = self.fishjam.create_room( diff --git a/examples/transcription/transcription/room.py b/examples/transcription/transcription/room.py index d5fd571..0250fab 100644 --- a/examples/transcription/transcription/room.py +++ b/examples/transcription/transcription/room.py @@ -6,7 +6,9 @@ from .agent import TranscriptionAgent from .config import FISHJAM_ID, FISHJAM_TOKEN -fishjam = FishjamClient(FISHJAM_ID, FISHJAM_TOKEN) +fishjam = FishjamClient.create_and_verify( + fishjam_id=FISHJAM_ID, management_token=FISHJAM_TOKEN +) class RoomService: diff --git a/fishjam/__init__.py b/fishjam/__init__.py index 15195a4..d7be1f0 100644 --- a/fishjam/__init__.py +++ b/fishjam/__init__.py @@ -24,6 +24,7 @@ Room, RoomOptions, ) +from fishjam.errors import MissingFishjamIdError, MissingManagementTokenError __version__ = version.__version__ @@ -39,6 +40,8 @@ "AgentOutputOptions", "Room", "Peer", + "MissingFishjamIdError", + "MissingManagementTokenError", "events", "errors", "room", diff --git a/fishjam/_ws_notifier.py b/fishjam/_ws_notifier.py index bfbd2da..bc2fd7c 100644 --- a/fishjam/_ws_notifier.py +++ b/fishjam/_ws_notifier.py @@ -21,7 +21,7 @@ ALLOWED_NOTIFICATIONS, AllowedNotification, ) -from fishjam.utils import get_fishjam_url +from fishjam.utils import get_fishjam_url, validate_fishjam_config NotificationHandler = ( Callable[[AllowedNotification], None] @@ -38,6 +38,7 @@ def __init__( management_token: str, ): """Create a FishjamNotifier instance with an ID and management token.""" + validate_fishjam_config(fishjam_id, management_token) websocket_url = get_fishjam_url(fishjam_id).replace("http", "ws") self._fishjam_url = f"{websocket_url}/socket/server/websocket" self._management_token: str = management_token diff --git a/fishjam/api/_client.py b/fishjam/api/_client.py index 4a30e7a..8c1a5a2 100644 --- a/fishjam/api/_client.py +++ b/fishjam/api/_client.py @@ -6,12 +6,13 @@ from fishjam._openapi_client.models import Error from fishjam._openapi_client.types import Response from fishjam.errors import HTTPError -from fishjam.utils import get_fishjam_url +from fishjam.utils import get_fishjam_url, validate_fishjam_config from fishjam.version import get_version class Client: def __init__(self, fishjam_id: str, management_token: str): + validate_fishjam_config(fishjam_id, management_token) self._fishjam_url = get_fishjam_url(fishjam_id) self.client = AuthenticatedClient( self._fishjam_url, diff --git a/fishjam/api/_fishjam_client.py b/fishjam/api/_fishjam_client.py index af0a0bf..1592afb 100644 --- a/fishjam/api/_fishjam_client.py +++ b/fishjam/api/_fishjam_client.py @@ -154,12 +154,56 @@ def __init__( ): """Create a FishjamClient instance. + Performs only required-field shape validation on the provided + credentials. The constructor does NOT contact the Fishjam backend. + Use :meth:`create_and_verify` or :meth:`check_credentials` to verify + the credentials against the backend. + Args: fishjam_id: The unique identifier for the Fishjam instance. management_token: The token used for authenticating management operations. + + Raises: + MissingFishjamIdError: If ``fishjam_id`` is empty. + MissingManagementTokenError: If ``management_token`` is empty. """ super().__init__(fishjam_id=fishjam_id, management_token=management_token) + @classmethod + def create_and_verify( + cls, *, fishjam_id: str, management_token: str + ) -> "FishjamClient": + """Construct a FishjamClient and verify credentials against the backend. + + Args: + fishjam_id: The unique identifier for the Fishjam instance. + management_token: The token used for authenticating management operations. + + Returns: + FishjamClient: A client whose credentials have been verified. + + Raises: + MissingFishjamIdError: If ``fishjam_id`` is empty. + MissingManagementTokenError: If ``management_token`` is empty. + UnauthorizedError: If the credentials are rejected by the backend. + NotFoundError: If ``fishjam_id`` does not refer to a known Fishjam. + """ + client = cls(fishjam_id=fishjam_id, management_token=management_token) + client.check_credentials() + return client + + def check_credentials(self) -> None: + """Verify configured credentials by pinging the backend. + + Performs a single lightweight call (``get_all_rooms``) and lets the + normal error translation surface any HTTP errors. + + Raises: + UnauthorizedError: On 401. + NotFoundError: On 404. + """ + self.get_all_rooms() + def create_peer( self, room_id: str, diff --git a/fishjam/errors.py b/fishjam/errors.py index 32d5dee..7ecc349 100644 --- a/fishjam/errors.py +++ b/fishjam/errors.py @@ -4,6 +4,16 @@ from fishjam._openapi_client.types import Response +class MissingFishjamIdError(ValueError): + def __init__(self) -> None: + super().__init__("Fishjam ID is required") + + +class MissingManagementTokenError(ValueError): + def __init__(self) -> None: + super().__init__("Management Token is required") + + class HTTPError(Exception): """""" diff --git a/fishjam/utils.py b/fishjam/utils.py index cf470b4..509ed2b 100644 --- a/fishjam/utils.py +++ b/fishjam/utils.py @@ -1,5 +1,14 @@ from urllib.parse import urlparse +from fishjam.errors import MissingFishjamIdError, MissingManagementTokenError + + +def validate_fishjam_config(fishjam_id: str, management_token: str) -> None: + if not fishjam_id: + raise MissingFishjamIdError() + if not management_token: + raise MissingManagementTokenError() + def validate_url(url: str) -> bool: try: diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py new file mode 100644 index 0000000..4998d56 --- /dev/null +++ b/tests/test_config_validation.py @@ -0,0 +1,75 @@ +# pylint: disable=missing-class-docstring, missing-function-docstring, missing-module-docstring + +from unittest.mock import patch + +import pytest + +from fishjam import FishjamClient +from fishjam.errors import ( + MissingFishjamIdError, + MissingManagementTokenError, + UnauthorizedError, +) + +VALID_FISHJAM_ID = "fjm_test" +VALID_MANAGEMENT_TOKEN = "tok_test" + + +class TestSyncValidation: + def test_empty_fishjam_id_raises(self): + with pytest.raises(MissingFishjamIdError): + FishjamClient(fishjam_id="", management_token=VALID_MANAGEMENT_TOKEN) + + def test_empty_management_token_raises(self): + with pytest.raises(MissingManagementTokenError): + FishjamClient(fishjam_id=VALID_FISHJAM_ID, management_token="") + + def test_both_provided_does_not_raise(self): + FishjamClient( + fishjam_id=VALID_FISHJAM_ID, management_token=VALID_MANAGEMENT_TOKEN + ) + + +class TestLiveCheck: + def test_create_and_verify_raises_unauthorized_on_401(self): + with patch.object( + FishjamClient, + "get_all_rooms", + side_effect=UnauthorizedError("Invalid token"), + ): + with pytest.raises(UnauthorizedError): + FishjamClient.create_and_verify( + fishjam_id=VALID_FISHJAM_ID, + management_token=VALID_MANAGEMENT_TOKEN, + ) + + def test_create_and_verify_returns_client_and_pings_once(self): + with patch.object( + FishjamClient, "get_all_rooms", return_value=[] + ) as mock_get_all: + client = FishjamClient.create_and_verify( + fishjam_id=VALID_FISHJAM_ID, + management_token=VALID_MANAGEMENT_TOKEN, + ) + + assert isinstance(client, FishjamClient) + assert mock_get_all.call_count == 1 + + def test_check_credentials_raises_unauthorized_on_401(self): + client = FishjamClient( + fishjam_id=VALID_FISHJAM_ID, management_token=VALID_MANAGEMENT_TOKEN + ) + with patch.object( + FishjamClient, + "get_all_rooms", + side_effect=UnauthorizedError("Invalid token"), + ): + with pytest.raises(UnauthorizedError): + client.check_credentials() + + def test_check_credentials_returns_none_on_success(self): + client = FishjamClient( + fishjam_id=VALID_FISHJAM_ID, management_token=VALID_MANAGEMENT_TOKEN + ) + with patch.object(FishjamClient, "get_all_rooms", return_value=[]): + assert client.check_credentials() is None