Skip to content

Commit 28991ac

Browse files
rename to visitor api key; better error handling; more tests;
1 parent 7c5178e commit 28991ac

File tree

7 files changed

+85
-25
lines changed

7 files changed

+85
-25
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: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22

33
from __future__ import annotations
44

5+
import os
6+
57
from requests import Response, Session
68
from typing_extensions import TYPE_CHECKING, overload
79

810
from . import hooks, me
11+
from ._utils import is_local
912
from .auth import Auth
1013
from .config import Config
1114
from .content import Content
@@ -174,16 +177,27 @@ def __init__(self, *args, **kwargs) -> None:
174177
self._ctx = Context(self)
175178

176179
@requires("2025.01.0-dev")
177-
def with_user_session_token(self, token: str) -> Client:
180+
def with_user_session_token(
181+
self, token: str, fallback_api_key_env_var: str = "CONNECT_API_KEY"
182+
) -> Client:
178183
"""Create a new Client scoped to the user specified in the user session token.
179184
180185
Create a new Client instance from a user session token exchange for an api key scoped to the
181-
user specified in the token.
186+
user specified in the token (the user viewing your app). If running your application locally,
187+
you will not have a user session token. In that case, this method will look for an API key in
188+
the environment variable specified in `fallback_api_key_env_var`. If that is not set, the API
189+
key of the original client will be used.
190+
191+
Environment Variables
192+
---------------------
193+
CONNECT_API_KEY - The API key credential for client authentication.
182194
183195
Parameters
184196
----------
185197
token : str
186198
The user session token.
199+
fallback_api_key_env_var: str
200+
Environment variable with a fallback API key for local development.
187201
188202
Returns
189203
-------
@@ -195,14 +209,27 @@ def with_user_session_token(self, token: str) -> Client:
195209
>>> from posit.connect import Client
196210
>>> client = Client().with_user_session_token("my-user-session-token")
197211
"""
198-
viewer_credentials = self.oauth.get_credentials(
212+
if is_local():
213+
# if user session token is not available when running locally,
214+
# default to using API set in environment variable
215+
return Client(
216+
url=self.cfg.url,
217+
api_key=os.getenv(fallback_api_key_env_var, self.cfg.api_key),
218+
)
219+
220+
if token is None or token == "":
221+
# if deployed to Connect, token must be set
222+
raise ValueError("token must be set to non-empty string.")
223+
224+
visitor_credentials = self.oauth.get_credentials(
199225
token, requested_token_type=API_KEY_TOKEN_TYPE
200226
)
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.")
204227

205-
return Client(url=self.cfg.url, api_key=viewer_api_key)
228+
visitor_api_key = visitor_credentials.get("access_token", "")
229+
if visitor_api_key == "":
230+
raise ValueError("Unable to retrieve visitor API key.")
231+
232+
return Client(url=self.cfg.url, api_key=visitor_api_key)
206233

207234
@property
208235
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/oauth/test_oauth.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import responses
55

66
from posit.connect import Client
7+
from posit.connect.errors import ClientError
78
from posit.connect.oauth.oauth import API_KEY_TOKEN_TYPE, _get_content_session_token
89

910

tests/posit/connect/test_client.py

Lines changed: 36 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,37 @@ 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 visitor API key."
145+
146+
@responses.activate
147+
@patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"})
148+
def test_with_user_session_token_bad_token_when_deployed(self):
149+
api_key = "12345"
150+
url = "https://connect.example.com"
151+
client = Client(api_key=api_key, url=url)
152+
client._ctx.version = None
153+
154+
with pytest.raises(ValueError) as err:
155+
client.with_user_session_token("")
156+
assert str(err.value) == "token must be set to non-empty string."
157+
158+
@responses.activate
159+
@patch.dict("os.environ", {"CONNECT_API_KEY": "ABC123", "CUSTOM_KEY": "DEF456"})
160+
def test_with_user_session_token_local_env_var_override(self):
161+
api_key = "12345"
162+
url = "https://connect.example.com"
163+
client = Client(api_key=api_key, url=url)
164+
client._ctx.version = None
165+
166+
visitor_client = client.with_user_session_token("")
167+
assert visitor_client.cfg.url == client.cfg.url
168+
assert visitor_client.cfg.api_key == "ABC123"
169+
170+
visitor_client = client.with_user_session_token("", fallback_api_key_env_var="CUSTOM_KEY")
171+
assert visitor_client.cfg.url == client.cfg.url
172+
assert visitor_client.cfg.api_key == "DEF456"
142173

143174
def test__del__(
144175
self,

0 commit comments

Comments
 (0)