From 250e9a2b7c5b5e34014e4fabb4fa9547a2cb5774 Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Thu, 23 Jan 2025 09:27:23 -0500 Subject: [PATCH 1/3] fix: Update with_user_session_token method to be more robust and allow local development (#376) Minor refactor of the with_user_session_token method to address feedback from team --- src/posit/connect/_utils.py | 12 ++++++++ src/posit/connect/client.py | 18 +++++++---- src/posit/connect/external/databricks.py | 2 +- src/posit/connect/external/external.py | 11 ------- src/posit/connect/external/snowflake.py | 2 +- tests/posit/connect/test_client.py | 38 ++++++++++++++++++++---- 6 files changed, 59 insertions(+), 24 deletions(-) delete mode 100644 src/posit/connect/external/external.py diff --git a/src/posit/connect/_utils.py b/src/posit/connect/_utils.py index c35dabd9..d9c1b083 100644 --- a/src/posit/connect/_utils.py +++ b/src/posit/connect/_utils.py @@ -1,5 +1,7 @@ from __future__ import annotations +import os + from typing_extensions import Any @@ -28,3 +30,13 @@ def update_dict_values(obj: dict[str, Any], /, **kwargs: Any) -> None: # Use the `dict` class to explicity update the object in-place dict.update(obj, **kwargs) + + +def is_local() -> bool: + """Returns true if called from a piece of content running on a Connect server. + + The connect server will always set the environment variable `RSTUDIO_PRODUCT=CONNECT`. + We can use this environment variable to determine if the content is running locally + or on a Connect server. + """ + return os.getenv("RSTUDIO_PRODUCT") != "CONNECT" diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index 5e02f52f..ce14525d 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -178,7 +178,9 @@ def with_user_session_token(self, token: str) -> Client: """Create a new Client scoped to the user specified in the user session token. Create a new Client instance from a user session token exchange for an api key scoped to the - user specified in the token. + user specified in the token (the user viewing your app). If running your application locally, + a user session token will not exist, which will cause this method to result in an error needing + to be handled in your application. Parameters ---------- @@ -195,14 +197,18 @@ def with_user_session_token(self, token: str) -> Client: >>> from posit.connect import Client >>> client = Client().with_user_session_token("my-user-session-token") """ - viewer_credentials = self.oauth.get_credentials( + if token is None or token == "": + raise ValueError("token must be set to non-empty string.") + + visitor_credentials = self.oauth.get_credentials( token, requested_token_type=API_KEY_TOKEN_TYPE ) - viewer_api_key = viewer_credentials.get("access_token") - if viewer_api_key is None: - raise ValueError("Unable to retrieve viewer api key.") - return Client(url=self.cfg.url, api_key=viewer_api_key) + visitor_api_key = visitor_credentials.get("access_token", "") + if visitor_api_key == "": + raise ValueError("Unable to retrieve token.") + + return Client(url=self.cfg.url, api_key=visitor_api_key) @property def content(self) -> Content: diff --git a/src/posit/connect/external/databricks.py b/src/posit/connect/external/databricks.py index 6578d659..1f3b0895 100644 --- a/src/posit/connect/external/databricks.py +++ b/src/posit/connect/external/databricks.py @@ -13,9 +13,9 @@ import requests from typing_extensions import Callable, Dict, Optional +from .._utils import is_local from ..client import Client from ..oauth import Credentials -from .external import is_local POSIT_OAUTH_INTEGRATION_AUTH_TYPE = "posit-oauth-integration" POSIT_LOCAL_CLIENT_CREDENTIALS_AUTH_TYPE = "posit-local-client-credentials" diff --git a/src/posit/connect/external/external.py b/src/posit/connect/external/external.py deleted file mode 100644 index b3492ce4..00000000 --- a/src/posit/connect/external/external.py +++ /dev/null @@ -1,11 +0,0 @@ -import os - - -def is_local() -> bool: - """Returns true if called from a piece of content running on a Connect server. - - The connect server will always set the environment variable `RSTUDIO_PRODUCT=CONNECT`. - We can use this environment variable to determine if the content is running locally - or on a Connect server. - """ - return not os.getenv("RSTUDIO_PRODUCT") == "CONNECT" diff --git a/src/posit/connect/external/snowflake.py b/src/posit/connect/external/snowflake.py index 54789c9b..c40c188d 100644 --- a/src/posit/connect/external/snowflake.py +++ b/src/posit/connect/external/snowflake.py @@ -9,8 +9,8 @@ from typing_extensions import Optional +from .._utils import is_local from ..client import Client -from .external import is_local class PositAuthenticator: diff --git a/tests/posit/connect/test_client.py b/tests/posit/connect/test_client.py index c256de89..be6fe9f9 100644 --- a/tests/posit/connect/test_client.py +++ b/tests/posit/connect/test_client.py @@ -85,6 +85,7 @@ def test_init( MockSession.assert_called_once() @responses.activate + @patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"}) def test_with_user_session_token(self): api_key = "12345" url = "https://connect.example.com" @@ -110,13 +111,14 @@ def test_with_user_session_token(self): }, ) - viewer_client = client.with_user_session_token("cit") + visitor_client = client.with_user_session_token("cit") - assert viewer_client.cfg.url == "https://connect.example.com/__api__" - assert viewer_client.cfg.api_key == "api-key" + assert visitor_client.cfg.url == "https://connect.example.com/__api__" + assert visitor_client.cfg.api_key == "api-key" @responses.activate - def test_with_user_session_token_bad_exchange(self): + @patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"}) + def test_with_user_session_token_bad_exchange_response_body(self): api_key = "12345" url = "https://connect.example.com" client = Client(api_key=api_key, url=url) @@ -137,8 +139,34 @@ def test_with_user_session_token_bad_exchange(self): json={}, ) - with pytest.raises(ValueError): + with pytest.raises(ValueError) as err: client.with_user_session_token("cit") + assert str(err.value) == "Unable to retrieve token." + + @patch.dict("os.environ", {"RSTUDIO_PRODUCT": "CONNECT"}) + def test_with_user_session_token_bad_token_deployed(self): + api_key = "12345" + url = "https://connect.example.com" + client = Client(api_key=api_key, url=url) + client._ctx.version = None + + with pytest.raises(ValueError) as err: + client.with_user_session_token("") + assert str(err.value) == "token must be set to non-empty string." + + def test_with_user_session_token_bad_token_local(self): + api_key = "12345" + url = "https://connect.example.com" + client = Client(api_key=api_key, url=url) + client._ctx.version = None + + with pytest.raises(ValueError) as e: + client.with_user_session_token("") + assert str(e.value) == "token must be set to non-empty string." + + with pytest.raises(ValueError) as e: + client.with_user_session_token(None) # type: ignore + assert str(e.value) == "token must be set to non-empty string." def test__del__( self, From d66eb1395ba6fb6293b2a1fb9336aa0a8879c6b8 Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Mon, 27 Jan 2025 09:40:03 -0500 Subject: [PATCH 2/3] chore: bump required connect verions for with_user_session_token() to 2025.01.0 (#378) PR to bump required version in SDK to 2025.01.0 without the dev suffix. This is slated for the 0.8.0 release of the Posit SDK. Fixes #373 --- src/posit/connect/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index ce14525d..b4f5c9cc 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -173,7 +173,7 @@ def __init__(self, *args, **kwargs) -> None: self.session = session self._ctx = Context(self) - @requires("2025.01.0-dev") + @requires("2025.01.0") def with_user_session_token(self, token: str) -> Client: """Create a new Client scoped to the user specified in the user session token. From e6057671cd3b86bea17c0da76f3fb666fbbd8b88 Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Tue, 28 Jan 2025 14:05:52 -0500 Subject: [PATCH 3/3] chore: added more docs/examples to with_user_session_token (#379) Added a couple more examples to make it clearer to people using the SDK how this function works to generate a visitor client. --- src/posit/connect/client.py | 55 +++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index b4f5c9cc..1804caec 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -182,6 +182,11 @@ def with_user_session_token(self, token: str) -> Client: a user session token will not exist, which will cause this method to result in an error needing to be handled in your application. + Depending on the type of application you are building, the user session token is retrieved in + a variety of ways. For example, in Streamlit and Shiny applications, the token is stored in the + context or session object headers using the `Posit-Connect-User-Session-Token` key. For API + applications, the token is added to the request headers. + Parameters ---------- token : str @@ -192,10 +197,56 @@ def with_user_session_token(self, token: str) -> Client: Client A new Client instance authenticated with an API key exchanged for the user session token. + Raises + ------ + ValueError + If the provided token is `None` or empty or if the exchange response is malformed. + ClientError + If the token exchange request with the Connect Server fails. + Examples -------- - >>> from posit.connect import Client - >>> client = Client().with_user_session_token("my-user-session-token") + ```python + from posit.connect import Client + client = Client().with_user_session_token("my-user-session-token") + ``` + + Example using user session token from Shiny session: + ```python + from posit.connect import Client + from shiny.express import render, session + + client = Client() + + @reactive.calc + def visitor_client(): + ## read the user session token and generate a new client + user_session_token = session.http_conn.headers.get( + "Posit-Connect-User-Session-Token" + ) + return client.with_user_session_token(user_session_token) + @render.text + def user_profile(): + # fetch the viewer's profile information + return visitor_client().me + ``` + + Example of when the visitor's token could not be retrieved (for + example, if this app allows unauthenticated access) and handle + that in cases where a token is expected. + ```python + from posit.connect import Client + import requests + + # Simulate request without header + mock_request = requests.Request() + visitor_client = None + token = request.headers.get("Posit-Connect-User-Session-Token") + if token: + visitor_client = Client().with_user_session_token(token) + else: + print("This app requires a user session token to operate.") + ``` """ if token is None or token == "": raise ValueError("token must be set to non-empty string.")