Skip to content

Commit

Permalink
Add utility method to convert Bundle into transaction (#5945)
Browse files Browse the repository at this point in the history
* Add utility method to convert Bundle into transaction

* Add utility method

* Spotless

* Build fix
  • Loading branch information
jamesagnew committed May 20, 2024
1 parent bff59b6 commit 97cfb6d
Show file tree
Hide file tree
Showing 5 changed files with 150 additions and 20 deletions.
68 changes: 68 additions & 0 deletions hapi-fhir-base/src/main/java/ca/uhn/fhir/util/BundleUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import com.google.common.collect.Sets;
import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
import org.apache.commons.lang3.Validate;
import org.apache.commons.lang3.tuple.Pair;
import org.hl7.fhir.instance.model.api.IBase;
import org.hl7.fhir.instance.model.api.IBaseBinary;
Expand All @@ -59,6 +60,7 @@
import java.util.function.Consumer;
import java.util.stream.Collectors;

import static org.apache.commons.lang3.StringUtils.defaultString;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
import static org.hl7.fhir.instance.model.api.IBaseBundle.LINK_PREV;
Expand All @@ -67,6 +69,12 @@
* Fetch resources from a bundle
*/
public class BundleUtil {

/** Non instantiable */
private BundleUtil() {
// nothing
}

private static final Logger ourLog = LoggerFactory.getLogger(BundleUtil.class);

private static final String PREVIOUS = LINK_PREV;
Expand Down Expand Up @@ -339,6 +347,66 @@ public static void sortEntriesIntoProcessingOrder(FhirContext theContext, IBaseB
TerserUtil.setField(theContext, "entry", theBundle, retVal.toArray(new IBase[0]));
}

/**
* Converts a Bundle containing resources into a FHIR transaction which
* creates/updates the resources. This method does not modify the original
* bundle, but returns a new copy.
* <p>
* This method is mostly intended for test scenarios where you have a Bundle
* containing search results or other sourced resources, and want to upload
* these resources to a server using a single FHIR transaction.
* </p>
* <p>
* The Bundle is converted using the following logic:
* <ul>
* <li>Bundle.type is changed to <code>transaction</code></li>
* <li>Bundle.request.method is changed to <code>PUT</code></li>
* <li>Bundle.request.url is changed to <code>[resourceType]/[id]</code></li>
* <li>Bundle.fullUrl is changed to <code>[resourceType]/[id]</code></li>
* </ul>
* </p>
*
* @param theContext The FhirContext to use with the bundle
* @param theBundle The Bundle to modify. All resources in the Bundle should have an ID.
* @param thePrefixIdsOrNull If not <code>null</code>, all resource IDs and all references in the Bundle will be
* modified to such that their IDs contain the given prefix. For example, for a value
* of "A", the resource "Patient/123" will be changed to be "Patient/A123". If set to
* <code>null</code>, resource IDs are unchanged.
* @since 7.4.0
*/
public static <T extends IBaseBundle> T convertBundleIntoTransaction(
@Nonnull FhirContext theContext, @Nonnull T theBundle, @Nullable String thePrefixIdsOrNull) {
String prefix = defaultString(thePrefixIdsOrNull);

BundleBuilder bb = new BundleBuilder(theContext);

FhirTerser terser = theContext.newTerser();
List<IBase> entries = terser.getValues(theBundle, "Bundle.entry");
for (var entry : entries) {
IBaseResource resource = terser.getSingleValueOrNull(entry, "resource", IBaseResource.class);
if (resource != null) {
Validate.isTrue(resource.getIdElement().hasIdPart(), "Resource in bundle has no ID");
String newId = theContext.getResourceType(resource) + "/" + prefix
+ resource.getIdElement().getIdPart();

IBaseResource resourceClone = terser.clone(resource);
resourceClone.setId(newId);

if (isNotBlank(prefix)) {
for (var ref : terser.getAllResourceReferences(resourceClone)) {
var refElement = ref.getResourceReference().getReferenceElement();
ref.getResourceReference()
.setReference(refElement.getResourceType() + "/" + prefix + refElement.getIdPart());
}
}

bb.addTransactionUpdateEntry(resourceClone);
}
}

return bb.getBundleTyped();
}

private static void validatePartsNotNull(LinkedHashSet<IBase> theDeleteParts) {
if (theDeleteParts == null) {
throw new IllegalStateException(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
type: add
issue: 5945
title: "A new utility method has been added to `BundleUtil` which converts a FHIR Bundle
containing resources (e.g. a search result bundle) into a FHIR transaction bundle which
could be used to upload those resources to a server."
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
import java.util.Optional;
import java.util.stream.Collectors;

import static ca.uhn.fhir.util.BundleUtil.convertBundleIntoTransaction;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.stringContainsInOrder;
Expand Down Expand Up @@ -81,11 +82,7 @@ public void afterEach() {
@Test
public void testGenerateLargePatientSummary() throws IOException {
Bundle sourceData = ClasspathUtil.loadCompressedResource(myFhirContext, Bundle.class, "/large-patient-everything.json.gz");
sourceData.setType(Bundle.BundleType.TRANSACTION);
for (Bundle.BundleEntryComponent nextEntry : sourceData.getEntry()) {
nextEntry.getRequest().setMethod(Bundle.HTTPVerb.PUT);
nextEntry.getRequest().setUrl(nextEntry.getResource().getIdElement().toUnqualifiedVersionless().getValue());
}
sourceData = convertBundleIntoTransaction(myFhirContext, sourceData, null);
Bundle outcome = mySystemDao.transaction(mySrd, sourceData);
ourLog.info("Created {} resources", outcome.getEntry().size());

Expand Down Expand Up @@ -119,11 +116,7 @@ public void testGenerateLargePatientSummary2() {
myStorageSettings.setResourceClientIdStrategy(JpaStorageSettings.ClientIdStrategyEnum.ANY);

Bundle sourceData = ClasspathUtil.loadCompressedResource(myFhirContext, Bundle.class, "/large-patient-everything-2.json.gz");
sourceData.setType(Bundle.BundleType.TRANSACTION);
for (Bundle.BundleEntryComponent nextEntry : sourceData.getEntry()) {
nextEntry.getRequest().setMethod(Bundle.HTTPVerb.PUT);
nextEntry.getRequest().setUrl(nextEntry.getResource().getIdElement().toUnqualifiedVersionless().getValue());
}
sourceData = convertBundleIntoTransaction(myFhirContext, sourceData, null);
Bundle outcome = mySystemDao.transaction(mySrd, sourceData);
ourLog.info("Created {} resources", outcome.getEntry().size());

Expand All @@ -145,11 +138,7 @@ public void testGenerateLargePatientSummary3() {
myStorageSettings.setResourceClientIdStrategy(JpaStorageSettings.ClientIdStrategyEnum.ANY);

Bundle sourceData = ClasspathUtil.loadCompressedResource(myFhirContext, Bundle.class, "/large-patient-everything-3.json.gz");
sourceData.setType(Bundle.BundleType.TRANSACTION);
for (Bundle.BundleEntryComponent nextEntry : sourceData.getEntry()) {
nextEntry.getRequest().setMethod(Bundle.HTTPVerb.PUT);
nextEntry.getRequest().setUrl(nextEntry.getResource().getIdElement().toUnqualifiedVersionless().getValue());
}
sourceData = convertBundleIntoTransaction(myFhirContext, sourceData, null);
Bundle outcome = mySystemDao.transaction(mySrd, sourceData);
ourLog.info("Created {} resources", outcome.getEntry().size());

Expand All @@ -166,16 +155,33 @@ public void testGenerateLargePatientSummary3() {
assertEquals(80, output.getEntry().size());
}

@Test
public void testGenerateLargePatientSummary4() {
Bundle sourceData = ClasspathUtil.loadCompressedResource(myFhirContext, Bundle.class, "/large-patient-everything-4.json.gz");
sourceData = convertBundleIntoTransaction(myFhirContext, sourceData, "EPD");

Bundle outcome = mySystemDao.transaction(mySrd, sourceData);
ourLog.info("Created {} resources", outcome.getEntry().size());

Bundle output = myClient
.operation()
.onInstance("Patient/EPD2223")
.named(JpaConstants.OPERATION_SUMMARY)
.withNoParameters(Parameters.class)
.returnResourceType(Bundle.class)
.execute();
ourLog.info("Output: {}", myFhirContext.newJsonParser().setPrettyPrint(true).encodeResourceToString(output));

// Verify
assertEquals(55, output.getEntry().size());
}

@Test
public void testGenerateTinyPatientSummary() throws IOException {
myStorageSettings.setResourceClientIdStrategy(JpaStorageSettings.ClientIdStrategyEnum.ANY);

Bundle sourceData = ClasspathUtil.loadCompressedResource(myFhirContext, Bundle.class, "/tiny-patient-everything.json.gz");
sourceData.setType(Bundle.BundleType.TRANSACTION);
for (Bundle.BundleEntryComponent nextEntry : sourceData.getEntry()) {
nextEntry.getRequest().setMethod(Bundle.HTTPVerb.PUT);
nextEntry.getRequest().setUrl(nextEntry.getResource().getIdElement().toUnqualifiedVersionless().getValue());
}
sourceData = convertBundleIntoTransaction(myFhirContext, sourceData, null);
Bundle outcome = mySystemDao.transaction(mySrd, sourceData);
ourLog.info("Created {} resources", outcome.getEntry().size());

Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,56 @@ public void testGetBundleTypeEnum_withNoBundleType_returnsNull(){
assertNull(BundleUtil.getBundleTypeEnum(ourCtx, bundle));
}

@Test
public void testConvertBundleIntoTransaction() {
Bundle input = createBundleWithPatientAndObservation();

Bundle output = BundleUtil.convertBundleIntoTransaction(ourCtx, input, null);
assertEquals(Bundle.BundleType.TRANSACTION, output.getType());
assertEquals("Patient/123", output.getEntry().get(0).getFullUrl());
assertEquals("Patient/123", output.getEntry().get(0).getRequest().getUrl());
assertEquals(Bundle.HTTPVerb.PUT, output.getEntry().get(0).getRequest().getMethod());
assertTrue(((Patient) output.getEntry().get(0).getResource()).getActive());
assertEquals("Observation/456", output.getEntry().get(1).getFullUrl());
assertEquals("Observation/456", output.getEntry().get(1).getRequest().getUrl());
assertEquals(Bundle.HTTPVerb.PUT, output.getEntry().get(1).getRequest().getMethod());
assertEquals("Patient/123", ((Observation)output.getEntry().get(1).getResource()).getSubject().getReference());
assertEquals(Observation.ObservationStatus.AMENDED, ((Observation)output.getEntry().get(1).getResource()).getStatus());
}

@Test
public void testConvertBundleIntoTransaction_WithPrefix() {
Bundle input = createBundleWithPatientAndObservation();

Bundle output = BundleUtil.convertBundleIntoTransaction(ourCtx, input, "A");
assertEquals(Bundle.BundleType.TRANSACTION, output.getType());
assertEquals("Patient/A123", output.getEntry().get(0).getFullUrl());
assertEquals("Patient/A123", output.getEntry().get(0).getRequest().getUrl());
assertEquals(Bundle.HTTPVerb.PUT, output.getEntry().get(0).getRequest().getMethod());
assertTrue(((Patient) output.getEntry().get(0).getResource()).getActive());
assertEquals("Observation/A456", output.getEntry().get(1).getFullUrl());
assertEquals("Observation/A456", output.getEntry().get(1).getRequest().getUrl());
assertEquals(Bundle.HTTPVerb.PUT, output.getEntry().get(1).getRequest().getMethod());
assertEquals("Patient/A123", ((Observation)output.getEntry().get(1).getResource()).getSubject().getReference());
assertEquals(Observation.ObservationStatus.AMENDED, ((Observation)output.getEntry().get(1).getResource()).getStatus());
}

private static @Nonnull Bundle createBundleWithPatientAndObservation() {
Bundle input = new Bundle();
input.setType(Bundle.BundleType.COLLECTION);
Patient patient = new Patient();
patient.setActive(true);
patient.setId("123");
input.addEntry().setResource(patient);
Observation observation = new Observation();
observation.setId("456");
observation.setStatus(Observation.ObservationStatus.AMENDED);
observation.setSubject(new Reference("Patient/123"));
input.addEntry().setResource(observation);
return input;
}


@Nonnull
private static Bundle withBundle(Resource theResource) {
final Bundle bundle = new Bundle();
Expand Down

0 comments on commit 97cfb6d

Please sign in to comment.