Skip to content

Commit 54d6bef

Browse files
committed
Initial PlatformApiClient
First pass at a central PlatformApiClient to handle authenticated get, post, patch, and delete requests against Platform API endpoints.
1 parent 0cb5835 commit 54d6bef

5 files changed

Lines changed: 148 additions & 4 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This package contains common Python utility classes and functions.
1010
* Connecting to and querying a MySQL database
1111
* Connecting to and querying a PostgreSQL database using a connection pool
1212
* Connecting to and querying Redshift
13+
* Making requests to the NYPL Platform API
1314

1415
## Functions
1516
* Reading a YAML config file and putting the contents in os.environ
@@ -63,4 +64,4 @@ This repo uses the [Main-QA-Production](https://github.com/NYPL/engineering-gene
6364
- Deploy app to production on GitHub and confirm it works
6465

6566
## Deployment
66-
The utils repo is deployed as a PyPI package [here](https://pypi.org/project/nypl-py-utils/) and as a Test PyPI package for QA purposes [here](https://test.pypi.org/project/nypl-py-utils/). In order to be deployed, the version listed in `pyproject.toml` **must be updated**. To deploy to Test PyPI, create a new release in GitHub and tag it `qa-vX.X.X`. The GitHub Actions deploy-qa workflow will then build and publish the package. To deploy to production PyPI, create a release and tag it `production-vX.X.X`.
67+
The utils repo is deployed as a PyPI package [here](https://pypi.org/project/nypl-py-utils/) and as a Test PyPI package for QA purposes [here](https://test.pypi.org/project/nypl-py-utils/). In order to be deployed, the version listed in `pyproject.toml` **must be updated**. To deploy to Test PyPI, create a new release in GitHub and tag it `qa-vX.X.X`. The GitHub Actions deploy-qa workflow will then build and publish the package. To deploy to production PyPI, create a release and tag it `production-vX.X.X`.

pyproject.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ dependencies = [
2525
"psycopg[binary,pool]>=3.1.6",
2626
"PyYAML>=6.0",
2727
"redshift-connector>=2.0.909",
28-
"requests>=2.28.1"
28+
"requests>=2.28.1",
29+
"oauthlib>=3.2.2",
30+
"requests_oauthlib>=1.3.1"
2931
]
3032

3133
[project.optional-dependencies]
@@ -40,4 +42,4 @@ tests = [
4042

4143
[project.urls]
4244
"Homepage" = "https://github.com/NYPL/python-utils"
43-
"Bug Tracker" = "https://github.com/NYPL/python-utils/issues"
45+
"Bug Tracker" = "https://github.com/NYPL/python-utils/issues"

src/nypl_py_utils/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@
44
from .classes.mysql_client import MySQLClient, MySQLClientError # noqa
55
from .classes.postgresql_client import PostgreSQLClient, PostgreSQLClientError # noqa
66
from .classes.redshift_client import RedshiftClient, RedshiftClientError # noqa
7-
from .classes.s3_client import S3Client, S3ClientError # noqa
7+
from .classes.s3_client import S3Client, S3ClientError # noqa
8+
from .classes.platform_api_client import PlatformApiClient # noqa
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import os
2+
from oauthlib.oauth2 import BackendApplicationClient, TokenExpiredError
3+
from requests_oauthlib import OAuth2Session
4+
from nypl_py_utils.functions.log_helper import create_log
5+
6+
7+
class PlatformApiClient:
8+
"""
9+
Client for interacting with NYPL Platform API endpoints
10+
"""
11+
12+
def __init__(self, client_id=None, client_secret=None, base_url=None,
13+
token_url=None):
14+
self.client_id = client_id \
15+
or os.environ.get('NYPL_API_CLIENT_ID', None)
16+
self.client_secret = client_secret \
17+
or os.environ.get('NYPL_API_CLIENT_SECRET', None)
18+
self.token_url = token_url \
19+
or os.environ.get('NYPL_API_TOKEN_URL', None)
20+
self.base_url = base_url \
21+
or os.environ.get('NYPL_API_BASE_URL', None)
22+
23+
self.client = None
24+
self.token = None
25+
26+
self.logger = create_log('platform_api_client')
27+
28+
def get(self, request_path, **kwargs):
29+
"""
30+
Issue an HTTP GET on the given request_path
31+
"""
32+
return self._do_http_method('GET', request_path, **kwargs)
33+
34+
def post(self, request_path, json, **kwargs):
35+
"""
36+
Issue an HTTP POST on the given request_path with given JSON body
37+
"""
38+
return self._do_http_method('GET', request_path, **kwargs)
39+
kwargs['json'] = json
40+
return self._do_http_method('POST', request_path, **kwargs)
41+
42+
def patch(self, request_path, json, **kwargs):
43+
"""
44+
Issue an HTTP PATCH on the given request_path with given JSON body
45+
"""
46+
kwargs['json'] = json
47+
return self._do_http_method('PATCH', request_path, **kwargs)
48+
49+
def delete(self, request_path, **kwargs):
50+
"""
51+
Issue an HTTP DELETE on the given request_path
52+
"""
53+
return self._do_http_method('DELETE', request_path, **kwargs)
54+
55+
def _do_http_method(self, method, request_path, **kwargs):
56+
"""
57+
Issue an HTTP method call on on the given request_path
58+
"""
59+
if not self.client:
60+
self._create_oauth_client()
61+
62+
url = f'{self.base_url}/{request_path}'
63+
self.logger.debug(f'{method} {url}')
64+
65+
try:
66+
return self.oauth_client.request(method, url, **kwargs).json()
67+
except TokenExpiredError as e:
68+
print(f'TokenExpiredError encountered: {e}')
69+
self._generate_access_token()
70+
71+
return self._do_http_method(method, request_path, **kwargs)
72+
except TimeoutError as e:
73+
print(f'TimeoutError encountered: {e}')
74+
return {}
75+
76+
def _create_oauth_client(self):
77+
"""
78+
Creates an authenticated a OAuth2Session instance for later requests
79+
"""
80+
self._generate_access_token()
81+
self.oauth_client = OAuth2Session(self.client_id, token=self.token)
82+
83+
def _generate_access_token(self):
84+
"""
85+
Fetch and store a fresh token
86+
"""
87+
client = BackendApplicationClient(client_id=self.client_id)
88+
oauth = OAuth2Session(client=client)
89+
self.logger.debug(f'Refreshing token via @{self.token_url}')
90+
self.token = oauth.fetch_token(
91+
token_url=self.token_url,
92+
client_id=self.client_id,
93+
client_secret=self.client_secret
94+
)

tests/test_platform_api_client.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import json
2+
import pytest
3+
from requests_oauthlib import OAuth2Session
4+
5+
from nypl_py_utils import PlatformApiClient
6+
# from requests.exceptions import ConnectTimeout
7+
8+
_TOKEN_RESPONSE = {
9+
'access_token': 'super-secret-token',
10+
'expires_in': 3600,
11+
'token_type': 'Bearer',
12+
'scope': ['offline_access', 'openid', 'login:staff', 'admin'],
13+
'id_token': 'super-secret-token',
14+
'expires_at': 1677599823.3180869
15+
}
16+
17+
BASE_URL = 'https://example.com/api/v0.1'
18+
19+
20+
class TestPlatformApiClient:
21+
22+
@pytest.fixture
23+
def test_instance(self, requests_mock):
24+
token_url = 'https://oauth.example.com/oauth/token'
25+
26+
requests_mock.post(token_url, text=json.dumps(_TOKEN_RESPONSE))
27+
28+
return PlatformApiClient(base_url=BASE_URL,
29+
token_url=token_url,
30+
client_id='clientid',
31+
client_secret='clientsecret'
32+
)
33+
34+
def test_generate_access_token(self, test_instance):
35+
test_instance._generate_access_token()
36+
assert test_instance.token['access_token']\
37+
== _TOKEN_RESPONSE['access_token']
38+
39+
def test_create_oauth_client(self, test_instance):
40+
test_instance._create_oauth_client()
41+
assert type(test_instance.oauth_client) is OAuth2Session
42+
43+
def test_do_http_method(self, requests_mock, test_instance):
44+
requests_mock.get(f'{BASE_URL}/foo', json={'foo': 'bar'})
45+
resp = test_instance._do_http_method('GET', 'foo')
46+
assert resp == {'foo': 'bar'}

0 commit comments

Comments
 (0)