Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DE-175: Create general cloudLibrary client #38

Merged
merged 11 commits into from
Nov 20, 2024
Merged
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
# Changelog
## v1.5.0 11/4/24
- Added cloudLibrary client

## v1.4.0 9/23/24
- Added SFTP client

Expand Down
1 change: 1 addition & 0 deletions README.md
fatimarahman marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ This package contains common Python utility classes and functions.
* Connecting to and querying a PostgreSQL database using a connection pool
* Connecting to and querying Redshift
* Making requests to the Oauth2 authenticated APIs such as NYPL Platform API and Sierra
* Interacting with vendor APIs such as cloudLibrary

## Functions
* 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
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
fatimarahman marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ avro-client = [
"avro>=1.11.1",
"requests>=2.28.1"
]
cloudlibrary-client = [
"requests>=2.28.1"
]
kinesis-client = [
"boto3>=1.26.5",
"botocore>=1.29.5"
Expand Down
146 changes: 146 additions & 0 deletions src/nypl_py_utils/classes/cloudlibrary_client.py
fatimarahman marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import base64
import hashlib
import hmac
import requests

from datetime import datetime, timedelta, timezone
from nypl_py_utils.functions.log_helper import create_log
from requests.adapters import HTTPAdapter, Retry

_API_URL = "https://partner.yourcloudlibrary.com"
_VERSION = "3.0.2"


class CloudLibraryClient:
"""Client for interacting with CloudLibrary API v3.0.2"""

def __init__(self, library_id, account_id, account_key):
self.logger = create_log("cloudlibrary_client")
self.library_id = library_id
self.account_id = account_id
self.account_key = account_key
self.setup_session()

def setup_session(self):
"""Authenticate and set up HTTP session"""
retry_policy = Retry(total=3, backoff_factor=45,
status_forcelist=[500, 502, 503, 504],
allowed_methods=frozenset(["GET"]))
self.session = requests.Session()
self.session.mount("https://",
HTTPAdapter(max_retries=retry_policy))
fatimarahman marked this conversation as resolved.
Show resolved Hide resolved

def get_library_events(self, start_date=None,
fatimarahman marked this conversation as resolved.
Show resolved Hide resolved
end_date=None) -> requests.Response:
"""
Retrieves all the events related to library-owned items within the
optional timeframe. Pulls yesterday's events by default.

start_date and end_date are optional parameters, and must be
fatimarahman marked this conversation as resolved.
Show resolved Hide resolved
formatted either YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS
"""
date_format = "%Y-%m-%dT%H:%M:%S"
today = datetime.now(timezone.utc)
yesterday = today - timedelta(1)
start_date = datetime.strftime(
yesterday, date_format) if start_date is None else start_date
end_date = datetime.strftime(
today, date_format) if end_date is None else end_date

if start_date > end_date:
fatimarahman marked this conversation as resolved.
Show resolved Hide resolved
error_message = (f"Start date {start_date} greater than end date "
f"{end_date}, cannot retrieve library events")
self.logger.error(error_message)
raise CloudLibraryClientError(error_message)

self.logger.info(
(f"Fetching all library events in "
f"time frame {start_date} to {end_date}..."))

path = f"data/cloudevents?startdate={start_date}&enddate={end_date}"
response = self.request(path=path, method_type="GET")
return response

def create_request_body(self, request_type,
item_id, patron_id) -> str:
"""
Helper function to generate request body when performing item
and/or patron-specific functions (ex. checking out a title).
"""
request_template = "<%(request_type)s><ItemId>%(item_id)s</ItemId><PatronId>%(patron_id)s</PatronId></%(request_type)s>" # noqa
return request_template % {
"request_type": request_type,
"item_id": item_id,
"patron_id": patron_id,
}

def request(self, path, method_type="POST",
fatimarahman marked this conversation as resolved.
Show resolved Hide resolved
body=None) -> requests.Response:
"""
Use this method to call specific paths in the cloudLibrary API.
This method is necessary for building headers/authorization.
Example usage of this method is in the get_library_events function.
"""
extended_path = f"/cirrus/library/{self.library_id}/{path}"
headers = self._build_headers(method_type, extended_path)
url = f"{_API_URL}{extended_path}"
method_type = method_type.upper()

try:
if method_type == "GET":
response = self.session.get(url=url,
data=body,
headers=headers,
timeout=60)
elif method_type == "PUT":
response = self.session.put(url=url,
data=body,
headers=headers,
timeout=60)
else:
response = self.session.post(url=url,
data=body,
headers=headers,
timeout=60)
response.raise_for_status()
except Exception as e:
error_message = f"Failed to retrieve response from {url}: {e}"
self.logger.error(error_message)
raise CloudLibraryClientError(error_message)

return response

def _build_headers(self, method_type, path) -> dict:
time, authorization = self._build_authorization(
method_type, path)
headers = {
"3mcl-Datetime": time,
"3mcl-Authorization": authorization,
"3mcl-APIVersion": _VERSION,
}

if method_type == "GET":
headers["Accept"] = "application/xml"
else:
headers["Content-Type"] = "application/xml"

return headers

def _build_authorization(self, method_type,
path) -> tuple[str, str]:
now = datetime.now(timezone.utc).strftime(
"%a, %d %b %Y %H:%M:%S GMT")
message = "\n".join([now, method_type, path])
digest = hmac.new(
self.account_key.encode("utf-8"),
msg=message.encode("utf-8"),
digestmod=hashlib.sha256
).digest()
fatimarahman marked this conversation as resolved.
Show resolved Hide resolved
signature = base64.standard_b64encode(digest).decode()

return now, f"3MCLAUTH {self.account_id}:{signature}"


class CloudLibraryClientError(Exception):
def __init__(self, message=None):
self.message = message
111 changes: 111 additions & 0 deletions tests/test_cloudlibrary_client.py
fatimarahman marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import pytest

from freezegun import freeze_time
from requests import ConnectTimeout
from nypl_py_utils.classes.cloudlibrary_client import (
CloudLibraryClient, CloudLibraryClientError)

_API_URL = "https://partner.yourcloudlibrary.com/cirrus/library/"

# catch-all API response since we're not testing actual data
_TEST_LIBRARY_EVENTS_RESPONSE = """<LibraryEventBatch
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<PublishId>4302fcca-ef99-49bf-bd29-d673e990f765</PublishId>
<PublishDateTimeInUTC>2024-11-10T17:35:18</PublishDateTimeInUTC>
<LastEventDateTimeInUTC>2012-11-11T13:58:52.055</LastEventDateTimeInUTC>
<Events>
<CloudLibraryEvent>
<EventId>4302fcca-ef99-49bf-bd29-d673e990f4a7</EventId>
<EventType>CHECKIN</EventType>
<EventStartDateTimeInUTC>2024-11-10T05:07:56</EventStartDateTimeInUTC>
<EventEndDateTimeInUTC>2024-11-10T07:50:59</EventEndDateTimeInUTC>
<ItemId>edbz9</ItemId>
<ItemLibraryId>1234</ItemLibraryId>
<ISBN>9780307238405</ISBN>
<PatronId>TestUser1</PatronId>
<PatronLibraryId>1234</PatronLibraryId>
<EventPublishDateTimeInUTC>2024-11-10T17:35:18</EventPublishDateTimeInUTC>
</CloudLibraryEvent>
</Events>
</LibraryEventBatch>
"""


@freeze_time("2024-11-11 10:00:00")
class TestCloudLibraryClient:
@pytest.fixture
def test_instance(self):
return CloudLibraryClient(
"library_id", "account_id", "account_key")

def test_get_library_events_success_no_args(
self, test_instance, requests_mock, caplog):
start = "2024-11-10T10:00:00"
end = "2024-11-11T10:00:00"
requests_mock.get(
f"{_API_URL}{test_instance.library_id}/data/cloudevents?startdate={start}&enddate={end}", # noqa
text=_TEST_LIBRARY_EVENTS_RESPONSE)
response = test_instance.get_library_events()

assert response.text == _TEST_LIBRARY_EVENTS_RESPONSE
assert (f"Fetching all library events in time frame "
f"{start} to {end}...") in caplog.text

fatimarahman marked this conversation as resolved.
Show resolved Hide resolved
def test_get_library_events_success_with_start_and_end_date(
self, test_instance, requests_mock, caplog):
start = "2024-11-01T10:00:00"
end = "2024-11-05T10:00:00"
requests_mock.get(
f"{_API_URL}{test_instance.library_id}/data/cloudevents?startdate={start}&enddate={end}", # noqa
text=_TEST_LIBRARY_EVENTS_RESPONSE)
response = test_instance.get_library_events(start, end)

assert response.text == _TEST_LIBRARY_EVENTS_RESPONSE
assert (f"Fetching all library events in time frame "
f"{start} to {end}...") in caplog.text

def test_get_library_events_success_with_no_end_date(
self, test_instance, requests_mock, caplog):
start = "2024-11-01T09:00:00"
end = "2024-11-11T10:00:00"
requests_mock.get(
f"{_API_URL}{test_instance.library_id}/data/cloudevents?startdate={start}&enddate={end}", # noqa
text=_TEST_LIBRARY_EVENTS_RESPONSE)
response = test_instance.get_library_events(start)

assert response.text == _TEST_LIBRARY_EVENTS_RESPONSE
assert (f"Fetching all library events in time frame "
f"{start} to {end}...") in caplog.text

def test_get_library_events_exception_when_start_date_greater_than_end(
fatimarahman marked this conversation as resolved.
Show resolved Hide resolved
self, test_instance, caplog):
start = "2024-11-11T09:00:00"
end = "2024-11-01T10:00:00"

with pytest.raises(CloudLibraryClientError):
test_instance.get_library_events(start, end)
assert (f"Start date {start} greater than end date "
f"{end}, cannot retrieve library events") in caplog.text

def test_get_library_events_failure(self, test_instance, requests_mock):
start = "2024-11-10T10:00:00"
end = "2024-11-11T10:00:00"
requests_mock.get(
f"{_API_URL}{test_instance.library_id}/data/cloudevents?startdate={start}&enddate={end}", # noqa
exc=ConnectTimeout)

with pytest.raises(CloudLibraryClientError):
test_instance.get_library_events()

def test_create_request_body_success(self, test_instance):
request_type = "CheckoutRequest"
item_id = "df45qw"
patron_id = "215555602845"
EXPECTED_REQUEST_BODY = (f"<{request_type}><ItemId>{item_id}</ItemId>"
f"<PatronId>{patron_id}</PatronId>"
f"</{request_type}>")
request_body = test_instance.create_request_body(
request_type, item_id, patron_id)

assert request_body == EXPECTED_REQUEST_BODY
fatimarahman marked this conversation as resolved.
Show resolved Hide resolved