diff --git a/README.md b/README.md index ebca0d8..e88cfc0 100644 --- a/README.md +++ b/README.md @@ -357,11 +357,10 @@ It is recommended to deploy a case-sensitive database prior to running HAPI FHIR Custom interceptors can be registered with the server by including the property `hapi.fhir.custom-interceptor-classes`. This will take a comma separated list of fully-qualified class names which will be registered with the server. Interceptors will be discovered in one of two ways: -1) discovered from the Spring application context as existing Beans (can be used in conjunction with `hapi.fhir.custom-bean-packages`) or registered with Spring via other methods +1) From the Spring application context as existing Beans (can be used in conjunction with `hapi.fhir.custom-bean-packages`) or registered with Spring via other methods +2) Classes will be instantiated via reflection if no matching Bean is found -or - -2) classes will be instantiated via reflection if no matching Bean is found +Interceptors can also be registered manually through `RestfulServer.registerInterceptor`. Take note that any interceptor registered in this way _will not fire_ for non-REST operations, e.g. creation through a DAO. To trigger in this case, you need to register your interceptors on the `IInterceptorService` bean. ## Adding custom operations(providers) Custom operations(providers) can be registered with the server by including the property `hapi.fhir.custom-provider-classes`. This will take a comma separated list of fully-qualified class names which will be registered with the server. diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java index 33c9115..0095b53 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java @@ -106,6 +106,8 @@ public class AppProperties { private Integer pre_expand_value_sets_max_count = 1000; private Integer maximum_expansion_size = 1000; + private Map remote_terminology_service = null; + public List getCustomInterceptorClasses() { return custom_interceptor_classes; } @@ -723,6 +725,14 @@ public class AppProperties { this.maximum_expansion_size = maximum_expansion_size; } + public Map getRemoteTerminologyServicesMap() { + return remote_terminology_service; + } + + public void setRemote_terminology_service(Map remote_terminology_service) { + this.remote_terminology_service = remote_terminology_service; + } + public static class Cors { private Boolean allow_Credentials = true; private List allowed_origin = List.of("*"); @@ -919,6 +929,27 @@ public class AppProperties { } } + public static class RemoteSystem { + private String system; + private String url; + + public String getSystem() { + return system; + } + + public void setSystem(String system) { + this.system = system; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + } + public static class Subscription { private Boolean resthook_enabled = false; diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/common/validation/OnRemoteTerminologyPresent.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/validation/OnRemoteTerminologyPresent.java new file mode 100644 index 0000000..bd11463 --- /dev/null +++ b/src/main/java/ca/uhn/fhir/jpa/starter/common/validation/OnRemoteTerminologyPresent.java @@ -0,0 +1,20 @@ +package ca.uhn.fhir.jpa.starter.common.validation; + +import ca.uhn.fhir.jpa.starter.AppProperties; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; + +public class OnRemoteTerminologyPresent implements Condition { + @Override + public boolean matches(ConditionContext conditionContext, AnnotatedTypeMetadata metadata) { + + AppProperties config = Binder.get(conditionContext.getEnvironment()) + .bind("hapi.fhir", AppProperties.class) + .orElse(null); + if (config == null) return false; + if (config.getRemoteTerminologyServicesMap() == null) return false; + return !config.getRemoteTerminologyServicesMap().isEmpty(); + } +} diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/terminology/TerminologyConfig.java b/src/main/java/ca/uhn/fhir/jpa/starter/terminology/TerminologyConfig.java new file mode 100644 index 0000000..9b61dde --- /dev/null +++ b/src/main/java/ca/uhn/fhir/jpa/starter/terminology/TerminologyConfig.java @@ -0,0 +1,68 @@ +package ca.uhn.fhir.jpa.starter.terminology; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.support.ConceptValidationOptions; +import ca.uhn.fhir.context.support.IValidationSupport; +import ca.uhn.fhir.context.support.ValidationSupportContext; +import ca.uhn.fhir.jpa.starter.AppProperties; +import ca.uhn.fhir.jpa.starter.common.StarterJpaConfig; +import ca.uhn.fhir.jpa.starter.common.validation.OnRemoteTerminologyPresent; +import org.hl7.fhir.common.hapi.validation.support.RemoteTerminologyServiceValidationSupport; +import org.hl7.fhir.common.hapi.validation.support.ValidationSupportChain; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +@Configuration +@Conditional(OnRemoteTerminologyPresent.class) +@Import(StarterJpaConfig.class) +public class TerminologyConfig { + + @Bean(name = "myHybridRemoteValidationSupportChain") + public IValidationSupport addRemoteValidation( + ValidationSupportChain theValidationSupport, FhirContext theFhirContext, AppProperties theAppProperties) { + var values = theAppProperties.getRemoteTerminologyServicesMap().values(); + + // If the remote terminology service is "*" and is the only one then forward all requests to the remote + // terminology service + if (values.size() == 1 && "*".equalsIgnoreCase(values.iterator().next().getSystem())) { + var remoteSystem = values.iterator().next(); + theValidationSupport.addValidationSupport( + 0, new RemoteTerminologyServiceValidationSupport(theFhirContext, remoteSystem.getUrl())); + return theValidationSupport; + + // If there are multiple remote terminology services, then add each one to the validation chain + } else { + values.forEach((remoteSystem) -> theValidationSupport.addValidationSupport( + 0, new RemoteTerminologyServiceValidationSupport(theFhirContext, remoteSystem.getUrl()) { + @Override + public boolean isCodeSystemSupported( + ValidationSupportContext theValidationSupportContext, String theSystem) { + return remoteSystem.getSystem().equalsIgnoreCase(theSystem); + } + + @Override + public CodeValidationResult validateCode( + ValidationSupportContext theValidationSupportContext, + ConceptValidationOptions theOptions, + String theCodeSystem, + String theCode, + String theDisplay, + String theValueSetUrl) { + if (remoteSystem.getSystem().equalsIgnoreCase(theCodeSystem)) { + return super.validateCode( + theValidationSupportContext, + theOptions, + theCodeSystem, + theCode, + theDisplay, + theValueSetUrl); + } + return null; + } + })); + } + return theValidationSupport; + } +} diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/util/EnvironmentHelper.java b/src/main/java/ca/uhn/fhir/jpa/starter/util/EnvironmentHelper.java index e62b089..e22a179 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/util/EnvironmentHelper.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/util/EnvironmentHelper.java @@ -40,6 +40,19 @@ public class EnvironmentHelper { properties.put(strippedKey, entry.getValue().toString()); } + // also check for JPA properties set as environment variables, this is slightly hacky and doesn't cover all + // the naming conventions Springboot allows + // but there doesn't seem to be a better/deterministic way to get these properties when they are set as ENV + // variables and this at least provides + // a way to set them (in a docker container, for instance) + Map jpaPropsEnv = getPropertiesStartingWith(environment, "SPRING_JPA_PROPERTIES"); + for (Map.Entry entry : jpaPropsEnv.entrySet()) { + String strippedKey = entry.getKey().replace("SPRING_JPA_PROPERTIES_", ""); + strippedKey = strippedKey.replaceAll("_", "."); + strippedKey = strippedKey.toLowerCase(); + properties.put(strippedKey, entry.getValue().toString()); + } + // Spring Boot Autoconfiguration defaults properties.putIfAbsent(AvailableSettings.SCANNER, "org.hibernate.boot.archive.scan.internal.DisabledScanner"); properties.putIfAbsent( diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index ae90091..d4df357 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -293,6 +293,18 @@ hapi: # max_page_size: 200 # retain_cached_searches_mins: 60 # reuse_cached_search_results_millis: 60000 + # The remote_terminology_service block is commented out by default because it requires external terminology service endpoints. + # Uncomment and configure the block below if you need to enable remote terminology validation or mapping. + #remote_terminology_service: + # all: + # system: '*' + # url: 'https://tx.fhir.org/r4/' + # snomed: + # system: 'http://snomed.info/sct' + # url: 'https://tx.fhir.org/r4/' + # loinc: + # system: 'http://loinc.org' + # url: 'https://hapi.fhir.org/baseR4/' tester: home: name: Local Tester diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerR4IT.java b/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerR4IT.java index 3a15b96..1aef83a 100644 --- a/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerR4IT.java +++ b/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerR4IT.java @@ -18,17 +18,7 @@ import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IIdType; -import org.hl7.fhir.r4.model.Bundle; -import org.hl7.fhir.r4.model.DateType; -import org.hl7.fhir.r4.model.IdType; -import org.hl7.fhir.r4.model.Measure; -import org.hl7.fhir.r4.model.MeasureReport; -import org.hl7.fhir.r4.model.Observation; -import org.hl7.fhir.r4.model.Parameters; -import org.hl7.fhir.r4.model.Patient; -import org.hl7.fhir.r4.model.Period; -import org.hl7.fhir.r4.model.StringType; -import org.hl7.fhir.r4.model.Subscription; +import org.hl7.fhir.r4.model.*; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; @@ -74,10 +64,14 @@ import static org.opencds.cqf.fhir.utility.r4.Parameters.stringPart; "hapi.fhir.implementationguides.dk-core.name=hl7.fhir.dk.core", "hapi.fhir.implementationguides.dk-core.version=1.1.0", "hapi.fhir.auto_create_placeholder_reference_targets=true", + "hibernate.search.enabled=true", // Override is currently required when using MDM as the construction of the MDM // beans are ambiguous as they are constructed multiple places. This is evident // when running in a spring boot environment - "spring.main.allow-bean-definition-overriding=true"}) + "spring.main.allow-bean-definition-overriding=true", + "hapi.fhir.remote_terminology_service.snomed.system=http://snomed.info/sct", + "hapi.fhir.remote_terminology_service.snomed.url=https://tx.fhir.org/r4" +}) class ExampleServerR4IT implements IServerSupport { private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(ExampleServerR4IT.class); private IGenericClient ourClient; @@ -358,6 +352,21 @@ class ExampleServerR4IT implements IServerSupport { assertTrue(foundDobChange); } + @Test + void testValidateRemoteTerminology() { + + String testCodeSystem = "http://foo/cs"; + String testValueSet = "http://foo/vs"; + ourClient.create().resource(new CodeSystem().setUrl(testCodeSystem).addConcept(new CodeSystem.ConceptDefinitionComponent().setCode("yes")).addConcept(new CodeSystem.ConceptDefinitionComponent().setCode("no"))).execute(); + ourClient.create().resource(new ValueSet().setUrl(testValueSet).setCompose(new ValueSet.ValueSetComposeComponent().addInclude(new ValueSet.ConceptSetComponent().setSystem(testValueSet)))).execute(); + + Parameters remoteResult = ourClient.operation().onType(ValueSet.class).named("$validate-code").withParameter(Parameters.class, "code", new StringType("22298006")).andParameter("system", new UriType("http://snomed.info/sct")).execute(); + assertEquals(true, ((BooleanType) remoteResult.getParameterValue("result")).getValue()); + assertEquals("Myocardial infarction", ((StringType) remoteResult.getParameterValue("display")).getValue()); + + Parameters localResult = ourClient.operation().onType(CodeSystem.class).named("$validate-code").withParameter(Parameters.class, "url", new UrlType(testCodeSystem)).andParameter("coding", new Coding(testCodeSystem, "yes", null)).execute(); + } + @BeforeEach void beforeEach() { diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerR5IT.java b/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerR5IT.java index e26ad37..f31d003 100644 --- a/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerR5IT.java +++ b/src/test/java/ca/uhn/fhir/jpa/starter/ExampleServerR5IT.java @@ -11,12 +11,7 @@ import jakarta.websocket.ContainerProvider; import jakarta.websocket.Session; import jakarta.websocket.WebSocketContainer; import org.hl7.fhir.instance.model.api.IIdType; -import org.hl7.fhir.r5.model.Bundle; -import org.hl7.fhir.r5.model.Enumerations; -import org.hl7.fhir.r5.model.Observation; -import org.hl7.fhir.r5.model.Patient; -import org.hl7.fhir.r5.model.Subscription; -import org.hl7.fhir.r5.model.SubscriptionTopic; +import org.hl7.fhir.r5.model.*; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -36,7 +31,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; "spring.datasource.url=jdbc:h2:mem:dbr5", "hapi.fhir.fhir_version=r5", "hapi.fhir.cr_enabled=false", - "hapi.fhir.subscription.websocket_enabled=true" + "hapi.fhir.subscription.websocket_enabled=true", + "hapi.fhir.remote_terminology_service.snomed.system=http://snomed.info/sct", + "hapi.fhir.remote_terminology_service.snomed.url=https://tx.fhir.org/r5" }) public class ExampleServerR5IT { @@ -156,6 +153,22 @@ public class ExampleServerR5IT { ourClient.delete().resourceById(mySubscriptionId).execute(); } + + @Test + void testValidateRemoteTerminology() { + + String testCodeSystem = "http://foo/cs"; + String testValueSet = "http://foo/vs"; + ourClient.create().resource(new CodeSystem().setUrl(testCodeSystem).addConcept(new CodeSystem.ConceptDefinitionComponent().setCode("yes")).addConcept(new CodeSystem.ConceptDefinitionComponent().setCode("no"))).execute(); + ourClient.create().resource(new ValueSet().setUrl(testValueSet).setCompose(new ValueSet.ValueSetComposeComponent().addInclude(new ValueSet.ConceptSetComponent().setSystem(testValueSet)))).execute(); + + Parameters remoteResult = ourClient.operation().onType(ValueSet.class).named("$validate-code").withParameter(Parameters.class, "code", new StringType("22298006")).andParameter("system", new UriType("http://snomed.info/sct")).execute(); + assertEquals(true, ((BooleanType) remoteResult.getParameterValue("result")).getValue()); + assertEquals("Myocardial infarction", ((StringType) remoteResult.getParameterValue("display")).getValue()); + + Parameters localResult = ourClient.operation().onType(CodeSystem.class).named("$validate-code").withParameter(Parameters.class, "url", new UrlType(testCodeSystem)).andParameter("coding", new Coding(testCodeSystem, "yes", null)).execute(); + } + @BeforeEach void beforeEach() { diff --git a/src/test/smoketest/plain_server.http b/src/test/smoketest/plain_server.http index 572a48f..72b300a 100644 --- a/src/test/smoketest/plain_server.http +++ b/src/test/smoketest/plain_server.http @@ -206,6 +206,7 @@ GET http://{{host}}/fhir/Patient/{{batch_patient_id}}/$everything ### Extended Operations - validate # https://hapifhir.io/hapi-fhir/docs/server_plain/rest_operations_operations.html +# @timeout 180 # wait up to 2 minutes for the validate response POST http://{{host}}/fhir/Patient/{{batch_patient_id}}/$validate > {%