diff --git a/src/pardner/services/base.py b/src/pardner/services/base.py index d6c3254..7cdd25a 100644 --- a/src/pardner/services/base.py +++ b/src/pardner/services/base.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod from typing import Any, Iterable, Optional +from requests import Response from requests_oauthlib import OAuth2Session from pardner.services.utils import scope_as_set, scope_as_string @@ -122,6 +123,20 @@ def verticals(self, verticals: Iterable[Vertical]) -> None: raise UnsupportedVerticalException(unsupported_verticals, self.name) self._verticals = set(verticals) + def _get_resource(self, uri: str, params: dict[str, Any] = {}) -> Response: + """ + Sends a GET request to ``uri`` using :class:`OAuth2Session`. + + :param uri: the destination of the request (a URI). + :param params: the extra parameters to be send with the request, optionally. + + :returns: The :class:`requests.Response` object obtained from making the request. + """ + response = self._oAuth2Session.get(uri, params=params) + if not response.ok: + response.raise_for_status() + return response + def add_verticals( self, verticals: Iterable[Vertical], should_reauth: bool = False ) -> bool: diff --git a/src/pardner/services/strava.py b/src/pardner/services/strava.py index 75db660..0e720ff 100644 --- a/src/pardner/services/strava.py +++ b/src/pardner/services/strava.py @@ -1,6 +1,11 @@ from typing import Any, Iterable, Optional, override +from urllib.parse import urljoin -from pardner.services.base import BaseTransferService, UnsupportedVerticalException +from pardner.services.base import ( + BaseTransferService, + UnsupportedRequestException, + UnsupportedVerticalException, +) from pardner.services.utils import scope_as_set, scope_as_string from pardner.verticals import Vertical @@ -13,6 +18,7 @@ class StravaTransferService(BaseTransferService): """ _authorization_url = 'https://www.strava.com/oauth/authorize' + _base_url = 'https://www.strava.com/' _token_url = 'https://www.strava.com/oauth/token' def __init__( @@ -29,7 +35,7 @@ def __init__( client_secret=client_secret, redirect_uri=redirect_uri, state=state, - supported_verticals={Vertical.FeedPost}, + supported_verticals={Vertical.PhysicalActivity}, verticals=verticals, ) @@ -56,6 +62,38 @@ def scope_for_verticals(self, verticals: Iterable[Vertical]) -> set[str]: for vertical in verticals: if vertical not in self._supported_verticals: raise UnsupportedVerticalException([vertical], self._service_name) - if vertical == Vertical.FeedPost: + if vertical == Vertical.PhysicalActivity: sub_scopes.update(['activity:read', 'profile:read_all']) return sub_scopes + + def fetch_athlete_activities( + self, count: int = 30, request_params: dict[str, Any] = {} + ) -> list[Any]: + """ + Fetches and returns activities completed by the authorized user. + + :param count: number of activities to request. At most 30 at a time. + :param request_params: any other endpoint-specific parameters to be sent + to the endpoint. Depending on the parameters passed, this could override + the other arguments to this method. + + :returns: a list of dictionary objects with information for the activities from + the authorized user. + + :raises: :class:`UnsupportedRequestException` if the request is unable to be + made. + """ + 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} + ).json() + ) + raise UnsupportedRequestException( + self._service_name, + f'can only make a request for at most {max_count} activities at a time.', + ) diff --git a/src/pardner/services/tumblr.py b/src/pardner/services/tumblr.py index 187e9d3..bc704af 100644 --- a/src/pardner/services/tumblr.py +++ b/src/pardner/services/tumblr.py @@ -73,17 +73,15 @@ def fetch_feed_posts( """ dashboard_uri = urljoin(self._base_url, '/v2/user/dashboard') if count <= 20: - dashboard_response = self._oAuth2Session.get( + dashboard_response = self._get_resource( dashboard_uri, - params={ + { 'limit': count, 'npf': True, 'type': 'text' if text_only else '', **request_params, }, ) - if not dashboard_response.ok: - dashboard_response.raise_for_status() return list(dashboard_response.json().get('response').get('posts')) raise UnsupportedRequestException( self._service_name, diff --git a/src/pardner/verticals/base.py b/src/pardner/verticals/base.py index 101a45f..6875312 100644 --- a/src/pardner/verticals/base.py +++ b/src/pardner/verticals/base.py @@ -8,3 +8,4 @@ class Vertical(StrEnum): """ FeedPost = 'feed_post' + PhysicalActivity = 'physical_activity' diff --git a/tests/test_transfer_services/conftest.py b/tests/test_transfer_services/conftest.py index 20854aa..c7fc399 100644 --- a/tests/test_transfer_services/conftest.py +++ b/tests/test_transfer_services/conftest.py @@ -1,10 +1,13 @@ import pytest +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 +# FIXTURES + @pytest.fixture def mock_oauth2_session_request(mocker): @@ -32,12 +35,33 @@ def mock_tumblr_transfer_service(verticals=[Vertical.FeedPost]): @pytest.fixture -def mock_strava_transfer_service(verticals=[Vertical.FeedPost]): +def mock_strava_transfer_service(verticals=[Vertical.PhysicalActivity]): return StravaTransferService( 'fake_client_id', 'fake_client_secret', 'https://redirect_uri', None, verticals ) +@pytest.fixture +def mock_oauth2_bad_response(mocker): + mock_response = mocker.create_autospec(Response) + mock_response.ok = False + mock_response.status_code = 400 + mock_response.reason = 'fake reason' + mock_response.url = 'fake url' + mock_response.raise_for_status = lambda: Response.raise_for_status(mock_response) + return mock_response + + +@pytest.fixture +def mock_oauth2_session_get_bad_response(mocker, mock_oauth2_bad_response): + oauth2_session_get = mocker.patch.object(OAuth2Session, 'get', autospec=True) + oauth2_session_get.return_value = mock_oauth2_bad_response + return oauth2_session_get + + +# HELPERS + + def mock_oauth2_session_get(mocker, response_object): oauth2_session_get = mocker.patch.object(OAuth2Session, 'get', autospec=True) oauth2_session_get.return_value = response_object diff --git a/tests/test_transfer_services/test_strava.py b/tests/test_transfer_services/test_strava.py index 78fa72d..5a3944c 100644 --- a/tests/test_transfer_services/test_strava.py +++ b/tests/test_transfer_services/test_strava.py @@ -1,9 +1,12 @@ import pytest +from requests import HTTPError -from pardner.services.base import UnsupportedVerticalException +from pardner.services.base import ( + UnsupportedRequestException, + UnsupportedVerticalException, +) from pardner.verticals import Vertical - -sample_scope = {'fake', 'scope'} +from tests.test_transfer_services.conftest import mock_oauth2_session_get def test_scope(mock_strava_transfer_service): @@ -12,7 +15,7 @@ def test_scope(mock_strava_transfer_service): @pytest.mark.parametrize( ['verticals', 'expected_scope'], - [([], set()), ([Vertical.FeedPost], {'activity:read', 'profile:read_all'})], + [([], set()), ([Vertical.PhysicalActivity], {'activity:read', 'profile:read_all'})], ) def test_scope_for_verticals(mock_strava_transfer_service, verticals, expected_scope): assert mock_strava_transfer_service.scope_for_verticals(verticals) == expected_scope @@ -21,3 +24,30 @@ def test_scope_for_verticals(mock_strava_transfer_service, verticals, expected_s def test_scope_for_verticals_raises_error(mock_strava_transfer_service, mock_vertical): with pytest.raises(UnsupportedVerticalException): mock_strava_transfer_service.scope_for_verticals([Vertical.NEW_VERTICAL]) + + +def test_fetch_athlete_activities_raises_exception(mock_strava_transfer_service): + with pytest.raises(UnsupportedRequestException): + mock_strava_transfer_service.fetch_athlete_activities(count=31) + + +def test_fetch_athlete_activities_raises_http_exception( + mock_strava_transfer_service, mock_oauth2_session_get_bad_response +): + with pytest.raises(HTTPError): + mock_strava_transfer_service.fetch_athlete_activities() + + +def test_fetch_athlete_activities(mocker, mock_strava_transfer_service): + sample_response = [{'object': 1}, {'object': 2}] + + response_object = mocker.MagicMock() + response_object.json.return_value = sample_response + + oauth2_session_get = mock_oauth2_session_get(mocker, response_object) + + assert mock_strava_transfer_service.fetch_athlete_activities() == sample_response + assert ( + oauth2_session_get.call_args.args[1] + == 'https://www.strava.com/api/v3/athlete/activities' + ) diff --git a/tests/test_transfer_services/test_tumblr.py b/tests/test_transfer_services/test_tumblr.py index b8f48a9..2ce6d80 100644 --- a/tests/test_transfer_services/test_tumblr.py +++ b/tests/test_transfer_services/test_tumblr.py @@ -1,12 +1,10 @@ import pytest -from requests import HTTPError, Response +from requests import HTTPError from pardner.services.base import UnsupportedRequestException from pardner.verticals import Vertical from tests.test_transfer_services.conftest import mock_oauth2_session_get -sample_scope = {'fake', 'scope'} - @pytest.mark.parametrize( ['verticals', 'expected_scope'], [([], {'basic'}), ([Vertical.FeedPost, {'basic'}])] @@ -20,16 +18,9 @@ def test_fetch_feed_posts_raises_exception(mock_tumblr_transfer_service): mock_tumblr_transfer_service.fetch_feed_posts(count=21) -def test_fetch_feed_posts_raises_http_exception(mocker, mock_tumblr_transfer_service): - mock_response = mocker.create_autospec(Response) - mock_response.ok = False - mock_response.status_code = 400 - mock_response.reason = 'fake reason' - mock_response.url = 'fake url' - mock_response.raise_for_status = lambda: Response.raise_for_status(mock_response) - - mock_oauth2_session_get(mocker, mock_response) - +def test_fetch_feed_posts_raises_http_exception( + mock_tumblr_transfer_service, mock_oauth2_session_get_bad_response +): with pytest.raises(HTTPError): mock_tumblr_transfer_service.fetch_feed_posts()