Skip to content

Commit 6879e3d

Browse files
authored
Merge branch 'main' into content_item_docs
2 parents 56b3555 + e605767 commit 6879e3d

File tree

6 files changed

+113
-27
lines changed

6 files changed

+113
-27
lines changed

src/posit/connect/_utils.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import os
4+
35
from typing_extensions import Any
46

57

@@ -28,3 +30,13 @@ def update_dict_values(obj: dict[str, Any], /, **kwargs: Any) -> None:
2830

2931
# Use the `dict` class to explicity update the object in-place
3032
dict.update(obj, **kwargs)
33+
34+
35+
def is_local() -> bool:
36+
"""Returns true if called from a piece of content running on a Connect server.
37+
38+
The connect server will always set the environment variable `RSTUDIO_PRODUCT=CONNECT`.
39+
We can use this environment variable to determine if the content is running locally
40+
or on a Connect server.
41+
"""
42+
return os.getenv("RSTUDIO_PRODUCT") != "CONNECT"

src/posit/connect/client.py

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -173,12 +173,19 @@ def __init__(self, *args, **kwargs) -> None:
173173
self.session = session
174174
self._ctx = Context(self)
175175

176-
@requires("2025.01.0-dev")
176+
@requires("2025.01.0")
177177
def with_user_session_token(self, token: str) -> Client:
178178
"""Create a new Client scoped to the user specified in the user session token.
179179
180180
Create a new Client instance from a user session token exchange for an api key scoped to the
181-
user specified in the token.
181+
user specified in the token (the user viewing your app). If running your application locally,
182+
a user session token will not exist, which will cause this method to result in an error needing
183+
to be handled in your application.
184+
185+
Depending on the type of application you are building, the user session token is retrieved in
186+
a variety of ways. For example, in Streamlit and Shiny applications, the token is stored in the
187+
context or session object headers using the `Posit-Connect-User-Session-Token` key. For API
188+
applications, the token is added to the request headers.
182189
183190
Parameters
184191
----------
@@ -190,19 +197,69 @@ def with_user_session_token(self, token: str) -> Client:
190197
Client
191198
A new Client instance authenticated with an API key exchanged for the user session token.
192199
200+
Raises
201+
------
202+
ValueError
203+
If the provided token is `None` or empty or if the exchange response is malformed.
204+
ClientError
205+
If the token exchange request with the Connect Server fails.
206+
193207
Examples
194208
--------
195-
>>> from posit.connect import Client
196-
>>> client = Client().with_user_session_token("my-user-session-token")
209+
```python
210+
from posit.connect import Client
211+
client = Client().with_user_session_token("my-user-session-token")
212+
```
213+
214+
Example using user session token from Shiny session:
215+
```python
216+
from posit.connect import Client
217+
from shiny.express import render, session
218+
219+
client = Client()
220+
221+
@reactive.calc
222+
def visitor_client():
223+
## read the user session token and generate a new client
224+
user_session_token = session.http_conn.headers.get(
225+
"Posit-Connect-User-Session-Token"
226+
)
227+
return client.with_user_session_token(user_session_token)
228+
@render.text
229+
def user_profile():
230+
# fetch the viewer's profile information
231+
return visitor_client().me
232+
```
233+
234+
Example of when the visitor's token could not be retrieved (for
235+
example, if this app allows unauthenticated access) and handle
236+
that in cases where a token is expected.
237+
```python
238+
from posit.connect import Client
239+
import requests
240+
241+
# Simulate request without header
242+
mock_request = requests.Request()
243+
visitor_client = None
244+
token = request.headers.get("Posit-Connect-User-Session-Token")
245+
if token:
246+
visitor_client = Client().with_user_session_token(token)
247+
else:
248+
print("This app requires a user session token to operate.")
249+
```
197250
"""
198-
viewer_credentials = self.oauth.get_credentials(
251+
if token is None or token == "":
252+
raise ValueError("token must be set to non-empty string.")
253+
254+
visitor_credentials = self.oauth.get_credentials(
199255
token, requested_token_type=API_KEY_TOKEN_TYPE
200256
)
201-
viewer_api_key = viewer_credentials.get("access_token")
202-
if viewer_api_key is None:
203-
raise ValueError("Unable to retrieve viewer api key.")
204257

205-
return Client(url=self.cfg.url, api_key=viewer_api_key)
258+
visitor_api_key = visitor_credentials.get("access_token", "")
259+
if visitor_api_key == "":
260+
raise ValueError("Unable to retrieve token.")
261+
262+
return Client(url=self.cfg.url, api_key=visitor_api_key)
206263

207264
@property
208265
def content(self) -> Content:

src/posit/connect/external/databricks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
import requests
1414
from typing_extensions import Callable, Dict, Optional
1515

16+
from .._utils import is_local
1617
from ..client import Client
1718
from ..oauth import Credentials
18-
from .external import is_local
1919

2020
POSIT_OAUTH_INTEGRATION_AUTH_TYPE = "posit-oauth-integration"
2121
POSIT_LOCAL_CLIENT_CREDENTIALS_AUTH_TYPE = "posit-local-client-credentials"

src/posit/connect/external/external.py

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

src/posit/connect/external/snowflake.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99

1010
from typing_extensions import Optional
1111

12+
from .._utils import is_local
1213
from ..client import Client
13-
from .external import is_local
1414

1515

1616
class PositAuthenticator:

tests/posit/connect/test_client.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ def test_init(
8585
MockSession.assert_called_once()
8686

8787
@responses.activate
88+
@patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"})
8889
def test_with_user_session_token(self):
8990
api_key = "12345"
9091
url = "https://connect.example.com"
@@ -110,13 +111,14 @@ def test_with_user_session_token(self):
110111
},
111112
)
112113

