diff --git a/README.md b/README.md index b30dd87..3d9d0b8 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,16 @@ Because the integration tests within the project rely on the default H2 database NOTE: MS SQL Server by default uses a case-insensitive codepage. This will cause errors with some operations - such as when expanding case-sensitive valuesets (UCUM) as there are unique indexes defined on the terminology tables for codes. It is recommended to deploy a case-sensitive database prior to running HAPI FHIR when using MS SQL Server to avoid these and potentially other issues. +## Adding custom interceptors +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 + +or + +2) classes will be instantiated via reflection if no matching Bean is found + ## Customizing The Web Testpage UI The UI that comes with this server is an exact clone of the server available at [http://hapi.fhir.org](http://hapi.fhir.org). You may skin this UI if you'd like. For example, you might change the introductory text or replace the logo with your own. 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 e7599a8..e075c1e 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java @@ -82,6 +82,12 @@ public class AppProperties { private Integer bundle_batch_pool_size = 20; private Integer bundle_batch_pool_max_size = 100; private final List local_base_urls = new ArrayList<>(); + + private final List custom_interceptor_classes = new ArrayList<>(); + + public List getCustomInterceptorClasses() { + return custom_interceptor_classes; + } public Boolean getOpenapi_enabled() { return openapi_enabled; diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/common/StarterJpaConfig.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/StarterJpaConfig.java index 3f481bd..74ac24c 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/common/StarterJpaConfig.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/common/StarterJpaConfig.java @@ -63,9 +63,11 @@ import ca.uhn.fhir.validation.ResultSeverityEnum; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import org.hl7.fhir.common.hapi.validation.support.CachingValidationSupport; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.*; import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.http.HttpHeaders; @@ -75,13 +77,16 @@ import org.springframework.web.cors.CorsConfiguration; import javax.persistence.EntityManagerFactory; import javax.sql.DataSource; + import java.util.*; import static ca.uhn.fhir.jpa.starter.common.validation.IRepositoryValidationInterceptorFactory.ENABLE_REPOSITORY_VALIDATING_INTERCEPTOR; @Configuration +//allow users to configure custom packages to scan for additional beans +@ComponentScan(basePackages = { "${hapi.fhir.custom-bean-packages:}" }) @Import( - ThreadPoolFactoryConfig.class + ThreadPoolFactoryConfig.class ) public class StarterJpaConfig { @@ -243,11 +248,9 @@ public class StarterJpaConfig { } @Bean - public RestfulServer restfulServer(IFhirSystemDao fhirSystemDao, AppProperties appProperties, DaoRegistry daoRegistry, Optional mdmProviderProvider, IJpaSystemProvider jpaSystemProvider, ResourceProviderFactory resourceProviderFactory, DaoConfig daoConfig, ISearchParamRegistry searchParamRegistry, IValidationSupport theValidationSupport, DatabaseBackedPagingProvider databaseBackedPagingProvider, LoggingInterceptor loggingInterceptor, Optional terminologyUploaderProvider, Optional subscriptionTriggeringProvider, Optional corsInterceptor, IInterceptorBroadcaster interceptorBroadcaster, Optional binaryAccessProvider, BinaryStorageInterceptor binaryStorageInterceptor, IValidatorModule validatorModule, Optional graphQLProvider, BulkDataExportProvider bulkDataExportProvider, BulkDataImportProvider bulkDataImportProvider, ValueSetOperationProvider theValueSetOperationProvider, ReindexProvider reindexProvider, PartitionManagementProvider partitionManagementProvider, Optional repositoryValidatingInterceptor, IPackageInstallerSvc packageInstallerSvc, ThreadSafeResourceDeleterSvc theThreadSafeResourceDeleterSvc) { + public RestfulServer restfulServer(IFhirSystemDao fhirSystemDao, AppProperties appProperties, DaoRegistry daoRegistry, Optional mdmProviderProvider, IJpaSystemProvider jpaSystemProvider, ResourceProviderFactory resourceProviderFactory, DaoConfig daoConfig, ISearchParamRegistry searchParamRegistry, IValidationSupport theValidationSupport, DatabaseBackedPagingProvider databaseBackedPagingProvider, LoggingInterceptor loggingInterceptor, Optional terminologyUploaderProvider, Optional subscriptionTriggeringProvider, Optional corsInterceptor, IInterceptorBroadcaster interceptorBroadcaster, Optional binaryAccessProvider, BinaryStorageInterceptor binaryStorageInterceptor, IValidatorModule validatorModule, Optional graphQLProvider, BulkDataExportProvider bulkDataExportProvider, BulkDataImportProvider bulkDataImportProvider, ValueSetOperationProvider theValueSetOperationProvider, ReindexProvider reindexProvider, PartitionManagementProvider partitionManagementProvider, Optional repositoryValidatingInterceptor, IPackageInstallerSvc packageInstallerSvc, ThreadSafeResourceDeleterSvc theThreadSafeResourceDeleterSvc, ApplicationContext appContext) { RestfulServer fhirServer = new RestfulServer(fhirSystemDao.getContext()); - - List supportedResourceTypes = appProperties.getSupported_resource_types(); if (!supportedResourceTypes.isEmpty()) { @@ -410,15 +413,52 @@ public class StarterJpaConfig { fhirServer.setTenantIdentificationStrategy(new UrlBaseTenantIdentificationStrategy()); fhirServer.registerProviders(partitionManagementProvider); } - - repositoryValidatingInterceptor.ifPresent(fhirServer::registerInterceptor); - + // register custom interceptors + registerCustomInterceptors(fhirServer, appContext, appProperties.getCustomInterceptorClasses()); return fhirServer; } + /** + * check the properties for custom interceptor classes and registers them. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + private void registerCustomInterceptors(RestfulServer fhirServer, ApplicationContext theAppContext, List customInterceptorClasses) { + + if (customInterceptorClasses == null) { + return; + } + + for (String className : customInterceptorClasses) { + Class clazz; + try { + clazz = Class.forName(className); + } catch (ClassNotFoundException e) { + throw new ConfigurationException("Interceptor class was not found on classpath: " + className, e); + } + + // first check if the class a Bean in the app context + Object interceptor = null; + try { + interceptor = theAppContext.getBean(clazz); + } catch (NoSuchBeanDefinitionException ex) { + // no op - if it's not a bean we'll try to create it + } + + // if not a bean, instantiate the interceptor via reflection + if (interceptor == null) { + try { + interceptor = clazz.getConstructor().newInstance(); + } catch (Exception e) { + throw new ConfigurationException("Unable to instantiate interceptor class : " + className, e); + } + } + fhirServer.registerInterceptor(interceptor); + } + } + public static IServerConformanceProvider calculateConformanceProvider(IFhirSystemDao fhirSystemDao, RestfulServer fhirServer, DaoConfig daoConfig, ISearchParamRegistry searchParamRegistry, IValidationSupport theValidationSupport) { FhirVersionEnum fhirVersion = fhirSystemDao.getContext().getVersion().getVersion(); if (fhirVersion == FhirVersionEnum.DSTU2) { diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 67a652f..deb2bf2 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -131,6 +131,14 @@ hapi: search-coord-core-pool-size: 20 search-coord-max-pool-size: 100 search-coord-queue-capacity: 200 + + # comma-separated package names, will be @ComponentScan'ed by Spring to allow for creating custom Spring beans + #custom-bean-packages: + + # comma-separated list of fully qualified interceptor classes. + # classes listed here will be fetched from the Spring context when combined with 'custom-bean-packages', + # or will be instantiated via reflection using an no-arg contructor; then registered with the server + #custom-interceptor-classes: # Threadpool size for BATCH'ed GETs in a bundle. # bundle_batch_pool_size: 10 diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/CustomBeanTest.java b/src/test/java/ca/uhn/fhir/jpa/starter/CustomBeanTest.java new file mode 100644 index 0000000..af9db0d --- /dev/null +++ b/src/test/java/ca/uhn/fhir/jpa/starter/CustomBeanTest.java @@ -0,0 +1,24 @@ +package ca.uhn.fhir.jpa.starter; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = Application.class, properties = { + "hapi.fhir.custom-bean-packages=some.custom.pkg1,some.custom.pkg2", + "spring.datasource.url=jdbc:h2:mem:dbr4", + // "hapi.fhir.enable_repository_validating_interceptor=true", + "hapi.fhir.fhir_version=r4" +}) +public class CustomBeanTest { + + @Autowired + some.custom.pkg1.CustomBean customBean1; + + @Test + void testCustomBeanExists() { + Assertions.assertNotNull(customBean1); + Assertions.assertEquals("I am alive", customBean1.getInitFlag()); + } +} diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/CustomInterceptorTest.java b/src/test/java/ca/uhn/fhir/jpa/starter/CustomInterceptorTest.java new file mode 100644 index 0000000..377719e --- /dev/null +++ b/src/test/java/ca/uhn/fhir/jpa/starter/CustomInterceptorTest.java @@ -0,0 +1,64 @@ +package ca.uhn.fhir.jpa.starter; + +import org.hl7.fhir.r4.model.Patient; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; +import ca.uhn.fhir.rest.client.api.IGenericClient; +import ca.uhn.fhir.rest.client.api.ServerValidationModeEnum; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = Application.class, properties = { + "hapi.fhir.custom-bean-packages=some.custom.pkg1", + "hapi.fhir.custom-interceptor-classes=some.custom.pkg1.CustomInterceptorBean,some.custom.pkg1.CustomInterceptorPojo", + "spring.datasource.url=jdbc:h2:mem:dbr4", + // "hapi.fhir.enable_repository_validating_interceptor=true", + "hapi.fhir.fhir_version=r4" +}) + +public class CustomInterceptorTest { + + @LocalServerPort + private int port; + + @Autowired + private IFhirResourceDao patientResourceDao; + + private IGenericClient client; + private FhirContext ctx; + + @BeforeEach + void setUp() { + ctx = FhirContext.forR4(); + ctx.getRestfulClientFactory().setServerValidationMode(ServerValidationModeEnum.NEVER); + ctx.getRestfulClientFactory().setSocketTimeout(1200 * 1000); + String ourServerBase = "http://localhost:" + port + "/fhir/"; + client = ctx.newRestfulGenericClient(ourServerBase); + + // Properties props = new Properties(); + // props.put("spring.autoconfigure.exclude", "org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration"); + } + + @Test + void testAuditInterceptors() { + + // we registered two custom interceptors via the property 'hapi.fhir.custom-interceptor-classes' + // one is discovered as a Spring Bean, one instantiated via reflection + // both should be registered with the server and will add a custom extension to any Patient resource created + // so we can verify they were registered + + Patient pat = new Patient(); + String patId = client.create().resource(pat).execute().getId().getIdPart(); + + Patient readPat = client.read().resource(Patient.class).withId(patId).execute(); + + Assertions.assertNotNull(readPat.getExtensionByUrl("http://some.custom.pkg1/CustomInterceptorBean")); + Assertions.assertNotNull(readPat.getExtensionByUrl("http://some.custom.pkg1/CustomInterceptorPojo")); + + } +} diff --git a/src/test/java/some/custom/pkg1/CustomBean.java b/src/test/java/some/custom/pkg1/CustomBean.java new file mode 100644 index 0000000..8160871 --- /dev/null +++ b/src/test/java/some/custom/pkg1/CustomBean.java @@ -0,0 +1,18 @@ +package some.custom.pkg1; + +import org.springframework.stereotype.Component; + +@Component +public class CustomBean { + + private String initFlag; + + public CustomBean() { + initFlag = "I am alive"; + } + + public String getInitFlag() { + return initFlag; + } + +} diff --git a/src/test/java/some/custom/pkg1/CustomInterceptorBean.java b/src/test/java/some/custom/pkg1/CustomInterceptorBean.java new file mode 100644 index 0000000..5a0b4df --- /dev/null +++ b/src/test/java/some/custom/pkg1/CustomInterceptorBean.java @@ -0,0 +1,31 @@ +package some.custom.pkg1; + +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r4.model.Extension; +import org.hl7.fhir.r4.model.Patient; +import org.hl7.fhir.r4.model.StringType; +import org.springframework.stereotype.Component; + +import ca.uhn.fhir.interceptor.api.Hook; +import ca.uhn.fhir.interceptor.api.Interceptor; +import ca.uhn.fhir.interceptor.api.Pointcut; +import ca.uhn.fhir.rest.api.RestOperationTypeEnum; +import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; + +@Interceptor +@Component +public class CustomInterceptorBean { + + @Hook(Pointcut.SERVER_INCOMING_REQUEST_PRE_HANDLED) + void preHandleResource(ServletRequestDetails servletRequestDetails, RestOperationTypeEnum opType) { + IBaseResource resource = servletRequestDetails.getResource(); + + // add an extension before saving the resource to mark it + if (opType == RestOperationTypeEnum.CREATE && resource instanceof Patient) { + Patient pat = (Patient) resource; + Extension ext = pat.addExtension(); + ext.setUrl("http://some.custom.pkg1/CustomInterceptorBean"); + ext.setValue(new StringType("CustomInterceptorBean wuz here")); + } + } +} diff --git a/src/test/java/some/custom/pkg1/CustomInterceptorPojo.java b/src/test/java/some/custom/pkg1/CustomInterceptorPojo.java new file mode 100644 index 0000000..1df67c9 --- /dev/null +++ b/src/test/java/some/custom/pkg1/CustomInterceptorPojo.java @@ -0,0 +1,26 @@ +package some.custom.pkg1; + +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r4.model.Extension; +import org.hl7.fhir.r4.model.Patient; +import org.hl7.fhir.r4.model.StringType; +import ca.uhn.fhir.interceptor.api.Hook; +import ca.uhn.fhir.interceptor.api.Pointcut; +import ca.uhn.fhir.rest.api.RestOperationTypeEnum; +import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; + +public class CustomInterceptorPojo { + + @Hook(Pointcut.SERVER_INCOMING_REQUEST_PRE_HANDLED) + void preHandleResource(ServletRequestDetails servletRequestDetails, RestOperationTypeEnum opType) { + IBaseResource resource = servletRequestDetails.getResource(); + + // add an extension before saving the resource to mark it + if (opType == RestOperationTypeEnum.CREATE && resource instanceof Patient) { + Patient pat = (Patient) resource; + Extension ext = pat.addExtension(); + ext.setUrl("http://some.custom.pkg1/CustomInterceptorPojo"); + ext.setValue(new StringType("CustomInterceptorPojo wuz here")); + } + } +}