diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/BundleUtil.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/BundleUtil.java index 770aa1498c9c..f0872b622877 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/BundleUtil.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/BundleUtil.java @@ -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; @@ -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; @@ -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; @@ -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. + *

+ * 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. + *

+ *

+ * The Bundle is converted using the following logic: + *

+ *

+ * + * @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 null, 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 + * null, resource IDs are unchanged. + * @since 7.4.0 + */ + public static 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 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 theDeleteParts) { if (theDeleteParts == null) { throw new IllegalStateException( diff --git a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_4_0/5945-add-bundleutil-utility-transaction-converter-method.yaml b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_4_0/5945-add-bundleutil-utility-transaction-converter-method.yaml new file mode 100644 index 000000000000..a397012f3041 --- /dev/null +++ b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_4_0/5945-add-bundleutil-utility-transaction-converter-method.yaml @@ -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." diff --git a/hapi-fhir-jpaserver-ips/src/test/java/ca/uhn/fhir/jpa/ips/generator/IpsGenerationR4Test.java b/hapi-fhir-jpaserver-ips/src/test/java/ca/uhn/fhir/jpa/ips/generator/IpsGenerationR4Test.java index e697ef20b2a6..34cf71c0435d 100644 --- a/hapi-fhir-jpaserver-ips/src/test/java/ca/uhn/fhir/jpa/ips/generator/IpsGenerationR4Test.java +++ b/hapi-fhir-jpaserver-ips/src/test/java/ca/uhn/fhir/jpa/ips/generator/IpsGenerationR4Test.java @@ -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; @@ -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()); @@ -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()); @@ -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()); @@ -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()); diff --git a/hapi-fhir-jpaserver-ips/src/test/resources/large-patient-everything-4.json.gz b/hapi-fhir-jpaserver-ips/src/test/resources/large-patient-everything-4.json.gz new file mode 100644 index 000000000000..cc2fd1e4eaa5 Binary files /dev/null and b/hapi-fhir-jpaserver-ips/src/test/resources/large-patient-everything-4.json.gz differ diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/bundle/BundleUtilTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/bundle/BundleUtilTest.java index ccbb3c600809..85d6c4b4a96a 100644 --- a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/bundle/BundleUtilTest.java +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/bundle/BundleUtilTest.java @@ -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();