EHRbase is an implementation of the openEHR specification with as minimal additions as possible when it comes to extra features or functionality. But in production implementation, additional features might be required to make EHRbase useful. These features such as logging, analytics, authentication etc while not included in the openEHR specification, does not alter it either. For such workflows, EHRbase plugins are the answer.
The plugin will be packaged and run along with EHRbase. It will be loaded into the runtime of EHRbase and will share dependencies with EHRbase, therefore, only dependencies that are not present in EHRbase need to be packaged with the plugin. The plugin will receive its application context directly from EHRbase during runtime and there are specific hooks that will enable the plugin to intercept the various events in EHRbase.
In this article we will go over a very basic implementation of a spring JDBC plugin in Java that will intercept a composition INSERT request made to EHRbase and save the composition JSON to a custom table within the EHRbase database. For a more comprehensive explanation about EHRbase plugins you can refer their docs.
I'll be going step by step and try to keep it as beginner friendly as possible, so let's begin.
Start by creating a new blank Maven project in Java. Lets now step up the pom.xml
file of the project.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>ehrbase-plugin</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>ehrbase-plugin</name>
<description>JDBC Plugin for ehrBase</description>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<!-- plugin metainformation -->
<plugin.id>ehrbase-plugin</plugin.id>
<plugin.class>
com.example.ehrbase_plugin.EhrBasePlugin
</plugin.class>
<plugin.version>0.0.1</plugin.version>
<plugin.provider>example</plugin.provider>
<plugin.dependencies />
</properties>
<dependencyManagement>
<dependencies>
<!-- Include the ehrbase bom -->
<dependency>
<groupId>org.ehrbase.openehr</groupId>
<artifactId>bom</artifactId>
<version>0.21.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.ehrbase.openehr</groupId>
<artifactId>service</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.ehrbase.openehr</groupId>
<artifactId>plugin</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.1.0</version>
<configuration>
<descriptors>
<descriptor>
src/main/assembly/assembly.xml
</descriptor>
</descriptors>
<finalName>
${project.artifactId}-${project.version}-
plugin
</finalName>
<appendAssemblyId>false</appendAssemblyId>
<attach>false</attach>
<archive>
<manifest>
<addDefaultImplementationEntries>
true
</addDefaultImplementationEntries>
<addDefaultSpecificationEntries>
true
</addDefaultSpecificationEntries>
</manifest>
<manifestEntries>
<Plugin-Id>${plugin.id}</Plugin-Id>
<Plugin-Version>
${plugin.version}
</Plugin-Version>
<Plugin-Provider>
${plugin.provider}
</Plugin-Provider>
<Plugin-Class>${plugin.class}</Plugin-Class>
<Plugin-Dependencies>
${plugin.dependencies}
</Plugin-Dependencies>
</manifestEntries>
</archive>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Let's go over the important parts of the configuration.
As you can see, all the dependencies used in the project have the scope as provided or runtime. This is because when the plugin is loaded into the EHRbase runtime, it will have access to all the required dependencies. Any dependency not present in EHRbase project only needs to be package along with the plugin jar.
<plugin.class>
com.example.ehrbase_plugin.EhrBasePlugin
</plugin.class>
This class is the entry point when the plugin is loaded into the runtime of EHRbase.
<descriptors>
<descriptor>src/main/assembly/assembly.xml</descriptor>
</descriptors>
The descriptors tag specifies the location of the assembly descriptor file (assembly.xml). This file defines the format of the distributable archive and its contents.
The following is the content of the assembly.xml file to be saved in the src/main/assembly/assembly.xml location
<assembly>
<id>plugin</id>
<formats>
<format>jar</format>
</formats>
<includeBaseDirectory>false</includeBaseDirectory>
<dependencySets>
<dependencySet>
<useProjectArtifact>true</useProjectArtifact>
<unpack>true</unpack>
<scope>runtime</scope>
<outputDirectory>/</outputDirectory>
<useTransitiveDependencies>true</useTransitiveDependencies>
</dependencySet>
</dependencySets>
</assembly>
Now that we have our pom.xml and assembly.xml files properly set up let's move on to the entry point of the plugin and making sure that Spring has access to all our class.
So inside src/main/java/com/example/ehrbase_plugin
we create a Java class called EhrBasePlugin
, make sure if you change this class name, you change the name in the pom.xml also for the plugin class.
package com.example.ehrbase_plugin;
import org.ehrbase.plugin.WebMvcEhrBasePlugin;
import org.pf4j.PluginWrapper;
import org.pf4j.spring.SpringPluginManager;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.context.ApplicationContext;
public class EhrBasePlugin extends WebMvcEhrBasePlugin {
public EhrBasePlugin(PluginWrapper wrapper) {
super(wrapper);
}
@Override
protected DispatcherServlet buildDispatcherServlet() {
ApplicationContext parentContext =((SpringPluginManager) getWrapper().getPluginManager()).getApplicationContext();
AnnotationConfigWebApplicationContext applicationContext =
new AnnotationConfigWebApplicationContext();
applicationContext.setParent(parentContext);
applicationContext.setClassLoader(getWrapper().getPluginClassLoader());
applicationContext.register(SpringConfiguration.class);
// The ApplicationContext will be automatically refreshed when the
// DispatcherServlet will be
// initialized.
return new DispatcherServlet(applicationContext);
}
@Override
public String getContextPath() {
return "/ehrbase_plugin";
}
}
EhrBasePlugin
class can extend either a WebMvcEhrBasePlugin
or a NonWebMvcEhrBasePlugin
- For plugins which will use the full WebApplicationContext (provide Controller endpoints) need to implement
org.ehrbase.plugin.WebMvcEhrBasePlugin
as in the web example plugin. - If the full WebApplicationContext is not required - the simplified default
org.ehrbase.plugin.NonWebMvcEhrBasePlugin
must be implemented as in the simple example plugin.
In this case we need to use DispatcherServlet
method which is available in WebMvcEhrBasePlugin
so that we can use the Spring MVC framework and initialises the AnnotationConfigWebApplicationContext
, ensuring the plugin's beans and configurations are properly set up.
Both WebMvcEhrBasePlugin
and NonWebMvcEhrBasePlugin
extend EhrBasePlugin
which extends PF4J SpringPlugin
. It is recommended that regardless of whether you use one or the other for your plugin, have a spring configuration class that triggers a ComponentScan on the plugin package.
Here the spring configuration class is registered in context as:
applicationContext.register(SpringConfiguration.class);
The SpringConfiguration.class is as follows:
package org.medblocks.ehrbase_plugin_2;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
@Configuration
@EnableWebMvc
@EnableTransactionManagement
@ComponentScan(basePackages = {"com.example.ehrbase_plugin_2"})
public class SpringConfiguration {
}
- The
@Configuration
annotation at the beginning of the class indicates that this class contains Spring configuration. - The
@EnableWebMvc
annotation is used to enable Spring's web MVC framework. - The
@EnableTransactionManagement
annotation is used to enable Spring's annotation-driven transaction management capability. This means that you can use@Transactional
on any method that you want to be executed within a database transaction. This will come in very handy when we put our custom JDBCINSERT
operation in the same transaction as the compositionINSERT
operation of EHRbase - The
@ComponentScan
annotation is used to specify the packages that Spring should scan for components, configurations, and services. The basePackages attribute is set to the package that this configuration class is in, which means that Spring will scan this package and its sub-packages for components.
Let's now implement the extension point in our plugin, which will enable us to intercept the various events that occur in EHRbase. For our purpose we will look at CompositionExtensionPoint
which will let us intercept a composition event. A full explanation of extension points can be found here. I highly recommend going through the docs to really understand this concept.
The CompositionExtensionPoint is an interface that basically provides us with four methods,
aroundCreation
aroundCreation
aroundDelete
aroundRetrieve
For our example here we'll look at the aroundCreation
method. The parameters of this method is similar to all the other methods which is a input object and a call chain function.
The input object contains all or parts of the arguments provided to the intercepted methods in the EHRbase service layer.
The call chain function is practically the continuation of the event being intercepted. (There are other ExtensionPointHelper available to further enhance control over the extension that can be found here).
package com.example.ehrbase_plugin;
import org.ehrbase.plugin.dto.CompositionVersionIdWithEhrId;
import org.ehrbase.plugin.dto.CompositionWithEhrId;
import org.ehrbase.plugin.dto.CompositionWithEhrIdAndPreviousVersion;
import org.ehrbase.plugin.extensionpoints.CompositionExtensionPoint;
import org.pf4j.Extension;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.UUID;
import java.util.function.Function;
@Extension
@Component
public class CompositionEvents implements CompositionExtensionPoint {
private final CompositionService compositionService;
@Autowired
public CompositionEvents(CompositionService compositionService) {
this.compositionService = compositionService;
}
@Override
public UUID aroundCreation(CompositionWithEhrId input,
Function<CompositionWithEhrId, UUID> chain) {
return compositionService.createAndAnalyzeComposition(input,
chain);
}
}
The CompositionService
class:
package com.example.ehrbase_plugin;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.ehrbase.plugin.dto.CompositionVersionIdWithEhrId;
import org.ehrbase.plugin.dto.CompositionWithEhrId;
import org.ehrbase.plugin.dto.CompositionWithEhrIdAndPreviousVersion;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
import java.util.function.Function;
@Service
public class CompositionService {
private final AnalyticsService analyticsService;
@Autowired
public CompositionService(AnalyticsService analyticsService) {
this.analyticsService = analyticsService;
}
@Transactional
public UUID createAndAnalyzeComposition(CompositionWithEhrId input,
Function<CompositionWithEhrId, UUID> chain) {
UUID compositionId = chain.apply(input);
AnalyticsObj analyticsObj = new AnalyticsObj();
analyticsObj.setCompositionId(compositionId.toString());
analyticsObj.setEhrId(input.getEhrId().toString());
analyticsObj.setTemplateId(input.getComposition().getArchetypeDetails().getTemplateId().getValue());
try {
ObjectMapper objectMapper = new ObjectMapper();
String compositionJson = objectMapper.writeValueAsString(input.getComposition());
analyticsObj.setCompositionJson(compositionJson);
} catch (Exception e) {
e.printStackTrace();
System.out.println("Error while converting composition to json: " + e.getMessage());
}
analyticsService.addAnalytics(analyticsObj);
return compositionId;
}
}
The AnalyticsObj
class :
package com.example.ehrbase_plugin;
public class AnalyticsObj {
private String compositionId;
private String ehrId;
private String templateId;
private String compositionJson;
public String getCompositionJson() {
return compositionJson;
}
public void setCompositionJson(String compositionJson) {
this.compositionJson = compositionJson;
}
public String getCompositionId() {
return compositionId;
}
public void setCompositionId(String compositionId) {
this.compositionId = compositionId;
}
public String getEhrId() {
return ehrId;
}
public void setEhrId(String ehrId) {
this.ehrId = ehrId;
}
public String getTemplateId() {
return templateId;
}
public void setTemplateId(String templateId) {
this.templateId = templateId;
}
}
The AnalyticsService
class:
package com.example.ehrbase_plugin;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AnalyticsService {
@Autowired
private AnalyticsRepo analyticsRepo;
@Transactional
public void addAnalytics(AnalyticsObj analyticsObj) {
analyticsRepo.save(analyticsObj);
}
}
We are now able to intercept the composition event and get access to the CompositionWithEhrId input
. The CompositionService
class defined above executes the composition INSERT event by chain.apply(input);
and stores the composition ID.
EHR ID, template ID and the composition JSON are all present in the CompositionWithEhrId input
. We store all these values into an AnalyticsObj
object and pass it to the addAnalytics
method in the AnalyticsService
class.
The AnalyticsRepo
class :
package com.example.ehrbase_plugin;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@Repository
public class AnalyticsRepo {
@Autowired
private JdbcTemplate jdbcTemplate;
@Transactional
public void save(AnalyticsObj analyticsObj) {
String sql =
"INSERT INTO ehr.composition_analytics (composition_id, ehr_id
, template_id, composition_json) VALUES (?, ?, ?, ?)";
try {
int rows = jdbcTemplate.update(
sql,
analyticsObj.getCompositionId(),
analyticsObj.getEhrId(),
analyticsObj.getTemplateId(),
analyticsObj.getCompositionJson()
);
System.out.println("Rows inserted: " + rows);
} catch (Exception e) {
System.err.println("Error saving analytics object: "
+ e.getMessage());
e.printStackTrace();
}
}
}
In this section I'll try and explain the sorcery performed by Spring. As you might have notice we haven't configured a data source for the JDBC template anywhere and yet we are using it here. But how? Well the answer is simple, our plugin when it's loaded into the runtime of EHRbase will be using the same JDBC context as EHRbase. So the insert operation we are performing is directly into a custom table called composition_analytics
in the EHRbase db.
As for the transactional outbox concept, i.e. making sure our custom INSERT operation and the composition INSERT operation occurs within the same database transaction, we use the @Transactional annotation to wrap both our custom INSERT and composition INSERT.
For context in ehrbase /service /src /main/java /org /ehrbase /repository / CompositionRepository.java the composition insert occurs in this method
@Transactional
public void commit(UUID ehrId, Composition composition, @Nullable UUID contributionId, @Nullable UUID auditId) {
UUID templateId = Optional.of(composition)
.map(Composition::getArchetypeDetails)
.map(Archetyped::getTemplateId)
.map(TemplateId::getValue)
.flatMap(knowledgeCache::findUuidByTemplateId)
.orElseThrow(
() -> new IllegalArgumentException("Unknown or missing template in composition to be stored"));
String rootConcept = AslRmTypeAndConcept.toEntityConcept(composition.getArchetypeNodeId());
commitHead(
ehrId,
composition,
contributionId,
auditId,
ContributionChangeType.creation,
r -> {
r.setTemplateId(templateId);
r.setRootConcept(rootConcept);
},
r -> {});
}
and in our code chain.apply(input);
is basically triggering this method. And since chain.apply(input)
and analyticsService.addAnalytics(analyticsObj)
(which in turn triggers our custom INSERT) are wrapped inside one @Transactional
annotation, all the child transactions will get wrapped into one top level database transaction, which will make sure that there is no inconsistencies between our custom analytics table and the EHRbase composition data tables.
Now we have an EHRbase plugin that can intercept composition events and create an entry of the composition input in a custom table in the EHRbase database. We can package this plugin into a JAR by running
mvn clean package
in the root directory path in the terminal. A JAR file that has the following name ehrbase-plugin-0.0.1-SNAPSHOT-plugin.jar
will appear in the "target" folder. Copy this package.
Now I am going to assume that you have EHRbase and its database running. If not detailed instructions are available in the README of ehrbase to get your EHRbase server up and running.
If you go to ehrbase/configuration/src/main/resources/application.yml
at line 229 you'll see plugin configuration.
plugin-manager:
plugin-dir: ./plugin_dir
plugin-config-dir: ./plugin_config_dir
enable: true
plugin-context-path: /plugin
You don't need to change anything here, by default plugins are already enabled, all you need to do is create a folder in the root of EHRbase repo, name it plugin_dir
and paste the ehrbase-plugin JAR package in it.
Now one more step before running EHRbase, make sure the custom table is created in EHRbase database with the required permissions.
CREATE TABLE ehr.composition_analytics (
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
composition_id TEXT PRIMARY KEY,
template_id TEXT,
ehr_id TEXT,
composition_json TEXT,
);
GRANT SELECT, INSERT, UPDATE, DELETE ON ehr.composition_analytics TO ehrbase;
GRANT SELECT, INSERT, UPDATE, DELETE ON ehr.composition_analytics TO ehrbase_restricted;
After all these steps run
mvn clean package
in the root directory path of EHRbase in the terminal and then once the build succeeded, run
java -jar application/target/ehrbase-*.jar
Replace the * with the current version, e.g. application/target/ehrbase-2.0.0.jar
.
Congratulations! You have now successfully added an additional functionality to EHRbase without having to fork the source code.