Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions decart/tokens/client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

import aiohttp

Expand All @@ -20,6 +20,9 @@ class TokensClient:
client = DecartClient(api_key=os.getenv("DECART_API_KEY"))
token = await client.tokens.create()
# Returns: CreateTokenResponse(api_key="ek_...", expires_at="...")

# With metadata:
token = await client.tokens.create(metadata={"role": "viewer"})
```
"""

Expand All @@ -29,17 +32,27 @@ def __init__(self, parent: "DecartClient") -> None:
async def _get_session(self) -> aiohttp.ClientSession:
return await self._parent._get_session()

async def create(self) -> CreateTokenResponse:
async def create(
self,
*,
metadata: dict[str, Any] | None = None,
) -> CreateTokenResponse:
"""
Create a client token.

Args:
metadata: Optional custom key-value pairs to attach to the 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="...")

# With metadata:
token = await client.tokens.create(metadata={"role": "viewer"})
```

Raises:
Expand All @@ -48,12 +61,17 @@ async def create(self) -> CreateTokenResponse:
session = await self._get_session()
endpoint = f"{self._parent.base_url}/v1/client/tokens"

headers = {
"X-API-KEY": self._parent.api_key,
"User-Agent": build_user_agent(self._parent.integration),
}

body = {"metadata": metadata} if metadata is not None else {}

async with session.post(
endpoint,
headers={
"X-API-KEY": self._parent.api_key,
"User-Agent": build_user_agent(self._parent.integration),
},
headers=headers,
json=body,
) as response:
if not response.ok:
error_text = await response.text()
Expand Down
48 changes: 48 additions & 0 deletions tests/test_tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,51 @@ async def test_create_token_403_error() -> None:
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_with_metadata() -> None:
"""Sends metadata as JSON body when provided."""
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(metadata={"role": "viewer"})

assert result.api_key == "ek_test123"
assert result.expires_at == "2024-12-15T12:10:00Z"
call_kwargs = mock_session.post.call_args
assert call_kwargs.kwargs["json"] == {"metadata": {"role": "viewer"}}


@pytest.mark.asyncio
async def test_create_token_without_metadata_sends_null() -> None:
"""Sends JSON body with null metadata when none provided."""
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)):
await client.tokens.create()

call_kwargs = mock_session.post.call_args
assert call_kwargs.kwargs["json"] == {}
Loading