Skip to content

Commit c65281d

Browse files
Merge branch 'main' into patron-data-methods
2 parents efc3ec4 + 30b18f4 commit c65281d

5 files changed

Lines changed: 359 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
# Changelog
2-
## v1.5.0 11/15/24
2+
## v1.6.0 11/20/24
33
- Use executemany instead of execute when appropriate in PostgreSQLClient
44
- Add capability to retry connecting to a database to the MySQL, PostgreSQL, and Redshift clients
55
- Automatically close database connection upon error in the MySQL, PostgreSQL, and Redshift clients
66
- Delete old PostgreSQLPoolClient, which was not production ready
77

8+
## v1.5.0 11/19/24
9+
- Added cloudLibrary client
10+
811
## v1.4.0 9/23/24
912
- Added SFTP client
1013

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ This package contains common Python utility classes and functions.
1313
* Connecting to and querying a PostgreSQL database
1414
* Connecting to and querying Redshift
1515
* Making requests to the Oauth2 authenticated APIs such as NYPL Platform API and Sierra
16+
* Interacting with vendor APIs such as cloudLibrary
1617

1718
## Functions
1819
* Reading a YAML config file and putting the contents in os.environ -- see `config/sample.yaml` for an example of how the config file should be formatted

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ avro-client = [
2727
"avro>=1.11.1",
2828
"requests>=2.28.1"
2929
]
30+
cloudlibrary-client = [
31+
"requests>=2.28.1"
32+
]
3033
kinesis-client = [
3134
"boto3>=1.26.5",
3235
"botocore>=1.29.5"
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import base64
2+
import hashlib
3+
import hmac
4+
import requests
5+
6+
from datetime import datetime, timedelta, timezone
7+
from nypl_py_utils.functions.log_helper import create_log
8+
from requests.adapters import HTTPAdapter, Retry
9+
10+
_API_URL = "https://partner.yourcloudlibrary.com"
11+
_VERSION = "3.0.2"
12+
13+
14+
class CloudLibraryClient:
15+
"""Client for interacting with CloudLibrary API v3.0.2"""
16+
17+
def __init__(self, library_id, account_id, account_key):
18+
self.logger = create_log("cloudlibrary_client")
19+
self.library_id = library_id
20+
self.account_id = account_id
21+
self.account_key = account_key
22+
23+
# authenticate & set up HTTP session
24+
retry_policy = Retry(total=3, backoff_factor=45,
25+
status_forcelist=[500, 502, 503, 504],
26+
allowed_methods=frozenset(["GET"]))
27+
self.session = requests.Session()
28+
self.session.mount("https://",
29+
HTTPAdapter(max_retries=retry_policy))
30+
31+
def get_library_events(self, start_date=None,
32+
end_date=None) -> requests.Response:
33+
"""
34+
Retrieves all the events related to library-owned items within the
35+
optional timeframe. Pulls past 24 hours of events by default.
36+
37+
start_date and end_date are optional parameters, and must be
38+
formatted either YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS
39+
"""
40+
date_format = "%Y-%m-%dT%H:%M:%S"
41+
today = datetime.now(timezone.utc)
42+
yesterday = today - timedelta(1)
43+
start_date = datetime.strftime(
44+
yesterday, date_format) if start_date is None else start_date
45+
end_date = datetime.strftime(
46+
today, date_format) if end_date is None else end_date
47+
48+
if (datetime.strptime(start_date, date_format) >
49+
datetime.strptime(end_date, date_format)):
50+
error_message = (f"Start date {start_date} greater than end date "
51+
f"{end_date}, cannot retrieve library events")
52+
self.logger.error(error_message)
53+
raise CloudLibraryClientError(error_message)
54+
55+
self.logger.info(
56+
(f"Fetching all library events in "
57+
f"time frame {start_date} to {end_date}..."))
58+
59+
path = f"data/cloudevents?startdate={start_date}&enddate={end_date}"
60+
response = self.request(path=path, method_type="GET")
61+
return response
62+
63+
def create_request_body(self, request_type,
64+
item_id, patron_id) -> str:
65+
"""
66+
Helper function to generate request body when performing item
67+
and/or patron-specific functions (ex. checking out a title).
68+
"""
69+
request_template = "<%(request_type)s><ItemId>%(item_id)s</ItemId><PatronId>%(patron_id)s</PatronId></%(request_type)s>" # noqa
70+
return request_template % {
71+
"request_type": request_type,
72+
"item_id": item_id,
73+
"patron_id": patron_id,
74+
}
75+
76+
def request(self, path, method_type="GET",
77+
body=None) -> requests.Response:
78+
"""
79+
Use this method to call specific paths in the cloudLibrary API.
80+
This method is necessary for building headers/authorization.
81+
Example usage of this method is in the get_library_events function.
82+
83+
Returns Response object by default -- you will need to parse this
84+
object to retrieve response text, status codes, etc.
85+
"""
86+
extended_path = f"/cirrus/library/{self.library_id}/{path}"
87+
headers = self._build_headers(method_type, extended_path)
88+
url = f"{_API_URL}{extended_path}"
89+
method_type = method_type.upper()
90+
91+
try:
92+
if method_type == "PUT":
93+
response = self.session.put(url=url,
94+
data=body,
95+
headers=headers,
96+
timeout=60)
97+
elif method_type == "POST":
98+
response = self.session.post(url=url,
99+
data=body,
100+
headers=headers,
101+
timeout=60)
102+
else:
103+
response = self.session.get(url=url,
104+
data=body,
105+
headers=headers,
106+
timeout=60)
107+
response.raise_for_status()
108+
except Exception as e:
109+
error_message = (f"Failed to retrieve response from {url}: "
110+
f"{repr(e)}")
111+
self.logger.error(error_message)
112+
raise CloudLibraryClientError(error_message)
113+
114+
return response
115+
116+
def _build_headers(self, method_type, path) -> dict:
117+
time, authorization = self._build_authorization(
118+
method_type, path)
119+
headers = {
120+
"3mcl-Datetime": time,
121+
"3mcl-Authorization": authorization,
122+
"3mcl-APIVersion": _VERSION,
123+
}
124+
125+
if method_type == "GET":
126+
headers["Accept"] = "application/xml"
127+
else:
128+
headers["Content-Type"] = "application/xml"
129+
130+
return headers
131+
132+
def _build_authorization(self, method_type,
133+
path) -> tuple[str, str]:
134+
now = datetime.now(timezone.utc).strftime(
135+
"%a, %d %b %Y %H:%M:%S GMT")
136+
message = "\n".join([now, method_type, path])
137+
digest = hmac.new(
138+
self.account_key.encode("utf-8"),
139+
msg=message.encode("utf-8"),
140+
digestmod=hashlib.sha256
141+
).digest()
142+
signature = base64.standard_b64encode(digest).decode()
143+
144+
return now, f"3MCLAUTH {self.account_id}:{signature}"
145+
146+
147+
class CloudLibraryClientError(Exception):
148+
def __init__(self, message=None):
149+
self.message = message

tests/test_cloudlibrary_client.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import pytest
2+
3+
from freezegun import freeze_time
4+
from requests import ConnectTimeout
5+
from nypl_py_utils.classes.cloudlibrary_client import (
6+
CloudLibraryClient, CloudLibraryClientError)
7+
8+
_API_URL = "https://partner.yourcloudlibrary.com/cirrus/library/"
9+
10+
# catch-all API response since we're not testing actual data
11+
_TEST_LIBRARY_EVENTS_RESPONSE = """<LibraryEventBatch
12+
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
13+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
14+
<PublishId>4302fcca-ef99-49bf-bd29-d673e990f765</PublishId>
15+
<PublishDateTimeInUTC>2024-11-10T17:35:18</PublishDateTimeInUTC>
16+
<LastEventDateTimeInUTC>2012-11-11T13:58:52.055</LastEventDateTimeInUTC>
17+
<Events>
18+
<CloudLibraryEvent>
19+
<EventId>4302fcca-ef99-49bf-bd29-d673e990f4a7</EventId>
20+
<EventType>CHECKIN</EventType>
21+
<EventStartDateTimeInUTC>2024-11-10T05:07:56</EventStartDateTimeInUTC>
22+
<EventEndDateTimeInUTC>2024-11-10T07:50:59</EventEndDateTimeInUTC>
23+
<ItemId>edbz9</ItemId>
24+
<ItemLibraryId>1234</ItemLibraryId>
25+
<ISBN>9780307238405</ISBN>
26+
<PatronId>TestUser1</PatronId>
27+
<PatronLibraryId>1234</PatronLibraryId>
28+
<EventPublishDateTimeInUTC>2024-11-10T17:35:18</EventPublishDateTimeInUTC>
29+
</CloudLibraryEvent>
30+
</Events>
31+
</LibraryEventBatch>
32+
"""
33+
34+
35+
@freeze_time("2024-11-11 10:00:00")
36+
class TestCloudLibraryClient:
37+
@pytest.fixture
38+
def test_instance(self):
39+
return CloudLibraryClient(
40+
"library_id", "account_id", "account_key")
41+
42+
def test_get_library_events_success_no_args(
43+
self, test_instance, mocker):
44+
start = "2024-11-10T10:00:00"
45+
end = "2024-11-11T10:00:00"
46+
mock_request = mocker.patch(
47+
"nypl_py_utils.classes.cloudlibrary_client.CloudLibraryClient.request", # noqa
48+
return_value=_TEST_LIBRARY_EVENTS_RESPONSE)
49+
response = test_instance.get_library_events()
50+
51+
mock_request.assert_called_once_with(
52+
path=f"data/cloudevents?startdate={start}&enddate={end}",
53+
method_type="GET")
54+
assert response == _TEST_LIBRARY_EVENTS_RESPONSE
55+
56+
def test_get_library_events_success_with_start_and_end_date(
57+
self, test_instance, mocker):
58+
start = "2024-11-01T10:00:00"
59+
end = "2024-11-05T10:00:00"
60+
mock_request = mocker.patch(
61+
"nypl_py_utils.classes.cloudlibrary_client.CloudLibraryClient.request", # noqa
62+
return_value=_TEST_LIBRARY_EVENTS_RESPONSE)
63+
response = test_instance.get_library_events(start, end)
64+
65+
mock_request.assert_called_once_with(
66+
path=f"data/cloudevents?startdate={start}&enddate={end}",
67+
method_type="GET")
68+
assert response == _TEST_LIBRARY_EVENTS_RESPONSE
69+
70+
def test_get_library_events_success_with_no_end_date(
71+
self, test_instance, mocker):
72+
start = "2024-11-01T09:00:00"
73+
end = "2024-11-11T10:00:00"
74+
mock_request = mocker.patch(
75+
"nypl_py_utils.classes.cloudlibrary_client.CloudLibraryClient.request", # noqa
76+
return_value=_TEST_LIBRARY_EVENTS_RESPONSE)
77+
response = test_instance.get_library_events(start)
78+
79+
mock_request.assert_called_once_with(
80+
path=f"data/cloudevents?startdate={start}&enddate={end}",
81+
method_type="GET")
82+
assert response == _TEST_LIBRARY_EVENTS_RESPONSE
83+
84+
def test_get_library_events_exception_when_start_date_greater_than_end(
85+
self, test_instance, caplog):
86+
start = "2024-11-11T09:00:00"
87+
end = "2024-11-01T10:00:00"
88+
89+
with pytest.raises(CloudLibraryClientError):
90+
test_instance.get_library_events(start, end)
91+
assert (f"Start date {start} greater than end date {end}, "
92+
f"cannot retrieve library events") in caplog.text
93+
94+
def test_get_library_events_exception_when_connection_timeout(
95+
self, test_instance, requests_mock, caplog):
96+
start = "2024-11-10T10:00:00"
97+
end = "2024-11-11T10:00:00"
98+
url = f"{_API_URL}{test_instance.library_id}/data/cloudevents?startdate={start}&enddate={end}" # noqa
99+
100+
# We're making sure that a separate error during a sub-method will
101+
# still result in CloudLibraryClientError
102+
requests_mock.get(
103+
url, exc=ConnectTimeout)
104+
105+
with pytest.raises(CloudLibraryClientError):
106+
test_instance.get_library_events()
107+
assert (f"Failed to retrieve response from {url}") in caplog.text
108+
109+
def test_get_request_success(self, test_instance, requests_mock):
110+
start = "2024-11-10T10:00:00"
111+
end = "2024-11-11T10:00:00"
112+
url = f"{_API_URL}{test_instance.library_id}/data/cloudevents?startdate={start}&enddate={end}" # noqa
113+
expected_headers = {"3mcl-Datetime": "Mon, 11 Nov 2024 10:00:00 GMT",
114+
"3mcl-Authorization": "3MCLAUTH account_id:KipNmbVsmsT2xPjP4oHAaR3n00JgcszfF6mQRffBoRk=", # noqa
115+
"3mcl-APIVersion": "3.0.2",
116+
"Accept": "application/xml"}
117+
requests_mock.get(
118+
url=url, text=_TEST_LIBRARY_EVENTS_RESPONSE)
119+
120+
response = test_instance.request(
121+
path=f"data/cloudevents?startdate={start}&enddate={end}",
122+
method_type="GET")
123+
124+
assert response.text == _TEST_LIBRARY_EVENTS_RESPONSE
125+
assert requests_mock.request_history[0].method == "GET"
126+
assert requests_mock.request_history[0].url == url
127+
assert requests_mock.request_history[0].body is None
128+
assert expected_headers.items() <= dict(
129+
requests_mock.request_history[0].headers).items()
130+
131+
def test_put_request_success(self, test_instance, requests_mock):
132+
start = "2024-11-10T10:00:00"
133+
end = "2024-11-11T10:00:00"
134+
url = f"{_API_URL}{test_instance.library_id}/data/cloudevents?startdate={start}&enddate={end}" # noqa
135+
expected_headers = {"3mcl-Datetime": "Mon, 11 Nov 2024 10:00:00 GMT",
136+
"3mcl-Authorization": "3MCLAUTH account_id:3M773C6ZVWmB/ISoSjQy9iBp48T4tUWhoNOwXaseMtE=", # noqa
137+
"3mcl-APIVersion": "3.0.2",
138+
"Content-Type": "application/xml"}
139+
requests_mock.put(
140+
url=url, text=_TEST_LIBRARY_EVENTS_RESPONSE)
141+
142+
response = test_instance.request(
143+
path=f"data/cloudevents?startdate={start}&enddate={end}",
144+
method_type="PUT",
145+
body={"test": "test"})
146+
147+
assert response.text == _TEST_LIBRARY_EVENTS_RESPONSE
148+
assert requests_mock.request_history[0].method == "PUT"
149+
assert requests_mock.request_history[0].url == url
150+
assert requests_mock.request_history[0].body == "test=test"
151+
assert expected_headers.items() <= dict(
152+
requests_mock.request_history[0].headers).items()
153+
154+
def test_post_request_success(self, test_instance, requests_mock):
155+
start = "2024-11-10T10:00:00"
156+
end = "2024-11-11T10:00:00"
157+
url = f"{_API_URL}{test_instance.library_id}/data/cloudevents?startdate={start}&enddate={end}" # noqa
158+
expected_headers = {"3mcl-Datetime": "Mon, 11 Nov 2024 10:00:00 GMT",
159+
"3mcl-Authorization": "3MCLAUTH account_id:vF0zI6ee1w1PbTLQ9EVvtxRly2vpCRxdBdAHb8DZQ4E=", # noqa
160+
"3mcl-APIVersion": "3.0.2",
161+
"Content-Type": "application/xml"}
162+
requests_mock.post(
163+
url=url, text=_TEST_LIBRARY_EVENTS_RESPONSE)
164+
165+
response = test_instance.request(
166+
path=f"data/cloudevents?startdate={start}&enddate={end}",
167+
method_type="POST",
168+
body={"test": "test"})
169+
170+
assert response.text == _TEST_LIBRARY_EVENTS_RESPONSE
171+
assert requests_mock.request_history[0].method == "POST"
172+
assert requests_mock.request_history[0].url == url
173+
assert requests_mock.request_history[0].body == "test=test"
174+
assert expected_headers.items() <= dict(
175+
requests_mock.request_history[0].headers).items()
176+
177+
def test_request_failure(self, test_instance,
178+
requests_mock, caplog):
179+
start = "2024-11-10T10:00:00"
180+
end = "2024-11-11T10:00:00"
181+
url = f"{_API_URL}{test_instance.library_id}/data/cloudevents?startdate={start}&enddate={end}" # noqa
182+
requests_mock.get(
183+
url, exc=ConnectTimeout)
184+
185+
with pytest.raises(CloudLibraryClientError):
186+
test_instance.request(
187+
path=f"data/cloudevents?startdate={start}&enddate={end}",
188+
method_type="GET")
189+
assert (f"Failed to retrieve response from "
190+
f"{url}: ConnectTimeout()") in caplog.text
191+
192+
def test_create_request_body_success(self, test_instance):
193+
request_type = "CheckoutRequest"
194+
item_id = "df45qw"
195+
patron_id = "215555602845"
196+
EXPECTED_REQUEST_BODY = (f"<{request_type}><ItemId>{item_id}</ItemId>"
197+
f"<PatronId>{patron_id}</PatronId>"
198+
f"</{request_type}>")
199+
request_body = test_instance.create_request_body(
200+
request_type, item_id, patron_id)
201+
202+
assert request_body == EXPECTED_REQUEST_BODY

0 commit comments

Comments
 (0)