Merge remote-tracking branch 'origin/master' into rel_8_1_tracking

This commit is contained in:
dotasek
2025-05-16 11:24:32 -04:00
9 changed files with 189 additions and 23 deletions

View File

@@ -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. 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: 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 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.
2) classes will be instantiated via reflection if no matching Bean is found
## Adding custom operations(providers) ## 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. 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.

View File

@@ -106,6 +106,8 @@ public class AppProperties {
private Integer pre_expand_value_sets_max_count = 1000; private Integer pre_expand_value_sets_max_count = 1000;
private Integer maximum_expansion_size = 1000; private Integer maximum_expansion_size = 1000;
private Map<String, RemoteSystem> remote_terminology_service = null;
public List<String> getCustomInterceptorClasses() { public List<String> getCustomInterceptorClasses() {
return custom_interceptor_classes; return custom_interceptor_classes;
} }
@@ -723,6 +725,14 @@ public class AppProperties {
this.maximum_expansion_size = maximum_expansion_size; this.maximum_expansion_size = maximum_expansion_size;
} }
public Map<String, RemoteSystem> getRemoteTerminologyServicesMap() {
return remote_terminology_service;
}
public void setRemote_terminology_service(Map<String, RemoteSystem> remote_terminology_service) {
this.remote_terminology_service = remote_terminology_service;
}
public static class Cors { public static class Cors {
private Boolean allow_Credentials = true; private Boolean allow_Credentials = true;
private List<String> allowed_origin = List.of("*"); private List<String> 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 { public static class Subscription {
private Boolean resthook_enabled = false; private Boolean resthook_enabled = false;

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -40,6 +40,19 @@ public class EnvironmentHelper {
properties.put(strippedKey, entry.getValue().toString()); 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<String, Object> jpaPropsEnv = getPropertiesStartingWith(environment, "SPRING_JPA_PROPERTIES");
for (Map.Entry<String, Object> 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 // Spring Boot Autoconfiguration defaults
properties.putIfAbsent(AvailableSettings.SCANNER, "org.hibernate.boot.archive.scan.internal.DisabledScanner"); properties.putIfAbsent(AvailableSettings.SCANNER, "org.hibernate.boot.archive.scan.internal.DisabledScanner");
properties.putIfAbsent( properties.putIfAbsent(

View File

@@ -293,6 +293,18 @@ hapi:
# max_page_size: 200 # max_page_size: 200
# retain_cached_searches_mins: 60 # retain_cached_searches_mins: 60
# reuse_cached_search_results_millis: 60000 # 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: tester:
home: home:
name: Local Tester name: Local Tester

View File

@@ -18,17 +18,7 @@ import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients; import org.apache.http.impl.client.HttpClients;
import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IIdType; import org.hl7.fhir.instance.model.api.IIdType;
import org.hl7.fhir.r4.model.Bundle; import org.hl7.fhir.r4.model.*;
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.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test; 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.name=hl7.fhir.dk.core",
"hapi.fhir.implementationguides.dk-core.version=1.1.0", "hapi.fhir.implementationguides.dk-core.version=1.1.0",
"hapi.fhir.auto_create_placeholder_reference_targets=true", "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 // 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 // beans are ambiguous as they are constructed multiple places. This is evident
// when running in a spring boot environment // 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 { class ExampleServerR4IT implements IServerSupport {
private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(ExampleServerR4IT.class); private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(ExampleServerR4IT.class);
private IGenericClient ourClient; private IGenericClient ourClient;
@@ -358,6 +352,21 @@ class ExampleServerR4IT implements IServerSupport {
assertTrue(foundDobChange); 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 @BeforeEach
void beforeEach() { void beforeEach() {

View File

@@ -11,12 +11,7 @@ import jakarta.websocket.ContainerProvider;
import jakarta.websocket.Session; import jakarta.websocket.Session;
import jakarta.websocket.WebSocketContainer; import jakarta.websocket.WebSocketContainer;
import org.hl7.fhir.instance.model.api.IIdType; import org.hl7.fhir.instance.model.api.IIdType;
import org.hl7.fhir.r5.model.Bundle; import org.hl7.fhir.r5.model.*;
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.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; 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", "spring.datasource.url=jdbc:h2:mem:dbr5",
"hapi.fhir.fhir_version=r5", "hapi.fhir.fhir_version=r5",
"hapi.fhir.cr_enabled=false", "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 { public class ExampleServerR5IT {
@@ -156,6 +153,22 @@ public class ExampleServerR5IT {
ourClient.delete().resourceById(mySubscriptionId).execute(); 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 @BeforeEach
void beforeEach() { void beforeEach() {

View File

@@ -206,6 +206,7 @@ GET http://{{host}}/fhir/Patient/{{batch_patient_id}}/$everything
### Extended Operations - validate ### Extended Operations - validate
# https://hapifhir.io/hapi-fhir/docs/server_plain/rest_operations_operations.html # 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 POST http://{{host}}/fhir/Patient/{{batch_patient_id}}/$validate
> {% > {%