-
Notifications
You must be signed in to change notification settings - Fork 12
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(oauth2): added the OAuth2Connector, a helper class used to retri…
…eve and store OAuth2 tokens we also added a quickstart to show how to used it, ans provided unit tests. We adapted the google_sheets_2 connector to use the OAuth2Connector
- Loading branch information
1 parent
3c7d1bb
commit 932c159
Showing
12 changed files
with
555 additions
and
24 deletions.
There are no files selected for viewing
107 changes: 107 additions & 0 deletions
107
.ipynb_checkpoints/Method forwarding in connectors-checkpoint.ipynb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,107 @@ | ||
{ | ||
"cells": [ | ||
{ | ||
"cell_type": "code", | ||
"execution_count": 38, | ||
"metadata": {}, | ||
"outputs": [], | ||
"source": [ | ||
"from pydantic import BaseModel\n", | ||
"from typing import Callable" | ||
] | ||
}, | ||
{ | ||
"cell_type": "code", | ||
"execution_count": 39, | ||
"metadata": {}, | ||
"outputs": [], | ||
"source": [ | ||
"class DummyDataSource(BaseModel):\n", | ||
" table: str\n", | ||
"\n", | ||
"class DummyConnector(BaseModel):\n", | ||
" host: str\n", | ||
" secrets_id: str\n", | ||
" \n", | ||
" get_secrets: Callable[[str], dict]\n", | ||
" \n", | ||
" def get_df(self, data_source: DummyDataSource):\n", | ||
" print('get_df for table', data_source.table)\n", | ||
" print('with secrets: ', self.get_secrets(self.secrets_id))" | ||
] | ||
}, | ||
{ | ||
"cell_type": "code", | ||
"execution_count": 40, | ||
"metadata": {}, | ||
"outputs": [ | ||
{ | ||
"name": "stdout", | ||
"output_type": "stream", | ||
"text": [ | ||
"get_df for table plop\n", | ||
"with secrets: {'token': '1234567890'}\n" | ||
] | ||
} | ||
], | ||
"source": [ | ||
"def get_dummy_secrets(secrets_id):\n", | ||
" if secrets_id == 'aaa':\n", | ||
" return {'token': '1234567890'}\n", | ||
" elif secrets_id == 'bbb':\n", | ||
" return {'token': 'azertyuiop'}\n", | ||
"\n", | ||
"conn_config = {'host': '0.2.3.4', 'secrets_id': 'aaa'}\n", | ||
"conn = DummyConnector(get_secrets=get_dummy_secrets, **conn_config)\n", | ||
"ds = DummyDataSource(table='plop')\n", | ||
"\n", | ||
"conn.get_df(ds)" | ||
] | ||
}, | ||
{ | ||
"cell_type": "code", | ||
"execution_count": 41, | ||
"metadata": {}, | ||
"outputs": [ | ||
{ | ||
"data": { | ||
"text/plain": [ | ||
"{'title': 'DummyConnector',\n", | ||
" 'type': 'object',\n", | ||
" 'properties': {'host': {'title': 'Host', 'type': 'string'},\n", | ||
" 'secrets_id': {'title': 'Secrets Id', 'type': 'string'}},\n", | ||
" 'required': ['host', 'secrets_id']}" | ||
] | ||
}, | ||
"execution_count": 41, | ||
"metadata": {}, | ||
"output_type": "execute_result" | ||
} | ||
], | ||
"source": [ | ||
"DummyConnector.schema()" | ||
] | ||
} | ||
], | ||
"metadata": { | ||
"kernelspec": { | ||
"display_name": "Python 3", | ||
"language": "python", | ||
"name": "python3" | ||
}, | ||
"language_info": { | ||
"codemirror_mode": { | ||
"name": "ipython", | ||
"version": 3 | ||
}, | ||
"file_extension": ".py", | ||
"mimetype": "text/x-python", | ||
"name": "python", | ||
"nbconvert_exporter": "python", | ||
"pygments_lexer": "ipython3", | ||
"version": "3.7.4" | ||
} | ||
}, | ||
"nbformat": 4, | ||
"nbformat_minor": 4 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,107 @@ | ||
{ | ||
"cells": [ | ||
{ | ||
"cell_type": "code", | ||
"execution_count": 38, | ||
"metadata": {}, | ||
"outputs": [], | ||
"source": [ | ||
"from pydantic import BaseModel\n", | ||
"from typing import Callable" | ||
] | ||
}, | ||
{ | ||
"cell_type": "code", | ||
"execution_count": 39, | ||
"metadata": {}, | ||
"outputs": [], | ||
"source": [ | ||
"class DummyDataSource(BaseModel):\n", | ||
" table: str\n", | ||
"\n", | ||
"class DummyConnector(BaseModel):\n", | ||
" host: str\n", | ||
" secrets_id: str\n", | ||
" \n", | ||
" get_secrets: Callable[[str], dict]\n", | ||
" \n", | ||
" def get_df(self, data_source: DummyDataSource):\n", | ||
" print('get_df for table', data_source.table)\n", | ||
" print('with secrets: ', self.get_secrets(self.secrets_id))" | ||
] | ||
}, | ||
{ | ||
"cell_type": "code", | ||
"execution_count": 42, | ||
"metadata": {}, | ||
"outputs": [ | ||
{ | ||
"name": "stdout", | ||
"output_type": "stream", | ||
"text": [ | ||
"get_df for table plop\n", | ||
"with secrets: {'token': '1234567890'}\n" | ||
] | ||
} | ||
], | ||
"source": [ | ||
"def get_dummy_secrets(secrets_id):\n", | ||
" if secrets_id == 'aaa':\n", | ||
" return {'token': '1234567890'}\n", | ||
" elif secrets_id == 'bbb':\n", | ||
" return {'token': 'azertyuiop'}\n", | ||
"\n", | ||
"conn_config = {'host': '0.2.3.4', 'secrets_id': 'aaa'}\n", | ||
"conn = DummyConnector(get_secrets=get_dummy_secrets, **conn_config)\n", | ||
"ds = DummyDataSource(table='plop')\n", | ||
"\n", | ||
"conn.get_df(ds)" | ||
] | ||
}, | ||
{ | ||
"cell_type": "code", | ||
"execution_count": 43, | ||
"metadata": {}, | ||
"outputs": [ | ||
{ | ||
"data": { | ||
"text/plain": [ | ||
"{'title': 'DummyConnector',\n", | ||
" 'type': 'object',\n", | ||
" 'properties': {'host': {'title': 'Host', 'type': 'string'},\n", | ||
" 'secrets_id': {'title': 'Secrets Id', 'type': 'string'}},\n", | ||
" 'required': ['host', 'secrets_id']}" | ||
] | ||
}, | ||
"execution_count": 43, | ||
"metadata": {}, | ||
"output_type": "execute_result" | ||
} | ||
], | ||
"source": [ | ||
"DummyConnector.schema()" | ||
] | ||
} | ||
], | ||
"metadata": { | ||
"kernelspec": { | ||
"display_name": "Python 3", | ||
"language": "python", | ||
"name": "python3" | ||
}, | ||
"language_info": { | ||
"codemirror_mode": { | ||
"name": "ipython", | ||
"version": 3 | ||
}, | ||
"file_extension": ".py", | ||
"mimetype": "text/x-python", | ||
"name": "python", | ||
"nbconvert_exporter": "python", | ||
"pygments_lexer": "ipython3", | ||
"version": "3.7.4" | ||
} | ||
}, | ||
"nbformat": 4, | ||
"nbformat_minor": 4 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
from toucan_connectors.google_sheets_2.google_sheets_2_connector import GoogleSheets2Connector, GoogleSheets2DataSource | ||
from toucan_connectors.oauth2_connector.oauth2_authorization_webserver import get_authorization_response | ||
from toucan_connectors.oauth2_connector.oauth2connector import JsonFileSecretsKeeper | ||
|
||
|
||
CLIENT_ID = '' | ||
CLIENT_SECRET = '' | ||
REDIRECT_URI = 'http://localhost:34097/' | ||
|
||
google_sheets_conn = GoogleSheets2Connector(name='test', auth_flow_id='test', client_id=CLIENT_ID, client_secret=CLIENT_SECRET, | ||
redirect_uri=REDIRECT_URI, secrets_keeper=JsonFileSecretsKeeper(filename="secrets.json")) | ||
sample_data_source_ss = GoogleSheets2DataSource(name='test', domain='test-connector', spreadsheet_id='1L5YraXEToFv7p0HMke7gXI4IhJotdT0q5bk_PInI1hA') | ||
|
||
# authorization_response = get_authorization_response(google_sheets_conn.build_authorization_url(), 'localhost', 34097) | ||
# google_sheets_conn.retrieve_tokens(authorization_response) | ||
|
||
df = google_sheets_conn.get_df(data_source=sample_data_source_ss) | ||
print(df) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
authlib == 0.14 | ||
aioresponses >= 0.6.0 | ||
black | ||
cryptography >= 2.7.0 | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
from typing import Any | ||
from unittest.mock import Mock | ||
|
||
import pytest | ||
|
||
from toucan_connectors.oauth2_connector.oauth2connector import ( | ||
NoOAuth2RefreshToken, | ||
OAuth2Connector, | ||
OAuth2Session, | ||
SecretsKeeper, | ||
) | ||
|
||
FAKE_AUTHORIZATION_URL = 'http://localhost:4242/foobar' | ||
FAKE_TOKEN_URL = 'http://service/token_endpoint' | ||
SCOPE: str = "openid email https://www.googleapis.com/auth/spreadsheets.readonly" | ||
|
||
|
||
@pytest.fixture | ||
def secrets_keeper(): | ||
class SimpleSecretsKeeper(SecretsKeeper): | ||
def __init__(self): | ||
self.store = {} | ||
|
||
def load(self, key: str) -> Any: | ||
return self.store[key] | ||
|
||
def save(self, key: str, value: Any): | ||
self.store[key] = value | ||
|
||
return SimpleSecretsKeeper() | ||
|
||
|
||
@pytest.fixture | ||
def oauth2_connector(secrets_keeper): | ||
return OAuth2Connector( | ||
name='test', | ||
authorization_url=FAKE_AUTHORIZATION_URL, | ||
scope=SCOPE, | ||
client_id='', | ||
client_secret='', | ||
redirect_uri='', | ||
token_url=FAKE_TOKEN_URL, | ||
secrets_keeper=secrets_keeper, | ||
) | ||
|
||
|
||
def test_build_authorization_url(mocker, oauth2_connector, secrets_keeper): | ||
""" | ||
It should return the authorization URL | ||
""" | ||
mock_create_authorization_url: Mock = mocker.patch( | ||
'toucan_connectors.oauth2_connector.oauth2connector.OAuth2Session.create_authorization_url', | ||
return_value=('authorization_url', 'state'), | ||
) | ||
url = oauth2_connector.build_authorization_url() | ||
mock_create_authorization_url.assert_called_once_with(FAKE_AUTHORIZATION_URL) | ||
assert url == 'authorization_url' | ||
assert secrets_keeper.load('test')['state'] == 'state' | ||
|
||
|
||
def test_retrieve_tokens(mocker, oauth2_connector, secrets_keeper): | ||
""" | ||
It should retrieve tokens and save them | ||
""" | ||
secrets_keeper.save('test', {'state': 'dummy_state'}) | ||
mock_fetch_token: Mock = mocker.patch( | ||
'toucan_connectors.oauth2_connector.oauth2connector.OAuth2Session.fetch_token', | ||
return_value={'access_token': 'dummy_token'}, | ||
) | ||
|
||
oauth2_connector.retrieve_tokens('http://localhost/?state=dummy_state') | ||
mock_fetch_token.assert_called() | ||
assert secrets_keeper.load('test')['access_token'] == 'dummy_token' | ||
|
||
|
||
def test_fail_retrieve_tokens(oauth2_connector, secrets_keeper): | ||
""" | ||
It should fail ig the stored state does not match the received state | ||
""" | ||
secrets_keeper.save('test', {'state': 'dummy_state'}) | ||
|
||
with pytest.raises(AssertionError): | ||
oauth2_connector.retrieve_tokens('http://localhost/?state=bad_state') | ||
|
||
|
||
def test_get_access_token(oauth2_connector, secrets_keeper): | ||
""" | ||
It should return the last saved access_token | ||
""" | ||
secrets_keeper.save('test', {'access_token': 'dummy_token'}) | ||
assert oauth2_connector.get_access_token() == 'dummy_token' | ||
|
||
|
||
def test_get_access_token_expired(mocker, oauth2_connector, secrets_keeper): | ||
""" | ||
It should refresh the token if it expired | ||
""" | ||
secrets_keeper.save( | ||
'test', | ||
{'access_token': 'dummy_token', 'expires_at': 0, 'refresh_token': 'dummy_refresh_token'}, | ||
) | ||
|
||
mock_refresh_token: Mock = mocker.patch( | ||
'toucan_connectors.oauth2_connector.oauth2connector.OAuth2Session.refresh_token', | ||
return_value={'access_token': 'new_token'}, | ||
) | ||
access_token = oauth2_connector.get_access_token() | ||
mock_refresh_token.assert_called_once_with(FAKE_TOKEN_URL, refresh_token='dummy_refresh_token') | ||
assert access_token == 'new_token' | ||
|
||
|
||
def test_get_access_token_expired_no_refresh_token(mocker, oauth2_connector, secrets_keeper): | ||
""" | ||
It should fail to refresh the token if no refresh token is provided | ||
""" | ||
secrets_keeper.save('test', {'access_token': 'dummy_token', 'expires_at': 0}) | ||
|
||
mock_refresh_token: Mock = mocker.patch( | ||
'toucan_connectors.oauth2_connector.oauth2connector.OAuth2Session.refresh_token', | ||
return_value={'access_token': 'new_token'}, | ||
) | ||
with pytest.raises(NoOAuth2RefreshToken): | ||
oauth2_connector.get_access_token() | ||
mock_refresh_token.assert_not_called() |
Oops, something went wrong.