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/_openapi_client/api/credentials/__init__.py b/fishjam/_openapi_client/api/credentials/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/fishjam/_openapi_client/api/credentials/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/fishjam/_openapi_client/api/credentials/validate_credentials.py b/fishjam/_openapi_client/api/credentials/validate_credentials.py new file mode 100644 index 0000000..dff8756 --- /dev/null +++ b/fishjam/_openapi_client/api/credentials/validate_credentials.py @@ -0,0 +1,91 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...types import Response + + +def _get_kwargs() -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/validate", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | None: + if response.status_code == 200: + return None + + if response.status_code == 404: + return None + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Any]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, +) -> Response[Any]: + """Validate Fishjam Management Token + + Returns 200 if the provided Fishjam Management Token is valid, 404 otherwise. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs() + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, +) -> Response[Any]: + """Validate Fishjam Management Token + + Returns 200 if the provided Fishjam Management Token is valid, 404 otherwise. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs() + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) diff --git a/fishjam/api/_fishjam_client.py b/fishjam/api/_fishjam_client.py index af0a0bf..0fe0f2b 100644 --- a/fishjam/api/_fishjam_client.py +++ b/fishjam/api/_fishjam_client.py @@ -1,8 +1,12 @@ """Fishjam client used to manage rooms.""" from dataclasses import dataclass, field +from http import HTTPStatus from typing import Any, Literal, cast +from fishjam._openapi_client.api.credentials import ( + validate_credentials as credentials_validate_credentials, +) from fishjam._openapi_client.api.mo_q import ( create_moq_token as moq_create_token, ) @@ -50,6 +54,9 @@ from fishjam._openapi_client.types import UNSET from fishjam.agent import Agent from fishjam.api._client import Client +from fishjam.errors import ( + InvalidFishjamCredentialsError, +) @dataclass @@ -154,12 +161,47 @@ 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 its 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 token is rejected. + """ + client = cls(fishjam_id=fishjam_id, management_token=management_token) + client.check_credentials() + return client + + def check_credentials(self) -> None: + """Verify the management token via a single ``/validate`` call. + + Raises: + InvalidFishjamCredentialsError: If the token is rejected. + """ + response = credentials_validate_credentials.sync_detailed(client=self.client) + self._handle_deprecation_header(response.headers) + + if response.status_code == HTTPStatus.NOT_FOUND: + raise InvalidFishjamCredentialsError("Invalid Fishjam credentials") + 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..df9fdf8 --- /dev/null +++ b/tests/test_config_validation.py @@ -0,0 +1,67 @@ +# pylint: disable=missing-class-docstring, missing-function-docstring, missing-module-docstring + +from http import HTTPStatus +from unittest.mock import Mock, patch + +import pytest + +from fishjam import FishjamClient +from fishjam.errors import ( + InvalidFishjamCredentialsError, + MissingFishjamIdError, +) + +VALID_FISHJAM_ID = "fjm_test" +VALID_MANAGEMENT_TOKEN = "tok_test" + +VALIDATE = "fishjam.api._fishjam_client.credentials_validate_credentials.sync_detailed" + + +def _response(status: HTTPStatus): + return Mock(status_code=status, headers={}) + + +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_404(self): + with patch(VALIDATE, return_value=_response(HTTPStatus.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(VALIDATE, return_value=_response(HTTPStatus.OK)) as mock_validate: + client = FishjamClient.create_and_verify( + fishjam_id=VALID_FISHJAM_ID, + management_token=VALID_MANAGEMENT_TOKEN, + ) + + assert isinstance(client, FishjamClient) + assert mock_validate.call_count == 1 + + def test_check_credentials_raises_invalid_credentials_on_404(self): + client = FishjamClient( + fishjam_id=VALID_FISHJAM_ID, management_token=VALID_MANAGEMENT_TOKEN + ) + with patch(VALIDATE, return_value=_response(HTTPStatus.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(VALIDATE, return_value=_response(HTTPStatus.OK)): + assert client.check_credentials() is None