diff --git a/src/main/java/App.java b/src/main/java/App.java index 444e6d5fc3..7ae1be2d96 100644 --- a/src/main/java/App.java +++ b/src/main/java/App.java @@ -220,6 +220,8 @@ public static void main(String[] args) throws Exception { if (flexporterMappingFile.exists()) { Mapping mapping = Mapping.parseMapping(flexporterMappingFile); exportOptions.addFlexporterMapping(mapping); + mapping.loadValueSets(); + // disable the graalVM warning when FlexporterJavascriptContext is instantiated System.getProperties().setProperty("polyglot.engine.WarnInterpreterOnly", "false"); } else { diff --git a/src/main/java/RunFlexporter.java b/src/main/java/RunFlexporter.java index a7918e2dbb..f91103cebf 100644 --- a/src/main/java/RunFlexporter.java +++ b/src/main/java/RunFlexporter.java @@ -1,9 +1,6 @@ import ca.uhn.fhir.parser.DataFormatException; import ca.uhn.fhir.parser.IParser; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; - import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; @@ -14,7 +11,6 @@ import java.nio.file.StandardOpenOption; import java.util.ArrayDeque; import java.util.Arrays; -import java.util.Map; import java.util.Queue; import org.apache.commons.io.FilenameUtils; @@ -125,6 +121,7 @@ private static void convertFhir(File mappingFile, File igDirectory, File sourceF throws IOException { Mapping mapping = Mapping.parseMapping(mappingFile); + mapping.loadValueSets(); if (igDirectory != null) { loadIG(igDirectory); diff --git a/src/main/java/org/mitre/synthea/export/FhirR4.java b/src/main/java/org/mitre/synthea/export/FhirR4.java index 2ad2689941..afa8cf3f33 100644 --- a/src/main/java/org/mitre/synthea/export/FhirR4.java +++ b/src/main/java/org/mitre/synthea/export/FhirR4.java @@ -3385,7 +3385,7 @@ private static Type convertFhirDateTime(long datetime, boolean time) { * @param system The system identifier, such as a URI. Optional; may be null. * @return The converted CodeableConcept */ - private static CodeableConcept mapCodeToCodeableConcept(Code from, String system) { + public static CodeableConcept mapCodeToCodeableConcept(Code from, String system) { CodeableConcept to = new CodeableConcept(); system = system == null ? null : ExportHelper.getSystemURI(system); from.system = ExportHelper.getSystemURI(from.system); diff --git a/src/main/java/org/mitre/synthea/export/flexporter/Actions.java b/src/main/java/org/mitre/synthea/export/flexporter/Actions.java index 1977350b98..daa7243e37 100644 --- a/src/main/java/org/mitre/synthea/export/flexporter/Actions.java +++ b/src/main/java/org/mitre/synthea/export/flexporter/Actions.java @@ -26,6 +26,7 @@ import org.hl7.fhir.r4.model.Bundle.BundleEntryRequestComponent; import org.hl7.fhir.r4.model.Bundle.BundleType; import org.hl7.fhir.r4.model.Bundle.HTTPVerb; +import org.hl7.fhir.r4.model.Coding; import org.hl7.fhir.r4.model.DateTimeType; import org.hl7.fhir.r4.model.Encounter; import org.hl7.fhir.r4.model.Meta; @@ -822,7 +823,7 @@ private static Object getValue(Bundle bundle, String valueDef, Resource currentR } else if (flag.equals("getAttribute")) { return getAttribute(person, flagValues); } else if (flag.equals("randomCode")) { - return randomCode(flagValues[0]); + return randomCode(flagValues); } return null; @@ -900,13 +901,22 @@ private static Base findValue(Bundle bundle, String... args) { return fieldValues.get(0); } - private static Map randomCode(String valueSetUrl) { + private static Object randomCode(String... args) { + String valueSetUrl = args[0]; + String outputType = (args.length > 1) ? args[1] : "Coding"; Code code = RandomCodeGenerator.getCode(valueSetUrl, (int) (Math.random() * Integer.MAX_VALUE)); - Map codeAsMap = Map.of( - "system", code.system, - "code", code.code, - "display", code.display == null ? "" : code.display); - return codeAsMap; + + if (outputType.equalsIgnoreCase("code")) { + return code.code; + } else if (outputType.equalsIgnoreCase("Coding")) { + return new Coding(code.system, code.code, code.display); + } else if (outputType.equalsIgnoreCase("CodeableConcept")) { + return FhirR4.mapCodeToCodeableConcept(code, null); + } else { + throw new IllegalArgumentException("Unexpected output type for randomCode: " + outputType + + ". Valid values are: code, Coding, CodeableConcept"); + } + } } diff --git a/src/main/java/org/mitre/synthea/export/flexporter/Mapping.java b/src/main/java/org/mitre/synthea/export/flexporter/Mapping.java index 6920c256e7..dc5bdeeb66 100644 --- a/src/main/java/org/mitre/synthea/export/flexporter/Mapping.java +++ b/src/main/java/org/mitre/synthea/export/flexporter/Mapping.java @@ -1,5 +1,7 @@ package org.mitre.synthea.export.flexporter; +import com.fasterxml.jackson.core.JsonProcessingException; + import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; @@ -7,6 +9,9 @@ import java.util.List; import java.util.Map; +import org.hl7.fhir.r4.model.ValueSet; +import org.mitre.synthea.helpers.RandomCodeGenerator; +import org.mitre.synthea.helpers.Utilities; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.Constructor; @@ -15,6 +20,7 @@ public class Mapping { public String applicability; public Map variables; + public List> customValueSets; /** * Each action is a {@code Map>String,?>}. Nested fields within the YAML become ArrayLists and @@ -34,4 +40,20 @@ public static Mapping parseMapping(File mappingFile) throws FileNotFoundExceptio return yaml.loadAs(selectorInputSteam, Mapping.class); } + + /** + * Load the custom ValueSets that this mapping defines, so that the codes can be selected + * in RandomCodeGenerator. + */ + public void loadValueSets() { + try { + if (this.customValueSets != null) { + List valueSets = + Utilities.parseYamlToResources(this.customValueSets, ValueSet.class); + valueSets.forEach(vs -> RandomCodeGenerator.loadValueSet(null, vs)); + } + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } } diff --git a/src/main/java/org/mitre/synthea/helpers/RandomCodeGenerator.java b/src/main/java/org/mitre/synthea/helpers/RandomCodeGenerator.java index c77856f59d..0650698639 100644 --- a/src/main/java/org/mitre/synthea/helpers/RandomCodeGenerator.java +++ b/src/main/java/org/mitre/synthea/helpers/RandomCodeGenerator.java @@ -18,6 +18,8 @@ import okhttp3.ResponseBody; import org.apache.commons.lang3.StringUtils; import org.apache.commons.validator.routines.UrlValidator; +import org.hl7.fhir.r4.model.OperationOutcome; +import org.hl7.fhir.r4.model.Resource; import org.hl7.fhir.r4.model.ValueSet; import org.hl7.fhir.r4.model.ValueSet.ConceptReferenceComponent; import org.hl7.fhir.r4.model.ValueSet.ConceptSetComponent; @@ -117,8 +119,25 @@ private static synchronized void expandValueSet(String valueSetUri) { ResponseBody body = response.body(); if (body != null) { IParser parser = FhirR4.getContext().newJsonParser(); - ValueSet valueSet = (ValueSet) parser.parseResource(body.charStream()); - loadValueSet(valueSetUri, valueSet); + Resource resource = (Resource) parser.parseResource(body.charStream()); + if (resource instanceof ValueSet) { + loadValueSet(valueSetUri, (ValueSet)resource); + } else if (resource instanceof OperationOutcome) { + OperationOutcome oo = (OperationOutcome)resource; + parser.setPrettyPrint(true); + System.err.println(parser.encodeResourceToString(oo)); + String details = oo.getIssueFirstRep().getDetails().getText(); + + throw new RuntimeException( + "Received OperationOutcome in ValueSet expand response. Detail: " + + details + ". See log for full resource"); + } else { + parser.setPrettyPrint(true); + System.err.println(parser.encodeResourceToString(resource)); + throw new RuntimeException( + "Unexpected resourceType received in expand ValueSet response: " + + resource.getResourceType() + ". See log for full resource"); + } } else { throw new RuntimeException("Value Set Expansion contained no body"); } diff --git a/src/main/java/org/mitre/synthea/helpers/Utilities.java b/src/main/java/org/mitre/synthea/helpers/Utilities.java index fe983ed8b3..3ffbe25e19 100644 --- a/src/main/java/org/mitre/synthea/helpers/Utilities.java +++ b/src/main/java/org/mitre/synthea/helpers/Utilities.java @@ -1,5 +1,9 @@ package org.mitre.synthea.helpers; +import ca.uhn.fhir.parser.IParser; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.base.Charsets; import com.google.common.io.Resources; import com.google.gson.FieldNamingPolicy; @@ -36,9 +40,11 @@ import java.util.regex.Pattern; import org.apache.commons.lang3.Range; +import org.hl7.fhir.r4.model.Resource; import org.mitre.synthea.engine.Logic; import org.mitre.synthea.engine.Module; import org.mitre.synthea.engine.State; +import org.mitre.synthea.export.FhirR4; import org.mitre.synthea.world.concepts.HealthRecord.Code; public class Utilities { @@ -669,4 +675,36 @@ public static void enableReadingURIFromJar(URI uri) throws IOException { } } } + + /** + * Helper method to parse FHIR resources from YAML. + * This is a workaround since the FHIR model classes don't work with our YAML parser. + * + * @param Resource type contained in the YAML + * @param yaml List of pre-parsed YAML as Map<String, Object> + * @param resourceClass Specific resource class, must not be Resource + * @return List of parsed resources + * @throws JsonProcessingException (should never happen) + */ + public static List parseYamlToResources( + List> yaml, Class resourceClass) + throws JsonProcessingException { + if (yaml.isEmpty()) { + return Collections.emptyList(); + } + ObjectMapper jsonMapper = new ObjectMapper(); + IParser jsonParser = FhirR4.getContext().newJsonParser(); + List results = new ArrayList<>(); + for (Map singleYaml : yaml) { + if (!singleYaml.containsKey("resourceType")) { + // allows the YAML to be cleaner by letting the resourceType be implied + singleYaml.put("resourceType", resourceClass.getSimpleName()); + } + String resourceJson = jsonMapper.writeValueAsString(singleYaml); + @SuppressWarnings("unchecked") + T resource = (T) jsonParser.parseResource(resourceJson); + results.add(resource); + } + return results; + } } \ No newline at end of file diff --git a/src/test/java/org/mitre/synthea/export/flexporter/ActionsTest.java b/src/test/java/org/mitre/synthea/export/flexporter/ActionsTest.java index 951abcd1cc..1f67d1984e 100644 --- a/src/test/java/org/mitre/synthea/export/flexporter/ActionsTest.java +++ b/src/test/java/org/mitre/synthea/export/flexporter/ActionsTest.java @@ -54,12 +54,15 @@ import org.hl7.fhir.r4.model.ServiceRequest; import org.hl7.fhir.r4.model.TimeType; import org.hl7.fhir.r4.model.Type; +import org.hl7.fhir.r4.model.ValueSet; +import org.hl7.fhir.r4.model.ValueSet.ConceptSetComponent; import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; import org.mitre.synthea.engine.Module; import org.mitre.synthea.engine.State; import org.mitre.synthea.export.FhirR4; +import org.mitre.synthea.helpers.RandomCodeGenerator; import org.mitre.synthea.world.agents.Person; public class ActionsTest { @@ -82,6 +85,7 @@ public static void setupClass() throws FileNotFoundException { File file = new File(classLoader.getResource("flexporter/test_mapping.yaml").getFile()); testMapping = Mapping.parseMapping(file); + testMapping.loadValueSets(); } @AfterClass @@ -817,4 +821,34 @@ public void testGetAttribute() throws Exception { assertEquals("Robert Rainbow", name.getText()); } + @Test + public void testRandomCode() { + Bundle b = new Bundle(); + b.setType(BundleType.COLLECTION); + + Map action = getActionByName("testRandomCode"); + Actions.applyAction(b, action, null, null); + + Encounter e = (Encounter) b.getEntryFirstRep().getResource(); + + Encounter.EncounterStatus status = e.getStatus(); + assertNotNull(status); + assertTrue(status == Encounter.EncounterStatus.PLANNED + || status == Encounter.EncounterStatus.FINISHED + || status == Encounter.EncounterStatus.CANCELLED); + + Coding encClass = e.getClass_(); + assertNotNull(encClass); + assertEquals("http://terminology.hl7.org/CodeSystem/v3-ActCode", encClass.getSystem()); + String code = encClass.getCode(); + assertTrue(code.equals("AMB") || code.equals("EMER") || code.equals("ACUTE")); + + CodeableConcept type = e.getTypeFirstRep(); + assertNotNull(type); + Coding typeCoding = type.getCodingFirstRep(); + assertNotNull(typeCoding); + assertEquals("http://terminology.hl7.org/CodeSystem/encounter-type", typeCoding.getSystem()); + code = typeCoding.getCode(); + assertTrue(code.equals("ADMS") || code.equals("OKI")); + } } diff --git a/src/test/resources/flexporter/test_mapping.yaml b/src/test/resources/flexporter/test_mapping.yaml index 3f1f292ec8..6de68a0cec 100644 --- a/src/test/resources/flexporter/test_mapping.yaml +++ b/src/test/resources/flexporter/test_mapping.yaml @@ -6,6 +6,49 @@ name: Random Testing # for now the assumption is 1 file = 1 synthea patient bundle. applicability: true +# Not a huge fan of this format, but it's better than defining yet another custom syntax +customValueSets: + - url: whats-for-dinner + compose: + include: + - system: http://snomed.info/sct + concept: + - code: 227360002 + display: Pinto beans (substance) + - code: 227319009 + display: Baked beans canned in tomato sauce with burgers (substance) + - url: http://example.org/encounterStatus + compose: + include: + - system: http://hl7.org/fhir/encounter-status + concept: + - code: planned + display: Planned + - code: finished + display: Finished + - code: cancelled + display: Cancelled + - url: http://example.org/encounterClass + compose: + include: + - system: http://terminology.hl7.org/CodeSystem/v3-ActCode + concept: + - code: AMB + display: ambulatory + - code: EMER + display: emergency + - code: ACUTE + display: inpatient acute + - url: http://example.org/encounterType + compose: + include: + - system: http://terminology.hl7.org/CodeSystem/encounter-type + concept: + - code: ADMS + display: Annual diabetes mellitus screening + - code: OKI + display: Outpatient Kenacort injection + actions: - name: Apply Profiles # v1: define specific profiles and an applicability statement on when to apply them @@ -280,6 +323,17 @@ actions: location: ServiceRequest.authoredOn value: $getField([Procedure.performed]) # datetime choice type + - name: testRandomCode + create_resource: + - resourceType: Encounter + fields: + - location: Encounter.status + value: $randomCode([http://example.org/encounterStatus,code]) + - location: Encounter.class + value: $randomCode([http://example.org/encounterClass]) + - location: Encounter.type + value: $randomCode([http://example.org/encounterType,CodeableConcept]) + - name: testExecuteScript execute_script: