Skip to content

Commit dc75743

Browse files
authored
Add pagination support to FHIRGateway.search method (#153)
* Add pagination support to FHIRGateway.search method and corresponding tests * Implement pagination support in AsyncFHIRGateway.search method and add corresponding tests
1 parent 21adbf0 commit dc75743

File tree

4 files changed

+309
-2
lines changed

4 files changed

+309
-2
lines changed

healthchain/gateway/fhir/aio.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22

33
from contextlib import asynccontextmanager
4-
from typing import Any, Dict, Type
4+
from typing import Any, Dict, Optional, Type
55

66
from fhir.resources.bundle import Bundle
77
from fhir.resources.capabilitystatement import CapabilityStatement
@@ -173,6 +173,8 @@ async def search(
173173
source: str = None,
174174
add_provenance: bool = False,
175175
provenance_tag: str = None,
176+
follow_pagination: bool = False,
177+
max_pages: Optional[int] = None,
176178
) -> Bundle:
177179
"""
178180
Search for FHIR resources.
@@ -183,6 +185,8 @@ async def search(
183185
source: Source name to search in (uses first available if None)
184186
add_provenance: If True, automatically add provenance metadata to resources
185187
provenance_tag: Optional tag code for provenance (e.g., "aggregated", "transformed")
188+
follow_pagination: If True, automatically fetch all pages
189+
max_pages: Maximum number of pages to fetch (None for unlimited)
186190
187191
Returns:
188192
Bundle containing search results
@@ -212,6 +216,33 @@ async def search(
212216
client_kwargs={"params": params},
213217
)
214218

219+
# Handle pagination if requested
220+
if follow_pagination:
221+
all_entries = bundle.entry or []
222+
page_count = 1
223+
224+
while bundle.link:
225+
next_link = next((link for link in bundle.link if link.relation == "next"), None)
226+
if not next_link or (max_pages and page_count >= max_pages):
227+
break
228+
229+
# Extract the relative URL from the next link
230+
next_url = next_link.url.split("/")[-2:] # Get resource_type/_search part
231+
next_params = dict(pair.split("=") for pair in next_link.url.split("?")[1].split("&"))
232+
233+
bundle = await self._execute_with_client(
234+
"search",
235+
source=source,
236+
resource_type=resource_type,
237+
client_args=(resource_type, next_params),
238+
)
239+
240+
if bundle.entry:
241+
all_entries.extend(bundle.entry)
242+
page_count += 1
243+
244+
bundle.entry = all_entries
245+
215246
if add_provenance and bundle.entry:
216247
source_name = source or next(iter(self.connection_manager.sources.keys()))
217248
for entry in bundle.entry:

healthchain/gateway/fhir/sync.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22

3-
from typing import Any, Dict, Type
3+
from typing import Any, Dict, Type, Optional
44

55
from fhir.resources.bundle import Bundle
66
from fhir.resources.capabilitystatement import CapabilityStatement
@@ -235,6 +235,8 @@ def search(
235235
source: str = None,
236236
add_provenance: bool = False,
237237
provenance_tag: str = None,
238+
follow_pagination: bool = False,
239+
max_pages: Optional[int] = None,
238240
) -> Bundle:
239241
"""
240242
Search for FHIR resources (sync version).
@@ -245,6 +247,8 @@ def search(
245247
source: Source name to search in (uses first available if None)
246248
add_provenance: If True, automatically add provenance metadata to resources
247249
provenance_tag: Optional tag code for provenance (e.g., "aggregated", "transformed")
250+
follow_pagination: If True, automatically fetch all pages
251+
max_pages: Maximum number of pages to fetch (None for unlimited)
248252
249253
Returns:
250254
Bundle containing search results
@@ -270,6 +274,33 @@ def search(
270274
client_args=(resource_type, params),
271275
)
272276

277+
# Handle pagination if requested
278+
if follow_pagination:
279+
all_entries = bundle.entry or []
280+
page_count = 1
281+
282+
while bundle.link:
283+
next_link = next((link for link in bundle.link if link.relation == "next"), None)
284+
if not next_link or (max_pages and page_count >= max_pages):
285+
break
286+
287+
# Extract the relative URL from the next link
288+
next_url = next_link.url.split("/")[-2:] # Get resource_type/_search part
289+
next_params = dict(pair.split("=") for pair in next_link.url.split("?")[1].split("&"))
290+
291+
bundle = self._execute_with_client(
292+
"search",
293+
source=source,
294+
resource_type=resource_type,
295+
client_args=(resource_type, next_params),
296+
)
297+
298+
if bundle.entry:
299+
all_entries.extend(bundle.entry)
300+
page_count += 1
301+
302+
bundle.entry = all_entries
303+
273304
# Add provenance metadata if requested
274305
if add_provenance and bundle.entry:
275306
source_name = source or next(iter(self.connection_manager.sources.keys()))

tests/gateway/test_fhir_gateway.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,3 +284,124 @@ def test_search_with_empty_bundle():
284284
provenance_tag="aggregated",
285285
)
286286
assert result.entry is None
287+
288+
def test_search_with_pagination(fhir_gateway):
289+
"""Gateway.search fetches all pages when pagination is enabled."""
290+
# Create mock bundles for pagination
291+
page1 = Bundle(
292+
type="searchset",
293+
entry=[BundleEntry(resource=Patient(id="1"))],
294+
link=[{"relation": "next", "url": "Patient?page=2"}]
295+
)
296+
page2 = Bundle(
297+
type="searchset",
298+
entry=[BundleEntry(resource=Patient(id="2"))],
299+
link=[{"relation": "next", "url": "Patient?page=3"}]
300+
)
301+
page3 = Bundle(
302+
type="searchset",
303+
entry=[BundleEntry(resource=Patient(id="3"))]
304+
)
305+
306+
with patch.object(
307+
fhir_gateway, "_execute_with_client", side_effect=[page1, page2, page3]
308+
) as mock_execute:
309+
result = fhir_gateway.search(
310+
Patient,
311+
{"name": "Smith"},
312+
follow_pagination=True
313+
)
314+
315+
assert mock_execute.call_count == 3
316+
assert result.entry is not None
317+
assert len(result.entry) == 3
318+
assert [entry.resource.id for entry in result.entry] == ["1", "2", "3"]
319+
320+
321+
def test_search_with_max_pages(fhir_gateway):
322+
"""Gateway.search respects maximum page limit."""
323+
# Create mock bundles for pagination
324+
page1 = Bundle(
325+
type="searchset",
326+
entry=[BundleEntry(resource=Patient(id="1"))],
327+
link=[{"relation": "next", "url": "Patient?page=2"}]
328+
)
329+
page2 = Bundle(
330+
type="searchset",
331+
entry=[BundleEntry(resource=Patient(id="2"))],
332+
link=[{"relation": "next", "url": "Patient?page=3"}]
333+
)
334+
335+
with patch.object(
336+
fhir_gateway, "_execute_with_client", side_effect=[page1, page2]
337+
) as mock_execute:
338+
result = fhir_gateway.search(
339+
Patient,
340+
{"name": "Smith"},
341+
follow_pagination=True,
342+
max_pages=2
343+
)
344+
345+
assert mock_execute.call_count == 2
346+
assert result.entry is not None
347+
assert len(result.entry) == 2
348+
assert [entry.resource.id for entry in result.entry] == ["1", "2"]
349+
350+
351+
def test_search_with_pagination_empty_next_link(fhir_gateway):
352+
"""Gateway.search handles missing next links correctly."""
353+
# Create mock bundle without next link
354+
bundle = Bundle(
355+
type="searchset",
356+
entry=[BundleEntry(resource=Patient(id="1"))],
357+
link=[{"relation": "self", "url": "Patient?name=Smith"}]
358+
)
359+
360+
with patch.object(
361+
fhir_gateway, "_execute_with_client", return_value=bundle
362+
) as mock_execute:
363+
result = fhir_gateway.search(
364+
Patient,
365+
{"name": "Smith"},
366+
follow_pagination=True
367+
)
368+
369+
mock_execute.assert_called_once()
370+
assert result.entry is not None
371+
assert len(result.entry) == 1
372+
assert result.entry[0].resource.id == "1"
373+
374+
375+
def test_search_with_pagination_and_provenance(fhir_gateway):
376+
"""Gateway.search combines pagination with provenance metadata."""
377+
page1 = Bundle(
378+
type="searchset",
379+
entry=[BundleEntry(resource=Patient(id="1"))],
380+
link=[{"relation": "next", "url": "Patient?page=2"}]
381+
)
382+
page2 = Bundle(
383+
type="searchset",
384+
entry=[BundleEntry(resource=Patient(id="2"))]
385+
)
386+
387+
with patch.object(
388+
fhir_gateway, "_execute_with_client", side_effect=[page1, page2]
389+
) as mock_execute:
390+
result = fhir_gateway.search(
391+
Patient,
392+
{"name": "Smith"},
393+
source="test_source",
394+
follow_pagination=True,
395+
add_provenance=True,
396+
provenance_tag="aggregated"
397+
)
398+
399+
assert mock_execute.call_count == 2
400+
assert result.entry is not None
401+
assert len(result.entry) == 2
402+
403+
# Check provenance metadata
404+
for entry in result.entry:
405+
assert entry.resource.meta is not None
406+
assert entry.resource.meta.source == "urn:healthchain:source:test_source"
407+
assert entry.resource.meta.tag[0].code == "aggregated"

tests/gateway/test_fhir_gateway_async.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,130 @@ async def test_search_operation_with_parameters(fhir_gateway):
126126
assert result == mock_bundle
127127

128128

129+
130+
@pytest.mark.asyncio
131+
async def test_search_with_pagination(fhir_gateway):
132+
"""AsyncFHIRGateway.search fetches all pages when pagination is enabled."""
133+
# Create mock bundles for pagination
134+
page1 = Bundle(
135+
type="searchset",
136+
entry=[{"resource": Patient(id="1")}],
137+
link=[{"relation": "next", "url": "Patient?page=2"}]
138+
)
139+
page2 = Bundle(
140+
type="searchset",
141+
entry=[{"resource": Patient(id="2")}],
142+
link=[{"relation": "next", "url": "Patient?page=3"}]
143+
)
144+
page3 = Bundle(
145+
type="searchset",
146+
entry=[{"resource": Patient(id="3")}]
147+
)
148+
149+
with patch.object(
150+
fhir_gateway, "_execute_with_client", side_effect=[page1, page2, page3]
151+
) as mock_execute:
152+
result = await fhir_gateway.search(
153+
Patient,
154+
{"name": "Smith"},
155+
follow_pagination=True
156+
)
157+
158+
assert mock_execute.call_count == 3
159+
assert result.entry is not None
160+
assert len(result.entry) == 3
161+
assert [entry.resource.id for entry in result.entry] == ["1", "2", "3"]
162+
163+
164+
@pytest.mark.asyncio
165+
async def test_search_with_max_pages(fhir_gateway):
166+
"""AsyncFHIRGateway.search respects maximum page limit."""
167+
page1 = Bundle(
168+
type="searchset",
169+
entry=[{"resource": Patient(id="1")}],
170+
link=[{"relation": "next", "url": "Patient?page=2"}]
171+
)
172+
page2 = Bundle(
173+
type="searchset",
174+
entry=[{"resource": Patient(id="2")}],
175+
link=[{"relation": "next", "url": "Patient?page=3"}]
176+
)
177+
178+
with patch.object(
179+
fhir_gateway, "_execute_with_client", side_effect=[page1, page2]
180+
) as mock_execute:
181+
result = await fhir_gateway.search(
182+
Patient,
183+
{"name": "Smith"},
184+
follow_pagination=True,
185+
max_pages=2
186+
)
187+
188+
assert mock_execute.call_count == 2
189+
assert result.entry is not None
190+
assert len(result.entry) == 2
191+
assert [entry.resource.id for entry in result.entry] == ["1", "2"]
192+
193+
194+
@pytest.mark.asyncio
195+
async def test_search_with_pagination_empty_next_link(fhir_gateway):
196+
"""AsyncFHIRGateway.search handles missing next links correctly."""
197+
bundle = Bundle(
198+
type="searchset",
199+
entry=[{"resource": Patient(id="1")}],
200+
link=[{"relation": "self", "url": "Patient?name=Smith"}]
201+
)
202+
203+
with patch.object(
204+
fhir_gateway, "_execute_with_client", return_value=bundle
205+
) as mock_execute:
206+
result = await fhir_gateway.search(
207+
Patient,
208+
{"name": "Smith"},
209+
follow_pagination=True
210+
)
211+
212+
mock_execute.assert_called_once()
213+
assert result.entry is not None
214+
assert len(result.entry) == 1
215+
assert result.entry[0].resource.id == "1"
216+
217+
218+
@pytest.mark.asyncio
219+
async def test_search_with_pagination_and_provenance(fhir_gateway):
220+
"""AsyncFHIRGateway.search combines pagination with provenance metadata."""
221+
page1 = Bundle(
222+
type="searchset",
223+
entry=[{"resource": Patient(id="1")}],
224+
link=[{"relation": "next", "url": "Patient?page=2"}]
225+
)
226+
page2 = Bundle(
227+
type="searchset",
228+
entry=[{"resource": Patient(id="2")}]
229+
)
230+
231+
with patch.object(
232+
fhir_gateway, "_execute_with_client", side_effect=[page1, page2]
233+
) as mock_execute:
234+
result = await fhir_gateway.search(
235+
Patient,
236+
{"name": "Smith"},
237+
source="test_source",
238+
follow_pagination=True,
239+
add_provenance=True,
240+
provenance_tag="aggregated"
241+
)
242+
243+
assert mock_execute.call_count == 2
244+
assert result.entry is not None
245+
assert len(result.entry) == 2
246+
247+
# Check provenance metadata
248+
for entry in result.entry:
249+
assert entry.resource.meta is not None
250+
assert entry.resource.meta.source == "urn:healthchain:source:test_source"
251+
assert entry.resource.meta.tag[0].code == "aggregated"
252+
129253
@pytest.mark.asyncio
130254
async def test_modify_context_for_existing_resource(fhir_gateway, test_patient):
131255
"""Modify context manager fetches, yields, and updates existing resources."""

0 commit comments

Comments
 (0)