Skip to content

Commit ff41695

Browse files
authored
Merge pull request #174 from LanaNYC/feat/pagination-Bundle
Implement Pagination Features for FHIRClient and Bundle Classes
2 parents e19a871 + 3ec3ef3 commit ff41695

File tree

9 files changed

+656
-316
lines changed

9 files changed

+656
-316
lines changed

fhirclient/_utils.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import urllib
2+
from typing import Optional
3+
4+
import requests
5+
6+
from typing import TYPE_CHECKING, Iterable
7+
8+
if TYPE_CHECKING:
9+
from fhirclient.server import FHIRServer
10+
from fhirclient.models.bundle import Bundle
11+
12+
13+
# Use forward references to avoid circular imports
14+
def _fetch_next_page(bundle: 'Bundle', server: 'FHIRServer') -> Optional['Bundle']:
15+
"""
16+
Fetch the next page of results using the `next` link provided in the bundle.
17+
18+
Args:
19+
bundle (Bundle): The FHIR Bundle containing the `next` link.
20+
server (FHIRServer): The FHIR server instance for handling requests and authentication.
21+
22+
Returns:
23+
Optional[Bundle]: The next page of results as a FHIR Bundle, or None if no "next" link is found.
24+
"""
25+
if next_link := _get_next_link(bundle):
26+
return _execute_pagination_request(next_link, server)
27+
return None
28+
29+
30+
def _get_next_link(bundle: 'Bundle') -> Optional[str]:
31+
"""
32+
Extract the `next` link from the Bundle's links.
33+
34+
Args:
35+
bundle (Bundle): The FHIR Bundle containing pagination links.
36+
37+
Returns:
38+
Optional[str]: The URL of the next page if available, None otherwise.
39+
"""
40+
if not bundle.link:
41+
return None
42+
43+
for link in bundle.link:
44+
if link.relation == "next":
45+
return _sanitize_next_link(link.url)
46+
return None
47+
48+
49+
def _sanitize_next_link(next_link: str) -> str:
50+
"""
51+
Sanitize the `next` link by validating its scheme and hostname against the origin server.
52+
53+
This function ensures the `next` link URL uses a valid scheme (`http` or `https`) and that it contains a
54+
hostname. This provides a basic safeguard against malformed URLs without overly restricting flexibility.
55+
56+
Args:
57+
next_link (str): The raw `next` link URL.
58+
59+
Returns:
60+
str: The validated URL.
61+
62+
Raises:
63+
ValueError: If the URL's scheme is not `http` or `https`, or if the hostname does not match the origin server.
64+
"""
65+
66+
parsed_url = urllib.parse.urlparse(next_link)
67+
68+
# Validate scheme and netloc (domain)
69+
if parsed_url.scheme not in ["http", "https"]:
70+
raise ValueError("Invalid URL scheme in `next` link.")
71+
if not parsed_url.netloc:
72+
raise ValueError("Invalid URL domain in `next` link.")
73+
74+
return next_link
75+
76+
77+
def _execute_pagination_request(sanitized_url: str, server: 'FHIRServer') -> 'Bundle':
78+
"""
79+
Execute the request to retrieve the next page using the sanitized URL via Bundle.read_from.
80+
81+
Args:
82+
sanitized_url (str): The sanitized URL to fetch the next page.
83+
server (FHIRServer): The FHIR server instance to perform the request.
84+
85+
Returns:
86+
Bundle: The next page of results as a FHIR Bundle.
87+
88+
Raises:
89+
HTTPError: If the request fails due to network issues or server errors.
90+
"""
91+
from fhirclient.models.bundle import Bundle
92+
return Bundle.read_from(sanitized_url, server)
93+
94+
95+
def iter_pages(first_bundle: 'Bundle', server: 'FHIRServer') -> Iterable['Bundle']:
96+
"""
97+
Iterator that yields each page of results as a FHIR Bundle.
98+
99+
Args:
100+
first_bundle (Optional[Bundle]): The first Bundle to start pagination.
101+
server (FHIRServer): The FHIR server instance to perform the request.
102+
103+
Yields:
104+
Bundle: Each page of results as a FHIR Bundle.
105+
"""
106+
# Since _fetch_next_page can return None
107+
bundle: Optional[Bundle] = first_bundle
108+
while bundle:
109+
yield bundle
110+
bundle = _fetch_next_page(bundle, server)
111+

fhirclient/client.py

