Plugin API development guide
Introduction
ReportPortal as a microservice application had services that integrate with external systems like JIRA or RALLY. These are problems of this approach:
- every service will run as a separate application consuming additional amount of resources for environment;
- user may not need all the integrations at the moment but need some (or a new one) later, so he should modify deployment configuration every time;
- every service modification requires re-deployment.
To solve these problems and support dynamic integrations ReportPortal implements plugin system on top of PF4J.
Documentation for the UI plugins can be found here
How does it work
Creating your first plugin
Result of the following steps can be found here - Plugin template. This is fully configured and ready-to-use plugin.
Base plugin configuration
We configure our build.gradle file as follows:
plugins {
id "io.spring.dependency-management" version "1.0.9.RELEASE"
id 'java'
}
repositories {
mavenCentral()
}
dependencies {
implementation 'com.epam.reportportal:plugin-api:5.4.0'
annotationProcessor 'com.epam.reportportal:plugin-api:5.4.0'
}
task plugin(type: Jar) {
getArchiveBaseName().set("plugin-${pluginId}")
into('classes') {
with jar
}
into('lib') {
from configurations.compile
}
extension('zip')
}
task assemblePlugin(type: Copy) {
from plugin
into pluginsDir
}
task assemblePlugins(type: Copy) {
dependsOn subprojects.assemblePlugin
}
This base configuration with plugin-api dependency grants access to extension points and core ReportPortal dependencies.
Create extension
Firstly we create our plugin representation (we also can override start() and stop() methods) that will be managed by pf4j plugin
manager.
package com.epam.reportportal.extension.example;
import org.pf4j.Plugin;
import org.pf4j.PluginWrapper;
public class ExamplePlugin extends Plugin {
public ExamplePlugin(PluginWrapper wrapper) {
super(wrapper);
}
}
Then we create our plugin entry point. We start with ReportPortalExtensionPoint implementation:
@Extension
public class ExampleExtension implements ReportPortalExtensionPoint {
private final Supplier<Map<String, PluginCommand<?>>> pluginCommandMapping = new MemoizingSupplier<>(this::getCommands);
public ExampleExtension(Map<String, Object> initParams) {
}
@Override
public Map<String, ?> getPluginParams() {
Map<String, Object> params = new HashMap<>();
params.put(ALLOWED_COMMANDS, new ArrayList<>(pluginCommandMapping.get().keySet()));
return params;
}
@Override
public PluginCommand<?> getCommandToExecute(String commandName) {
return pluginCommandMapping.get().get(commandName);
}
private Map<String, PluginCommand<?>> getCommands() {
Map<String, PluginCommand<?>> pluginCommandMapping = new HashMap<>();
pluginCommandMapping.put("testConnection", (integration, params) -> true);
return pluginCommandMapping;
}
}
We implement getPluginParams() to get list of supported plugin commands from the client side. We implement getCommandToExecute() to
get command from mapping to execute. As for now we only have testConnection command that implements base command interface:
public interface PluginCommand<T> {
/**
* Executes plugin command
*
* @param integration Configured ReportPortal integration
* @param params Plugin Command parameters
* @return Result
*/
T executeCommand(Integration integration, Map<String, Object> params);
}
Command testConnection is mandatory and should either always return true or execute logic of connection test with external system.
Autowire dependencies
Being loaded in runtime plugin extension can be handled as Spring bean. That's why we can autowire dependencies just as we do in core
application:
@Extension
public class ExampleExtension implements ReportPortalExtensionPoint {
...
@Autowired
private ApplicationContext applicationContext;
@Autowired
private IntegrationTypeRepository integrationTypeRepository;
@Autowired
private IntegrationRepository integrationRepository;
public ExampleExtension(Map<String, Object> initParams) {
}
...
}
Get file command
We can store in resources folder files that can be loaded from the client side later. During plugin installation plugin manager provides
directory in the file system to store plugin resources. This directory passed through the constructor (with Map parameter) and can be
accessed as follows:
@Extension
public class ExampleExtension implements ReportPortalExtensionPoint {
private final String resourcesDir;
...
public ExampleExtension(Map<String, Object> initParams) {
resourcesDir = IntegrationTypeProperties.RESOURCES_DIRECTORY.getValue(initParams).map(String::valueOf).orElse("");
}
...
}
GetFileCommand accepts resourcesDir and propertyFile as constructor parameters. Property file should be stored in resources folder
of the plugin. It contains key-value mapping that represents files allowed to be loaded from the client side:
icon=plugin-icon.svg
We created example-binary-data.properties file with these contents and now can access plugin-icon.svg file by passing icon key
to getFileCommand.
That's how our extension looks now:
@Extension
public class ExampleExtension implements ReportPortalExtensionPoint {
public static final String BINARY_DATA_PROPERTIES_FILE_ID = "example-binary-data.properties";
private final String resourcesDir;
private final Supplier<Map<String, PluginCommand<?>>> pluginCommandMapping = new MemoizingSupplier<>(this::getCommands);
@Autowired
private ApplicationContext applicationContext;
@Autowired
private IntegrationTypeRepository integrationTypeRepository;
@Autowired
private IntegrationRepository integrationRepository;
public ExampleExtension(Map<String, Object> initParams) {
resourcesDir = IntegrationTypeProperties.RESOURCES_DIRECTORY.getValue(initParams).map(String::valueOf).orElse("");
}
@Override
public Map<String, ?> getPluginParams() {
Map<String, Object> params = new HashMap<>();
params.put(ALLOWED_COMMANDS, new ArrayList<>(pluginCommandMapping.get().keySet()));
return params;
}
@Override
public PluginCommand<?> getCommandToExecute(String commandName) {
return pluginCommandMapping.get().get(commandName);
}
private Map<String, PluginCommand<?>> getCommands() {
Map<String, PluginCommand<?>> pluginCommandMapping = new HashMap<>();
pluginCommandMapping.put("getFile", new GetFileCommand(resourcesDir, BINARY_DATA_PROPERTIES_FILE_ID));
pluginCommandMapping.put("testConnection", (integration, params) -> true);
return pluginCommandMapping;
}
}
Assemble plugin
Our plugin can be built either as:
- simple jar (without external dependencies) and use dependencies from core application;
- shadow jar (with external dependencies) and still use dependencies from core application.
We should configure plugin jar manifest with mandatory properties:
- id
- version
- plugin class (class marked with @Extension - our entry point)
We should configure resource folder contents handling.
As we load api plugin contents in runtime we can do so with ui contents too. To make it possible we should modify our configuration.
We provide new ui.gradle config:
node {
version = '10.14.1'
npmVersion = '6.4.1'
download = true
workDir = file("${project.buildDir}/ui")
nodeModulesDir = file("${project.rootDir}/ui")
}
npm_run_build {
inputs.files fileTree("ui/src")
inputs.file 'ui/package.json'
inputs.file 'ui/package-lock.json'
outputs.dir 'ui/build'
}
Load it to the main configuration as a dependency and make some changes to include generated main.js file to resources folder that
allows us to load it using GetFileCommand.
That's how our build.gradle looks now:
import com.github.spotbugs.SpotBugsTask
plugins {
id "io.spring.dependency-management" version "1.0.9.RELEASE"
id 'java'
id 'com.github.johnrengelman.shadow' version '5.2.0'
id "com.moowork.node" version "1.3.1"
}
apply from: 'ui.gradle'
repositories {
mavenCentral()
}
dependencies {
implementation 'com.epam.reportportal:plugin-api:5.4.0'
annotationProcessor 'com.epam.reportportal:plugin-api:5.4.0'
}
artifacts {
archives shadowJar
}
sourceSets {
main {
resources
{
exclude '**'
}
}
}
jar {
from("src/main/resources") {
into("/resources")
}
from("ui/build") {
into("/resources")
}
manifest {
attributes(
"Class-Path": configurations.compile.collect { it.getName() }.join(' '),
"Plugin-Id": "${pluginId}",
"Plugin-Version": "${project.version}",
"Plugin-Provider": "Report Portal",
"Plugin-Class": "com.epam.reportportal.extension.example.ExamplePlugin",
"Plugin-Service": "api"
)
}
}
shadowJar {
from("src/main/resources") {
into("/resources")
}
from("ui/build") {
into("/resources")
}
configurations = [project.configurations.compile]
zip64 true
dependencies {
}
}
task plugin(type: Jar) {
getArchiveBaseName().set("plugin-${pluginId}")
into('classes') {
with jar
}
into('lib') {
from configurations.compile
}
extension('zip')
}
task assemblePlugin(type: Copy) {
from plugin
into pluginsDir
}
task assemblePlugins(type: Copy) {
dependsOn subprojects.assemblePlugin
}
compileJava.dependsOn npm_run_build
Now we can just execute ./gradlew build and get plugin binaries (as jar and as shadowJar) that can be loaded to the application.
Event listeners
All plugin commands are executed through the core application end-point with mapping:
https://host:port/v1/integration/{projectName}/{integrationId}/{command}
As we can see integrationId is a mandatory parameter that specifies integration to be used in the command execution.
We can affect logic executed in core application from the plugin by handling predefined set of events. As for now we will use
mandatory PluginLoadedEventHandler as an example.
This handler creates the very first integration and uses PluginInfoProvider to update plugin data in the database.
To add a new listener we should use ApplicationContext after plugin was loaded - so we do it in the method marked by @PostConstruct.
Also, we should remove listeners when we unload plugin - so we implement DisposableBean interface and provide this logic in the preDestroy() method.
That's how our extension looks now:
@Extension
public class ExampleExtension implements ReportPortalExtensionPoint, DisposableBean {
public static final String BINARY_DATA_PROPERTIES_FILE_ID = "example-binary-data.properties";
private static final String PLUGIN_ID = "example";
private final String resourcesDir;
private final Supplier<Map<String, PluginCommand<?>>> pluginCommandMapping = new MemoizingSupplier<>(this::getCommands);
private final Supplier<ApplicationListener<PluginEvent>> pluginLoadedListenerSupplier;
@Autowired
private ApplicationContext applicationContext;
@Autowired
private IntegrationTypeRepository integrationTypeRepository;
@Autowired
private IntegrationRepository integrationRepository;
public ExampleExtension(Map<String, Object> initParams) {
resourcesDir = IntegrationTypeProperties.RESOURCES_DIRECTORY.getValue(initParams).map(String::valueOf).orElse("");
pluginLoadedListenerSupplier = new MemoizingSupplier<>(() -> new ExamplePluginEventListener(PLUGIN_ID,
new PluginEventHandlerFactory(integrationTypeRepository,
integrationRepository,
new PluginInfoProviderImpl(resourcesDir, BINARY_DATA_PROPERTIES_FILE_ID)
)
));
}
@Override
public Map<String, ?> getPluginParams() {
Map<String, Object> params = new HashMap<>();
params.put(ALLOWED_COMMANDS, new ArrayList<>(pluginCommandMapping.get().keySet()));
return params;
}
@Override
public PluginCommand<?> getCommandToExecute(String commandName) {
return pluginCommandMapping.get().get(commandName);
}
@PostConstruct
public void createIntegration() throws IOException {
initListeners();
}
private void initListeners() {
ApplicationEventMulticaster applicationEventMulticaster = applicationContext.getBean(AbstractApplicationContext.APPLICATION_EVENT_MULTICASTER_BEAN_NAME,
ApplicationEventMulticaster.class
);
applicationEventMulticaster.addApplicationListener(pluginLoadedListenerSupplier.get());
}
@Override
public void destroy() {
removeListeners();
}
private void removeListeners() {
ApplicationEventMulticaster applicationEventMulticaster = applicationContext.getBean(AbstractApplicationContext.APPLICATION_EVENT_MULTICASTER_BEAN_NAME,
ApplicationEventMulticaster.class
);
applicationEventMulticaster.removeApplicationListener(pluginLoadedListenerSupplier.get());
}
private Map<String, PluginCommand<?>> getCommands() {
Map<String, PluginCommand<?>> pluginCommandMapping = new HashMap<>();
pluginCommandMapping.put("getFile", new GetFileCommand(resourcesDir, BINARY_DATA_PROPERTIES_FILE_ID));
pluginCommandMapping.put("testConnection", (integration, params) -> true);
return pluginCommandMapping;
}
}
Lazy initialization
All plugin components that relies on @Autowired dependencies should be loaded lazily using MemoizingSupplier or another lazy-load mechanism.
This is the restriction of plugin installation flow:
We create extension object using constructor and only then we autowire dependencies. If we don't use lazy initialization - all objects created in the constructor will be created with NULL objects that were marked as @Autowired