Skip to content
Merged
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "folioclient"
version = "1.0.3"
version = "1.0.4"
description = "An API wrapper over the FOLIO LSP API Suite (Formerly OKAPI)."
authors = [
{ name = "Theodor Tolstoy", email = "github.teddes@tolstoy.se" },
Expand Down
58 changes: 41 additions & 17 deletions src/folioclient/FolioClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,6 @@
else:
_HAS_ORJSON = False

def _orjson_loads(data):
return orjson.loads(data)

def _orjson_dumps(obj): # noqa: F841
return orjson.dumps(obj).decode("utf-8")

# Define exception tuples for different operations
JSON_DECODE_ERRORS = (json.JSONDecodeError, orjson.JSONDecodeError) # type: ignore
JSON_ENCODE_ERRORS = (TypeError, orjson.JSONEncodeError) # type: ignore
Expand Down Expand Up @@ -1349,7 +1343,7 @@ def handle_json_response(response) -> Any:
"""
try:
if _HAS_ORJSON:
return _orjson_loads(response.content)
return orjson.loads(response.content)
else:
return response.json()
except JSON_DECODE_ERRORS: # Catch both JSONDecodeError types
Expand Down Expand Up @@ -1450,7 +1444,7 @@ def handle_delete_response(response, path: str) -> Any:

try:
if _HAS_ORJSON:
return _orjson_loads(response.content)
return orjson.loads(response.content)
else:
return response.json()
except JSON_DECODE_ERRORS: # Catch both JSONDecodeError types
Expand Down Expand Up @@ -1913,7 +1907,7 @@ def folio_put(

Args:
path (str): FOLIO API endpoint path.
payload (dict): The data to update as JSON.
payload (dict or str): The data to update as JSON dict or JSON string.
query_params (dict, optional): Additional query parameters. Defaults to None.

Returns:
Expand All @@ -1934,9 +1928,10 @@ def folio_put(
"""
# Ensure path doesn't start with / for httpx base_url to work properly
path = path.lstrip("/")
payload = prepare_payload(payload)
req = self.httpx_client.put(
path,
json=payload,
data=payload,
params=query_params,
)
req.raise_for_status()
Expand All @@ -1953,17 +1948,18 @@ async def folio_put_async(

Args:
path (str): FOLIO API endpoint path.
payload (dict): The data to update as JSON.
payload (dict or str): The data to update as JSON dict or JSON string.
query_params (dict, optional): Additional query parameters. Defaults to None.

Returns:
dict: The JSON response from FOLIO.
None: If the response is empty.
"""
path = path.lstrip("/")
payload = prepare_payload(payload)
req = await self.async_httpx_client.put(
path,
json=payload,
data=payload,
params=query_params,
)
req.raise_for_status()
Expand All @@ -1980,7 +1976,7 @@ def folio_post(

Args:
path (str): FOLIO API endpoint path.
payload (dict): The data to post as JSON.
payload (dict or str): The data to post as JSON dict or JSON string.
query_params (dict, optional): Additional query parameters. Defaults to None.

Returns:
Expand All @@ -2000,9 +1996,10 @@ def folio_post(
"""
# Ensure path doesn't start with / for httpx base_url to work properly
path = path.lstrip("/")
payload = prepare_payload(payload)
req = self.httpx_client.post(
path,
json=payload,
data=payload,
params=query_params,
)
req.raise_for_status()
Expand All @@ -2019,7 +2016,7 @@ async def folio_post_async(

Args:
path (str): FOLIO API endpoint path.
payload (dict): The data to post as JSON.
payload (dict or str): The data to post as JSON dict or JSON string.
query_params (dict, optional): Additional query parameters. Defaults to None.

Returns:
Expand All @@ -2028,9 +2025,10 @@ async def folio_post_async(
"""
# Ensure path doesn't start with / for httpx base_url to work properly
path = path.lstrip("/")
payload = prepare_payload(payload)
req = await self.async_httpx_client.post(
path,
json=payload,
data=payload,
params=query_params,
)
req.raise_for_status()
Expand Down Expand Up @@ -2424,8 +2422,34 @@ def get_loan_policy_hash(item_type_id, loan_type_id, patron_type_id, shelving_lo
)


def validate_uuid(my_uuid) -> bool:
def validate_uuid(my_uuid: str) -> bool:
"""Validates that a string is a valid UUID"""
reg = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" # noqa
pattern = re.compile(reg)
return bool(pattern.match(my_uuid))


def prepare_payload(payload: Dict | str) -> bytes:
"""Prepares a payload for sending to FOLIO by converting it to JSON bytes.

Uses orjson for faster encoding if available, otherwise falls back to
the standard json library.

Args:
payload (dict or str): The payload to prepare.

Returns:
bytes: The JSON-encoded payload as bytes.

Raises:
TypeError: If the payload is not a dict or str.
"""
if isinstance(payload, dict):
if _HAS_ORJSON:
return orjson.dumps(payload)
else:
return json.dumps(payload).encode("utf-8")
elif isinstance(payload, str):
return payload.encode("utf-8")
else:
raise TypeError(f"Payload must be a dictionary or a string, got {type(payload).__name__}")
8 changes: 8 additions & 0 deletions src/folioclient/_httpx.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from datetime import datetime, timedelta, timezone
import logging
import threading
import httpx

Expand All @@ -12,6 +13,8 @@
from collections.abc import AsyncGenerator, Generator
import ssl

logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class FolioConnectionParameters:
Expand Down Expand Up @@ -66,6 +69,7 @@ def tenant_id(self) -> str:
@tenant_id.setter
def tenant_id(self, value: str):
if value != self._tenant_id:
logger.debug("Switching tenant_id from %s to %s", self._tenant_id, value)
self._tenant_id = value

def reset_tenant_id(self):
Expand All @@ -88,6 +92,7 @@ def sync_auth_flow(
response = yield request

if response.status_code == HTTPStatus.UNAUTHORIZED:
logger.debug("Received 401 Unauthorized, refreshing token")
with self._lock:
if self._token and not self._token_is_expiring():
# Another thread refreshed the token while we were waiting for the lock
Expand Down Expand Up @@ -124,6 +129,7 @@ async def async_auth_flow(
response = yield request

if response.status_code == HTTPStatus.UNAUTHORIZED:
logger.debug("Received 401 Unauthorized, refreshing token")
with self._lock:
if self._token and not self._token_is_expiring():
# Another thread refreshed the token while we were waiting for the lock
Expand Down Expand Up @@ -154,6 +160,7 @@ def _do_sync_auth(self) -> _Token:
auth_data = {"username": self._params.username, "password": self._params.password}

with httpx.Client(timeout=self._params.timeout, verify=self._params.ssl_verify) as client:
logger.debug("Authenticating synchronously with URL: %s", auth_url)
response = client.post(auth_url, json=auth_data, headers=headers)
response.raise_for_status()

Expand Down Expand Up @@ -193,6 +200,7 @@ async def _do_async_auth(self) -> _Token:
async with httpx.AsyncClient(
timeout=self._params.timeout, verify=self._params.ssl_verify
) as client:
logger.debug("Authenticating asynchronously with URL: %s", auth_url)
response = await client.post(auth_url, json=auth_data, headers=headers)
response.raise_for_status()

Expand Down
Loading