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..221e611 100644 --- a/fishjam/__init__.py +++ b/fishjam/__init__.py @@ -24,6 +24,7 @@ Room, RoomOptions, ) +from fishjam.errors import InvalidFishjamCredentialsError, MissingFishjamIdError __version__ = version.__version__ @@ -39,6 +40,8 @@ "AgentOutputOptions", "Room", "Peer", + "MissingFishjamIdError", + "InvalidFishjamCredentialsError", "events", "errors", "room", diff --git a/fishjam/api/_fishjam_client.py b/fishjam/api/_fishjam_client.py index af0a0bf..626fa3b 100644 --- a/fishjam/api/_fishjam_client.py +++ b/fishjam/api/_fishjam_client.py @@ -50,6 +50,11 @@ from fishjam._openapi_client.types import UNSET from fishjam.agent import Agent from fishjam.api._client import Client +from fishjam.errors import ( + InvalidFishjamCredentialsError, + NotFoundError, + UnauthorizedError, +) @dataclass @@ -154,12 +159,53 @@ def __init__( ): """Create a FishjamClient instance. + Does not contact the Fishjam backend — use :meth:`create_and_verify` + or :meth:`check_credentials` to verify credentials live. + Args: fishjam_id: The unique identifier for the Fishjam instance. management_token: The token used for authenticating management operations. """ 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: + InvalidFishjamCredentialsError: If the ``fishjam_id`` / + ``management_token`` pair is rejected by the backend. + """ + 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 ``get_all_rooms`` call. A 401 or 404 + from the backend is translated into + :class:`InvalidFishjamCredentialsError`; other HTTP errors propagate + as their normal mapped types. + + Raises: + InvalidFishjamCredentialsError: If the ``fishjam_id`` / + ``management_token`` pair is rejected by the backend. + """ + try: + self.get_all_rooms() + except (UnauthorizedError, NotFoundError) as exc: + raise InvalidFishjamCredentialsError(*exc.args) from exc + def create_peer( self, room_id: str, diff --git a/fishjam/errors.py b/fishjam/errors.py index 32d5dee..620b4ac 100644 --- a/fishjam/errors.py +++ b/fishjam/errors.py @@ -4,6 +4,11 @@ from fishjam._openapi_client.types import Response +class MissingFishjamIdError(ValueError): + def __init__(self) -> None: + super().__init__("Fishjam ID is required") + + class HTTPError(Exception): """""" @@ -69,3 +74,9 @@ class ConflictError(HTTPError): def __init__(self, errors): """@private""" super().__init__(errors) + + +class InvalidFishjamCredentialsError(HTTPError): + def __init__(self, errors): + """@private""" + super().__init__(errors) diff --git a/fishjam/utils.py b/fishjam/utils.py index cf470b4..2434966 100644 --- a/fishjam/utils.py +++ b/fishjam/utils.py @@ -1,5 +1,7 @@ from urllib.parse import urlparse +from fishjam.errors import MissingFishjamIdError + def validate_url(url: str) -> bool: try: @@ -10,6 +12,9 @@ def validate_url(url: str) -> bool: def get_fishjam_url(fishjam_id: str) -> str: + if not fishjam_id: + raise MissingFishjamIdError() + if not validate_url(fishjam_id): return f"https://fishjam.io/api/v1/connect/{fishjam_id}" diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py new file mode 100644 index 0000000..c861d72 --- /dev/null +++ b/tests/test_config_validation.py @@ -0,0 +1,96 @@ +# 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 ( + InvalidFishjamCredentialsError, + MissingFishjamIdError, + NotFoundError, + 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_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_invalid_credentials_on_401(self): + with patch.object( + FishjamClient, + "get_all_rooms", + side_effect=UnauthorizedError("Invalid token"), + ): + with pytest.raises(InvalidFishjamCredentialsError): + FishjamClient.create_and_verify( + fishjam_id=VALID_FISHJAM_ID, + management_token=VALID_MANAGEMENT_TOKEN, + ) + + def test_create_and_verify_raises_invalid_credentials_on_404(self): + with patch.object( + FishjamClient, + "get_all_rooms", + side_effect=NotFoundError("Fishjam not found"), + ): + with pytest.raises(InvalidFishjamCredentialsError): + 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_invalid_credentials_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(InvalidFishjamCredentialsError): + client.check_credentials() + + def test_check_credentials_raises_invalid_credentials_on_404(self): + client = FishjamClient( + fishjam_id=VALID_FISHJAM_ID, management_token=VALID_MANAGEMENT_TOKEN + ) + with patch.object( + FishjamClient, + "get_all_rooms", + side_effect=NotFoundError("Fishjam not found"), + ): + with pytest.raises(InvalidFishjamCredentialsError): + 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