Skip to content

Commit 0a82692

Browse files
Merge pull request #169 from LanaNYC/pagination
Add pagination functionality to FHIRClient and related unit tests
2 parents 796ffa6 + 5facb52 commit 0a82692

File tree

2 files changed

+296
-0
lines changed

2 files changed

+296
-0
lines changed

fhirclient/client.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
import logging
2+
import urllib
3+
from typing import Optional, Iterable
4+
5+
import requests
6+
from .models.bundle import Bundle
27

38
from .server import FHIRServer, FHIRUnauthorizedException, FHIRNotFoundException
49

@@ -240,3 +245,118 @@ def from_state(self, state):
240245
def save_state (self):
241246
self._save_func(self.state)
242247

248+
# MARK: Pagination
249+
def _fetch_next_page(self, bundle: Bundle) -> Optional[Bundle]:
250+
"""
251+
Fetch the next page of results using the `next` link provided in the bundle.
252+
253+
Args:
254+
bundle (Bundle): The FHIR Bundle containing the `next` link.
255+
256+
Returns:
257+
Optional[Bundle]: The next page of results as a FHIR Bundle, or None if no "next" link is found.
258+
"""
259+
next_link = self._get_next_link(bundle)
260+
if next_link:
261+
sanitized_next_link = self._sanitize_next_link(next_link)
262+
return self._execute_pagination_request(sanitized_next_link)
263+
return None
264+
265+
def _get_next_link(self, bundle: Bundle) -> Optional[str]:
266+
"""
267+
Extract the `next` link from the Bundle's links.
268+
269+
Args:
270+
bundle (Bundle): The FHIR Bundle containing pagination links.
271+
272+
Returns:
273+
Optional[str]: The URL of the next page if available, None otherwise.
274+
"""
275+
if not bundle.link:
276+
return None
277+
278+
for link in bundle.link:
279+
if link.relation == "next":
280+
return link.url
281+
return None
282+
283+
def _sanitize_next_link(self, next_link: str) -> str:
284+
"""
285+
Sanitize the `next` link to ensure it is safe to use.
286+
287+
Args:
288+
next_link (str): The raw `next` link URL.
289+
290+
Returns:
291+
str: The sanitized URL.
292+
293+
Raises:
294+
ValueError: If the URL scheme or domain is invalid.
295+
"""
296+
parsed_url = urllib.parse.urlparse(next_link)
297+
298+
# Validate scheme and netloc (domain)
299+
if parsed_url.scheme not in ["http", "https"]:
300+
raise ValueError("Invalid URL scheme in `next` link.")
301+
if not parsed_url.netloc:
302+
raise ValueError("Invalid URL domain in `next` link.")
303+
304+
# Additional sanitization if necessary, e.g., removing dangerous query parameters
305+
query_params = urllib.parse.parse_qs(parsed_url.query)
306+
sanitized_query = {k: v for k, v in query_params.items()}
307+
308+
# Rebuild the sanitized URL
309+
sanitized_url = urllib.parse.urlunparse(
310+
(
311+
parsed_url.scheme,
312+
parsed_url.netloc,
313+
parsed_url.path,
314+
parsed_url.params,
315+
urllib.parse.urlencode(sanitized_query, doseq=True),
316+
parsed_url.fragment,
317+
)
318+
)
319+
320+
return sanitized_url
321+
322+
def _execute_pagination_request(self, sanitized_url: str) -> Optional[Bundle]:
323+
"""
324+
Execute the request to retrieve the next page using the sanitized URL.
325+
326+
Args:
327+
sanitized_url (str): The sanitized URL to fetch the next page.
328+
329+
Returns:
330+
Optional[Bundle]: The next page of results as a FHIR Bundle, or None.
331+
332+
Raises:
333+
HTTPError: If the request fails due to network issues or server errors.
334+
"""
335+
try:
336+
# Use requests.get directly to make the HTTP request
337+
response = requests.get(sanitized_url)
338+
response.raise_for_status()
339+
next_bundle_data = response.json()
340+
next_bundle = Bundle(next_bundle_data)
341+
342+
return next_bundle
343+
344+
except requests.exceptions.HTTPError as e:
345+
# Handle specific HTTP errors as needed, possibly including retry logic
346+
raise e
347+
348+
def iter_pages(self, first_bundle: Bundle) -> Iterable[Bundle]:
349+
"""
350+
Iterator that yields each page of results as a FHIR Bundle.
351+
352+
Args:
353+
first_bundle (Bundle): The first Bundle to start pagination.
354+
355+
Yields:
356+
Bundle: Each page of results as a FHIR Bundle.
357+
"""
358+
bundle = first_bundle
359+
while bundle:
360+
yield bundle
361+
bundle = self._fetch_next_page(bundle)
362+