113-
viewer_client = client.with_user_session_token("cit")
114+
visitor_client = client.with_user_session_token("cit")
114115

115-
assert viewer_client.cfg.url == "https://connect.example.com/__api__"
116-
assert viewer_client.cfg.api_key == "api-key"
116+
assert visitor_client.cfg.url == "https://connect.example.com/__api__"
117+
assert visitor_client.cfg.api_key == "api-key"
117118

118119
@responses.activate
119-
def test_with_user_session_token_bad_exchange(self):
120+
@patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"})
121+
def test_with_user_session_token_bad_exchange_response_body(self):
120122
api_key = "12345"
121123
url = "https://connect.example.com"
122124
client = Client(api_key=api_key, url=url)
@@ -137,8 +139,34 @@ def test_with_user_session_token_bad_exchange(self):
137139
json={},
138140
)
139141

140-
with pytest.raises(ValueError):
142+
with pytest.raises(ValueError) as err:
141143
client.with_user_session_token("cit")
144+
assert str(err.value) == "Unable to retrieve token."
145+
146+
@patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"})
147+
def test_with_user_session_token_bad_token_deployed(self):
148+
api_key = "12345"
149+
url = "https://connect.example.com"
150+
client = Client(api_key=api_key, url=url)
151+
client._ctx.version = None
152+
153+
with pytest.raises(ValueError) as err:
154+
client.with_user_session_token("")
155+
assert str(err.value) == "token must be set to non-empty string."
156+
157+
def test_with_user_session_token_bad_token_local(self):
158+
api_key = "12345"
159+
url = "https://connect.example.com"
160+
client = Client(api_key=api_key, url=url)
161+
client._ctx.version = None
162+
163+
with pytest.raises(ValueError) as e:
164+
client.with_user_session_token("")
165+
assert str(e.value) == "token must be set to non-empty string."
166+
167+
with pytest.raises(ValueError) as e:
168+
client.with_user_session_token(None) # type: ignore
169+
assert str(e.value) == "token must be set to non-empty string."
142170

143171
def test__del__(
144172
self,

0 commit comments

Comments
 (0)