How to Create an EHRbase Plugin

Vipin Santhosh

Vipin Santhosh

Developer & E-Learning Instructor

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.

Setup

1. Project Configuration and Dependencies

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>

2. Plugin Class and Spring configurations

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 {  
}
  1. The @Configuration annotation at the beginning of the class indicates that this class contains Spring configuration.
  2. The @EnableWebMvc annotation is used to enable Spring's web MVC framework.
  3. 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 JDBC INSERT operation in the same transaction as the composition INSERT operation of EHRbase
  4. 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.

3. Extension Class

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,

  1. aroundCreation
  2. aroundCreation
  3. aroundDelete
  4. 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);  
    }  
}

4. Triggering composition INSERT in EHRbase and reading the input values

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.

5. JDBC context and the transactional outbox

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.

6. Putting it all together

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.