diff --git a/src/pardner/__init__.py b/src/pardner/__init__.py index e69de29..149291f 100644 --- a/src/pardner/__init__.py +++ b/src/pardner/__init__.py @@ -0,0 +1,2 @@ +from pardner import services as services +from pardner import verticals as verticals diff --git a/src/pardner/services/__init__.py b/src/pardner/services/__init__.py index 45a6225..dc0c681 100644 --- a/src/pardner/services/__init__.py +++ b/src/pardner/services/__init__.py @@ -5,5 +5,6 @@ from pardner.services.base import ( UnsupportedVerticalException as UnsupportedVerticalException, ) +from pardner.services.groupme import GroupMeTransferService as GroupMeTransferService from pardner.services.strava import StravaTransferService as StravaTransferService from pardner.services.tumblr import TumblrTransferService as TumblrTransferService diff --git a/src/pardner/services/base.py b/src/pardner/services/base.py index 7cdd25a..eee69f1 100644 --- a/src/pardner/services/base.py +++ b/src/pardner/services/base.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod from typing import Any, Iterable, Optional +from urllib.parse import urljoin from requests import Response from requests_oauthlib import OAuth2Session @@ -41,7 +42,7 @@ class BaseTransferService(ABC): _authorization_url: str _base_url: str - _client_secret: str + _client_secret: str | None _oAuth2Session: OAuth2Session _service_name: str _supported_verticals: set[Vertical] = set() @@ -52,9 +53,9 @@ def __init__( self, service_name: str, client_id: str, - client_secret: str, redirect_uri: str, supported_verticals: set[Vertical], + client_secret: Optional[str] = None, state: Optional[str] = None, verticals: set[Vertical] = set(), ) -> None: @@ -64,9 +65,9 @@ def __init__( :param service_name: Name of the service for which the transfer is being built. :param client_id: Client identifier given by the OAuth provider upon registration. - :param client_secret: The `client_secret` paired to the `client_id`. :param redirect_uri: The registered callback URI. :param supported_verticals: The `Vertical`s that can be fetched on the service. + :param client_secret: The `client_secret` paired to the `client_id`. :param state: State string used to prevent CSRF and identify flow. :param verticals: The `Vertical`s for which the transfer service has appropriate scope to fetch. @@ -137,6 +138,40 @@ def _get_resource(self, uri: str, params: dict[str, Any] = {}) -> Response: response.raise_for_status() return response + def _build_resource_url(self, path_suffix: str, base: Optional[str] = None) -> str: + """ + Constructs the resource URL from a domain and path suffix. + + :param path_suffix: the path to append to ``base``. + :param base: the prefix of the resource URL. If not given, defaults to + ``self._base_url``. + + :returns: the complete resource URL. + """ + if not base: + base = self._base_url + if not base.endswith('/'): + base += '/' + + if path_suffix.startswith('/'): + path_suffix = path_suffix[1:] + + return urljoin(base, path_suffix) + + def _get_resource_from_path( + self, path_suffix: str, params: dict[str, Any] = {} + ) -> Response: + """ + Sends a GET request to the endpoint URL built using ``endpoint_path``. + + :param path_suffix: the path of the endpoint being accessed. + :param params: the extra parameters to be send with the request, optionally. + + :returns: The :class:`requests.Response` object obtained from making the request. + """ + resource_url = self._build_resource_url(path_suffix) + return self._get_resource(resource_url, params) + def add_verticals( self, verticals: Iterable[Vertical], should_reauth: bool = False ) -> bool: diff --git a/src/pardner/services/groupme.py b/src/pardner/services/groupme.py new file mode 100644 index 0000000..114fec5 --- /dev/null +++ b/src/pardner/services/groupme.py @@ -0,0 +1,183 @@ +import json +from typing import Any, Iterable, Optional, override +from urllib.parse import parse_qs, urlparse + +from oauthlib.oauth2 import MobileApplicationClient +from requests import Response +from requests_oauthlib import OAuth2Session + +from pardner.services.base import BaseTransferService, UnsupportedRequestException +from pardner.verticals import Vertical + + +class GroupMeTransferService(BaseTransferService): + """ + Class responsible for obtaining end-user authorization to make requests to GroupMe's + API. + See API documentation: https://dev.groupme.com/docs/v3 + + Note that GroupMe authorizes user access via the implicit OAuth flow as of August + 2025. This means the access token is obtained after the first request to GroupMe's + servers. This is less secure than the traditional OAuth 2.0 flow. + """ + + _authorization_url = 'https://oauth.groupme.com/oauth/authorize' + _base_url = 'https://api.groupme.com/v3/' + _token_url = 'https://oauth.groupme.com/oauth/authorize' + _user_id: str | None + + def __init__( + self, client_id: str, redirect_uri: str, verticals: set[Vertical] = set() + ) -> None: + super().__init__( + service_name='GroupMe', + client_id=client_id, + redirect_uri=redirect_uri, + supported_verticals={ + Vertical.BlockedUser, + Vertical.ChatBot, + Vertical.ConversationDirect, + Vertical.ConversationGroup, + }, + verticals=verticals, + ) + implicit_grant_application_client = MobileApplicationClient(client_id=client_id) + self._oAuth2Session = OAuth2Session( + client=implicit_grant_application_client, redirect_uri=redirect_uri + ) + + @override + def _get_resource_from_path( + self, path_suffix: str, params: dict[str, Any] = {} + ) -> Response: + access_token = self._oAuth2Session.token.get('access_token') + if not access_token: + raise UnsupportedRequestException( + self._service_name, + 'Must send token as a parameter for GET requests. Use fetch_token(...) ' + 'to automatically fetch and send the token.', + ) + return super()._get_resource_from_path( + path_suffix, params={'token': access_token, **params} + ) + + def _fetch_resource_common( + self, path_suffix: str, params: dict[str, Any] = {} + ) -> Any: + """ + Helper method for fetching data that follows a common format with common + parameters. + + :param path_suffix: the part of the path that identifies a GroupMe API endpoint. + :param params: an optional dictionary of parameters to pass with the GET request + to GroupMe's API. + + :returns: a JSON object with the result of the request. + """ + if not self._user_id: + self.fetch_user_data() + + return ( + self._get_resource_from_path( + path_suffix, params={'user': self._user_id, **params} + ) + .json() + .get('response') + ) + + @override + def fetch_token( + self, + code: Optional[str] = None, + authorization_response: Optional[str] = None, + include_client_id: bool = True, + ) -> dict[str, Any]: + if not authorization_response: + raise ValueError( + 'GroupMe requires an authorization response URL instead of code.' + ) + url_query = urlparse(authorization_response).query + url_query_params = parse_qs(url_query) + + access_token_list = url_query_params.get('access_token') + if not access_token_list: + raise ValueError( + f'Could not fetch access token using the {authorization_response}.' + ) + + self._oAuth2Session.token['access_token'] = access_token_list[0] + return self._oAuth2Session.token if self._oAuth2Session.token else {} + + @override + def scope_for_verticals(self, verticals: Iterable[Vertical]) -> set[str]: + # GroupMe does not require scope + return set() + + def fetch_user_data(self) -> Any: + """ + Fetches user identifiers and profile data. Also sets ``self._user_id``, which + is necessary for most requests. + + :returns: user identifiers and profile data in dictionary format. + """ + user_data = self._get_resource_from_path('users/me').json().get('response') + self._user_id = user_data['id'] + return user_data + + def fetch_blocked_users(self) -> Any: + """ + Sends a GET request to fetch the users blocked by the authenticated user. + + :returns: a JSON object with the result of the request. + """ + blocked_users = self._fetch_resource_common('blocks') + + if 'blocks' not in blocked_users: + raise ValueError( + f'Unexpected response format: {json.dumps(blocked_users, indent=2)}' + ) + + return blocked_users['blocks'] + + def fetch_chat_bots(self) -> Any: + """ + Sends a GET request to fetch the chat bots created by the authenticated user. + + :returns: a JSON object with the result of the request. + """ + return self._fetch_resource_common('bots') + + def fetch_conversations_direct(self, count: int = 10) -> Any: + """ + Sends a GET request to fetch the conversations the authenticated user is a part + of with only one other member (i.e., a direct message). The response will + include metadata associated with the conversation, but no more than one message + from the conversation. + + :param count: the number of conversations to fetch. Defaults to 10. + + :returns: a JSON object with the result of the request. + """ + if count <= 10: + return self._fetch_resource_common('chats', params={'per_page': count}) + raise UnsupportedRequestException( + self._service_name, + 'can only make a request for at most 10 direct conversations at a time.', + ) + + def fetch_conversations_group(self, count: int = 10) -> Any: + """ + Sends a GET request to fetch the group conversations the authenticated user is + a part of. The response will include metadata associated with the conversation, + but no more than one message from the conversation. + + :param count: the number of conversations to fetch. Defaults to 10. + + :returns: a JSON object with the result of the request. + """ + if count <= 10: + return self._fetch_resource_common('groups', params={'per_page': count}) + raise UnsupportedRequestException( + self._service_name, + 'can only make a request for at most 10 group conversations at a time.', + ) diff --git a/src/pardner/services/strava.py b/src/pardner/services/strava.py index 0e720ff..46a07d1 100644 --- a/src/pardner/services/strava.py +++ b/src/pardner/services/strava.py @@ -1,5 +1,4 @@ from typing import Any, Iterable, Optional, override -from urllib.parse import urljoin from pardner.services.base import ( BaseTransferService, @@ -18,7 +17,7 @@ class StravaTransferService(BaseTransferService): """ _authorization_url = 'https://www.strava.com/oauth/authorize' - _base_url = 'https://www.strava.com/' + _base_url = 'https://www.strava.com/api/v3/' _token_url = 'https://www.strava.com/oauth/token' def __init__( @@ -85,12 +84,9 @@ def fetch_athlete_activities( """ max_count = 30 if count <= max_count: - athlete_activities_uri = urljoin( - self._base_url, '/api/v3/athlete/activities' - ) return list( - self._get_resource( - athlete_activities_uri, params={'per_page': count, **request_params} + self._get_resource_from_path( + 'athlete/activities', params={'per_page': count, **request_params} ).json() ) raise UnsupportedRequestException( diff --git a/src/pardner/services/tumblr.py b/src/pardner/services/tumblr.py index bc704af..af4f523 100644 --- a/src/pardner/services/tumblr.py +++ b/src/pardner/services/tumblr.py @@ -1,5 +1,4 @@ from typing import Any, Iterable, Optional, override -from urllib.parse import urljoin from pardner.services import BaseTransferService from pardner.services.base import UnsupportedRequestException @@ -14,7 +13,7 @@ class TumblrTransferService(BaseTransferService): """ _authorization_url = 'https://www.tumblr.com/oauth2/authorize' - _base_url = 'https://api.tumblr.com/' + _base_url = 'https://api.tumblr.com/v2/' _token_url = 'https://api.tumblr.com/v2/oauth2/token' def __init__( @@ -71,10 +70,9 @@ def fetch_feed_posts( :raises: :class:`UnsupportedRequestException` if the request is unable to be made. """ - dashboard_uri = urljoin(self._base_url, '/v2/user/dashboard') if count <= 20: - dashboard_response = self._get_resource( - dashboard_uri, + dashboard_response = self._get_resource_from_path( + 'user/dashboard', { 'limit': count, 'npf': True, diff --git a/src/pardner/verticals/base.py b/src/pardner/verticals/base.py index 6875312..4cef5b4 100644 --- a/src/pardner/verticals/base.py +++ b/src/pardner/verticals/base.py @@ -7,5 +7,10 @@ class Vertical(StrEnum): Not all verticals are supported by every transfer service. """ + BlockedUser = 'blocked_user' + ChatBot = 'chat_bot' + ConversationDirect = 'conversation_direct' + ConversationGroup = 'conversation_group' + ConversationMessage = 'conversation_message' FeedPost = 'feed_post' PhysicalActivity = 'physical_activity' diff --git a/tests/test_transfer_services/conftest.py b/tests/test_transfer_services/conftest.py index c7fc399..4ac42d7 100644 --- a/tests/test_transfer_services/conftest.py +++ b/tests/test_transfer_services/conftest.py @@ -2,9 +2,12 @@ from requests import Response from requests_oauthlib import OAuth2Session -from pardner.services.strava import StravaTransferService -from pardner.services.tumblr import TumblrTransferService -from pardner.verticals.base import Vertical +from pardner.services import ( + GroupMeTransferService, + StravaTransferService, + TumblrTransferService, +) +from pardner.verticals import Vertical # FIXTURES @@ -41,6 +44,20 @@ def mock_strava_transfer_service(verticals=[Vertical.PhysicalActivity]): ) +@pytest.fixture +def mock_groupme_transfer_service( + verticals=[Vertical.BlockedUser, Vertical.ConversationDirect], +): + groupme = GroupMeTransferService( + client_id='fake_client_id', + redirect_uri='https://redirect_uri', + verticals=verticals, + ) + groupme._oAuth2Session.token = {'access_token': 'fake_token'} + groupme._user_id = None + return groupme + + @pytest.fixture def mock_oauth2_bad_response(mocker): mock_response = mocker.create_autospec(Response) @@ -62,7 +79,7 @@ def mock_oauth2_session_get_bad_response(mocker, mock_oauth2_bad_response): # HELPERS -def mock_oauth2_session_get(mocker, response_object): +def mock_oauth2_session_get(mocker, response_object=None): oauth2_session_get = mocker.patch.object(OAuth2Session, 'get', autospec=True) oauth2_session_get.return_value = response_object return oauth2_session_get diff --git a/tests/test_transfer_services/test_base.py b/tests/test_transfer_services/test_base.py index 93a3dc8..9908a08 100644 --- a/tests/test_transfer_services/test_base.py +++ b/tests/test_transfer_services/test_base.py @@ -9,28 +9,30 @@ ) from pardner.verticals import Vertical -sample_scope = {'fake', 'scope'} +SAMPLE_SCOPE = {'fake', 'scope'} +SAMPLE_BASE_URL = 'https://api.example.com/v1' class FakeTransferService(BaseTransferService): _authorization_url = 'https://auth_url' + _base_url = SAMPLE_BASE_URL _token_url = 'https://token_url' def __init__(self, supported_verticals, verticals): super().__init__( - 'Fake Transfer Service', - 'fake_client_id', - 'fake_client_secret', - 'https://redirect_uri', - set(supported_verticals), - None, - set(verticals), + service_name='Fake Transfer Service', + client_id='fake_client_id', + client_secret='fake_client_secret', + redirect_uri='https://redirect_uri', + supported_verticals=set(supported_verticals), + state=None, + verticals=set(verticals), ) def scope_for_verticals(self, verticals): if Vertical.NEW_VERTICAL_EXTRA_SCOPE in verticals: - return sample_scope | {'extra_scope'} - return sample_scope + return SAMPLE_SCOPE | {'extra_scope'} + return SAMPLE_SCOPE @pytest.fixture @@ -54,7 +56,7 @@ def mock_transfer_service(mock_vertical): [Vertical.FeedPost, Vertical.NEW_VERTICAL, Vertical.NEW_VERTICAL_EXTRA_SCOPE], [Vertical.FeedPost], ) - mock_transfer_service.scope = sample_scope + mock_transfer_service.scope = SAMPLE_SCOPE return mock_transfer_service @@ -74,7 +76,7 @@ def test_add_unsupported_vertical_new_scope_required( def _mock_scope_for_verticals(verticals): if Vertical.NEW_VERTICAL_EXTRA_SCOPE in verticals: return {'new_scope'} - return sample_scope + return SAMPLE_SCOPE mock_transfer_service._oAuth2Session.access_token = 'access_token' monkeypatch.setattr( @@ -118,3 +120,18 @@ def test_fetch_token( mock_strava_transfer_service.fetch_token(code='123code123') mock_oauth2_session_request.assert_called_once() mock_oauth2_session_response.assert_called_once() + + +@pytest.mark.parametrize( + ['path', 'base'], + [ + ('test/path', SAMPLE_BASE_URL), + ('test/path', f'{SAMPLE_BASE_URL}/'), + ('/test/path', SAMPLE_BASE_URL), + ('test/path', None), + ('/test/path', None), + ], +) +def test__build_resource_url(path, base, mock_transfer_service): + resource_url = mock_transfer_service._build_resource_url(path, base) + assert resource_url == 'https://api.example.com/v1/test/path' diff --git a/tests/test_transfer_services/test_groupme.py b/tests/test_transfer_services/test_groupme.py new file mode 100644 index 0000000..bd6be83 --- /dev/null +++ b/tests/test_transfer_services/test_groupme.py @@ -0,0 +1,145 @@ +import pytest + +from pardner.services.base import UnsupportedRequestException +from tests.test_transfer_services.conftest import mock_oauth2_session_get + +USER_ID = 'fake_user_id' +FAKE_LIST_RESPONSE = ['fake', 'list', 'response'] +TOKEN = 'fake_token' + + +def test_fetch_token_raises_no_authorization_response(mock_groupme_transfer_service): + with pytest.raises(ValueError): + mock_groupme_transfer_service.fetch_token(code='code') + + +def test_fetch_token_raises_no_access_token(mock_groupme_transfer_service): + with pytest.raises(ValueError): + mock_groupme_transfer_service.fetch_token( + authorization_response='https://localhostfake?token=badtoken' + ) + + +def test_fetch_token(mock_oauth2_session_request, mock_groupme_transfer_service): + token = 'faketoken123' + mock_groupme_transfer_service.fetch_token( + authorization_response=f'https://localhostfake?access_token={token}' + ) + mock_oauth2_session_request.assert_not_called() + assert ( + mock_groupme_transfer_service._oAuth2Session.token.get('access_token', {}) + == token + ) + + +def test_fetch_user_data_raises_no_token(mock_groupme_transfer_service): + mock_groupme_transfer_service._oAuth2Session.token = {} + with pytest.raises(UnsupportedRequestException): + mock_groupme_transfer_service.fetch_user_data() + + +def test_fetch_user_data(mocker, mock_groupme_transfer_service): + expected_respose = {'id': USER_ID} + response_object = mocker.MagicMock() + response_object.json.return_value = {'response': expected_respose} + oauth2_session_get = mock_oauth2_session_get(mocker, response_object) + + assert mock_groupme_transfer_service.fetch_user_data() == expected_respose + assert mock_groupme_transfer_service._user_id == USER_ID + assert oauth2_session_get.call_args.args[1] == 'https://api.groupme.com/v3/users/me' + + +@pytest.mark.parametrize( + [ + 'method_name', + 'json_response', + 'expected_path', + 'expected_params', + 'expected_return_val', + ], + [ + ( + 'fetch_blocked_users', + {'response': {'blocks': FAKE_LIST_RESPONSE}}, + 'https://api.groupme.com/v3/blocks', + {}, + FAKE_LIST_RESPONSE, + ), + ( + 'fetch_chat_bots', + {'response': FAKE_LIST_RESPONSE}, + 'https://api.groupme.com/v3/bots', + {}, + FAKE_LIST_RESPONSE, + ), + ( + 'fetch_conversations_direct', + {'response': FAKE_LIST_RESPONSE}, + 'https://api.groupme.com/v3/chats', + {'per_page': 10}, + FAKE_LIST_RESPONSE, + ), + ( + 'fetch_conversations_group', + {'response': FAKE_LIST_RESPONSE}, + 'https://api.groupme.com/v3/groups', + {'per_page': 10}, + FAKE_LIST_RESPONSE, + ), + ], +) +def test_fetch_vertical( + method_name, + json_response, + expected_path, + expected_params, + expected_return_val, + mock_groupme_transfer_service, + mocker, +): + full_expected_params = {'user': USER_ID, 'token': TOKEN, **expected_params} + + mock_groupme_transfer_service._user_id = USER_ID + + response_object = mocker.MagicMock() + response_object.json.return_value = json_response + + oauth2_session_get = mock_oauth2_session_get(mocker, response_object) + + assert getattr(mock_groupme_transfer_service, method_name)() == expected_return_val + assert oauth2_session_get.call_args.args[1] == expected_path + assert oauth2_session_get.call_args.kwargs.get('params') == full_expected_params + + +@pytest.mark.parametrize( + 'method_name', ['fetch_conversations_direct', 'fetch_conversations_group'] +) +def test_fetch_conversations_raises_exception( + method_name, mock_groupme_transfer_service +): + with pytest.raises(UnsupportedRequestException): + getattr(mock_groupme_transfer_service, method_name)(count=11) + + +def test__fetch_resource_common_raises_exception(mock_groupme_transfer_service, mocker): + response_object = mocker.MagicMock() + response_object.status_code = 200 + response_object.json.return_value = {'response': {'id': USER_ID}} + oauth2_session_get = mock_oauth2_session_get(mocker, response_object) + + mock_groupme_transfer_service._fetch_resource_common( + 'https://api.groupme.com/v3/fake' + ) + + assert oauth2_session_get.call_count == 2 + assert mock_groupme_transfer_service._user_id == USER_ID + + +def test_fetch_blocked_users_raises_exception(mock_groupme_transfer_service, mocker): + mock_groupme_transfer_service._user_id = USER_ID + response_object = mocker.MagicMock() + response_object.status_code = 200 + response_object.json.return_value = {'response': {'no_blocks': []}} + mock_oauth2_session_get(mocker, response_object) + with pytest.raises(ValueError): + mock_groupme_transfer_service.fetch_blocked_users()