Skip to content

Commit 4226648

Browse files
authored
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
1 parent 3ea85a0 commit 4226648

File tree

7 files changed

+193
-0
lines changed

7 files changed

+193
-0
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,16 @@ or
345345

346346
2) classes will be instantiated via reflection if no matching Bean is found
347347

348+
## Adding custom operations(providers)
349+
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.
350+
Providers will be discovered in one of two ways:
351+
352+
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
353+
354+
or
355+
356+
2) classes will be instantiated via reflection if no matching Bean is found
357+
348358
## Customizing The Web Testpage UI
349359

350360
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.

src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,16 @@ public class AppProperties {
9797

9898
private final List<String> custom_interceptor_classes = new ArrayList<>();
9999

100+
private final List<String> custom_provider_classes = new ArrayList<>();
100101

101102

102103
public List<String> getCustomInterceptorClasses() {
103104
return custom_interceptor_classes;
104105
}
105106

107+
public List<String> getCustomProviderClasses() {
108+
return custom_provider_classes;
109+
}
106110

107111

108112
public Boolean getOpenapi_enabled() {

src/main/java/ca/uhn/fhir/jpa/starter/common/StarterJpaConfig.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,9 @@ public RestfulServer restfulServer(
455455
fhirServer.registerProvider(theIpsOperationProvider.get());
456456
}
457457

458+
// register custom providers
459+
registerCustomProviders(fhirServer, appContext, appProperties.getCustomProviderClasses());
460+
458461
return fhirServer;
459462
}
460463

@@ -497,6 +500,45 @@ private void registerCustomInterceptors(
497500
}
498501
}
499502

