From 422664886780afbab6865f479005f7bdd592d8de Mon Sep 17 00:00:00 2001 From: David Conlan <8952947+dconlan@users.noreply.github.com> Date: Mon, 4 Mar 2024 02:39:03 +1000 Subject: [PATCH] Allow custom operations/providers in addition to interceptors (#654) * Allow custom operations/providers to be added in the same way custom interceptors are currently loaded * Add new property to documentation and default yaml file * Add test for custom operation --- README.md | 10 +++ .../uhn/fhir/jpa/starter/AppProperties.java | 4 ++ .../jpa/starter/common/StarterJpaConfig.java | 42 +++++++++++ src/main/resources/application.yaml | 5 ++ .../fhir/jpa/starter/CustomOperationTest.java | 71 +++++++++++++++++++ .../some/custom/pkg1/CustomOperationBean.java | 33 +++++++++ .../some/custom/pkg1/CustomOperationPojo.java | 28 ++++++++ 7 files changed, 193 insertions(+) create mode 100644 src/test/java/ca/uhn/fhir/jpa/starter/CustomOperationTest.java create mode 100644 src/test/java/some/custom/pkg1/CustomOperationBean.java create mode 100644 src/test/java/some/custom/pkg1/CustomOperationPojo.java diff --git a/README.md b/README.md index 94fee83..bf6d29a 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,16 @@ or 2) classes will be instantiated via reflection if no matching Bean is found +## 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. +Providers 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 67a4656..69abd21 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java @@ -97,12 +97,16 @@ public class AppProperties { private final List custom_interceptor_classes = new ArrayList<>(); + private final List custom_provider_classes = new ArrayList<>(); public List getCustomInterceptorClasses() { return custom_interceptor_classes; } + public List getCustomProviderClasses() { + return custom_provider_classes; + } public Boolean getOpenapi_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 01ff15b..6ff4c5e 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 @@ -455,6 +455,9 @@ public class StarterJpaConfig { fhirServer.registerProvider(theIpsOperationProvider.get()); } + // register custom providers + registerCustomProviders(fhirServer, appContext, appProperties.getCustomProviderClasses()); + return fhirServer; } @@ -497,6 +500,45 @@ public class StarterJpaConfig { } } + /** + * check the properties for custom provider classes and registers them. + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private void registerCustomProviders( + RestfulServer fhirServer, ApplicationContext theAppContext, List customProviderClasses) { + + if (customProviderClasses == null) { + return; + } + + for (String className : customProviderClasses) { + Class clazz; + try { + clazz = Class.forName(className); + } catch (ClassNotFoundException e) { + throw new ConfigurationException("Provider class was not found on classpath: " + className, e); + } + + // first check if the class a Bean in the app context + Object provider = null; + try { + provider = 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 (provider == null) { + try { + provider = clazz.getConstructor().newInstance(); + } catch (Exception e) { + throw new ConfigurationException("Unable to instantiate provider class : " + className, e); + } + } + fhirServer.registerProvider(provider); + } + } + public static IServerConformanceProvider calculateConformanceProvider( IFhirSystemDao fhirSystemDao, RestfulServer fhirServer, diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index fa7c762..353815a 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -178,6 +178,11 @@ hapi: # or will be instantiated via reflection using an no-arg contructor; then registered with the server #custom-interceptor-classes: + # comma-separated list of fully qualified provider 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-provider-classes: + # Threadpool size for BATCH'ed GETs in a bundle. # bundle_batch_pool_size: 10 # bundle_batch_pool_max_size: 50 diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/CustomOperationTest.java b/src/test/java/ca/uhn/fhir/jpa/starter/CustomOperationTest.java new file mode 100644 index 0000000..00c68f0 --- /dev/null +++ b/src/test/java/ca/uhn/fhir/jpa/starter/CustomOperationTest.java @@ -0,0 +1,71 @@ +package ca.uhn.fhir.jpa.starter; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.api.MethodOutcome; +import ca.uhn.fhir.rest.client.api.IGenericClient; +import ca.uhn.fhir.rest.client.api.ServerValidationModeEnum; +import org.hl7.fhir.r4.model.Binary; +import org.hl7.fhir.r4.model.Parameters; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {Application.class}, properties = { + "hapi.fhir.custom-bean-packages=some.custom.pkg1", + "hapi.fhir.custom-provider-classes=some.custom.pkg1.CustomOperationBean,some.custom.pkg1.CustomOperationPojo", + "spring.datasource.url=jdbc:h2:mem:dbr4", + "hapi.fhir.cr_enabled=false", + // "hapi.fhir.enable_repository_validating_interceptor=true", + "hapi.fhir.fhir_version=r4" +}) + +class CustomOperationTest { + + @LocalServerPort + private int port; + + 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 testCustomOperations() { + + // we registered two custom operations via the property 'hapi.fhir.custom-provider-classes' + // one is discovered as a Spring Bean ($springBeanOperation), one instantiated via reflection ($pojoOperation) + // both should be registered with the server and will add a custom operation. + + // test Spring bean operation + MethodOutcome springBeanOutcome = client.operation().onServer().named("$springBeanOperation") + .withNoParameters(Parameters.class).returnMethodOutcome().execute(); + + // the hapi client will return our operation result (just a string) as a Binary with the string stored as the + // data + Assertions.assertEquals(200, springBeanOutcome.getResponseStatusCode()); + Binary springReturnResource = (Binary) springBeanOutcome.getResource(); + String springReturn = new String(springReturnResource.getData()); + Assertions.assertEquals("springBean", springReturn); + + // test Pojo bean + MethodOutcome pojoOutcome = client.operation().onServer().named("$pojoOperation") + .withNoParameters(Parameters.class).returnMethodOutcome().execute(); + + Assertions.assertEquals(200, pojoOutcome.getResponseStatusCode()); + Binary pojoReturnResource = (Binary) pojoOutcome.getResource(); + String pojoReturn = new String(pojoReturnResource.getData()); + Assertions.assertEquals("pojo", pojoReturn); + } +} diff --git a/src/test/java/some/custom/pkg1/CustomOperationBean.java b/src/test/java/some/custom/pkg1/CustomOperationBean.java new file mode 100644 index 0000000..94b4960 --- /dev/null +++ b/src/test/java/some/custom/pkg1/CustomOperationBean.java @@ -0,0 +1,33 @@ +package some.custom.pkg1; + +import ca.uhn.fhir.rest.annotation.Operation; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.io.IOUtils; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * Code taken from hapi documentation on how to implement an operation which handles its own request/response + * ... + */ + +@Component +public class CustomOperationBean { + + private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(CustomOperationBean.class); + + @Operation(name = "$springBeanOperation", manualResponse = true, manualRequest = true) + public void springBeanOperation(HttpServletRequest theServletRequest, HttpServletResponse theServletResponse) + throws IOException { + String contentType = theServletRequest.getContentType(); + byte[] bytes = IOUtils.toByteArray(theServletRequest.getInputStream()); + + ourLog.info("Received call with content type {} and {} bytes", contentType, bytes.length); + + theServletResponse.setContentType("text/plain"); + theServletResponse.getWriter().write("springBean"); + theServletResponse.getWriter().close(); + } +} diff --git a/src/test/java/some/custom/pkg1/CustomOperationPojo.java b/src/test/java/some/custom/pkg1/CustomOperationPojo.java new file mode 100644 index 0000000..8e7c8d5 --- /dev/null +++ b/src/test/java/some/custom/pkg1/CustomOperationPojo.java @@ -0,0 +1,28 @@ +package some.custom.pkg1; + +import ca.uhn.fhir.rest.annotation.Operation; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.io.IOUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +public class CustomOperationPojo { + + private final Logger LOGGER = LoggerFactory.getLogger(CustomOperationPojo.class); + + @Operation(name = "$pojoOperation", manualResponse = true, manualRequest = true) + public void $pojoOperation(HttpServletRequest theServletRequest, HttpServletResponse theServletResponse) + throws IOException { + String contentType = theServletRequest.getContentType(); + byte[] bytes = IOUtils.toByteArray(theServletRequest.getInputStream()); + + LOGGER.info("Received call with content type {} and {} bytes", contentType, bytes.length); + + theServletResponse.setContentType("text/plain"); + theServletResponse.getWriter().write("pojo"); + theServletResponse.getWriter().close(); + } +}