tests/client_pagination_test.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import unittest
2+
from unittest.mock import patch, MagicMock
3+
4+
import requests
5+
from fhirclient.models.bundle import Bundle
6+
7+
from fhirclient.client import FHIRClient
8+
9+
10+
class TestFHIRClientPagination(unittest.TestCase):
11+
def setUp(self) -> None:
12+
state = {
13+
"app_id": "AppID",
14+
"app_secret": "AppSecret",
15+
"scope": "user/*.read",
16+
"redirect": "http://test.invalid/redirect",
17+
"patient_id": "PatientID",
18+
"server": {
19+
"base_uri": "http://test.invalid/",
20+
"auth_type": "none",
21+
"auth": {
22+
"app_id": "AppId",
23+
},
24+
},
25+
"launch_token": "LaunchToken",
26+
"launch_context": {
27+
"encounter": "EncounterID",
28+
"patient": "PatientID",
29+
},
30+
"jwt_token": "JwtToken",
31+
}
32+
33+
# Confirm round trip
34+
self.client = FHIRClient(state=state)
35+
36+
self.bundle = {
37+
"resourceType": "Bundle",
38+
"type": "searchset",
39+
"link": [
40+
{"relation": "self", "url": "http://example.com/fhir/Bundle/1"},
41+
{"relation": "next", "url": "http://example.com/fhir/Bundle/2"},
42+
],
43+
"entry": [
44+
{
45+
"fullUrl": "http://example.com/fhir/Patient/1",
46+
"resource": {
47+
"resourceType": "Patient",
48+
"id": "1",
49+
"name": [{"family": "Doe", "given": ["John"]}],
50+
"gender": "male",
51+
"birthDate": "1980-01-01",
52+
},
53+
},
54+
{
55+
"fullUrl": "http://example.com/fhir/Patient/2",
56+
"resource": {
57+
"resourceType": "Patient",
58+
"id": "2",
59+
"name": [{"family": "Smith", "given": ["Jane"]}],
60+
"gender": "female",
61+
"birthDate": "1990-05-15",
62+
},
63+
},
64+
],
65+
}
66+
67+
def test_get_next_link(self):
68+
next_link = self.client._get_next_link(Bundle(self.bundle))
69+
self.assertEqual(next_link, "http://example.com/fhir/Bundle/2")
70+
71+
def test_get_next_link_no_next(self):
72+
bundle_without_next = {
73+
"resourceType": "Bundle",
74+
"type": "searchset",
75+
"link": [{"relation": "self", "url": "http://example.com/fhir/Bundle/1"}],
76+
}
77+
bundle = Bundle(bundle_without_next)
78+
next_link = self.client._get_next_link(bundle)
79+
self.assertIsNone(next_link)
80+
81+
def test_sanitize_next_link_valid(self):
82+
next_link = "http://example.com/fhir/Bundle/2?page=2&size=10"
83+
sanitized_link = self.client._sanitize_next_link(next_link)
84+
self.assertEqual(sanitized_link, next_link)
85+
86+
def test_sanitize_next_link_invalid_scheme(self):
87+
next_link = "ftp://example.com/fhir/Bundle/2?page=2&size=10"
88+
with self.assertRaises(ValueError):
89+
self.client._sanitize_next_link(next_link)
90+
91+
def test_sanitize_next_link_invalid_domain(self):
92+
next_link = "http:///fhir/Bundle/2?page=2&size=10"
93+
with self.assertRaises(ValueError):
94+
self.client._sanitize_next_link(next_link)
95+
96+
@patch("requests.get")
97+
def test_execute_pagination_request_success(self, mock_get):
98+
mock_response = MagicMock()
99+
# Set up the mock to return a specific JSON payload when its json() method is called
100+
mock_response.json.return_value = self.bundle
101+
mock_response.raise_for_status = MagicMock()
102+
mock_get.return_value = mock_response
103+
104+
next_link = "http://example.com/fhir/Bundle/2"
105+
sanitized_link = self.client._sanitize_next_link(next_link)
106+
result = self.client._execute_pagination_request(sanitized_link)
107+
self.assertIsInstance(result, Bundle)
108+
self.assertIn("entry", result.as_json())
109+
mock_get.assert_called_once_with(sanitized_link)
110+
111+
@patch("requests.get")
112+
def test_execute_pagination_request_http_error(self, mock_get):
113+
mock_get.side_effect = requests.exceptions.HTTPError("HTTP Error")
114+
115+
next_link = "http://example.com/fhir/Bundle/2"
116+
sanitized_link = self.client._sanitize_next_link(next_link)
117+
118+
with self.assertRaises(requests.exceptions.HTTPError):
119+
self.client._execute_pagination_request(sanitized_link)
120+
121+
@patch("requests.get")
122+
def test_execute_pagination_request_returns_last_bundle_if_no_next_link(self, mock_get):
123+
# Mock the response to simulate a Bundle with no next link
124+
mock_response = MagicMock()
125+
mock_response.json.return_value = {
126+
"resourceType": "Bundle",
127+
"type": "searchset",
128+
"link": [{"relation": "self", "url": "http://example.com/fhir/Bundle/1"}],
129+
"entry": [{"resource": {"resourceType": "Patient", "id": "1"}}],
130+
}
131+
mock_response.raise_for_status = MagicMock()
132+
mock_get.return_value = mock_response
133+
134+
sanitized_link = "http://example.com/fhir/Bundle/1"
135+
result = self.client._execute_pagination_request(sanitized_link)
136+
137+
# Check that the result is the Bundle itself, not None
138+
self.assertIsInstance(result, Bundle)
139+
self.assertTrue(hasattr(result, 'entry'))
140+
mock_get.assert_called_once_with(sanitized_link)
141+
142+
@patch("fhirclient.client.FHIRClient._execute_pagination_request")
143+
def test_fetch_next_page(self, mock_execute_request):
144+
mock_execute_request.return_value = Bundle(self.bundle)
145+
result = self.client._fetch_next_page(Bundle(self.bundle))
146+
self.assertIsInstance(result, Bundle)
147+
self.assertTrue(hasattr(result, "entry"))
148+
mock_execute_request.assert_called_once()
149+
150+
def test_fetch_next_page_no_next_link(self):
151+
bundle_without_next = {
152+
"resourceType": "Bundle",
153+
"type": "searchset",
154+
"link": [{"relation": "self", "url": "http://example.com/fhir/Bundle/1"}],
155+
}
156+
bundle = Bundle(bundle_without_next)
157+
result = self.client._fetch_next_page(bundle)
158+
self.assertIsNone(result)
159+
160+
@patch("fhirclient.client.FHIRClient._fetch_next_page")
161+
def test_iter_pages(self, mock_fetch_next_page):
162+
# Set up the mock to return a new bundle, then None to stop iteration
163+
mock_fetch_next_page.side_effect = [Bundle(self.bundle), None]
164+
pages = list(self.client.iter_pages(Bundle(self.bundle)))
165+
166+
# Check that two bundles were returned (the first bundle and the one from mock)
167+
self.assertEqual(len(pages), 2)
168+
self.assertIsInstance(pages[0], Bundle)
169+
self.assertIsInstance(pages[1], Bundle)
170+
171+
# Compare JSON representations instead of object instances
172+
self.assertEqual(pages[0].as_json(), self.bundle)
173+
self.assertEqual(pages[1].as_json(), self.bundle)
174+
175+
# Ensure that _fetch_next_page was called twice
176+
self.assertEqual(mock_fetch_next_page.call_count, 2)

0 commit comments

Comments
 (0)