503+
/**
504+
* check the properties for custom provider classes and registers them.
505+
*/
506+
@SuppressWarnings({"unchecked", "rawtypes"})
507+
private void registerCustomProviders(
508+
RestfulServer fhirServer, ApplicationContext theAppContext, List<String> customProviderClasses) {
509+
510+
if (customProviderClasses == null) {
511+
return;
512+
}
513+
514+
for (String className : customProviderClasses) {
515+
Class clazz;
516+
try {
517+
clazz = Class.forName(className);
518+
} catch (ClassNotFoundException e) {
519+
throw new ConfigurationException("Provider class was not found on classpath: " + className, e);
520+
}
521+
522+
// first check if the class a Bean in the app context
523+
Object provider = null;
524+
try {
525+
provider = theAppContext.getBean(clazz);
526+
} catch (NoSuchBeanDefinitionException ex) {
527+
// no op - if it's not a bean we'll try to create it
528+
}
529+
530+
// if not a bean, instantiate the interceptor via reflection
531+
if (provider == null) {
532+
try {
533+
provider = clazz.getConstructor().newInstance();
534+
} catch (Exception e) {
535+
throw new ConfigurationException("Unable to instantiate provider class : " + className, e);
536+
}
537+
}
538+
fhirServer.registerProvider(provider);
539+
}
540+
}
541+
500542
public static IServerConformanceProvider<?> calculateConformanceProvider(
501543
IFhirSystemDao fhirSystemDao,
502544
RestfulServer fhirServer,

src/main/resources/application.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,11 @@ hapi:
178178
# or will be instantiated via reflection using an no-arg contructor; then registered with the server
179179
#custom-interceptor-classes:
180180

181+
# comma-separated list of fully qualified provider classes.
182+
# classes listed here will be fetched from the Spring context when combined with 'custom-bean-packages',
183+
# or will be instantiated via reflection using an no-arg contructor; then registered with the server
184+
#custom-provider-classes:
185+
181186
# Threadpool size for BATCH'ed GETs in a bundle.
182187
# bundle_batch_pool_size: 10
183188
# bundle_batch_pool_max_size: 50
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package ca.uhn.fhir.jpa.starter;
2+
3+
import ca.uhn.fhir.context.FhirContext;
4+
import ca.uhn.fhir.rest.api.MethodOutcome;
5+
import ca.uhn.fhir.rest.client.api.IGenericClient;
6+
import ca.uhn.fhir.rest.client.api.ServerValidationModeEnum;
7+
import org.hl7.fhir.r4.model.Binary;
8+
import org.hl7.fhir.r4.model.Parameters;
9+
import org.junit.jupiter.api.Assertions;
10+
import org.junit.jupiter.api.BeforeEach;
11+
import org.junit.jupiter.api.Test;
12+
import org.springframework.boot.test.context.SpringBootTest;
13+
import org.springframework.boot.test.web.server.LocalServerPort;
14+
15+
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {Application.class}, properties = {
16+
"hapi.fhir.custom-bean-packages=some.custom.pkg1",
17+
"hapi.fhir.custom-provider-classes=some.custom.pkg1.CustomOperationBean,some.custom.pkg1.CustomOperationPojo",
18+
"spring.datasource.url=jdbc:h2:mem:dbr4",
19+
"hapi.fhir.cr_enabled=false",
20+
// "hapi.fhir.enable_repository_validating_interceptor=true",
21+
"hapi.fhir.fhir_version=r4"
22+
})
23+
24+
class CustomOperationTest {
25+
26+
@LocalServerPort
27+
private int port;
28+
29+
private IGenericClient client;
30+
private FhirContext ctx;
31+
32+
@BeforeEach
33+
void setUp() {
34+
ctx = FhirContext.forR4();
35+
ctx.getRestfulClientFactory().setServerValidationMode(ServerValidationModeEnum.NEVER);
36+
ctx.getRestfulClientFactory().setSocketTimeout(1200 * 1000);
37+
String ourServerBase = "http://localhost:" + port + "/fhir/";
38+
client = ctx.newRestfulGenericClient(ourServerBase);
39+
40+
// Properties props = new Properties();
41+
// props.put("spring.autoconfigure.exclude", "org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration");
42+
}
43+
44+
@Test
45+
void testCustomOperations() {
46+
47+
// we registered two custom operations via the property 'hapi.fhir.custom-provider-classes'
48+
// one is discovered as a Spring Bean ($springBeanOperation), one instantiated via reflection ($pojoOperation)
49+
// both should be registered with the server and will add a custom operation.
50+
51+
// test Spring bean operation
52+
MethodOutcome springBeanOutcome = client.operation().onServer().named("$springBeanOperation")
53+
.withNoParameters(Parameters.class).returnMethodOutcome().execute();
54+
55+
// the hapi client will return our operation result (just a string) as a Binary with the string stored as the
56+
// data
57+
Assertions.assertEquals(200, springBeanOutcome.getResponseStatusCode());
58+
Binary springReturnResource = (Binary) springBeanOutcome.getResource();
59+
String springReturn = new String(springReturnResource.getData());
60+
Assertions.assertEquals("springBean", springReturn);
61+
62+
// test Pojo bean
63+
MethodOutcome pojoOutcome = client.operation().onServer().named("$pojoOperation")
64+
.withNoParameters(Parameters.class).returnMethodOutcome().execute();
65+
66+
Assertions.assertEquals(200, pojoOutcome.getResponseStatusCode());
67+
Binary pojoReturnResource = (Binary) pojoOutcome.getResource();
68+
String pojoReturn = new String(pojoReturnResource.getData());
69+
Assertions.assertEquals("pojo", pojoReturn);
70+
}
71+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package some.custom.pkg1;
2+
3+
import ca.uhn.fhir.rest.annotation.Operation;
4+
import jakarta.servlet.http.HttpServletRequest;
5+
import jakarta.servlet.http.HttpServletResponse;
6+
import org.apache.commons.io.IOUtils;
7+
import org.springframework.stereotype.Component;
8+
9+
import java.io.IOException;
10+
11+
/**
12+
* Code taken from hapi documentation on how to implement an operation which handles its own request/response
13+
* <a href="https://hapifhir.io/hapi-fhir/docs/server_plain/rest_operations_operations.html#manually-handing-requestresponse">...</a>
14+
*/
15+
16+
@Component
17+
public class CustomOperationBean {
18+
19+
private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(CustomOperationBean.class);
20+
21+
@Operation(name = "$springBeanOperation", manualResponse = true, manualRequest = true)
22+
public void springBeanOperation(HttpServletRequest theServletRequest, HttpServletResponse theServletResponse)
23+
throws IOException {
24+
String contentType = theServletRequest.getContentType();
25+
byte[] bytes = IOUtils.toByteArray(theServletRequest.getInputStream());
26+
27+
ourLog.info("Received call with content type {} and {} bytes", contentType, bytes.length);
28+
29+
theServletResponse.setContentType("text/plain");
30+
theServletResponse.getWriter().write("springBean");
31+
theServletResponse.getWriter().close();
32+
}
33+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package some.custom.pkg1;
2+
3+
import ca.uhn.fhir.rest.annotation.Operation;
4+
import jakarta.servlet.http.HttpServletRequest;
5+
import jakarta.servlet.http.HttpServletResponse;
6+
import org.apache.commons.io.IOUtils;
7+
import org.slf4j.Logger;
8+
import org.slf4j.LoggerFactory;
9+
10+
import java.io.IOException;
11+
12+
public class CustomOperationPojo {
13+
14+
private final Logger LOGGER = LoggerFactory.getLogger(CustomOperationPojo.class);
15+
16+
@Operation(name = "$pojoOperation", manualResponse = true, manualRequest = true)
17+
public void $pojoOperation(HttpServletRequest theServletRequest, HttpServletResponse theServletResponse)
18+
throws IOException {
19+
String contentType = theServletRequest.getContentType();
20+
byte[] bytes = IOUtils.toByteArray(theServletRequest.getInputStream());
21+
22+
LOGGER.info("Received call with content type {} and {} bytes", contentType, bytes.length);
23+
24+
theServletResponse.setContentType("text/plain");
25+
theServletResponse.getWriter().write("pojo");
26+
theServletResponse.getWriter().close();
27+
}
28+
}

0 commit comments

Comments
 (0)