Skip to content

Commit 30b18f4

Browse files
authored
Merge pull request #38 from NYPL/de-175/cloudlibrary-client
DE-175: Create general cloudLibrary client
2 parents 7aca508 + 5a882c6 commit 30b18f4

5 files changed

Lines changed: 361 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
# Changelog
2+
## v1.5.0 11/19/24
3+
- Added cloudLibrary client
4+
25
## v1.4.0 9/23/24
36
- Added SFTP client
47

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ This package contains common Python utility classes and functions.
1414
* Connecting to and querying a PostgreSQL database using a connection pool
1515
* Connecting to and querying Redshift
1616
* Making requests to the Oauth2 authenticated APIs such as NYPL Platform API and Sierra
17+
* Interacting with vendor APIs such as cloudLibrary
1718

1819
## Functions
1920
* 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
@@ -37,7 +38,7 @@ kinesis_client = KinesisClient(...)
3738
# Do not use any version below 1.0.0
3839
# All available optional dependencies can be found in pyproject.toml.
3940
# See the "Managing dependencies" section below for more details.
40-
nypl-py-utils[kinesis-client,config-helper]==1.4.0
41+
nypl-py-utils[kinesis-client,config-helper]==1.5.0
4142
```
4243

4344
## Developing locally
@@ -63,7 +64,7 @@ The optional dependency sets also give the developer the option to manually list
6364
### Using PostgreSQLClient in an AWS Lambda
6465
Because `psycopg` requires a statically linked version of the `libpq` library, the `PostgreSQLClient` cannot be installed as-is in an AWS Lambda function. Instead, it must be packaged as follows:
6566
```bash
66-
pip install --target ./package nypl-py-utils[postgresql-client]==1.4.0
67+
pip install --target ./package nypl-py-utils[postgresql-client]==1.5.0
6768

6869
pip install \
6970
--platform manylinux2014_x86_64 \

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "nypl_py_utils"
7-
version = "1.4.0"
7+
version = "1.5.0"
88
authors = [
99
{ name="Aaron Friedman", email="aaronfriedman@nypl.org" },
1010
]
@@ -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

0 commit comments

Comments
 (0)