From 08a8535ebaf02b4572dd7793ea9605a5eaa79607 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 iterators, added iter and next to Bundle, and moved pagination logic to _utils.py --- fhir-parser | 2 +- fhirclient/_utils.py | 127 ++++++++++++ fhirclient/client.py | 122 ------------ fhirclient/models/bundle.py | 40 ++-- fhirclient/models/fhirsearch.py | 44 ++++- tests/client_pagination_test.py | 176 ----------------- tests/data/examples/bundle-example-page2.json | 65 ++++++ tests/models/bundle_iterator_test.py | 22 +++ tests/models/fhirsearch_perform_iter_test.py | 185 ++++++++++++++++++ tests/utils_pagination_test.py | 140 +++++++++++++ 10 files changed, 611 insertions(+), 312 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/bundle_iterator_test.py create mode 100644 tests/models/fhirsearch_perform_iter_test.py create mode 100644 tests/utils_pagination_test.py diff --git a/fhir-parser b/fhir-parser index 8cae8eef2..daf471059 160000 --- a/fhir-parser +++ b/fhir-parser @@ -1 +1 @@ -Subproject commit 8cae8eef22ebb69499619beb0560b1c289104a62 +Subproject commit daf471059892f117e98387426c3ae723a56e332c diff --git a/fhirclient/_utils.py b/fhirclient/_utils.py new file mode 100644 index 000000000..1d0e95cd6 --- /dev/null +++ b/fhirclient/_utils.py @@ -0,0 +1,127 @@ +import urllib +from typing import Optional, Iterable + +import requests + + +# Use forward references to avoid circular imports +def _fetch_next_page(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 = _get_next_link(bundle) + if next_link: + sanitized_next_link = _sanitize_next_link(next_link) + return _execute_pagination_request(sanitized_next_link) + 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 link.url + return None + + +def _sanitize_next_link(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(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. + """ + from fhirclient.models.bundle import Bundle # Import here to avoid circular import + + 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(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 = _fetch_next_page(bundle) + diff --git a/fhirclient/client.py b/fhirclient/client.py index 9495d2a8a..3cafbeae8 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.2.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..77468fbae 100644 --- a/fhirclient/models/bundle.py +++ b/fhirclient/models/bundle.py @@ -50,19 +50,35 @@ def __init__(self, jsondict=None, strict=True): Type `str`. """ super(Bundle, self).__init__(jsondict=jsondict, strict=strict) - + + def __iter__(self): + """ Makes the Bundle itself an iterator by returning an iterator over its entries. """ + if self.entry is None: + self._entry_iter = iter([]) + else: + self._entry_iter = iter(self.entry) + return self + + def __next__(self): + """ Returns the next BundleEntry in the Bundle's entry list using the internal iterator. """ + # return next(self._entry_iter) + + if not hasattr(self, '_entry_iter'): + self.__iter__() + return next(self._entry_iter) + def elementProperties(self): - js = super(Bundle, self).elementProperties() - js.extend([ - ("entry", "entry", BundleEntry, True, None, False), - ("identifier", "identifier", identifier.Identifier, False, None, False), - ("link", "link", BundleLink, True, None, False), - ("signature", "signature", signature.Signature, False, None, False), - ("timestamp", "timestamp", fhirinstant.FHIRInstant, False, None, False), - ("total", "total", int, False, None, False), - ("type", "type", str, False, None, True), - ]) - return js + js = super(Bundle, self).elementProperties() + js.extend([ + ("entry", "entry", BundleEntry, True, None, False), + ("identifier", "identifier", identifier.Identifier, False, None, False), + ("link", "link", BundleLink, True, None, False), + ("signature", "signature", signature.Signature, False, None, False), + ("timestamp", "timestamp", fhirinstant.FHIRInstant, False, None, False), + ("total", "total", int, False, None, False), + ("type", "type", str, False, None, True), + ]) + return js from . import backboneelement diff --git a/fhirclient/models/fhirsearch.py b/fhirclient/models/fhirsearch.py index af1ac4a8e..d1819d462 100644 --- a/fhirclient/models/fhirsearch.py +++ b/fhirclient/models/fhirsearch.py @@ -5,8 +5,12 @@ # 2014, SMART Health IT. import logging +import warnings +from typing import Iterator + from . import fhirreference +from .._utils import iter_pages try: from urllib import quote_plus @@ -124,7 +128,21 @@ def perform(self, server): bundle = bundle.Bundle(res) bundle.origin_server = server return bundle - + + # Use forward references to avoid circular imports + def perform_iter(self, server) -> Iterator['Resource']: + """ 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 + """ + + first_bundle = self.perform(server) + if not first_bundle: + return iter([]) + yield first_bundle + def perform_resources(self, server): """ Performs the search by calling `perform`, then extracts all Bundle entries and returns a list of Resource instances. @@ -132,6 +150,13 @@ def perform_resources(self, server): :param server: The server against which to perform the search :returns: A list of Resource instances """ + # 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, + ) + bundle = self.perform(server) resources = [] if bundle is not None and bundle.entry is not None: @@ -140,6 +165,23 @@ def perform_resources(self, server): return resources + # Use forward references to avoid circular imports + def perform_resources_iter(self, server) -> Iterator['Resource']: + """ Performs the search by calling `perform`, then extracts all Bundle + entries and returns an iterator of Resource instances. + + :param server: The server against which to perform the search + :returns: An iterator of Resource instances + """ + first_bundle = self.perform(server) + + if not first_bundle or not first_bundle.entry: + return iter([]) + + for bundle in iter_pages(first_bundle): + if bundle.entry: + yield from (entry.resource for entry in bundle.entry) + class FHIRSearchParam(object): """ Holds one search parameter. 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..206624acb --- /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": "next", + "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/bundle_iterator_test.py b/tests/models/bundle_iterator_test.py new file mode 100644 index 000000000..80408633e --- /dev/null +++ b/tests/models/bundle_iterator_test.py @@ -0,0 +1,22 @@ +import unittest +from unittest.mock import MagicMock + +from fhirclient.models.bundle import Bundle + + +class TestBundleIterator(unittest.TestCase): + + def test_bundle_iter_and_next(self): + entry1 = MagicMock() + entry2 = MagicMock() + bundle = Bundle() + bundle.entry = [entry1, entry2] + + iterator = iter(bundle) + first_entry = next(iterator) + second_entry = next(iterator) + + self.assertEqual(first_entry, entry1) + self.assertEqual(second_entry, entry2) + with self.assertRaises(StopIteration): + next(iterator) diff --git a/tests/models/fhirsearch_perform_iter_test.py b/tests/models/fhirsearch_perform_iter_test.py new file mode 100644 index 000000000..a63f2626b --- /dev/null +++ b/tests/models/fhirsearch_perform_iter_test.py @@ -0,0 +1,185 @@ +import io +import json +import os +import unittest +from unittest.mock import MagicMock, patch + +import fhirclient + +from fhirclient import server + +from fhirclient.models import bundle +from fhirclient.models.fhirsearch import FHIRSearch +from fhirclient.models.bundle import Bundle, BundleEntry + + +class TestFHIRSearchIter(unittest.TestCase): + def setUp(self): + self.mock_server = MockServer(tmpdir=os.path.join(os.path.dirname(__file__), '..', 'data', 'examples')) + self.search = FHIRSearch(resource_type='Bundle') + self.mock_bundle = self.instantiate_from('bundle-example.json') + + 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_perform_iter_single_bundle(self): + self.search.perform = MagicMock(return_value=self.mock_bundle) + + result = list(self.search.perform_iter(self.mock_server)) + + self.assertEqual(len(result), 1) + self.assertIsInstance(result[0], Bundle) + self.assertEqual(result[0].resource_type, 'Bundle') + self.assertEqual(result[0].id, self.mock_bundle.id) + + def test_perform_iter_no_first_bundle(self): + self.search.perform = MagicMock(return_value=None) + result = list(self.search.perform_iter(self.mock_server)) + self.assertEqual(result, []) + + @patch('fhirclient._utils.iter_pages') + @patch('requests.get') # Mock requests.get to avoid actual HTTP calls + def test_perform_resources_iter_single_page(self, mock_iter_pages, mock_get): + + # Test the case where there is only a single page in the bundle (no pagination). + + # Manually create valid FHIR resources + medication_request_resource = { + 'resourceType': 'MedicationRequest', + 'id': '3123', + 'subject': { + 'reference': 'Patient/347' + }, + 'intent': 'order', + 'status': 'unknown', + 'medicationReference': { + 'reference': 'Medication/example' + } + } + + # Create a valid BundleEntry with the MedicationRequest resource + mock_entry1 = BundleEntry({ + 'fullUrl': 'https://example.com/base/MedicationRequest/3123', + 'resource': medication_request_resource + }) + + # Create a valid Bundle + mock_bundle = Bundle({ + 'resourceType': 'Bundle', + 'type': 'searchset', # Required field + 'entry': [ + {'fullUrl': 'https://example.com/base/MedicationRequest/3123', 'resource': medication_request_resource} + ] + }) + + # Mock the behavior of `perform` to return a bundle with entries + self.search.perform = MagicMock(return_value=mock_bundle) + + # Mock `iter_pages` to return a single page (mock bundle) + mock_iter_pages.return_value = iter([mock_bundle]) + + # Ensure `requests.get` does not actually run + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = {'resourceType': 'Bundle'} + + resource_iter = self.search.perform_resources_iter(self.mock_server) + resources = list(resource_iter) + + self.assertEqual(len(resources), len(mock_bundle.entry)) + self.assertTrue(all(isinstance(entry, BundleEntry) for entry in mock_bundle.entry)) + + @patch('fhirclient._utils.iter_pages') + def test_perform_resources_iter_multiple_pages(self, mock_iter_pages): + # Create first bundle with one entry and a next link + mock_bundle_page1 = Bundle({ + 'resourceType': 'Bundle', + 'type': 'searchset', + 'entry': [ + {'fullUrl': 'https://example.com/base/MedicationRequest/3123', + 'resource': { + 'resourceType': 'MedicationRequest', + 'id': '3123', + 'subject': { + 'reference': 'Patient/347' + }, + 'intent': 'order', + 'status': 'unknown', + 'medicationReference': { + 'reference': 'Medication/example' + } + }} + ], + 'link': [ + { + 'relation': 'next', + 'url': 'https://example.com/base/MedicationRequest?page=2' # Simulating the next page link + } + ] + }) + + # Create second bundle with another entry + mock_bundle_page2 = Bundle({ + 'resourceType': 'Bundle', + 'type': 'searchset', + 'entry': [ + {'fullUrl': 'https://example.com/base/MedicationRequest/3124', + 'resource': { + 'resourceType': 'MedicationRequest', + 'id': '3124', + 'subject': { + 'reference': 'Patient/348' + }, + 'intent': 'order', + 'status': 'unknown', + 'medicationReference': { + 'reference': 'Medication/example2' + } + }} + ] + }) + + # Directly mock requests.get to return a proper response + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() # Simulate that there is no HTTP error + mock_response.json.return_value = mock_bundle_page2.as_json() # Return the second bundle's JSON + + # Now override the requests.get behavior + with patch('requests.get', return_value=mock_response): + # Mock perform to return the first page bundle + self.search.perform = MagicMock(return_value=mock_bundle_page1) + + # Mock iter_pages to return both bundles + mock_iter_pages.return_value = iter([mock_bundle_page1, mock_bundle_page2]) + + # Execute the method to get resources + resource_iter = self.search.perform_resources_iter(self.mock_server) + resources = list(resource_iter) + + # Ensure that both resources from both pages are included + self.assertEqual(len(resources), 2) # Expect 2 resources, one per page + + # Ensure that the entries are from the correct pages + self.assertEqual(resources[0].id, '3123') + self.assertEqual(resources[1].id, '3124') + + # Ensure that all resources are correctly typed + self.assertTrue(all( + isinstance(resource, fhirclient.models.medicationrequest.MedicationRequest) for resource in resources)) + + +class MockServer(server.FHIRServer): + """ Reads local files. + """ + + def __init__(self, tmpdir: str): + super().__init__(None, base_uri='https://fhir.smarthealthit.org') + self.directory = tmpdir + + def request_json(self, path, nosign=False): + assert path + with io.open(os.path.join(self.directory, path), encoding='utf-8') as handle: + return json.load(handle) diff --git a/tests/utils_pagination_test.py b/tests/utils_pagination_test.py new file mode 100644 index 000000000..5b8892031 --- /dev/null +++ b/tests/utils_pagination_test.py @@ -0,0 +1,140 @@ +import io +import json +import os +import unittest +from unittest.mock import patch, MagicMock + +import requests + +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") + next_link = _get_next_link(inst) + self.assertEqual(next_link, "https://example.com/base/MedicationRequest?patient=347&searchId=ff15fd40-ff71-4b48-b366-09c706bed9d0&page=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 = _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_invalid_domain(self): + next_link = "https:///example.com/base/MedicationRequest?patient=347&searchId=ff15fd40-ff71-4b48-b366-09c706bed9d0&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) + result = _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 = "https://example.com/base/MedicationRequest?patient=347&searchId=ff15fd40-ff71-4b48-b366-09c706bed9d0&page=2" + sanitized_link = _sanitize_next_link(next_link) + + with self.assertRaises(requests.exceptions.HTTPError): + _execute_pagination_request(sanitized_link) + + @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" + result = _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._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 + result = _fetch_next_page(inst) + 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 = _fetch_next_page(bundle) + 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] + pages = list(iter_pages(inst)) + + # 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, 2)