diff --git a/decart/__init__.py b/decart/__init__.py index 491a3e7..988bc73 100644 --- a/decart/__init__.py +++ b/decart/__init__.py @@ -10,6 +10,7 @@ QueueSubmitError, QueueStatusError, QueueResultError, + TokenCreateError, ) from .models import models, ModelDefinition from .types import FileInput, ModelState, Prompt @@ -20,6 +21,10 @@ JobStatusResponse, QueueJobResult, ) +from .tokens import ( + TokensClient, + CreateTokenResponse, +) try: from .realtime import ( @@ -59,6 +64,9 @@ "JobSubmitResponse", "JobStatusResponse", "QueueJobResult", + "TokensClient", + "CreateTokenResponse", + "TokenCreateError", ] if REALTIME_AVAILABLE: diff --git a/decart/client.py b/decart/client.py index c2fb5aa..247976a 100644 --- a/decart/client.py +++ b/decart/client.py @@ -6,6 +6,7 @@ from .models import ImageModelDefinition, _MODELS from .process.request import send_request from .queue.client import QueueClient +from .tokens.client import TokensClient try: from .realtime.client import RealtimeClient @@ -66,6 +67,7 @@ def __init__( self.integration = integration self._session: Optional[aiohttp.ClientSession] = None self._queue: Optional[QueueClient] = None + self._tokens: Optional[TokensClient] = None @property def queue(self) -> QueueClient: @@ -91,6 +93,23 @@ def queue(self) -> QueueClient: self._queue = QueueClient(self) return self._queue + @property + def tokens(self) -> TokensClient: + """ + Client for creating client tokens. + Client tokens are short-lived API keys safe for client-side use. + + Example: + ```python + client = DecartClient(api_key=os.getenv("DECART_API_KEY")) + token = await client.tokens.create() + # Returns: CreateTokenResponse(api_key="ek_...", expires_at="...") + ``` + """ + if self._tokens is None: + self._tokens = TokensClient(self) + return self._tokens + async def _get_session(self) -> aiohttp.ClientSession: """Get or create the aiohttp session.""" if self._session is None or self._session.closed: diff --git a/decart/errors.py b/decart/errors.py index cb47456..258a5e5 100644 --- a/decart/errors.py +++ b/decart/errors.py @@ -82,3 +82,9 @@ class QueueResultError(DecartSDKError): """Raised when getting queue job result fails.""" pass + + +class TokenCreateError(DecartSDKError): + """Raised when token creation fails.""" + + pass diff --git a/decart/tokens/__init__.py b/decart/tokens/__init__.py new file mode 100644 index 0000000..1f7d477 --- /dev/null +++ b/decart/tokens/__init__.py @@ -0,0 +1,4 @@ +from .client import TokensClient +from .types import CreateTokenResponse + +__all__ = ["TokensClient", "CreateTokenResponse"] diff --git a/decart/tokens/client.py b/decart/tokens/client.py new file mode 100644 index 0000000..d4daa17 --- /dev/null +++ b/decart/tokens/client.py @@ -0,0 +1,68 @@ +from typing import TYPE_CHECKING + +import aiohttp + +from ..errors import TokenCreateError +from .._user_agent import build_user_agent +from .types import CreateTokenResponse + +if TYPE_CHECKING: + from ..client import DecartClient + + +class TokensClient: + """ + Client for creating client tokens. + Client tokens are short-lived API keys safe for client-side use. + + Example: + ```python + client = DecartClient(api_key=os.getenv("DECART_API_KEY")) + token = await client.tokens.create() + # Returns: CreateTokenResponse(api_key="ek_...", expires_at="...") + ``` + """ + + def __init__(self, parent: "DecartClient") -> None: + self._parent = parent + + async def _get_session(self) -> aiohttp.ClientSession: + return await self._parent._get_session() + + async def create(self) -> CreateTokenResponse: + """ + Create a client token. + + Returns: + A short-lived API key safe for client-side use. + + Example: + ```python + token = await client.tokens.create() + # Returns: CreateTokenResponse(api_key="ek_...", expires_at="...") + ``` + + Raises: + TokenCreateError: If token creation fails (401, 403, etc.) + """ + session = await self._get_session() + endpoint = f"{self._parent.base_url}/v1/client/tokens" + + async with session.post( + endpoint, + headers={ + "X-API-KEY": self._parent.api_key, + "User-Agent": build_user_agent(self._parent.integration), + }, + ) as response: + if not response.ok: + error_text = await response.text() + raise TokenCreateError( + f"Failed to create token: {response.status} - {error_text}", + data={"status": response.status}, + ) + data = await response.json() + return CreateTokenResponse( + api_key=data["apiKey"], + expires_at=data["expiresAt"], + ) diff --git a/decart/tokens/types.py b/decart/tokens/types.py new file mode 100644 index 0000000..f0f1c8c --- /dev/null +++ b/decart/tokens/types.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel + + +class CreateTokenResponse(BaseModel): + """Response from creating a client token.""" + + api_key: str + expires_at: str diff --git a/examples/create_token.py b/examples/create_token.py new file mode 100644 index 0000000..69c01ce --- /dev/null +++ b/examples/create_token.py @@ -0,0 +1,26 @@ +import asyncio +import os +from decart import DecartClient + + +async def main() -> None: + # Server-side: Create client token using API key + async with DecartClient(api_key=os.getenv("DECART_API_KEY")) as server_client: + print("Creating client token...") + + token = await server_client.tokens.create() + + print("Token created successfully:") + print(f" API Key: {token.api_key[:10]}...") + print(f" Expires At: {token.expires_at}") + + # Client-side: Use the client token + # In a real app, you would send token.api_key to the frontend + _client = DecartClient(api_key=token.api_key) + + print("Client created with client token.") + print("This token can now be used for realtime connections.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/test_tokens.py b/tests/test_tokens.py new file mode 100644 index 0000000..ccaa40e --- /dev/null +++ b/tests/test_tokens.py @@ -0,0 +1,68 @@ +"""Tests for the tokens API.""" + +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from decart import DecartClient, TokenCreateError + + +@pytest.mark.asyncio +async def test_create_token() -> None: + """Creates a client token successfully.""" + client = DecartClient(api_key="test-api-key") + + mock_response = AsyncMock() + mock_response.ok = True + mock_response.json = AsyncMock( + return_value={"apiKey": "ek_test123", "expiresAt": "2024-12-15T12:10:00Z"} + ) + + mock_session = MagicMock() + mock_session.post = MagicMock( + return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response)) + ) + + with patch.object(client, "_get_session", AsyncMock(return_value=mock_session)): + result = await client.tokens.create() + + assert result.api_key == "ek_test123" + assert result.expires_at == "2024-12-15T12:10:00Z" + + +@pytest.mark.asyncio +async def test_create_token_401_error() -> None: + """Handles 401 error.""" + client = DecartClient(api_key="test-api-key") + + mock_response = AsyncMock() + mock_response.ok = False + mock_response.status = 401 + mock_response.text = AsyncMock(return_value="Invalid API key") + + mock_session = MagicMock() + mock_session.post = MagicMock( + return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response)) + ) + + with patch.object(client, "_get_session", AsyncMock(return_value=mock_session)): + with pytest.raises(TokenCreateError, match="Failed to create token"): + await client.tokens.create() + + +@pytest.mark.asyncio +async def test_create_token_403_error() -> None: + """Handles 403 error.""" + client = DecartClient(api_key="test-api-key") + + mock_response = AsyncMock() + mock_response.ok = False + mock_response.status = 403 + mock_response.text = AsyncMock(return_value="Cannot create token from client token") + + mock_session = MagicMock() + mock_session.post = MagicMock( + return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response)) + ) + + with patch.object(client, "_get_session", AsyncMock(return_value=mock_session)): + with pytest.raises(TokenCreateError, match="Failed to create token"): + await client.tokens.create() diff --git a/uv.lock b/uv.lock index a13836a..6bf97be 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.9" resolution-markers = [ "python_full_version >= '3.12' and sys_platform == 'darwin'", @@ -900,7 +900,7 @@ wheels = [ [[package]] name = "decart" -version = "0.0.11" +version = "0.0.15" source = { editable = "." } dependencies = [ { name = "aiofiles" },