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