Lines changed: 0 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
11
import logging
2-
import urllib
3-
from typing import Optional, Iterable
4-
5-
import requests
6-
from .models.bundle import Bundle
7-
82
from .server import FHIRServer, FHIRUnauthorizedException, FHIRNotFoundException
93

104
__version__ = '4.3.0'
@@ -244,119 +238,3 @@ def from_state(self, state):
244238

245239
def save_state (self):
246240
self._save_func(self.state)
247-
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-

fhirclient/models/bundle.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def __init__(self, jsondict=None, strict=True):
5050
Type `str`. """
5151

5252
super(Bundle, self).__init__(jsondict=jsondict, strict=strict)
53-
53+
5454
def elementProperties(self):
5555
js = super(Bundle, self).elementProperties()
5656
js.extend([

fhirclient/models/fhirsearch.py

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,22 @@
55
# 2014, SMART Health IT.
66

77
import logging
8+
import warnings
9+
from typing import Iterator, TYPE_CHECKING
10+
811

912
from . import fhirreference
13+
from .._utils import iter_pages
1014

1115
try:
1216
from urllib import quote_plus
1317
except Exception as e:
1418
from urllib.parse import quote_plus
1519

20+
if TYPE_CHECKING:
21+
from fhirclient.models.resource import Resource
22+
from fhirclient.models.bundle import Bundle
23+
1624
logger = logging.getLogger(__name__)
1725

1826

@@ -110,35 +118,61 @@ def include(self, reference_field, reference_model=None, reverse=False):
110118
self.includes.append((reference_model, reference_field, reverse))
111119
return self
112120

113-
def perform(self, server):
121+
def perform(self, server) -> 'Bundle':
114122
""" Construct the search URL and execute it against the given server.
115123
116124
:param server: The server against which to perform the search
117125
:returns: A Bundle resource
118126
"""
127+
# Old method with deprecation warning
128+
warnings.warn(
129+
"perform() is deprecated and will be removed in a future release. "
130+
"Please use perform_iter() instead.",
131+
DeprecationWarning,
132+
)
133+
119134
if server is None:
120135
raise Exception("Need a server to perform search")
121-
122-
from . import bundle
123-
res = server.request_json(self.construct())
124-
bundle = bundle.Bundle(res)
125-
bundle.origin_server = server
126-
return bundle
127-
128-
def perform_resources(self, server):
129-
""" Performs the search by calling `perform`, then extracts all Bundle
130-
entries and returns a list of Resource instances.
136+
137+
from .bundle import Bundle
138+
return Bundle.read_from(self.construct(), server)
139+
140+
# Use forward references to avoid circular imports
141+
def perform_iter(self, server) -> Iterator['Bundle']:
142+
""" Perform the search by calling `perform` and return an iterator that yields
143+
Bundle instances.
144+
145+
:param server: The server against which to perform the search
146+
:returns: An iterator of Bundle instances
147+
"""
148+
return iter_pages(self.perform(server), server)
149+
150+
def perform_resources(self, server) -> 'list[Resource]':
151+
""" Performs the search by calling `perform_resources_iter` and returns a list of Resource instances.
131152
132153
:param server: The server against which to perform the search
133154
:returns: A list of Resource instances
134155
"""
135-
bundle = self.perform(server)
136-
resources = []
137-
if bundle is not None and bundle.entry is not None:
156+
# Old method with deprecation warning
157+
warnings.warn(
158+
"perform_resources() is deprecated and will be removed in a future release. "
159+
"Please use perform_resources_iter() instead.",
160+
DeprecationWarning,
161+
)
162+
163+
return list(self.perform_resources_iter(server))
164+
165+
# Use forward references to avoid circular imports
166+
def perform_resources_iter(self, server) -> Iterator['Resource']:
167+
""" Performs the search by calling `perform_iter` and yields Resource instances
168+
from each Bundle returned by the search.
169+
170+
:param server: The server against which to perform the search
171+
:returns: An iterator of Resource instances
172+
"""
173+
for bundle in self.perform_iter(server):
138174
for entry in bundle.entry:
139-
resources.append(entry.resource)
140-
141-
return resources
175+
yield entry.resource
142176

143177

144178
class FHIRSearchParam(object):

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,5 @@ testpaths = "tests"
3535
tests = [
3636
"pytest >= 2.5",
3737
"pytest-cov",
38+
"responses",
3839
]

0 commit comments

Comments
 (0)