diff --git a/README.md b/README.md index b02a6e2..b8ccc45 100755 --- a/README.md +++ b/README.md @@ -185,6 +185,42 @@ URL: This approach ensures consistency and clarity when accessing various endpoint types through the gateway. +### Custom Headers + +#### FHIR-Gateway-Mode + +##### Overview + +The FHIR Gateway Mode allows for custom processing of responses from the FHIR +server. The mode is triggered by a HTTP Header sent by the client named +`FHIR-Gateway-Mode` with a value e.g. `list-entries`(Currently only supported). + +##### FHIR-Gateway-Mode: list-entries + +This mode is used when using the `/List` endpoint. Normally, fetching using this +endpoint returns a list of references which can then be used to query for the +actual resources. With this header value configured the response is instead a +Bundle that contains all the actual (referenced) resources. + +###### Pagination + +Pagination is supported in fetching the data from a FHIR server. This can be +useful when dealing with List resources that have a large number of referenced +entries like Locations. + +To enable pagination, you need to include two parameters in the request URL: + +- `_page`: This parameter specifies the page number and has a default value + of 1. +- `_count`: This parameter sets the number of items per page and has a default + value of 20. + +Example: + +``` +[GET] /List?_id=&_count=&_page=&_sort= +``` + #### Important Note: Developers, please update your client applications accordingly to accommodate diff --git a/plugins/src/main/java/org/smartregister/fhir/gateway/plugins/Constants.java b/plugins/src/main/java/org/smartregister/fhir/gateway/plugins/Constants.java index e858928..96eac4f 100644 --- a/plugins/src/main/java/org/smartregister/fhir/gateway/plugins/Constants.java +++ b/plugins/src/main/java/org/smartregister/fhir/gateway/plugins/Constants.java @@ -26,6 +26,14 @@ public class Constants { public static final String KEYCLOAK_UUID = "keycloak-uuid"; public static final String IDENTIFIER = "identifier"; + public static final String PAGINATION_PAGE_SIZE = "_count"; + + public static final String PAGINATION_PAGE_NUMBER = "_page"; + + public static final int PAGINATION_DEFAULT_PAGE_SIZE = 20; + + public static final int PAGINATION_DEFAULT_PAGE_NUMBER = 1; + public interface Literals { String EQUALS = "="; } diff --git a/plugins/src/main/java/org/smartregister/fhir/gateway/plugins/SyncAccessDecision.java b/plugins/src/main/java/org/smartregister/fhir/gateway/plugins/SyncAccessDecision.java index a2629ab..88dd567 100755 --- a/plugins/src/main/java/org/smartregister/fhir/gateway/plugins/SyncAccessDecision.java +++ b/plugins/src/main/java/org/smartregister/fhir/gateway/plugins/SyncAccessDecision.java @@ -18,6 +18,7 @@ import org.apache.http.HttpResponse; import org.apache.http.impl.client.BasicResponseHandler; import org.apache.http.util.TextUtils; +import org.hl7.fhir.instance.model.api.IBaseBundle; import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.r4.model.Bundle; import org.hl7.fhir.r4.model.ListResource; @@ -29,6 +30,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.fhir.gateway.ExceptionUtil; +import com.google.fhir.gateway.ProxyConstants; import com.google.fhir.gateway.interfaces.AccessDecision; import com.google.fhir.gateway.interfaces.RequestDetailsReader; import com.google.fhir.gateway.interfaces.RequestMutation; @@ -168,7 +170,7 @@ public String postProcess(RequestDetailsReader request, HttpResponse response) switch (gatewayMode) { case SyncAccessDecisionConstants.LIST_ENTRIES: - resultContentBundle = postProcessModeListEntries(responseResource); + resultContentBundle = postProcessModeListEntries(responseResource, request); break; default: @@ -216,11 +218,16 @@ private static OperationOutcome createOperationOutcome(String exception) { @NotNull private static Bundle processListEntriesGatewayModeByListResource( - ListResource responseListResource) { + ListResource responseListResource, int start, int count) { Bundle requestBundle = new Bundle(); requestBundle.setType(Bundle.BundleType.BATCH); - for (ListResource.ListEntryComponent listEntryComponent : responseListResource.getEntry()) { + int end = start + count; + + List entries = responseListResource.getEntry(); + + for (int i = start; i < Math.min(end, entries.size()); i++) { + ListResource.ListEntryComponent listEntryComponent = entries.get(i); requestBundle.addEntry( createBundleEntryComponent( Bundle.HTTPVerb.GET, @@ -230,7 +237,8 @@ private static Bundle processListEntriesGatewayModeByListResource( return requestBundle; } - private Bundle processListEntriesGatewayModeByBundle(IBaseResource responseResource) { + private Bundle processListEntriesGatewayModeByBundle( + IBaseResource responseResource, int start, int count) { Bundle requestBundle = new Bundle(); requestBundle.setType(Bundle.BundleType.BATCH); @@ -242,6 +250,8 @@ private Bundle processListEntriesGatewayModeByBundle(IBaseResource responseResou bundleEntryComponent -> ((ListResource) bundleEntryComponent.getResource()) .getEntry().stream()) + .skip(start) + .limit(count) .map( listEntryComponent -> createBundleEntryComponent( @@ -271,25 +281,85 @@ static Bundle.BundleEntryComponent createBundleEntryComponent( * Generates a Bundle result from making a batch search request with the contained entries in * the List as parameters * - * @param responseResource FHIR Resource result returned byt the HTTPResponse + * @param responseResource FHIR Resource result returned by the HTTPResponse * @return String content of the result Bundle */ - private Bundle postProcessModeListEntries(IBaseResource responseResource) { - + private Bundle postProcessModeListEntries( + IBaseResource responseResource, RequestDetailsReader request) { + + Map parameters = new HashMap<>(request.getParameters()); + String[] pageSize = parameters.get(Constants.PAGINATION_PAGE_SIZE); + String[] pageNumber = parameters.get(Constants.PAGINATION_PAGE_NUMBER); + + int totalEntries = 0; + int count = + pageSize != null && pageSize.length > 0 + ? Integer.parseInt(pageSize[0]) + : Constants.PAGINATION_DEFAULT_PAGE_SIZE; + int page = + pageNumber != null && pageNumber.length > 0 + ? Integer.parseInt(pageNumber[0]) + : Constants.PAGINATION_DEFAULT_PAGE_NUMBER; + + int start = Math.max(0, (page - 1)) * count; Bundle requestBundle = null; if (responseResource instanceof ListResource && ((ListResource) responseResource).hasEntry()) { - + totalEntries = ((ListResource) responseResource).getEntry().size(); requestBundle = - processListEntriesGatewayModeByListResource((ListResource) responseResource); + processListEntriesGatewayModeByListResource( + (ListResource) responseResource, start, count); } else if (responseResource instanceof Bundle) { + List entries = ((Bundle) responseResource).getEntry(); + for (Bundle.BundleEntryComponent entry : entries) { + if (entry.getResource() instanceof ListResource) { + totalEntries = ((ListResource) entry.getResource()).getEntry().size(); + break; + } + } - requestBundle = processListEntriesGatewayModeByBundle(responseResource); + requestBundle = processListEntriesGatewayModeByBundle(responseResource, start, count); + } + + Bundle resultBundle = fhirR4Client.transaction().withBundle(requestBundle).execute(); + + // add total + resultBundle.setTotal(requestBundle.getEntry().size()); + + // add pagination links + int nextPage = page < totalEntries / count ? page + 1 : 0; // 0 indicates no next page + int prevPage = page > 1 ? page - 1 : 0; // 0 indicates no previous page + + Bundle.BundleLinkComponent selfLink = new Bundle.BundleLinkComponent(); + List link = new ArrayList<>(); + String selfUrl = constructUpdatedUrl(request, parameters); + selfLink.setRelation(IBaseBundle.LINK_SELF); + selfLink.setUrl(selfUrl); + link.add(selfLink); + resultBundle.setLink(link); + + if (nextPage > 0) { + parameters.put( + Constants.PAGINATION_PAGE_NUMBER, new String[] {String.valueOf(nextPage)}); + String nextUrl = constructUpdatedUrl(request, parameters); + Bundle.BundleLinkComponent nextLink = new Bundle.BundleLinkComponent(); + nextLink.setRelation(IBaseBundle.LINK_NEXT); + nextLink.setUrl(nextUrl); + resultBundle.addLink(nextLink); + } + if (prevPage > 0) { + parameters.put( + Constants.PAGINATION_PAGE_NUMBER, new String[] {String.valueOf(prevPage)}); + String prevUrl = constructUpdatedUrl(request, parameters); + Bundle.BundleLinkComponent previousLink = new Bundle.BundleLinkComponent(); + previousLink.setRelation(IBaseBundle.LINK_PREV); + previousLink.setUrl(prevUrl); + resultBundle.addLink(previousLink); } - return fhirR4Client.transaction().withBundle(requestBundle).execute(); + return resultBundle; } private String getSyncTagUrl(String syncStrategy) { @@ -369,10 +439,33 @@ private boolean isSyncUrl(RequestDetailsReader requestDetailsReader) { return false; } + private static String constructUpdatedUrl( + RequestDetailsReader requestDetails, Map parameters) { + StringBuilder updatedUrlBuilder = new StringBuilder(requestDetails.getFhirServerBase()); + + updatedUrlBuilder.append("/").append(requestDetails.getRequestPath()); + + updatedUrlBuilder.append("?"); + for (Map.Entry entry : parameters.entrySet()) { + String paramName = entry.getKey(); + String[] paramValues = entry.getValue(); + + for (String paramValue : paramValues) { + updatedUrlBuilder.append(paramName).append("=").append(paramValue).append("&"); + } + } + + // Remove the trailing '&' if present + if (updatedUrlBuilder.charAt(updatedUrlBuilder.length() - 1) == '&') { + updatedUrlBuilder.deleteCharAt(updatedUrlBuilder.length() - 1); + } + + return updatedUrlBuilder.toString(); + } + private boolean isResourceTypeRequest(String requestPath) { if (!TextUtils.isEmpty(requestPath)) { - String[] sections = - requestPath.split(com.google.fhir.gateway.ProxyConstants.HTTP_URL_SEPARATOR); + String[] sections = requestPath.split(ProxyConstants.HTTP_URL_SEPARATOR); return sections.length == 1 || (sections.length == 2 && TextUtils.isEmpty(sections[1])); } diff --git a/plugins/src/test/java/org/smartregister/fhir/gateway/plugins/SyncAccessDecisionTest.java b/plugins/src/test/java/org/smartregister/fhir/gateway/plugins/SyncAccessDecisionTest.java index 2072af2..51cf978 100755 --- a/plugins/src/test/java/org/smartregister/fhir/gateway/plugins/SyncAccessDecisionTest.java +++ b/plugins/src/test/java/org/smartregister/fhir/gateway/plugins/SyncAccessDecisionTest.java @@ -368,7 +368,9 @@ public void testPostProcessWithListModeHeaderShouldFetchListEntriesBundle() thro Mockito.mock(HttpResponse.class, Answers.RETURNS_DEEP_STUBS); TestUtil.setUpFhirResponseMock(fhirResponseMock, testListJson); - + String fhirServerBase = "http://test:8080/fhir"; + Mockito.when(requestDetailsSpy.getFhirServerBase()).thenReturn(fhirServerBase); + Mockito.when(requestDetailsSpy.getRequestPath()).thenReturn("List"); String resultContent = testInstance.postProcess(requestDetailsSpy, fhirResponseMock); Mockito.verify(iTransaction).withBundle(bundleArgumentCaptor.capture()); @@ -393,7 +395,7 @@ public void testPostProcessWithListModeHeaderShouldFetchListEntriesBundle() thro // Verify returned result content from the server request Assert.assertNotNull(resultContent); Assert.assertEquals( - "{\"resourceType\":\"Bundle\",\"id\":\"bundle-result-id\",\"type\":\"batch-response\"}", + "{\"resourceType\":\"Bundle\",\"id\":\"bundle-result-id\",\"type\":\"batch-response\",\"total\":2,\"link\":[{\"relation\":\"self\",\"url\":\"http://test:8080/fhir/List?\"}]}", resultContent); } @@ -466,6 +468,9 @@ public void testPostProcessWithListModeHeaderSearchByTagShouldFetchListEntriesBu testInstance.setFhirR4Client(iGenericClient); testInstance.setFhirR4Context(FhirContext.forR4()); + String fhirServerBase = "http://test:8080/fhir"; + Mockito.when(requestDetailsSpy.getFhirServerBase()).thenReturn(fhirServerBase); + Mockito.when(requestDetailsSpy.getRequestPath()).thenReturn("List"); String resultContent = testInstance.postProcess(requestDetailsSpy, fhirResponseMock); Mockito.verify(iTransaction).withBundle(bundleArgumentCaptor.capture()); @@ -490,7 +495,170 @@ public void testPostProcessWithListModeHeaderSearchByTagShouldFetchListEntriesBu // Verify returned result content from the server request Assert.assertNotNull(resultContent); Assert.assertEquals( - "{\"resourceType\":\"Bundle\",\"id\":\"bundle-result-id\",\"type\":\"batch-response\"}", + "{\"resourceType\":\"Bundle\",\"id\":\"bundle-result-id\",\"type\":\"batch-response\",\"total\":2,\"link\":[{\"relation\":\"self\",\"url\":\"http://test:8080/fhir/List?\"}]}", + resultContent); + } + + @Test + public void testPostProcessWithListModeHeaderPaginateEntriesBundle() throws IOException { + locationIds.add("Location-1"); + testInstance = Mockito.spy(createSyncAccessDecisionTestInstance(Constants.LOCATION)); + + FhirContext fhirR4Context = mock(FhirContext.class); + IGenericClient iGenericClient = mock(IGenericClient.class); + ITransaction iTransaction = mock(ITransaction.class); + ITransactionTyped iClientExecutable = mock(ITransactionTyped.class); + testInstance.setFhirR4Client(iGenericClient); + testInstance.setFhirR4Context(fhirR4Context); + + Mockito.when(iGenericClient.transaction()).thenReturn(iTransaction); + Mockito.when(iTransaction.withBundle(any(Bundle.class))).thenReturn(iClientExecutable); + + Bundle resultBundle = new Bundle(); + resultBundle.setType(Bundle.BundleType.BATCHRESPONSE); + resultBundle.setId("bundle-result-id"); + + Mockito.when(iClientExecutable.execute()).thenReturn(resultBundle); + + ArgumentCaptor bundleArgumentCaptor = ArgumentCaptor.forClass(Bundle.class); + + testInstance.setFhirR4Context(fhirR4Context); + + RequestDetailsReader requestDetailsSpy = Mockito.mock(RequestDetailsReader.class); + + Mockito.when( + requestDetailsSpy.getHeader( + SyncAccessDecision.SyncAccessDecisionConstants.FHIR_GATEWAY_MODE)) + .thenReturn(SyncAccessDecision.SyncAccessDecisionConstants.LIST_ENTRIES); + + Map params = new HashMap<>(); + params.put("_count", new String[] {"1"}); + params.put("_page", new String[] {"1"}); + + String fhirServerBase = "http://test:8080/fhir"; + + Mockito.when(requestDetailsSpy.getParameters()).thenReturn(params); + Mockito.when(requestDetailsSpy.getFhirServerBase()).thenReturn(fhirServerBase); + Mockito.when(requestDetailsSpy.getRequestPath()).thenReturn("List"); + + URL listUrl = Resources.getResource("test_list_resource.json"); + String testListJson = Resources.toString(listUrl, StandardCharsets.UTF_8); + + HttpResponse fhirResponseMock = + Mockito.mock(HttpResponse.class, Answers.RETURNS_DEEP_STUBS); + + TestUtil.setUpFhirResponseMock(fhirResponseMock, testListJson); + + String resultContent = testInstance.postProcess(requestDetailsSpy, fhirResponseMock); + + Mockito.verify(iTransaction).withBundle(bundleArgumentCaptor.capture()); + Bundle requestBundle = bundleArgumentCaptor.getValue(); + + // Verify modified request to the server + Assert.assertNotNull(requestBundle); + Assert.assertEquals(Bundle.BundleType.BATCH, requestBundle.getType()); + List requestBundleEntries = requestBundle.getEntry(); + + // Only one returned one _page = 1 and _count = 1 + Assert.assertEquals(1, requestBundleEntries.size()); + + Assert.assertEquals( + Bundle.HTTPVerb.GET, requestBundleEntries.get(0).getRequest().getMethod()); + Assert.assertEquals( + "Group/proxy-list-entry-id-1", requestBundleEntries.get(0).getRequest().getUrl()); + + Assert.assertEquals( + Bundle.HTTPVerb.GET, requestBundleEntries.get(0).getRequest().getMethod()); + + // Verify returned result content from the server request has pagination links + Assert.assertNotNull(resultContent); + Assert.assertEquals( + "{\"resourceType\":\"Bundle\",\"id\":\"bundle-result-id\",\"type\":\"batch-response\",\"total\":1,\"link\":[{\"relation\":\"self\",\"url\":\"http://test:8080/fhir/List?_page=1&_count=1\"},{\"relation\":\"next\",\"url\":\"http://test:8080/fhir/List?_page=2&_count=1\"}]}", + resultContent); + } + + @Test + public void testPostProcessWithListModeHeaderSearchByTagPaginateEntriesBundle() + throws IOException { + locationIds.add("Location-1"); + testInstance = Mockito.spy(createSyncAccessDecisionTestInstance(Constants.LOCATION)); + + FhirContext fhirR4Context = mock(FhirContext.class); + IGenericClient iGenericClient = mock(IGenericClient.class); + ITransaction iTransaction = mock(ITransaction.class); + ITransactionTyped iClientExecutable = mock(ITransactionTyped.class); + + Mockito.when(iGenericClient.transaction()).thenReturn(iTransaction); + Mockito.when(iTransaction.withBundle(any(Bundle.class))).thenReturn(iClientExecutable); + + Bundle resultBundle = new Bundle(); + resultBundle.setType(Bundle.BundleType.BATCHRESPONSE); + resultBundle.setId("bundle-result-id"); + + Mockito.when(iClientExecutable.execute()).thenReturn(resultBundle); + + ArgumentCaptor bundleArgumentCaptor = ArgumentCaptor.forClass(Bundle.class); + + testInstance.setFhirR4Context(fhirR4Context); + + RequestDetailsReader requestDetailsSpy = Mockito.mock(RequestDetailsReader.class); + + Mockito.when( + requestDetailsSpy.getHeader( + SyncAccessDecision.SyncAccessDecisionConstants.FHIR_GATEWAY_MODE)) + .thenReturn(SyncAccessDecision.SyncAccessDecisionConstants.LIST_ENTRIES); + + Map params = new HashMap<>(); + params.put("_count", new String[] {"1"}); + params.put("_page", new String[] {"2"}); + String fhirServerBase = "http://test:8080/fhir"; + + Mockito.when(requestDetailsSpy.getParameters()).thenReturn(params); + Mockito.when(requestDetailsSpy.getFhirServerBase()).thenReturn(fhirServerBase); + Mockito.when(requestDetailsSpy.getRequestPath()).thenReturn("List"); + + URL listUrl = Resources.getResource("test_list_resource.json"); + String testListJson = Resources.toString(listUrl, StandardCharsets.UTF_8); + + ListResource listResource = + (ListResource) FhirContext.forR4().newJsonParser().parseResource(testListJson); + + Bundle bundle = new Bundle(); + Bundle.BundleEntryComponent bundleEntryComponent = new Bundle.BundleEntryComponent(); + bundleEntryComponent.setResource(listResource); + bundle.setType(Bundle.BundleType.BATCHRESPONSE); + bundle.setEntry(Arrays.asList(bundleEntryComponent)); + + HttpResponse fhirResponseMock = + Mockito.mock(HttpResponse.class, Answers.RETURNS_DEEP_STUBS); + + TestUtil.setUpFhirResponseMock( + fhirResponseMock, + FhirContext.forR4().newJsonParser().encodeResourceToString(bundle)); + + testInstance.setFhirR4Client(iGenericClient); + testInstance.setFhirR4Context(FhirContext.forR4()); + String resultContent = testInstance.postProcess(requestDetailsSpy, fhirResponseMock); + + Mockito.verify(iTransaction).withBundle(bundleArgumentCaptor.capture()); + Bundle requestBundle = bundleArgumentCaptor.getValue(); + + Assert.assertNotNull(requestBundle); + Assert.assertEquals(Bundle.BundleType.BATCH, requestBundle.getType()); + List requestBundleEntries = requestBundle.getEntry(); + + // Only one bundle is returned based on the _page and _count params provided above + Assert.assertEquals(1, requestBundleEntries.size()); + + Assert.assertEquals( + Bundle.HTTPVerb.GET, requestBundleEntries.get(0).getRequest().getMethod()); + Assert.assertEquals( + "Group/proxy-list-entry-id-2", requestBundleEntries.get(0).getRequest().getUrl()); + + // Verify returned result content from the server request, has pagination links + Assert.assertNotNull(resultContent); + Assert.assertEquals( + "{\"resourceType\":\"Bundle\",\"id\":\"bundle-result-id\",\"type\":\"batch-response\",\"total\":1,\"link\":[{\"relation\":\"self\",\"url\":\"http://test:8080/fhir/List?_page=2&_count=1\"},{\"relation\":\"previous\",\"url\":\"http://test:8080/fhir/List?_page=1&_count=1\"}]}", resultContent); }