From 3ec3ef3de89d1d34f80e39afc8812d00c9989b77 Mon Sep 17 00:00:00 2001 From: Lana Date: Tue, 27 Aug 2024 19:20:52 -0400 Subject: [PATCH] feat(pagination): Refactor FHIRSearch with iterable Bundle, add iter to Bundle, move pagination logic to _utils.py, add tests Simplify url sanitization, adjust testse Replace redundant code on perform() Add tests perform-iter and perform_resources_iter via Response Revert fhir-parser submodule to original commit --- fhirclient/_utils.py | 111 ++++++++ fhirclient/client.py | 122 --------- fhirclient/models/bundle.py | 2 +- fhirclient/models/fhirsearch.py | 68 +++-- pyproject.toml | 1 + tests/client_pagination_test.py | 176 ------------ tests/data/examples/bundle-example-page2.json | 65 +++++ tests/models/fhirsearch_perform_iter_test.py | 255 ++++++++++++++++++ tests/utils_pagination_test.py | 172 ++++++++++++ 9 files changed, 656 insertions(+), 316 deletions(-) create mode 100644 fhirclient/_utils.py delete mode 100644 tests/client_pagination_test.py create mode 100644 tests/data/examples/bundle-example-page2.json create mode 100644 tests/models/fhirsearch_perform_iter_test.py create mode 100644 tests/utils_pagination_test.py diff --git a/fhirclient/_utils.py b/fhirclient/_utils.py new file mode 100644 index 000000000..dbe59a1e8 --- /dev/null +++ b/fhirclient/_utils.py @@ -0,0 +1,111 @@ +import urllib +from typing import Optional + +import requests + +from typing import TYPE_CHECKING, Iterable + +if TYPE_CHECKING: + from fhirclient.server import FHIRServer + from fhirclient.models.bundle import Bundle + + +# Use forward references to avoid circular imports +def _fetch_next_page(bundle: 'Bundle', server: 'FHIRServer') -> Optional['Bundle']: + """ + Fetch the next page of results using the `next` link provided in the bundle. + + Args: + bundle (Bundle): The FHIR Bundle containing the `next` link. + server (FHIRServer): The FHIR server instance for handling requests and authentication. + + Returns: + Optional[Bundle]: The next page of results as a FHIR Bundle, or None if no "next" link is found. + """ + if next_link := _get_next_link(bundle): + return _execute_pagination_request(next_link, server) + return None + + +def _get_next_link(bundle: 'Bundle') -> Optional[str]: + """ + Extract the `next` link from the Bundle's links. + + Args: + bundle (Bundle): The FHIR Bundle containing pagination links. + + Returns: + Optional[str]: The URL of the next page if available, None otherwise. + """ + if not bundle.link: + return None + + for link in bundle.link: + if link.relation == "next": + return _sanitize_next_link(link.url) + return None + + +def _sanitize_next_link(next_link: str) -> str: + """ + Sanitize the `next` link by validating its scheme and hostname against the origin server. + + This function ensures the `next` link URL uses a valid scheme (`http` or `https`) and that it contains a + hostname. This provides a basic safeguard against malformed URLs without overly restricting flexibility. + + Args: + next_link (str): The raw `next` link URL. + + Returns: + str: The validated URL. + + Raises: + ValueError: If the URL's scheme is not `http` or `https`, or if the hostname does not match the origin server. + """ + + parsed_url = urllib.parse.urlparse(next_link) + + # Validate scheme and netloc (domain) + if parsed_url.scheme not in ["http", "https"]: + raise ValueError("Invalid URL scheme in `next` link.") + if not parsed_url.netloc: + raise ValueError("Invalid URL domain in `next` link.") + + return next_link + + +def _execute_pagination_request(sanitized_url: str, server: 'FHIRServer') -> 'Bundle': + """ + Execute the request to retrieve the next page using the sanitized URL via Bundle.read_from. + + Args: + sanitized_url (str): The sanitized URL to fetch the next page. + server (FHIRServer): The FHIR server instance to perform the request. + + Returns: + Bundle: The next page of results as a FHIR Bundle. + + Raises: + HTTPError: If the request fails due to network issues or server errors. + """ + from fhirclient.models.bundle import Bundle + return Bundle.read_from(sanitized_url, server) + + +def iter_pages(first_bundle: 'Bundle', server: 'FHIRServer') -> Iterable['Bundle']: + """ + Iterator that yields each page of results as a FHIR Bundle. + + Args: + first_bundle (Optional[Bundle]): The first Bundle to start pagination. + server (FHIRServer): The FHIR server instance to perform the request. + + Yields: + Bundle: Each page of results as a FHIR Bundle. + """ + # Since _fetch_next_page can return None + bundle: Optional[Bundle] = first_bundle + while bundle: + yield bundle + bundle = _fetch_next_page(bundle, server) + diff --git a/fhirclient/client.py b/fhirclient/client.py index a0dbec515..b2473a8b7 100644 --- a/fhirclient/client.py +++ b/fhirclient/client.py @@ -1,10 +1,4 @@ import logging -import urllib -from typing import Optional, Iterable - -import requests -from .models.bundle import Bundle - from .server import FHIRServer, FHIRUnauthorizedException, FHIRNotFoundException __version__ = '4.3.0' @@ -244,119 +238,3 @@ def from_state(self, state): def save_state (self): self._save_func(self.state) - - # MARK: Pagination - def _fetch_next_page(self, bundle: Bundle) -> Optional[Bundle]: - """ - Fetch the next page of results using the `next` link provided in the bundle. - - Args: - bundle (Bundle): The FHIR Bundle containing the `next` link. - - Returns: - Optional[Bundle]: The next page of results as a FHIR Bundle, or None if no "next" link is found. - """ - next_link = self._get_next_link(bundle) - if next_link: - sanitized_next_link = self._sanitize_next_link(next_link) - return self._execute_pagination_request(sanitized_next_link) - return None - - def _get_next_link(self, bundle: Bundle) -> Optional[str]: - """ - Extract the `next` link from the Bundle's links. - - Args: - bundle (Bundle): The FHIR Bundle containing pagination links. - - Returns: - Optional[str]: The URL of the next page if available, None otherwise. - """ - if not bundle.link: - return None - - for link in bundle.link: - if link.relation == "next": - return link.url - return None - - def _sanitize_next_link(self, next_link: str) -> str: - """ - Sanitize the `next` link to ensure it is safe to use. - - Args: - next_link (str): The raw `next` link URL. - - Returns: - str: The sanitized URL. - - Raises: - ValueError: If the URL scheme or domain is invalid. - """ - parsed_url = urllib.parse.urlparse(next_link) - - # Validate scheme and netloc (domain) - if parsed_url.scheme not in ["http", "https"]: - raise ValueError("Invalid URL scheme in `next` link.") - if not parsed_url.netloc: - raise ValueError("Invalid URL domain in `next` link.") - - # Additional sanitization if necessary, e.g., removing dangerous query parameters - query_params = urllib.parse.parse_qs(parsed_url.query) - sanitized_query = {k: v for k, v in query_params.items()} - - # Rebuild the sanitized URL - sanitized_url = urllib.parse.urlunparse( - ( - parsed_url.scheme, - parsed_url.netloc, - parsed_url.path, - parsed_url.params, - urllib.parse.urlencode(sanitized_query, doseq=True), - parsed_url.fragment, - ) - ) - - return sanitized_url - - def _execute_pagination_request(self, sanitized_url: str) -> Optional[Bundle]: - """ - Execute the request to retrieve the next page using the sanitized URL. - - Args: - sanitized_url (str): The sanitized URL to fetch the next page. - - Returns: - Optional[Bundle]: The next page of results as a FHIR Bundle, or None. - - Raises: - HTTPError: If the request fails due to network issues or server errors. - """ - try: - # Use requests.get directly to make the HTTP request - response = requests.get(sanitized_url) - response.raise_for_status() - next_bundle_data = response.json() - next_bundle = Bundle(next_bundle_data) - - return next_bundle - - except requests.exceptions.HTTPError as e: - # Handle specific HTTP errors as needed, possibly including retry logic - raise e - - def iter_pages(self, first_bundle: Bundle) -> Iterable[Bundle]: - """ - Iterator that yields each page of results as a FHIR Bundle. - - Args: - first_bundle (Bundle): The first Bundle to start pagination. - - Yields: - Bundle: Each page of results as a FHIR Bundle. - """ - bundle = first_bundle - while bundle: - yield bundle - bundle = self._fetch_next_page(bundle) - diff --git a/fhirclient/models/bundle.py b/fhirclient/models/bundle.py index 637aca37f..a86521972 100644 --- a/fhirclient/models/bundle.py +++ b/fhirclient/models/bundle.py @@ -50,7 +50,7 @@ def __init__(self, jsondict=None, strict=True): Type `str`. """ super(Bundle, self).__init__(jsondict=jsondict, strict=strict) - + def elementProperties(self): js = super(Bundle, self).elementProperties() js.extend([ diff --git a/fhirclient/models/fhirsearch.py b/fhirclient/models/fhirsearch.py index af1ac4a8e..856cb5803 100644 --- a/fhirclient/models/fhirsearch.py +++ b/fhirclient/models/fhirsearch.py @@ -5,14 +5,22 @@ # 2014, SMART Health IT. import logging +import warnings +from typing import Iterator, TYPE_CHECKING + from . import fhirreference +from .._utils import iter_pages try: from urllib import quote_plus except Exception as e: from urllib.parse import quote_plus +if TYPE_CHECKING: + from fhirclient.models.resource import Resource + from fhirclient.models.bundle import Bundle + logger = logging.getLogger(__name__) @@ -110,35 +118,61 @@ def include(self, reference_field, reference_model=None, reverse=False): self.includes.append((reference_model, reference_field, reverse)) return self - def perform(self, server): + def perform(self, server) -> 'Bundle': """ Construct the search URL and execute it against the given server. :param server: The server against which to perform the search :returns: A Bundle resource """ + # Old method with deprecation warning + warnings.warn( + "perform() is deprecated and will be removed in a future release. " + "Please use perform_iter() instead.", + DeprecationWarning, + ) + if server is None: raise Exception("Need a server to perform search") - - from . import bundle - res = server.request_json(self.construct()) - bundle = bundle.Bundle(res) - bundle.origin_server = server - return bundle - - def perform_resources(self, server): - """ Performs the search by calling `perform`, then extracts all Bundle - entries and returns a list of Resource instances. + + from .bundle import Bundle + return Bundle.read_from(self.construct(), server) + + # Use forward references to avoid circular imports + def perform_iter(self, server) -> Iterator['Bundle']: + """ Perform the search by calling `perform` and return an iterator that yields + Bundle instances. + + :param server: The server against which to perform the search + :returns: An iterator of Bundle instances + """ + return iter_pages(self.perform(server), server) + + def perform_resources(self, server) -> 'list[Resource]': + """ Performs the search by calling `perform_resources_iter` and returns a list of Resource instances. :param server: The server against which to perform the search :returns: A list of Resource instances """ - bundle = self.perform(server) - resources = [] - if bundle is not None and bundle.entry is not None: + # Old method with deprecation warning + warnings.warn( + "perform_resources() is deprecated and will be removed in a future release. " + "Please use perform_resources_iter() instead.", + DeprecationWarning, + ) + + return list(self.perform_resources_iter(server)) + + # Use forward references to avoid circular imports + def perform_resources_iter(self, server) -> Iterator['Resource']: + """ Performs the search by calling `perform_iter` and yields Resource instances + from each Bundle returned by the search. + + :param server: The server against which to perform the search + :returns: An iterator of Resource instances + """ + for bundle in self.perform_iter(server): for entry in bundle.entry: - resources.append(entry.resource) - - return resources + yield entry.resource class FHIRSearchParam(object): diff --git a/pyproject.toml b/pyproject.toml index c90286a9a..d18cfc926 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,4 +35,5 @@ testpaths = "tests" tests = [ "pytest >= 2.5", "pytest-cov", + "responses", ] diff --git a/tests/client_pagination_test.py b/tests/client_pagination_test.py deleted file mode 100644 index 5bf76ad8f..000000000 --- a/tests/client_pagination_test.py +++ /dev/null @@ -1,176 +0,0 @@ -import unittest -from unittest.mock import patch, MagicMock - -import requests -from fhirclient.models.bundle import Bundle - -from fhirclient.client import FHIRClient - - -class TestFHIRClientPagination(unittest.TestCase): - def setUp(self) -> None: - state = { - "app_id": "AppID", - "app_secret": "AppSecret", - "scope": "user/*.read", - "redirect": "http://test.invalid/redirect", - "patient_id": "PatientID", - "server": { - "base_uri": "http://test.invalid/", - "auth_type": "none", - "auth": { - "app_id": "AppId", - }, - }, - "launch_token": "LaunchToken", - "launch_context": { - "encounter": "EncounterID", - "patient": "PatientID", - }, - "jwt_token": "JwtToken", - } - - # Confirm round trip - self.client = FHIRClient(state=state) - - self.bundle = { - "resourceType": "Bundle", - "type": "searchset", - "link": [ - {"relation": "self", "url": "http://example.com/fhir/Bundle/1"}, - {"relation": "next", "url": "http://example.com/fhir/Bundle/2"}, - ], - "entry": [ - { - "fullUrl": "http://example.com/fhir/Patient/1", - "resource": { - "resourceType": "Patient", - "id": "1", - "name": [{"family": "Doe", "given": ["John"]}], - "gender": "male", - "birthDate": "1980-01-01", - }, - }, - { - "fullUrl": "http://example.com/fhir/Patient/2", - "resource": { - "resourceType": "Patient", - "id": "2", - "name": [{"family": "Smith", "given": ["Jane"]}], - "gender": "female", - "birthDate": "1990-05-15", - }, - }, - ], - } - - def test_get_next_link(self): - next_link = self.client._get_next_link(Bundle(self.bundle)) - self.assertEqual(next_link, "http://example.com/fhir/Bundle/2") - - def test_get_next_link_no_next(self): - bundle_without_next = { - "resourceType": "Bundle", - "type": "searchset", - "link": [{"relation": "self", "url": "http://example.com/fhir/Bundle/1"}], - } - bundle = Bundle(bundle_without_next) - next_link = self.client._get_next_link(bundle) - self.assertIsNone(next_link) - - def test_sanitize_next_link_valid(self): - next_link = "http://example.com/fhir/Bundle/2?page=2&size=10" - sanitized_link = self.client._sanitize_next_link(next_link) - self.assertEqual(sanitized_link, next_link) - - def test_sanitize_next_link_invalid_scheme(self): - next_link = "ftp://example.com/fhir/Bundle/2?page=2&size=10" - with self.assertRaises(ValueError): - self.client._sanitize_next_link(next_link) - - def test_sanitize_next_link_invalid_domain(self): - next_link = "http:///fhir/Bundle/2?page=2&size=10" - with self.assertRaises(ValueError): - self.client._sanitize_next_link(next_link) - - @patch("requests.get") - def test_execute_pagination_request_success(self, mock_get): - mock_response = MagicMock() - # Set up the mock to return a specific JSON payload when its json() method is called - mock_response.json.return_value = self.bundle - mock_response.raise_for_status = MagicMock() - mock_get.return_value = mock_response - - next_link = "http://example.com/fhir/Bundle/2" - sanitized_link = self.client._sanitize_next_link(next_link) - result = self.client._execute_pagination_request(sanitized_link) - self.assertIsInstance(result, Bundle) - self.assertIn("entry", result.as_json()) - mock_get.assert_called_once_with(sanitized_link) - - @patch("requests.get") - def test_execute_pagination_request_http_error(self, mock_get): - mock_get.side_effect = requests.exceptions.HTTPError("HTTP Error") - - next_link = "http://example.com/fhir/Bundle/2" - sanitized_link = self.client._sanitize_next_link(next_link) - - with self.assertRaises(requests.exceptions.HTTPError): - self.client._execute_pagination_request(sanitized_link) - - @patch("requests.get") - def test_execute_pagination_request_returns_last_bundle_if_no_next_link(self, mock_get): - # Mock the response to simulate a Bundle with no next link - mock_response = MagicMock() - mock_response.json.return_value = { - "resourceType": "Bundle", - "type": "searchset", - "link": [{"relation": "self", "url": "http://example.com/fhir/Bundle/1"}], - "entry": [{"resource": {"resourceType": "Patient", "id": "1"}}], - } - mock_response.raise_for_status = MagicMock() - mock_get.return_value = mock_response - - sanitized_link = "http://example.com/fhir/Bundle/1" - result = self.client._execute_pagination_request(sanitized_link) - - # Check that the result is the Bundle itself, not None - self.assertIsInstance(result, Bundle) - self.assertTrue(hasattr(result, 'entry')) - mock_get.assert_called_once_with(sanitized_link) - - @patch("fhirclient.client.FHIRClient._execute_pagination_request") - def test_fetch_next_page(self, mock_execute_request): - mock_execute_request.return_value = Bundle(self.bundle) - result = self.client._fetch_next_page(Bundle(self.bundle)) - self.assertIsInstance(result, Bundle) - self.assertTrue(hasattr(result, "entry")) - mock_execute_request.assert_called_once() - - def test_fetch_next_page_no_next_link(self): - bundle_without_next = { - "resourceType": "Bundle", - "type": "searchset", - "link": [{"relation": "self", "url": "http://example.com/fhir/Bundle/1"}], - } - bundle = Bundle(bundle_without_next) - result = self.client._fetch_next_page(bundle) - self.assertIsNone(result) - - @patch("fhirclient.client.FHIRClient._fetch_next_page") - def test_iter_pages(self, mock_fetch_next_page): - # Set up the mock to return a new bundle, then None to stop iteration - mock_fetch_next_page.side_effect = [Bundle(self.bundle), None] - pages = list(self.client.iter_pages(Bundle(self.bundle))) - - # Check that two bundles were returned (the first bundle and the one from mock) - self.assertEqual(len(pages), 2) - self.assertIsInstance(pages[0], Bundle) - self.assertIsInstance(pages[1], Bundle) - - # Compare JSON representations instead of object instances - self.assertEqual(pages[0].as_json(), self.bundle) - self.assertEqual(pages[1].as_json(), self.bundle) - - # Ensure that _fetch_next_page was called twice - self.assertEqual(mock_fetch_next_page.call_count, 2) diff --git a/tests/data/examples/bundle-example-page2.json b/tests/data/examples/bundle-example-page2.json new file mode 100644 index 000000000..f3c5c6f68 --- /dev/null +++ b/tests/data/examples/bundle-example-page2.json @@ -0,0 +1,65 @@ +{ + "resourceType": "Bundle", + "id": "bundle-example", + "meta": { + "lastUpdated": "2014-08-18T01:43:30Z", + "tag": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-ActReason", + "code": "HTEST", + "display": "test health data" + } + ] + }, + "type": "searchset", + "total": 3, + "link": [ + { + "relation": "self", + "url": "https://example.com/base/MedicationRequest?patient\u003d347\u0026_include\u003dMedicationRequest.medication\u0026_count\u003d2" + }, + { + "relation": "previous", + "url": "https://example.com/base/MedicationRequest?patient\u003d347\u0026searchId\u003dff15fd40-ff71-4b48-b366-09c706bed9d0\u0026page\u003d2" + } + ], + "entry": [ + { + "fullUrl": "https://example.com/base/MedicationRequest/3123", + "resource": { + "resourceType": "MedicationRequest", + "id": "9999", + "text": { + "status": "generated", + "div": "\u003cdiv xmlns\u003d\"http://www.w3.org/1999/xhtml\"\u003e\u003cp\u003e\u003cb\u003eGenerated Narrative with Details\u003c/b\u003e\u003c/p\u003e\u003cp\u003e\u003cb\u003eid\u003c/b\u003e: 3123\u003c/p\u003e\u003cp\u003e\u003cb\u003estatus\u003c/b\u003e: unknown\u003c/p\u003e\u003cp\u003e\u003cb\u003eintent\u003c/b\u003e: order\u003c/p\u003e\u003cp\u003e\u003cb\u003emedication\u003c/b\u003e: \u003ca\u003eMedication/example\u003c/a\u003e\u003c/p\u003e\u003cp\u003e\u003cb\u003esubject\u003c/b\u003e: \u003ca\u003ePatient/347\u003c/a\u003e\u003c/p\u003e\u003c/div\u003e" + }, + "status": "unknown", + "intent": "order", + "medicationReference": { + "reference": "Medication/example" + }, + "subject": { + "reference": "Patient/347" + } + }, + "search": { + "mode": "match", + "score": 1 + } + }, + { + "fullUrl": "https://example.com/base/Medication/example", + "resource": { + "resourceType": "Medication", + "id": "example2", + "text": { + "status": "generated", + "div": "\u003cdiv xmlns\u003d\"http://www.w3.org/1999/xhtml\"\u003e\u003cp\u003e\u003cb\u003eGenerated Narrative with Details\u003c/b\u003e\u003c/p\u003e\u003cp\u003e\u003cb\u003eid\u003c/b\u003e: example\u003c/p\u003e\u003c/div\u003e" + } + }, + "search": { + "mode": "include" + } + } + ] +} \ No newline at end of file diff --git a/tests/models/fhirsearch_perform_iter_test.py b/tests/models/fhirsearch_perform_iter_test.py new file mode 100644 index 000000000..6e0897e78 --- /dev/null +++ b/tests/models/fhirsearch_perform_iter_test.py @@ -0,0 +1,255 @@ +import unittest +import requests +import responses + +from fhirclient import server + +from fhirclient.models.fhirsearch import FHIRSearch, FHIRSearchParam +from fhirclient.models.bundle import Bundle + + +class TestFHIRSearchIter(unittest.TestCase): + def setUp(self): + self.search = FHIRSearch(resource_type=Bundle) + self.search.params = [ + FHIRSearchParam(name="patient", value="347"), + FHIRSearchParam(name="_count", value="1") + ] + self.mock_server = MockServer("https://example.com") + + def create_bundle_entry(self, resource_id): + """Helper to create a Bundle entry with a specific resource ID.""" + return { + "fullUrl": f"https://example.com/base/MedicationRequest/{resource_id}", + "resource": { + "resourceType": "MedicationRequest", + "id": resource_id, + "subject": {"reference": "Patient/347"}, + "intent": "order", + "status": "unknown", + "medicationReference": {"reference": "Medication/example"} + } + } + + def add_mock_response(self, url, bundle_content): + """Helper to set up a mock response for the given URL and bundle content.""" + responses.add( + responses.GET, + url, + json=bundle_content, + status=200 + ) + + @responses.activate + def test_perform_iter_single_bundle(self): + # Mock the network response for the initial search request + bundle_content = { + "resourceType": "Bundle", + "type": "searchset", + "entry": [self.create_bundle_entry("3123")] + } + + # Mock the single page response + self.add_mock_response("https://example.com/Bundle?patient=347&_count=1", bundle_content) + + # Call perform_iter with the server URL + result = list(self.search.perform_iter(self.mock_server)) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0].entry[0].resource.id, "3123") + + @responses.activate + def test_perform_iter_pagination(self): + bundle_page_1 = { + "resourceType": "Bundle", + "type": "searchset", + "link": [ + {"relation": "next", + "url": "https://example.com/base/MedicationRequest?patient=347&searchId=ff15fd40-ff71-4b48-b366-09c706bed9d0&page=2"} + ], + "entry": [self.create_bundle_entry("3123")] + } + + # Second page bundle without a "next" link + bundle_page_2 = { + "resourceType": "Bundle", + "type": "searchset", + "entry": [self.create_bundle_entry("3124")] + } + + # Mock both page responses + self.add_mock_response("https://example.com/Bundle?patient=347&_count=1", bundle_page_1) + self.add_mock_response( + "https://example.com/base/MedicationRequest?patient=347&searchId=ff15fd40-ff71-4b48-b366-09c706bed9d0&page=2", + bundle_page_2) + + # Execute perform_iter to iterate over both pages + result = list(self.search.perform_iter(self.mock_server)) + + self.assertEqual(len(result), 2) # Expect two pages of results + self.assertEqual(result[0].entry[0].resource.id, "3123") + self.assertEqual(result[1].entry[0].resource.id, "3124") + + @responses.activate + def test_perform_iter_empty_bundle(self): + # Mock an empty Bundle with no entries + empty_bundle = { + "resourceType": "Bundle", + "type": "searchset", + "entry": [] + } + self.add_mock_response("https://example.com/Bundle?patient=347&_count=1", empty_bundle) + + # Execute perform_iter with empty result + result = list(self.search.perform_iter(self.mock_server)) + + # Assertion: Should return an empty list + self.assertEqual(len(result), 1) + self.assertEqual(len(result[0].entry), 0) + + @responses.activate + def test_perform_iter_multiple_pages(self): + # First page with a "next" link + bundle_page_1 = { + "resourceType": "Bundle", + "type": "searchset", + "link": [ + {"relation": "next", "url": "https://example.com/base/MedicationRequest?patient=347&page=2"} + ], + "entry": [self.create_bundle_entry("3123")] + } + + # Second page with a "next" link + bundle_page_2 = { + "resourceType": "Bundle", + "type": "searchset", + "link": [ + {"relation": "next", "url": "https://example.com/base/MedicationRequest?patient=347&page=3"} + ], + "entry": [self.create_bundle_entry("3124")] + } + + # Third page without a "next" link (end of pagination) + bundle_page_3 = { + "resourceType": "Bundle", + "type": "searchset", + "entry": [self.create_bundle_entry("3125")] + } + + # Mock all page responses + self.add_mock_response("https://example.com/Bundle?patient=347&_count=1", bundle_page_1) + self.add_mock_response("https://example.com/base/MedicationRequest?patient=347&page=2", bundle_page_2) + self.add_mock_response("https://example.com/base/MedicationRequest?patient=347&page=3", bundle_page_3) + + # Execute perform_iter to iterate over all pages + result = list(self.search.perform_iter(self.mock_server)) + + self.assertEqual(len(result), 3) # Expect three pages of results + self.assertEqual(result[0].entry[0].resource.id, "3123") + self.assertEqual(result[1].entry[0].resource.id, "3124") + self.assertEqual(result[2].entry[0].resource.id, "3125") + + @responses.activate + def test_perform_resources_iter_single_page(self): + # Single page bundle with one entry + bundle_content = { + "resourceType": "Bundle", + "type": "searchset", + "entry": [self.create_bundle_entry("3123")] + } + + # Mock the single page response + self.add_mock_response("https://example.com/Bundle?patient=347&_count=1", bundle_content) + + # Call perform_resources_iter and collect resources + result = list(self.search.perform_resources_iter(self.mock_server)) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0].id, "3123") + + @responses.activate + def test_perform_resources_iter_pagination(self): + # First page bundle with a "next" link + bundle_page_1 = { + "resourceType": "Bundle", + "type": "searchset", + "link": [ + {"relation": "next", + "url": "https://example.com/base/MedicationRequest?patient=347&searchId=ff15fd40-ff71-4b48-b366-09c706bed9d0&page=2"} + ], + "entry": [self.create_bundle_entry("3123")] + } + + # Second page bundle without a "next" link + bundle_page_2 = { + "resourceType": "Bundle", + "type": "searchset", + "entry": [self.create_bundle_entry("3124")] + } + + # Mock both page responses + self.add_mock_response("https://example.com/Bundle?patient=347&_count=1", bundle_page_1) + self.add_mock_response( + "https://example.com/base/MedicationRequest?patient=347&searchId=ff15fd40-ff71-4b48-b366-09c706bed9d0&page=2", + bundle_page_2) + + # Execute perform_resources_iter to retrieve resources across pages + result = list(self.search.perform_resources_iter(self.mock_server)) + + self.assertEqual(len(result), 2) # Expect resources from both pages + self.assertEqual(result[0].id, "3123") # First page resource + self.assertEqual(result[1].id, "3124") # Second page resource + + @responses.activate + def test_perform_resources_iter_multiple_pages(self): + # Mock similar pagination for perform_resources_iter + bundle_page_1 = { + "resourceType": "Bundle", + "type": "searchset", + "link": [ + {"relation": "next", "url": "https://example.com/base/MedicationRequest?patient=347&page=2"} + ], + "entry": [self.create_bundle_entry("3123")] + } + + bundle_page_2 = { + "resourceType": "Bundle", + "type": "searchset", + "link": [ + {"relation": "next", "url": "https://example.com/base/MedicationRequest?patient=347&page=3"} + ], + "entry": [self.create_bundle_entry("3124")] + } + + bundle_page_3 = { + "resourceType": "Bundle", + "type": "searchset", + "entry": [self.create_bundle_entry("3125")] + } + + # Mock responses for each page + self.add_mock_response("https://example.com/Bundle?patient=347&_count=1", bundle_page_1) + self.add_mock_response("https://example.com/base/MedicationRequest?patient=347&page=2", bundle_page_2) + self.add_mock_response("https://example.com/base/MedicationRequest?patient=347&page=3", bundle_page_3) + + # Execute perform_resources_iter to retrieve resources across all pages + result = list(self.search.perform_resources_iter(self.mock_server)) + + self.assertEqual(len(result), 3) # Expect resources from three pages + self.assertEqual(result[0].id, "3123") + self.assertEqual(result[1].id, "3124") + self.assertEqual(result[2].id, "3125") + + +# Network-level Mocking +class MockServer(server.FHIRServer): + def __init__(self, base_url): + super().__init__(None, base_uri=base_url) + + def request_json(self, path): + if path.startswith("http"): + full_url = path + else: + full_url = f"{self.base_uri.rstrip('/')}/{path.lstrip('/')}" + response = requests.get(full_url) + return response.json() if response else None diff --git a/tests/utils_pagination_test.py b/tests/utils_pagination_test.py new file mode 100644 index 000000000..735cfd0cf --- /dev/null +++ b/tests/utils_pagination_test.py @@ -0,0 +1,172 @@ +import io +import json +import os +import unittest +from unittest.mock import patch, MagicMock + +import requests +from fhirclient import server + +from fhirclient.models import bundle +from fhirclient.models.bundle import Bundle +from fhirclient._utils import _get_next_link, _sanitize_next_link, _execute_pagination_request, _fetch_next_page, \ + iter_pages + + +class TestUtilsPagination(unittest.TestCase): + + def instantiate_from(self, filename): + datadir = os.path.join(os.path.dirname(__file__), 'data', 'examples') + with io.open(os.path.join(datadir, filename), 'r', encoding='utf-8') as handle: + js = json.load(handle) + self.assertEqual("Bundle", js["resourceType"]) + return bundle.Bundle(js) + + def test_get_next_link(self): + inst = self.instantiate_from("bundle-example.json") + self.assertIsNotNone(inst, "Must have instantiated a Bundle instance") + + # The returned URL should be the sanitized version + next_link = _get_next_link(inst) + sanitized_expected_url = "https://example.com/base/MedicationRequest?patient=347&searchId=ff15fd40-ff71-4b48-b366-09c706bed9d0&page=2" + + self.assertEqual(next_link, sanitized_expected_url) + + def test_get_next_link_no_next(self): + bundle_without_next = { + "resourceType": "Bundle", + "type": "searchset", + "link": [{"relation": "self", "url": "http://example.com/fhir/Bundle/1"}], + } + bundle = Bundle(bundle_without_next) + next_link = _get_next_link(bundle) + self.assertIsNone(next_link) + + def test_sanitize_next_link_valid(self): + next_link = "https://example.com/base/MedicationRequest?patient=347&searchId=ff15fd40-ff71-4b48-b366-09c706bed9d0&page=2" + sanitized_link = _sanitize_next_link(next_link) + self.assertEqual(sanitized_link, next_link) + + def test_sanitize_next_link_invalid_scheme(self): + next_link = "ftp://example.com/base/MedicationRequest?patient=347&searchId=ff15fd40-ff71-4b48-b366-09c706bed9d0&page=2" + with self.assertRaises(ValueError): + _sanitize_next_link(next_link) + + def test_sanitize_next_link_missing_scheme(self): + next_link = "example.com/base/MedicationRequest?patient=347&page=2" + with self.assertRaises(ValueError): + _sanitize_next_link(next_link) + + def test_sanitize_next_link_missing_netloc(self): + next_link = "https:///MedicationRequest?page=2" + with self.assertRaises(ValueError): + _sanitize_next_link(next_link) + + @patch("requests.get") + def test_execute_pagination_request_success(self, mock_get): + inst = self.instantiate_from("bundle-example.json") + + mock_response = MagicMock() + mock_response.json.return_value = inst.as_json() + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + next_link = "https://example.com/base/MedicationRequest?patient=347&searchId=ff15fd40-ff71-4b48-b366-09c706bed9d0&page=2" + sanitized_link = _sanitize_next_link(next_link) + + mock_server = MockServer("https://example.com/base") + result = _execute_pagination_request(sanitized_link, mock_server) + + self.assertIsInstance(result, Bundle) + self.assertIn("entry", result.as_json()) + mock_get.assert_called_once_with(sanitized_link) + + @patch("requests.get") + def test_execute_pagination_request_http_error(self, mock_get): + mock_get.side_effect = requests.exceptions.HTTPError("HTTP Error") + + next_link = "https://example.com/base/MedicationRequest?patient=347&searchId=ff15fd40-ff71-4b48-b366-09c706bed9d0&page=2" + sanitized_link = _sanitize_next_link(next_link) + + mock_server = MockServer("https://example.com/base") + + with self.assertRaises(requests.exceptions.HTTPError): + _execute_pagination_request(sanitized_link, mock_server) + + @patch("requests.get") + def test_execute_pagination_request_returns_last_bundle_if_no_next_link(self, mock_get): + mock_response = MagicMock() + mock_response.json.return_value = { + "resourceType": "Bundle", + "type": "searchset", + "link": [{"relation": "self", "url": "http://example.com/fhir/Bundle/1"}], + "entry": [{"resource": {"resourceType": "Patient", "id": "1"}}], + } + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + sanitized_link = "https://example.com/base/MedicationRequest?patient\u003d347\u0026_include\u003dMedicationRequest.medication\u0026_count\u003d2" + mock_server = MockServer("https://example.com/base") + result = _execute_pagination_request(sanitized_link, mock_server) + + # Check that the result is the Bundle itself, not None + self.assertIsInstance(result, Bundle) + self.assertTrue(hasattr(result, 'entry')) + mock_get.assert_called_once_with(sanitized_link) + + @patch("fhirclient._utils._execute_pagination_request") + def test_fetch_next_page(self, mock_execute_request): + inst = self.instantiate_from("bundle-example.json") + mock_execute_request.return_value = inst + mock_server = MockServer("https://example.com/base") + + result = _fetch_next_page(inst, mock_server) + self.assertIsInstance(result, Bundle) + self.assertTrue(hasattr(result, "entry")) + mock_execute_request.assert_called_once_with(_get_next_link(inst), mock_server) + + def test_fetch_next_page_no_next_link(self): + bundle_without_next = { + "resourceType": "Bundle", + "type": "searchset", + "link": [{"relation": "self", "url": "http://example.com/fhir/Bundle/1"}], + } + bundle = Bundle(bundle_without_next) + mock_server = MockServer("https://example.com/base") + result = _fetch_next_page(bundle, mock_server) + self.assertIsNone(result) + + @patch("fhirclient._utils._execute_pagination_request") + def test_iter_pages(self, mock_fetch_next_page): + inst = self.instantiate_from("bundle-example.json") + inst_page2 = self.instantiate_from("bundle-example-page2.json") + + # Set up the mock to return a new bundle, then None to stop iteration + mock_fetch_next_page.side_effect = [inst_page2, None] + mock_server = MockServer("https://example.com/base") + pages = list(iter_pages(inst, mock_server)) + + # Check that two bundles were returned (the first bundle and the one from mock) + self.assertEqual(len(pages), 2) + self.assertIsInstance(pages[0], Bundle) + self.assertIsInstance(pages[1], Bundle) + + self.assertNotEqual(pages[0].as_json(), pages[1].as_json()) # Ensure the two pages are different + self.assertEqual(pages[0].as_json(), inst.as_json()) + self.assertEqual(pages[1].as_json(), inst_page2.as_json()) # Ensure the second page is correct + + # Ensure that _fetch_next_page was called twice + self.assertEqual(mock_fetch_next_page.call_count, 1) + + +class MockServer(server.FHIRServer): + def __init__(self, base_url): + super().__init__(None, base_uri=base_url) + + def request_json(self, path): + if path.startswith("http"): + full_url = path + else: + full_url = f"{self.base_uri.rstrip('/')}/{path.lstrip('/')}" + response = requests.get(full_url) + return response.json() if response else None