From 648e49c65ad6eaaabe824957a96f3480012b3688 Mon Sep 17 00:00:00 2001 From: Brooks Travis Date: Sat, 13 Dec 2025 22:35:32 -0600 Subject: [PATCH 1/7] Add debug logging to FolioAuth for tenant ID setting and authentication flows --- src/folioclient/_httpx.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/folioclient/_httpx.py b/src/folioclient/_httpx.py index dc5f383..09602cc 100644 --- a/src/folioclient/_httpx.py +++ b/src/folioclient/_httpx.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone +import logging import threading import httpx @@ -12,6 +13,8 @@ from collections.abc import AsyncGenerator, Generator import ssl +logger = logging.getLogger(__name__) + @dataclass(frozen=True) class FolioConnectionParameters: @@ -65,6 +68,7 @@ def tenant_id(self) -> str: @tenant_id.setter def tenant_id(self, value: str): + logger.debug("Setting tenant_id to %s", value) if value != self._tenant_id: self._tenant_id = value @@ -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 @@ -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 @@ -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() @@ -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() From cb65a23302ba313caf9ed60802dcaf4db2731299 Mon Sep 17 00:00:00 2001 From: Brooks Travis Date: Sat, 13 Dec 2025 22:38:39 -0600 Subject: [PATCH 2/7] Refactor payload handling in FolioClient PUT and POST helper methods to support dict or str payload arguments. - Added helper function properly convert provided payload object encoded bytes - Refactor orjson usage to directly call orjson.dumps and orjson.loads and remove private functions - Minor updates to type annotations --- src/folioclient/FolioClient.py | 55 +++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/src/folioclient/FolioClient.py b/src/folioclient/FolioClient.py index 66484d6..5543ff8 100644 --- a/src/folioclient/FolioClient.py +++ b/src/folioclient/FolioClient.py @@ -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 @@ -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 @@ -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 @@ -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: @@ -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() @@ -1953,7 +1948,7 @@ 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: @@ -1961,9 +1956,10 @@ async def folio_put_async( 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() @@ -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: @@ -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() @@ -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: @@ -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() @@ -2424,8 +2422,31 @@ 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. + """ + 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("Payload must be a dictionary or a string.") From cf1d98bf50250c425628963e97cfc85845c6b6eb Mon Sep 17 00:00:00 2001 From: Brooks Travis Date: Sat, 13 Dec 2025 22:40:31 -0600 Subject: [PATCH 3/7] Add tests for prepare_payload function to validate input handling and encoding --- tests/test_folio_client.py | 81 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tests/test_folio_client.py b/tests/test_folio_client.py index 9adcb34..fb9ca91 100644 --- a/tests/test_folio_client.py +++ b/tests/test_folio_client.py @@ -1,3 +1,4 @@ +import json import pytest from httpx import HTTPError, UnsupportedProtocol from unittest.mock import Mock, patch, MagicMock, AsyncMock @@ -734,3 +735,83 @@ def test_http_client_creation_with_no_timeout(): assert async_client.timeout.read is None assert async_client.timeout.write is None assert async_client.timeout.pool is None + + +class TestPreparePayload: + """Tests for the prepare_payload helper function.""" + + def test_prepare_payload_with_dict(self): + """Test that prepare_payload correctly encodes a dictionary to JSON bytes.""" + from folioclient.FolioClient import prepare_payload + + payload = {"key": "value", "number": 42} + result = prepare_payload(payload) + + assert isinstance(result, bytes) + # Verify the JSON is valid by decoding + decoded = json.loads(result) + assert decoded == payload + + def test_prepare_payload_with_string(self): + """Test that prepare_payload correctly encodes a string to bytes.""" + from folioclient.FolioClient import prepare_payload + + payload = '{"key": "value"}' + result = prepare_payload(payload) + + assert isinstance(result, bytes) + assert result == payload.encode("utf-8") + + def test_prepare_payload_with_invalid_type(self): + """Test that prepare_payload raises TypeError for invalid input types.""" + from folioclient.FolioClient import prepare_payload + + with pytest.raises(TypeError, match="Payload must be a dictionary or a string"): + prepare_payload([1, 2, 3]) + + with pytest.raises(TypeError, match="Payload must be a dictionary or a string"): + prepare_payload(42) + + with pytest.raises(TypeError, match="Payload must be a dictionary or a string"): + prepare_payload(None) + + def test_prepare_payload_uses_orjson_when_available(self): + """Test that prepare_payload uses orjson when available for dicts.""" + from folioclient.FolioClient import prepare_payload, _HAS_ORJSON + + payload = {"key": "value", "nested": {"data": [1, 2, 3]}} + result = prepare_payload(payload) + + # Result should always be valid JSON bytes + assert isinstance(result, bytes) + decoded = json.loads(result) + assert decoded == payload + + # If orjson is available, verify the module can be imported + if _HAS_ORJSON: + import orjson + # orjson.dumps returns bytes directly + orjson_result = orjson.dumps(payload) + assert isinstance(orjson_result, bytes) + + def test_prepare_payload_with_unicode_string(self): + """Test that prepare_payload correctly handles unicode strings.""" + from folioclient.FolioClient import prepare_payload + + payload = '{"name": "Müller", "emoji": "🎉"}' + result = prepare_payload(payload) + + assert isinstance(result, bytes) + assert result == payload.encode("utf-8") + + def test_prepare_payload_with_unicode_dict(self): + """Test that prepare_payload correctly handles dicts with unicode values.""" + from folioclient.FolioClient import prepare_payload + + payload = {"name": "Müller", "emoji": "🎉", "chinese": "你好"} + result = prepare_payload(payload) + + assert isinstance(result, bytes) + # Verify the JSON is valid and preserves unicode + decoded = json.loads(result) + assert decoded == payload From 27da8efc3519c7f569f7fa54a4d46cae43818f11 Mon Sep 17 00:00:00 2001 From: Brooks Travis Date: Sat, 13 Dec 2025 22:40:57 -0600 Subject: [PATCH 4/7] Bump version to 1.0.4 in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 78175b1..0f980eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" }, From f83dd82adc8302baaf25e0e478b3d627d8d5170a Mon Sep 17 00:00:00 2001 From: Brooks Travis Date: Sat, 13 Dec 2025 23:37:47 -0600 Subject: [PATCH 5/7] Add tests for folio_post and folio_put methods with dict and string payloads --- tests/test_folio_client.py | 201 +++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) diff --git a/tests/test_folio_client.py b/tests/test_folio_client.py index fb9ca91..4cd0241 100644 --- a/tests/test_folio_client.py +++ b/tests/test_folio_client.py @@ -815,3 +815,204 @@ def test_prepare_payload_with_unicode_dict(self): # Verify the JSON is valid and preserves unicode decoded = json.loads(result) assert decoded == payload + + +@patch.object(FolioClient, '_initial_ecs_check') +class TestPostPutPayloadTypes: + """Tests for folio_post and folio_put with different payload types.""" + + def test_folio_post_with_dict_payload(self, mock_ecs_check): + """Test that folio_post works with dict payload.""" + with folio_auth_patcher() as mock_folio_auth: + mock_auth_instance = Mock() + mock_auth_instance.tenant_id = "test_tenant" + mock_folio_auth.return_value = mock_auth_instance + + with patch.object(FolioClient, 'get_folio_http_client') as mock_get_client: + # Mock the httpx client with context manager support + mock_client = MagicMock() + mock_response = Mock() + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "123", "name": "test"} + mock_response.content = b'{"id": "123", "name": "test"}' + mock_response.raise_for_status.return_value = None + mock_client.post.return_value = mock_response + mock_client.is_closed = False + mock_client.__enter__.return_value = mock_client + mock_client.__exit__.return_value = False + mock_get_client.return_value = mock_client + + fc = FolioClient("https://example.com", "test_tenant", "user", "pass") + + payload = {"name": "test", "value": 42} + result = fc.folio_post("/test", payload) + + # Verify the call was made with bytes data + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + assert call_args[0][0] == "test" + assert isinstance(call_args[1]["data"], bytes) + assert result == {"id": "123", "name": "test"} + + def test_folio_post_with_string_payload(self, mock_ecs_check): + """Test that folio_post works with JSON string payload.""" + with folio_auth_patcher() as mock_folio_auth: + mock_auth_instance = Mock() + mock_auth_instance.tenant_id = "test_tenant" + mock_folio_auth.return_value = mock_auth_instance + + with patch.object(FolioClient, 'get_folio_http_client') as mock_get_client: + # Mock the httpx client with context manager support + mock_client = MagicMock() + mock_response = Mock() + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "456", "status": "created"} + mock_response.content = b'{"id": "456", "status": "created"}' + mock_response.raise_for_status.return_value = None + mock_client.post.return_value = mock_response + mock_client.is_closed = False + mock_client.__enter__.return_value = mock_client + mock_client.__exit__.return_value = False + mock_get_client.return_value = mock_client + + fc = FolioClient("https://example.com", "test_tenant", "user", "pass") + + payload = '{"name": "test", "value": 42}' + result = fc.folio_post("/test", payload) + + # Verify the call was made with bytes data + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + assert isinstance(call_args[1]["data"], bytes) + assert call_args[1]["data"] == payload.encode("utf-8") + assert result == {"id": "456", "status": "created"} + + def test_folio_put_with_dict_payload(self, mock_ecs_check): + """Test that folio_put works with dict payload.""" + with folio_auth_patcher() as mock_folio_auth: + mock_auth_instance = Mock() + mock_auth_instance.tenant_id = "test_tenant" + mock_folio_auth.return_value = mock_auth_instance + + with patch.object(FolioClient, 'get_folio_http_client') as mock_get_client: + # Mock the httpx client with context manager support + mock_client = MagicMock() + mock_response = Mock() + mock_response.status_code = 204 + mock_response.json.return_value = None + mock_response.content = b'' + mock_response.raise_for_status.return_value = None + mock_client.put.return_value = mock_response + mock_client.is_closed = False + mock_client.__enter__.return_value = mock_client + mock_client.__exit__.return_value = False + mock_get_client.return_value = mock_client + + fc = FolioClient("https://example.com", "test_tenant", "user", "pass") + + payload = {"id": "123", "name": "updated"} + result = fc.folio_put("/test/123", payload) + + # Verify the call was made with bytes data + mock_client.put.assert_called_once() + call_args = mock_client.put.call_args + assert call_args[0][0] == "test/123" + assert isinstance(call_args[1]["data"], bytes) + + def test_folio_put_with_string_payload(self, mock_ecs_check): + """Test that folio_put works with JSON string payload.""" + with folio_auth_patcher() as mock_folio_auth: + mock_auth_instance = Mock() + mock_auth_instance.tenant_id = "test_tenant" + mock_folio_auth.return_value = mock_auth_instance + + with patch.object(FolioClient, 'get_folio_http_client') as mock_get_client: + # Mock the httpx client with context manager support + mock_client = MagicMock() + mock_response = Mock() + mock_response.status_code = 204 + mock_response.json.return_value = None + mock_response.content = b'' + mock_response.raise_for_status.return_value = None + mock_client.put.return_value = mock_response + mock_client.is_closed = False + mock_client.__enter__.return_value = mock_client + mock_client.__exit__.return_value = False + mock_get_client.return_value = mock_client + + fc = FolioClient("https://example.com", "test_tenant", "user", "pass") + + payload = '{"id": "123", "name": "updated"}' + result = fc.folio_put("/test/123", payload) + + # Verify the call was made with bytes data + mock_client.put.assert_called_once() + call_args = mock_client.put.call_args + assert isinstance(call_args[1]["data"], bytes) + assert call_args[1]["data"] == payload.encode("utf-8") + + @pytest.mark.asyncio + async def test_folio_post_async_with_dict_payload(self, mock_ecs_check): + """Test that folio_post_async works with dict payload.""" + with folio_auth_patcher() as mock_folio_auth: + mock_auth_instance = Mock() + mock_auth_instance.tenant_id = "test_tenant" + mock_folio_auth.return_value = mock_auth_instance + + with patch.object(FolioClient, 'get_folio_http_client_async') as mock_get_client: + # Mock the async httpx client with async context manager support + mock_client = MagicMock() + mock_response = Mock() + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "789", "async": True} + mock_response.content = b'{"id": "789", "async": true}' + mock_response.raise_for_status = Mock(return_value=None) + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.is_closed = False + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_get_client.return_value = mock_client + + fc = FolioClient("https://example.com", "test_tenant", "user", "pass") + + payload = {"name": "async_test", "value": 100} + result = await fc.folio_post_async("/test", payload) + + # Verify the call was made with bytes data + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + assert isinstance(call_args[1]["data"], bytes) + assert result == {"id": "789", "async": True} + + @pytest.mark.asyncio + async def test_folio_put_async_with_string_payload(self, mock_ecs_check): + """Test that folio_put_async works with JSON string payload.""" + with folio_auth_patcher() as mock_folio_auth: + mock_auth_instance = Mock() + mock_auth_instance.tenant_id = "test_tenant" + mock_folio_auth.return_value = mock_auth_instance + + with patch.object(FolioClient, 'get_folio_http_client_async') as mock_get_client: + # Mock the async httpx client with async context manager support + mock_client = MagicMock() + mock_response = Mock() + mock_response.status_code = 204 + mock_response.json.return_value = None + mock_response.content = b'' + mock_response.raise_for_status = Mock(return_value=None) + mock_client.put = AsyncMock(return_value=mock_response) + mock_client.is_closed = False + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_get_client.return_value = mock_client + + fc = FolioClient("https://example.com", "test_tenant", "user", "pass") + + payload = '{"id": "999", "name": "async_updated"}' + result = await fc.folio_put_async("/test/999", payload) + + # Verify the call was made with bytes data + mock_client.put.assert_called_once() + call_args = mock_client.put.call_args + assert isinstance(call_args[1]["data"], bytes) + assert call_args[1]["data"] == payload.encode("utf-8") From 203892612b958f3cfd461470072f44687c3fddb5 Mon Sep 17 00:00:00 2001 From: Brooks Travis Date: Sat, 13 Dec 2025 23:54:37 -0600 Subject: [PATCH 6/7] Add type error handling to prepare_payload function and enhance debug logging in FolioAuth --- src/folioclient/FolioClient.py | 3 +++ src/folioclient/_httpx.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/folioclient/FolioClient.py b/src/folioclient/FolioClient.py index 5543ff8..0616842 100644 --- a/src/folioclient/FolioClient.py +++ b/src/folioclient/FolioClient.py @@ -2440,6 +2440,9 @@ def prepare_payload(payload: Dict | str) -> bytes: 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: diff --git a/src/folioclient/_httpx.py b/src/folioclient/_httpx.py index 09602cc..c9c3118 100644 --- a/src/folioclient/_httpx.py +++ b/src/folioclient/_httpx.py @@ -68,8 +68,8 @@ def tenant_id(self) -> str: @tenant_id.setter def tenant_id(self, value: str): - logger.debug("Setting tenant_id to %s", value) if value != self._tenant_id: + logger.debug("Setting tenant_id to %s", value) self._tenant_id = value def reset_tenant_id(self): From 919a15bb7aa3f213321bd553a0d9b64fb4aae6eb Mon Sep 17 00:00:00 2001 From: Brooks Travis Date: Mon, 15 Dec 2025 01:17:57 -0600 Subject: [PATCH 7/7] Enhance debug logging in FolioAuth and improve error message in prepare_payload function --- src/folioclient/FolioClient.py | 2 +- src/folioclient/_httpx.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/folioclient/FolioClient.py b/src/folioclient/FolioClient.py index 0616842..a6cb36a 100644 --- a/src/folioclient/FolioClient.py +++ b/src/folioclient/FolioClient.py @@ -2452,4 +2452,4 @@ def prepare_payload(payload: Dict | str) -> bytes: elif isinstance(payload, str): return payload.encode("utf-8") else: - raise TypeError("Payload must be a dictionary or a string.") + raise TypeError(f"Payload must be a dictionary or a string, got {type(payload).__name__}") diff --git a/src/folioclient/_httpx.py b/src/folioclient/_httpx.py index c9c3118..69be025 100644 --- a/src/folioclient/_httpx.py +++ b/src/folioclient/_httpx.py @@ -69,7 +69,7 @@ def tenant_id(self) -> str: @tenant_id.setter def tenant_id(self, value: str): if value != self._tenant_id: - logger.debug("Setting tenant_id to %s", value) + logger.debug("Switching tenant_id from %s to %s", self._tenant_id, value) self._tenant_id = value def reset_tenant_id(self):