From faf3f5a6ad3924beccafae9259d1807a34004477 Mon Sep 17 00:00:00 2001 From: Adrian Czerwiec Date: Fri, 22 May 2026 15:52:15 +0200 Subject: [PATCH 1/4] add creds valitdation --- fishjam/__init__.py | 3 ++ fishjam/_ws_notifier.py | 3 +- fishjam/api/_client.py | 3 +- fishjam/api/_fishjam_client.py | 44 +++++++++++++++++++ fishjam/errors.py | 10 +++++ fishjam/utils.py | 9 ++++ tests/test_config_validation.py | 75 +++++++++++++++++++++++++++++++++ 7 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 tests/test_config_validation.py 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 From a22c39c212567bb318c221156f696beff7dccc77 Mon Sep 17 00:00:00 2001 From: Adrian Czerwiec Date: Fri, 22 May 2026 16:30:48 +0200 Subject: [PATCH 2/4] bump examples --- examples/multimodal/multimodal/room.py | 4 +++- examples/poet_chat/poet_chat/config.py | 4 +++- examples/room_manager/room_service.py | 2 +- .../selective_subscription/room_service.py | 2 +- examples/transcription/transcription/room.py | 4 +++- 5 files changed, 11 insertions(+), 5 deletions(-) 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: From f3134fea01140f20edaf20390d371c3c12092982 Mon Sep 17 00:00:00 2001 From: Adrian Czerwiec Date: Tue, 2 Jun 2026 11:24:23 +0200 Subject: [PATCH 3/4] simpllify validation --- fishjam/__init__.py | 4 ++-- fishjam/_ws_notifier.py | 3 +-- fishjam/api/_client.py | 3 +-- fishjam/api/_fishjam_client.py | 36 ++++++++++++++---------------- fishjam/errors.py | 11 +++++----- fishjam/utils.py | 12 ++++------ tests/test_config_validation.py | 39 +++++++++++++++++++++++++-------- 7 files changed, 61 insertions(+), 47 deletions(-) diff --git a/fishjam/__init__.py b/fishjam/__init__.py index d7be1f0..221e611 100644 --- a/fishjam/__init__.py +++ b/fishjam/__init__.py @@ -24,7 +24,7 @@ Room, RoomOptions, ) -from fishjam.errors import MissingFishjamIdError, MissingManagementTokenError +from fishjam.errors import InvalidFishjamCredentialsError, MissingFishjamIdError __version__ = version.__version__ @@ -41,7 +41,7 @@ "Room", "Peer", "MissingFishjamIdError", - "MissingManagementTokenError", + "InvalidFishjamCredentialsError", "events", "errors", "room", diff --git a/fishjam/_ws_notifier.py b/fishjam/_ws_notifier.py index bc2fd7c..bfbd2da 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, validate_fishjam_config +from fishjam.utils import get_fishjam_url NotificationHandler = ( Callable[[AllowedNotification], None] @@ -38,7 +38,6 @@ 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 8c1a5a2..4a30e7a 100644 --- a/fishjam/api/_client.py +++ b/fishjam/api/_client.py @@ -6,13 +6,12 @@ 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, validate_fishjam_config +from fishjam.utils import get_fishjam_url 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 1592afb..6ac6587 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,18 +159,12 @@ 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. + 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. - - Raises: - MissingFishjamIdError: If ``fishjam_id`` is empty. - MissingManagementTokenError: If ``management_token`` is empty. """ super().__init__(fishjam_id=fishjam_id, management_token=management_token) @@ -183,10 +182,8 @@ def create_and_verify( 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. + 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() @@ -195,14 +192,15 @@ def create_and_verify( 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. + 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. """ - self.get_all_rooms() + try: + self.get_all_rooms() + except (UnauthorizedError, NotFoundError) as exc: + raise InvalidFishjamCredentialsError(*exc.args) from exc def create_peer( self, diff --git a/fishjam/errors.py b/fishjam/errors.py index 7ecc349..620b4ac 100644 --- a/fishjam/errors.py +++ b/fishjam/errors.py @@ -9,11 +9,6 @@ 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): """""" @@ -79,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 509ed2b..2434966 100644 --- a/fishjam/utils.py +++ b/fishjam/utils.py @@ -1,13 +1,6 @@ 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() +from fishjam.errors import MissingFishjamIdError def validate_url(url: str) -> bool: @@ -19,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 index 4998d56..c861d72 100644 --- a/tests/test_config_validation.py +++ b/tests/test_config_validation.py @@ -6,8 +6,9 @@ from fishjam import FishjamClient from fishjam.errors import ( + InvalidFishjamCredentialsError, MissingFishjamIdError, - MissingManagementTokenError, + NotFoundError, UnauthorizedError, ) @@ -20,10 +21,6 @@ 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 @@ -31,13 +28,25 @@ def test_both_provided_does_not_raise(self): class TestLiveCheck: - def test_create_and_verify_raises_unauthorized_on_401(self): + 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(UnauthorizedError): + 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, @@ -55,7 +64,7 @@ def test_create_and_verify_returns_client_and_pings_once(self): assert isinstance(client, FishjamClient) assert mock_get_all.call_count == 1 - def test_check_credentials_raises_unauthorized_on_401(self): + def test_check_credentials_raises_invalid_credentials_on_401(self): client = FishjamClient( fishjam_id=VALID_FISHJAM_ID, management_token=VALID_MANAGEMENT_TOKEN ) @@ -64,7 +73,19 @@ def test_check_credentials_raises_unauthorized_on_401(self): "get_all_rooms", side_effect=UnauthorizedError("Invalid token"), ): - with pytest.raises(UnauthorizedError): + 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): From 68a843a399690b496b850cd4d2e822c7ba83427c Mon Sep 17 00:00:00 2001 From: Adrian Czerwiec Date: Tue, 2 Jun 2026 15:51:29 +0200 Subject: [PATCH 4/4] lint --- fishjam/api/_fishjam_client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fishjam/api/_fishjam_client.py b/fishjam/api/_fishjam_client.py index 6ac6587..626fa3b 100644 --- a/fishjam/api/_fishjam_client.py +++ b/fishjam/api/_fishjam_client.py @@ -196,6 +196,10 @@ def check_credentials(self) -> None: 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()