Skip to content

Commit 797f0a9

Browse files
authored
Feature/stream accept tokens (#255)
1 parent c112150 commit 797f0a9

12 files changed

Lines changed: 333 additions & 184 deletions

getstream/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def __init__(
1818
self.base_url = base_url
1919
self.params = {"api_key": api_key}
2020
self.api_key = api_key
21+
self.token = token
2122

2223
# Avoid shared mutable defaults and copy any provided headers
2324
headers_dict = dict(headers) if headers is not None else {}

getstream/stream.py

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
class Settings(BaseSettings):
3232
# Env names: STREAM_API_KEY, STREAM_API_SECRET, STREAM_BASE_URL, STREAM_TIMEOUT
3333
api_key: str
34-
api_secret: str
34+
api_secret: Optional[str] = None
3535
base_url: Optional[str] = None
3636
timeout: float = 6.0
3737

@@ -50,22 +50,70 @@ def __init__(
5050
user_agent: Optional[str] = None,
5151
transport=None,
5252
http_client=None,
53+
token: Optional[str] = None,
5354
):
55+
"""Build a Stream client.
56+
57+
Pass exactly one of ``api_secret`` or ``token``:
58+
- ``api_secret`` enables a server-side client that can mint user tokens
59+
and call protected admin endpoints.
60+
- ``token`` enables a client-side client authenticated as a single
61+
user. Token-only clients cannot mint tokens or call admin endpoints.
62+
63+
Any of ``api_key``, ``api_secret``, ``base_url`` left as ``None`` are
64+
loaded from ``STREAM_*`` env vars; passing ``token`` skips the
65+
``api_secret`` env fallback.
66+
67+
Args:
68+
api_key: Project API key. Falls back to ``STREAM_API_KEY``.
69+
api_secret: Project API secret. Mutually exclusive with ``token``.
70+
Falls back to ``STREAM_API_SECRET`` only when ``token`` is also
71+
``None``.
72+
timeout: HTTP request timeout in seconds; must be > 0.
73+
base_url: API base URL. Falls back to ``STREAM_BASE_URL`` then to
74+
the SDK default.
75+
user_agent: Optional custom ``User-Agent`` string.
76+
transport: Optional ``httpx`` transport. Mutually exclusive with
77+
``http_client``.
78+
http_client: Optional pre-built ``httpx`` client. Mutually
79+
exclusive with ``transport``. When provided, sub-clients
80+
(video/chat/moderation) reuse it instead of opening their own.
81+
token: Pre-minted user JWT. Mutually exclusive with ``api_secret``.
82+
83+
Raises:
84+
ValueError: If both ``transport`` and ``http_client`` are set; if
85+
neither ``api_secret`` nor ``token`` can be resolved; if both
86+
are provided; if either is the empty string; if ``api_key`` is
87+
missing; or if ``timeout`` is not a positive number.
88+
"""
5489
if transport is not None and http_client is not None:
5590
raise ValueError("Cannot specify both 'transport' and 'http_client'")
5691

57-
if None in (api_key, api_secret, timeout, base_url):
58-
s = Settings() # loads from env and optional .env
92+
# Env fallback for anything not explicitly provided. A caller-supplied
93+
# token short-circuits api_secret loading so token-only callers don't
94+
# need STREAM_API_SECRET in env.
95+
if (
96+
api_key is None
97+
or base_url is None
98+
or (api_secret is None and token is None)
99+
):
100+
s = Settings()
59101
api_key = api_key or s.api_key
60-
api_secret = api_secret or s.api_secret
61102
base_url = base_url or (s.base_url or BASE_URL)
103+
if token is None and api_secret is None:
104+
api_secret = s.api_secret
62105

63-
if api_key is None or api_key == "":
106+
if not api_key:
64107
raise ValueError("api_key is required")
65-
if api_secret is None or api_secret == "":
66-
raise ValueError("api_secret is required")
108+
if api_secret and token:
109+
raise ValueError("Pass either api_secret or token, not both")
110+
if api_secret == "" or token == "":
111+
raise ValueError("api_secret and token must not be empty strings")
112+
if api_secret is None and token is None:
113+
raise ValueError("Either api_secret or token is required")
114+
67115
self.api_key = api_key
68-
self.api_secret = api_secret
116+
self._api_secret = api_secret or None
69117

70118
if timeout is not None:
71119
if not isinstance(timeout, (int, float)) or timeout <= 0.0:
@@ -76,7 +124,7 @@ def __init__(
76124
self.user_agent = user_agent
77125
self._transport = transport
78126
self._http_client = http_client
79-
self.token = self._create_token()
127+
self.token = token or self._create_token()
80128
super().__init__(
81129
self.api_key, self.base_url, self.token, self.timeout, self.user_agent
82130
)
@@ -88,6 +136,25 @@ def __init__(
88136
else:
89137
self._shared_client = None
90138

139+
@property
140+
def api_secret(self) -> str:
141+
"""
142+
Get api secret if it's set.
143+
Otherwise, raise a ValueError.
144+
"""
145+
if self._api_secret is None:
146+
raise ValueError(
147+
"api_secret is required; this client was initialized with a token"
148+
)
149+
return self._api_secret
150+
151+
@property
152+
def has_api_secret(self) -> bool:
153+
"""
154+
Check if api secret is set for this client.
155+
"""
156+
return self._api_secret is not None
157+
91158
def _apply_shared_client(self, sub_client):
92159
"""Replace a sub-client's auto-created httpx client with the shared
93160
one built from user-provided transport/http_client config."""
@@ -131,6 +198,20 @@ def create_token(
131198
user_id=user_id, expiration=expiration, iat=int(time.time()) - 5
132199
)
133200

201+
def clone_for_token(self, token: str):
202+
"""Return a sibling client authenticated with the given user token.
203+
204+
Keeps this client's ``api_key`` and ``base_url``. The clone is
205+
token-only; it cannot mint further tokens.
206+
"""
207+
return self.__class__(
208+
api_key=self.api_key,
209+
token=token,
210+
base_url=self.base_url,
211+
timeout=self.timeout,
212+
user_agent=self.user_agent,
213+
)
214+
134215
def create_call_token(
135216
self,
136217
user_id: str,
@@ -358,7 +439,8 @@ def from_env(cls, timeout: float = 6.0) -> Stream:
358439
def as_async(self) -> "AsyncStream":
359440
return AsyncStream(
360441
api_key=self.api_key,
361-
api_secret=self.api_secret,
442+
api_secret=self._api_secret,
443+
token=None if self.has_api_secret else self.token,
362444
timeout=self.timeout,
363445
base_url=self.base_url,
364446
user_agent=self.user_agent,

getstream/video/rtc/connection_manager.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,9 +307,11 @@ async def _connect_coordinator_ws(self):
307307
with telemetry.start_as_current_span(
308308
"coordinator-ws-connect",
309309
):
310+
stream = self.call.client.stream
310311
self._coordinator_ws_client = StreamAPIWS(
311312
call=self.call,
312313
user_details={"id": self.user_id},
314+
user_token=None if stream.has_api_secret else stream.token,
313315
)
314316
self._coordinator_ws_client.on_wildcard("*", _log_event)
315317
self._coordinator_ws_client.on(

getstream/video/rtc/connection_utils.py

Lines changed: 26 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -107,33 +107,22 @@ class ConnectionOptions:
107107
previous_session_id: Optional[str] = None
108108

109109

110-
def user_client(call: Call, user_id: str):
111-
token = call.client.stream.create_token(user_id=user_id)
112-
client = call.client.stream.__class__(
113-
api_key=call.client.stream.api_key,
114-
api_secret=call.client.stream.api_secret,
115-
base_url=call.client.stream.base_url,
116-
)
117-
# set up authentication
118-
client.token = token
119-
client.headers["authorization"] = token
120-
client.client.headers["authorization"] = token
121-
return client
122-
123-
124110
async def watch_call(call: Call, user_id: str, connection_id: str):
125-
client = user_client(call, user_id)
126-
127-
# Make the POST request to join the call
128-
return await client.post(
129-
"/api/v2/video/call/{type}/{id}",
130-
JoinCallResponse,
131-
path_params={
132-
"type": call.call_type,
133-
"id": call.id,
134-
},
135-
query_params=build_query_param(connection_id=connection_id),
111+
stream = call.client.stream
112+
token = (
113+
stream.create_token(user_id=user_id) if stream.has_api_secret else stream.token
136114
)
115+
# Make the POST request to join the call
116+
async with stream.clone_for_token(token) as client:
117+
return await client.post(
118+
"/api/v2/video/call/{type}/{id}",
119+
JoinCallResponse,
120+
path_params={
121+
"type": call.call_type,
122+
"id": call.id,
123+
},
124+
query_params=build_query_param(connection_id=connection_id),
125+
)
137126

138127

139128
async def join_call(
@@ -195,7 +184,6 @@ async def join_call_coordinator_request(
195184
Returns:
196185
A response containing the call information and credentials
197186
"""
198-
client = user_client(call, user_id)
199187

200188
# Prepare path parameters for the request
201189
path_params = {
@@ -218,12 +206,19 @@ async def join_call_coordinator_request(
218206
json_body["migrating_from_list"] = migrating_from_list
219207

220208
# Make the POST request to join the call
221-
return await client.post(
222-
"/api/v2/video/call/{type}/{id}/join",
223-
JoinCallResponse,
224-
path_params=path_params,
225-
json=json_body,
209+
stream = call.client.stream
210+
# Create a new client instance with the token for the given user_id
211+
token = (
212+
stream.create_token(user_id=user_id) if stream.has_api_secret else stream.token
226213
)
214+
client = stream.clone_for_token(token)
215+
async with client:
216+
return await client.post(
217+
"/api/v2/video/call/{type}/{id}/join",
218+
JoinCallResponse,
219+
path_params=path_params,
220+
json=json_body,
221+
)
227222

228223

229224
async def create_join_request(token: str, session_id: str) -> events_pb2.JoinRequest:

getstream/video/rtc/coordinator/ws.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515

1616
from websockets import ClientConnection
1717

18-
from getstream import AsyncStream
1918
from getstream.utils import StreamAsyncIOEventEmitter
2019
from getstream.video.async_call import Call
2120
from .errors import (
@@ -192,15 +191,10 @@ async def _open_socket(self) -> dict:
192191
return message
193192

194193
# make sure we update connection_id to subscribe to events
195-
client = AsyncStream(
196-
api_key=self.call.client.stream.api_key,
197-
api_secret=self.call.client.stream.api_secret,
198-
base_url=self.call.client.stream.base_url,
199-
)
194+
if self.user_token is None:
195+
raise ValueError("user_token is required")
200196

201-
client.token = self.user_token
202-
client.headers["authorization"] = self.user_token
203-
client.client.headers["authorization"] = self.user_token # type: ignore[index]
197+
client = self.call.client.stream.clone_for_token(self.user_token)
204198
if self.call.id is None:
205199
raise ValueError("call.id is required")
206200
path_params = {

getstream/video/rtc/coordinator_api.py

Lines changed: 0 additions & 81 deletions
This file was deleted.

pytest.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
addopts = --doctest-modules -s --timeout=120 --import-mode=importlib
33
testpaths = tests getstream
44

5+
asyncio_mode = auto
6+
57
log_cli = true
68
log_cli_level = INFO
79
log_cli_format = %(asctime)s - %(name)s - %(levelname)s - %(message)s

0 commit comments

Comments
 (0)