Skip to content

Commit eeda4e2

Browse files
authored
Creates stateless version of OAuth 2 functions in generic case (#34)
Closes #30 and discussed in dtinit/pardner-site#10 (comment) * Creates stateless OAuth functions in `src/pardner/stateless/base.py`, which are adapted from the base service transfer class and its methods created in #24 #16 . Eventually other stateless functions will use these to make their requests (they're essentially wrappers for these core ones). * Extracts common logic and helpers in tests into conftest.py. Also needed to create new directories and modules in the test directory This doesn't change anything about the existing classes because using a class instance implies that you're okay with state, so making those optionally stateless is not necessary.
1 parent 84dca27 commit eeda4e2

File tree

11 files changed

+247
-8
lines changed

11 files changed

+247
-8
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
Python library for authorizing access and fetching personal data from portability APIs and services
44

5+
## Using `pardner`
6+
7+
### With classes
8+
9+
In `services/` you'll find classes defined for different services that are supported by the library (e.g., Tumblr). You can make an instance of that class and use that same object for getting initial authorization from a user and for making data transfer requests.
10+
11+
### Stateless mode
12+
13+
In `stateless/` there are modules that expose functions grouped by service that allow you to complete the same tasks as in the classes described above. Unlike using pardner with the classes we provide, however, you supply the necessary data each time you make a request for each request.
14+
515
## Developer set-up
616

717
> **tl;dr**:

src/pardner/stateless/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from pardner.stateless.base import Scope as Scope

src/pardner/stateless/base.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from typing import Any, Optional, TypeAlias
2+
3+
from requests_oauthlib import OAuth2Session
4+
5+
Scope: TypeAlias = str | set[object] | tuple[object] | list[object]
6+
7+
8+
def generic_construct_authorization_url(
9+
authorization_url_endpoint: str, client_id: str, redirect_uri: str, scope: Scope
10+
) -> tuple[str, str]:
11+
"""
12+
Builds the authorization URL and state. Once the end-user (i.e., resource owner)
13+
navigates to the authorization URL they can begin the authorization flow.
14+
15+
:param authorization_url_endpoint: The service's endpoint that must be hit to begin
16+
the OAuth 2 flow.
17+
:param client_id: Client identifier given by the OAuth provider upon registration.
18+
:param redirect_uri: The registered callback URI.
19+
:param scope: The scope of the access request. These may be any string but are
20+
commonly URIs or various categories such as ``videos`` or ``documents``.
21+
22+
:returns: the authorization URL and state, respectively.
23+
"""
24+
oAuth2Session = OAuth2Session(
25+
client_id=client_id, redirect_uri=redirect_uri, scope=scope
26+
)
27+
return oAuth2Session.authorization_url(authorization_url_endpoint)
28+
29+
30+
def generic_fetch_token(
31+
client_id: str,
32+
redirect_uri: str,
33+
scope: Scope,
34+
token_url: str,
35+
authorization_response: Optional[str] = None,
36+
client_secret: Optional[str] = None,
37+
code: Optional[str] = None,
38+
include_client_id: Optional[bool] = None,
39+
) -> dict[str, Any]:
40+
"""
41+
Once the end-user authorizes the application to access their data, the
42+
resource server sends a request to `redirect_uri` with the authorization code as
43+
a parameter. Using this authorization code, this method makes a request to the
44+
resource server to obtain the access token.
45+
46+
One of either `code` or `authorization_response` must not be None.
47+
48+
:param client_id: Client identifier given by the OAuth provider upon registration.
49+
:param redirect_uri: The registered callback URI.
50+
:param scope: The scope of the access request. These may be any string but are
51+
commonly URIs or various categories such as ``videos`` or ``documents``.
52+
:param token_url: Token endpoint HTTPS URL.
53+
:param authorization_response: the URL (with parameters) the end-user's browser
54+
redirected to after authorization.
55+
:param client_secret: The `client_secret` paired to the `client_id`.
56+
:param code: Authorization code (used by WebApplicationClients).
57+
:param include_client_id: Should the request body include the
58+
`client_id` parameter.
59+
60+
:returns: the authorization URL and state, respectively.
61+
"""
62+
oAuth2Session = OAuth2Session(
63+
client_id=client_id, redirect_uri=redirect_uri, scope=scope
64+
)
65+
return oAuth2Session.fetch_token(
66+
token_url=token_url,
67+
code=code,
68+
authorization_response=authorization_response,
69+
include_client_id=include_client_id,
70+
client_secret=client_secret,
71+
)

src/pardner/stateless/utils.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from typing import Any, Optional
2+
3+
from oauthlib.oauth2.rfc6749.utils import scope_to_list
4+
5+
from pardner.stateless import Scope
6+
7+
8+
def scope_to_set(scope: Any) -> set[str]:
9+
"""
10+
Splits `scope` into each individual scope and puts the values in a set of strings.
11+
Leverages OAuthlib library helpers.
12+
13+
:param scope: the string or sequence/iterable of objects that will be converted to
14+
a set of strings.
15+
16+
:returns: a set of strings where each string is an individual scope.
17+
"""
18+
return set(scope_to_list(scope)) if scope else set()
19+
20+
21+
def has_sufficient_scope(
22+
old_scope: Optional[Scope], new_scope: Optional[Scope]
23+
) -> bool:
24+
"""
25+
Given an old scope and a new scope, determines if the new scope has scopes that are
26+
not in the original `old_scope`.
27+
28+
:param old_scope: zero or more scopes.
29+
:param new_scope: zero or more scopes.
30+
31+
:returns: True if `new_scope` has no new scopes and False otherwise.
32+
"""
33+
old_scope_set = scope_to_set(old_scope)
34+
new_scope_set = scope_to_set(new_scope)
35+
return new_scope_set.issubset(old_scope_set)

tests/__init__.py

Whitespace-only changes.

tests/conftest.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from typing import Any
2+
from urllib import parse
3+
4+
import pytest
5+
6+
7+
@pytest.fixture
8+
def mock_outbound_requests(mocker):
9+
mock_oauth2session_request = mocker.patch('requests_oauthlib.OAuth2Session.request')
10+
mock_client_parse_request_body_response = mocker.patch(
11+
'oauthlib.oauth2.rfc6749.clients.WebApplicationClient.parse_request_body_response'
12+
)
13+
return (mock_oauth2session_request, mock_client_parse_request_body_response)
14+
15+
16+
def get_url_params(url: str) -> dict[str, Any]:
17+
url_query = parse.urlsplit(url).query
18+
return dict(parse.parse_qsl(url_query))

tests/test_stateless/__init__.py

Whitespace-only changes.

tests/test_stateless/test_base.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import pytest
2+
3+
from pardner.stateless.base import (
4+
generic_construct_authorization_url,
5+
generic_fetch_token,
6+
)
7+
from tests.conftest import get_url_params
8+
9+
10+
def test_generic_construct_authorization_url():
11+
auth_url, state = generic_construct_authorization_url(
12+
'https://authorize.com',
13+
'fake_client_id',
14+
'https://redirect_uri',
15+
{'fake', 'scope'},
16+
)
17+
assert auth_url.startswith('https://authorize.com')
18+
19+
auth_url_params = get_url_params(auth_url)
20+
21+
assert 'client_id' in auth_url_params
22+
assert auth_url_params['client_id'] == 'fake_client_id'
23+
assert 'redirect_uri' in auth_url_params
24+
assert auth_url_params['redirect_uri'] == 'https://redirect_uri'
25+
assert 'state' in auth_url_params
26+
assert auth_url_params['state'] == state
27+
assert 'scope' in auth_url_params
28+
assert 'fake' in auth_url_params['scope']
29+
assert 'scope' in auth_url_params['scope']
30+
31+
32+
def test_generic_fetch_token_raises_error():
33+
with pytest.raises(ValueError):
34+
generic_fetch_token(
35+
'fake_client_id',
36+
'https://redirect_uri',
37+
{'fake', 'scope'},
38+
'https://token_url.com',
39+
authorization_response=None,
40+
client_secret='fake client secret',
41+
code=None,
42+
)
43+
44+
45+
def test_generic_fetch_token_with_code(mock_outbound_requests):
46+
mock_oauth2session_request, mock_client_parse_request_body_response = (
47+
mock_outbound_requests
48+
)
49+
generic_fetch_token(
50+
'fake_client_id',
51+
'https://redirect_uri',
52+
{'fake', 'scope'},
53+
'https://token_url.com',
54+
client_secret='fake client secret',
55+
code='the_best_code',
56+
)
57+
mock_oauth2session_request.assert_called_once()
58+
mock_client_parse_request_body_response.assert_called_once()
59+
60+
61+
def test_generic_fetch_token_with_authorization_response(mock_outbound_requests):
62+
mock_oauth2session_request, mock_client_parse_request_body_response = (
63+
mock_outbound_requests
64+
)
65+
generic_fetch_token(
66+
'fake_client_id',
67+
'https://redirect_uri',
68+
{'fake', 'scope'},
69+
'https://token_url.com',
70+
authorization_response='https://redirect/?code=the_best_code',
71+
client_secret='fake client secret',
72+
)
73+
mock_oauth2session_request.assert_called_once()
74+
mock_client_parse_request_body_response.assert_called_once()

tests/test_stateless/test_utils.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import pytest
2+
3+
from pardner.stateless.utils import has_sufficient_scope, scope_to_set
4+
5+
expected_set = {'scope1', 'scope2', 'scope3'}
6+
7+
8+
@pytest.mark.parametrize(
9+
['scopes', 'expected'],
10+
[
11+
('scope1 scope2 scope3', expected_set),
12+
(['scope1', 'scope2', 'scope3'], expected_set),
13+
(['scope2', 'scope1', 'scope3'], expected_set),
14+
(('scope1', 'scope2', 'scope3'), expected_set),
15+
(['scope1', 'scope2', 'scope3'], expected_set),
16+
({'scope1', 'scope2', 'scope3'}, expected_set),
17+
],
18+
)
19+
def test_scope_to_set(scopes, expected):
20+
assert scope_to_set(scopes) == expected
21+
22+
23+
@pytest.mark.parametrize(
24+
['old_scope', 'new_scope', 'expected'],
25+
[
26+
('scope1 scope2 scope3', 'scope1 scope2 scope3', True),
27+
('scope1 scope2 scope3', ['scope1', 'scope2'], True),
28+
({'scope1', 'scope2'}, 'scope1 scope2 scope3', False),
29+
('', 'scope1 scope2 scope3', False),
30+
],
31+
)
32+
def test_has_sufficient_scope(old_scope, new_scope, expected):
33+
assert has_sufficient_scope(old_scope, new_scope) == expected

tests/test_transfer_services/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)