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
This commit is contained in:
David Conlan
2024-03-04 02:39:03 +10:00
committed by GitHub
parent 3ea85a05aa
commit 4226648867
7 changed files with 193 additions and 0 deletions

View File

@@ -345,6 +345,16 @@ or
2) classes will be instantiated via reflection if no matching Bean is found 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 ## 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. 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.

View File

@@ -97,12 +97,16 @@ public class AppProperties {
private final List<String> custom_interceptor_classes = new ArrayList<>(); private final List<String> custom_interceptor_classes = new ArrayList<>();
private final List<String> custom_provider_classes = new ArrayList<>();
public List<String> getCustomInterceptorClasses() { public List<String> getCustomInterceptorClasses() {
return custom_interceptor_classes; return custom_interceptor_classes;
} }
public List<String> getCustomProviderClasses() {
return custom_provider_classes;
}
public Boolean getOpenapi_enabled() { public Boolean getOpenapi_enabled() {

View File

@@ -455,6 +455,9 @@ public class StarterJpaConfig {
fhirServer.registerProvider(theIpsOperationProvider.get()); fhirServer.registerProvider(theIpsOperationProvider.get());
} }
// register custom providers
registerCustomProviders(fhirServer, appContext, appProperties.getCustomProviderClasses());
return fhirServer; 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<String> 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( public static IServerConformanceProvider<?> calculateConformanceProvider(
IFhirSystemDao fhirSystemDao, IFhirSystemDao fhirSystemDao,
RestfulServer fhirServer, RestfulServer fhirServer,

View File

@@ -178,6 +178,11 @@ hapi:
# or will be instantiated via reflection using an no-arg contructor; then registered with the server # or will be instantiated via reflection using an no-arg contructor; then registered with the server
#custom-interceptor-classes: #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. # Threadpool size for BATCH'ed GETs in a bundle.
# bundle_batch_pool_size: 10 # bundle_batch_pool_size: 10
# bundle_batch_pool_max_size: 50 # bundle_batch_pool_max_size: 50

View File

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

View File

@@ -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
* <a href="https://hapifhir.io/hapi-fhir/docs/server_plain/rest_operations_operations.html#manually-handing-requestresponse">...</a>
*/
@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();
}
}

View File

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