Skip to content

Commit

Permalink
Merge pull request #38 from NYPL/de-175/cloudlibrary-client
Browse files Browse the repository at this point in the history
DE-175: Create general cloudLibrary client
  • Loading branch information
fatimarahman authored Nov 20, 2024
2 parents 7aca508 + 5a882c6 commit 30b18f4
Show file tree
Hide file tree
Showing 5 changed files with 361 additions and 3 deletions.
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/19/24
- Added cloudLibrary client

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

Expand Down
5 changes: 3 additions & 2 deletions README.md
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 All @@ -37,7 +38,7 @@ kinesis_client = KinesisClient(...)
# Do not use any version below 1.0.0
# All available optional dependencies can be found in pyproject.toml.
# See the "Managing dependencies" section below for more details.
nypl-py-utils[kinesis-client,config-helper]==1.4.0
nypl-py-utils[kinesis-client,config-helper]==1.5.0
```

## Developing locally
Expand All @@ -63,7 +64,7 @@ The optional dependency sets also give the developer the option to manually list
### Using PostgreSQLClient in an AWS Lambda
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:
```bash
pip install --target ./package nypl-py-utils[postgresql-client]==1.4.0
pip install --target ./package nypl-py-utils[postgresql-client]==1.5.0

pip install \
--platform manylinux2014_x86_64 \
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "nypl_py_utils"
version = "1.4.0"
version = "1.5.0"
authors = [
{ name="Aaron Friedman", email="[email protected]" },
]
Expand All @@ -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
149 changes: 149 additions & 0 deletions src/nypl_py_utils/classes/cloudlibrary_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
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

# authenticate & 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))

def get_library_events(self, start_date=None,
end_date=None) -> requests.Response:
"""
Retrieves all the events related to library-owned items within the
optional timeframe. Pulls past 24 hours of events by default.
start_date and end_date are optional parameters, and must be
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 (datetime.strptime(start_date, date_format) >
datetime.strptime(end_date, date_format)):
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="GET",
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.
Returns Response object by default -- you will need to parse this
object to retrieve response text, status codes, etc.
"""
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 == "PUT":
response = self.session.put(url=url,
data=body,
headers=headers,
timeout=60)
elif method_type == "POST":
response = self.session.post(url=url,
data=body,
headers=headers,
timeout=60)
else:
response = self.session.get(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}: "
f"{repr(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()
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
Loading

0 comments on commit 30b18f4

Please sign in to comment.