Make your Jira Server/Data Center app work without dependant 3d party app

Hello!

In this article we will talk about specific to Atlassian app problem concerning 3d party apps.

Let’s say we want to create an app which have dependencies to Jira Software and Jira Service Desk. In this case you will inject services into your app provided by Jira Software and Jira Service Desk. But suppose your app is installed on a Jira instance without Jira Software but with Jira Service Desk. It is fine. You want still your app to work with Jira Service Desk. But your app will not be able to be installed because you are injecting services from Jira Software. How to make your app so that your app would be able to be installed even if Jira Software or Jira Service Desk is not available in the Jira instance where your app is going to be installed?

This problem arises every time if you use 3d party app’s services in your app. For example, let’s say you want to use Table Grid API or Insight API. You will inject services from those apps and your app will not work if those apps are not installed in the Jira instance. But how to make it work?

And that is exactly what we are going to talk about in this article.

We will take as an example Jira Cloud Migration Assistant (JCMA). Our app will work with JCMA but we want also our app to be able to work if JCMA is not installed.

We will start from this code:

git clone git clone https://alex1mmm@bitbucket.org/alex1mmm/third-party-dependency-tutorial.git --branch v.1 --single-branch

Initial code

In the initial code we have two files.

The first one is the src/main/java/ru/matveev/alexey/atlassian/tutorial/impl/MyPluginComponentImpl.java file

@Named ("myPluginComponent")
public class MyPluginComponentImpl implements MyPluginComponent
{

    private final AppCloudMigrationGateway appCloudMigrationGateway;

    @Inject
    public MyPluginComponentImpl(@ComponentImport AppCloudMigrationGateway appCloudMigrationGateway)
    {
        this.appCloudMigrationGateway = appCloudMigrationGateway;
    }

    public String getName()
    {
        if(null != this.appCloudMigrationGateway)
        {
            return "myComponent:" + appCloudMigrationGateway.getClass().getName();
        }
        
        return "myComponent";
    }
}

Here we import AppCloudMigrationGateway service in the constructor.

The second file is src/main/java/ru/matveev/alexey/atlassian/tutorial/Caller.java:

@Slf4j
@Named
public class Caller {
    public Caller(MyPluginComponent myPluginComponent) {
        log.error(String.format("Component Class Name: %s", myPluginComponent.getName()));
    }

}

Here we inject our MyPluginComponent and log a error message with the getName method of MyPluginComponent

Run app

Move into the app folder and type atlas-run. Jira should be started but your app will be disabled:

If you have a look in the atlassian-jira.log file you will see the following error:

2020-08-31 12:59:43,234+0300 ThreadPoolAsyncTaskExecutor::Thread 24 ERROR      [o.e.g.b.e.i.dependencies.startup.DependencyWaiterApplicationContextExecutor] Unable to create application context for [ru.matveev.alexey.atlassian.tutorial.third-party-dependency-tutorial], unsatisfied dependencies: none
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'caller': Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'myPluginComponent': Resolution of declared constructors on bean Class [ru.matveev.alexey.atlassian.tutorial.impl.MyPluginComponentImpl] from ClassLoader [ru.matveev.alexey.atlassian.tutorial.third-party-dependency-tutorial [231]] failed; nested exception is java.lang.NoClassDefFoundError: com/atlassian/migration/app/AppCloudMigrationGateway

Which means that com/atlassian/migration/app/AppCloudMigrationGateway class not found.

Now install JCMA:

Now go to Manage Apps, uninstall and install your app. This time your app will be enabled:

And you will also discover our message:

2020-08-31 13:03:26,388+0300 ThreadPoolAsyncTaskExecutor::Thread 27 ERROR admin 779x2400x1 1m4naq8 0:0:0:0:0:0:0:1 /rest/plugins/1.0/ [r.m.a.atlassian.tutorial.Caller] Component Class Name: myComponent:com.sun.proxy.$Proxy3421

Now Unistall JCMA and your app will become disabled.

Ok. Everything works as expected.

Change code

Now let’s make modification to our code to make our app enabled even if JCMA is absent.

The problem is in the src/main/java/ru/matveev/alexey/atlassian/tutorial/impl/MyPluginComponentImpl.java file with the following code:

@Inject
    public MyPluginComponentImpl(@ComponentImport AppCloudMigrationGateway appCloudMigrationGateway)
    {
        this.appCloudMigrationGateway = appCloudMigrationGateway;
    }

We inject AppCloudMigrationGateway as dependency in our class. We need to get rid of this injection.

How to do it?

First of all let’s create an accessor class for AppCloudMigrationGateway service.

Here is the src/main/java/ru/matveev/alexey/atlassian/tutorial/JCMAAccessor.java file

public class JCMAAccessor {

    public static AppCloudMigrationGateway getJCMAGateway() {
        if(ComponentAccessor.getPluginAccessor().getPlugin("com.atlassian.jira.migration.jira-migration-plugin") == null) {
            return null;
        }
        return ComponentAccessor.getOSGiComponentInstanceOfType(AppCloudMigrationGateway.class);
    }
}

We do not inject AppCloudMigrationGateway service here. We check if JCMA is installed then we return a reference to the AppCloudMigrationGateway service with the getOSGiComponentInstanceOfType method. If JCMA is not installed then we return null.

Now let’s rewrite our src/main/java/ru/matveev/alexey/atlassian/tutorial/impl/MyPluginComponentImpl.java file to this one :

@Named ("myPluginComponent")
public class MyPluginComponentImpl implements MyPluginComponent
{

    public String getName()
    {
        if(null != JCMAAccessor.getJCMAGateway())
        {
            return "myComponent:" + JCMAAccessor.getJCMAGateway().getClass().getName();
        }
        
        return "myComponent";
    }
}

We check if JCMAAccessor.getJCMAGateway() returns not null, then we get the name of the class. If JCMAAccessor.getJCMAGateway() returns null then we return myComponent.

Run our app again

Now let’s install our app again. This time our app will be enabled even if we have not installed JCMA and we will see the following message in the logs:

2020-08-31 13:37:24,274+0300 ThreadPoolAsyncTaskExecutor::Thread 27 ERROR admin 812x2453x5 28lz3m 0:0:0:0:0:0:0:1 /rest/plugins/1.0/installed-marketplace [r.m.a.atlassian.tutorial.Caller] Component Class Name: myComponent

Well. It is done. Nice and clean. You can see the final code for this part under v.2 tag

Make it more complicated

Actually if we want to use JCMA gateway service we need to create a listener of the AppCloudMigrationListener class.

Let’s do it.

First change our interface src/main/java/ru/matveev/alexey/atlassian/tutorial/api/MyPluginComponent.java

public interface MyPluginComponent extends AppCloudMigrationListener
{
    String getName();
}

We extended our interface from AppCloudMigrationListener

Now we need to register our AppCloudMigrationListener.

Change src/main/java/ru/matveev/alexey/atlassian/tutorial/impl/MyPluginComponentImpl.java to the following code:

@Slf4j
@Named ("myPluginComponent")
public class MyPluginComponentImpl  implements MyPluginComponent, DisposableBean
{
    public MyPluginComponentImpl() {
        JCMAAccessor.getJCMAGateway().registerListener(this);
        log.error("Listener registered");
    }

    public String getName()
    {
        if(null != JCMAAccessor.getJCMAGateway())
        {
            return "myComponent:" + JCMAAccessor.getJCMAGateway().getClass().getName();
        }
        
        return "myComponent";
    }

    @Override
    public void onStartAppMigration(String transferId, MigrationDetails migrationDetails) {

        // Retrieving ID mappings from the migration.
        // A complete guide can be found at https://developer.atlassian.com/platform/app-migration/getting-started/mappings/
        try {
            log.info("Migration context summary: " + new ObjectMapper().writeValueAsString(migrationDetails));
            PaginatedMapping paginatedMapping = JCMAAccessor.getJCMAGateway().getPaginatedMapping(transferId, "identity:user", 5);
            while (paginatedMapping.next()) {
                Map<String, String> mappings = paginatedMapping.getMapping();
                log.info("mappings = {}", new ObjectMapper().writeValueAsString(mappings));
            }
        } catch (IOException e) {
            log.error("Error retrieving migration mappings", e);
        }

        // You can also upload one or more files to the cloud. You'll be able to retrieve them through Atlassian Connect
        try {
            OutputStream firstDataStream = JCMAAccessor.getJCMAGateway().createAppData(transferId);
            // You can even upload big files in here
            firstDataStream.write("Your binary data goes here".getBytes());
            firstDataStream.close();

            // You can also apply labels to distinguish files or to add meta data to support your import process
            OutputStream secondDataStream = JCMAAccessor.getJCMAGateway().createAppData(transferId, "some-optional-label");
            secondDataStream.write("more bytes".getBytes());
            secondDataStream.close();
        } catch (IOException e) {
            log.error("Error uploading files to the cloud", e);
        }
    }

    @Override
    public String getCloudAppKey() {
        return "my-cloud-app-key";
    }

    @Override
    public String getServerAppKey() {
        return "my-server-app-key";
    }

    /**
     * Declare what categories of data your app handles.
     */
    @Override
    public Set<AccessScope> getDataAccessScopes() {
        return Stream.of(AccessScope.APP_DATA_OTHER, AccessScope.PRODUCT_DATA_OTHER, AccessScope.MIGRATION_TRACING_IDENTITY)
                .collect(Collectors.toCollection(HashSet::new));
    }

    @Override
    public void destroy() {
        JCMAAccessor.getJCMAGateway().deregisterListener(this);
        log.error("Listener deregistered");
    }

I made MyPluginComponentImpl as a listener.

Let’s have a look at the constructor:

public MyPluginComponentImpl() {
        JCMAAccessor.getJCMAGateway().registerListener(this);
        log.error("Listener registered");
    }

I register our listener. And I deregister this listener in the destroy method:

@Override
    public void destroy() {
        JCMAAccessor.getJCMAGateway().deregisterListener(this);
        log.error("Listener deregistered");
    }

All other methods are the implementations of the methods from the AppCloudMigrationListener interface. Implementation of those modules is not important for this tutorial.

Run app again

If we run our app again. Our app will be disabled with the following error:

Caused by: java.lang.NoClassDefFoundError: com/atlassian/migration/app/AppCloudMigrationListener

It is correct. AppCloudMigrationListener belongs to JCMA and JCMA is not installed. You can find the complete code for this part under the v.3 tag.

Fix our code

First of all let’s make our AppCloudMigrationListener a separate class.

src/main/java/ru/matveev/alexey/atlassian/tutorial/MyAppCloudMigrationListener.java:

@Slf4j
public class MyAppCloudMigrationListener implements AppCloudMigrationListener {
    @Override
    public void onStartAppMigration(String transferId, MigrationDetails migrationDetails) {

        // Retrieving ID mappings from the migration.
        // A complete guide can be found at https://developer.atlassian.com/platform/app-migration/getting-started/mappings/
        try {
            log.info("Migration context summary: " + new ObjectMapper().writeValueAsString(migrationDetails));
            PaginatedMapping paginatedMapping = JCMAAccessor.getJCMAGateway().getPaginatedMapping(transferId, "identity:user", 5);
            while (paginatedMapping.next()) {
                Map<String, String> mappings = paginatedMapping.getMapping();
                log.info("mappings = {}", new ObjectMapper().writeValueAsString(mappings));
            }
        } catch (IOException e) {
            log.error("Error retrieving migration mappings", e);
        }

        // You can also upload one or more files to the cloud. You'll be able to retrieve them through Atlassian Connect
        try {
            OutputStream firstDataStream = JCMAAccessor.getJCMAGateway().createAppData(transferId);
            // You can even upload big files in here
            firstDataStream.write("Your binary data goes here".getBytes());
            firstDataStream.close();

            // You can also apply labels to distinguish files or to add meta data to support your import process
            OutputStream secondDataStream = JCMAAccessor.getJCMAGateway().createAppData(transferId, "some-optional-label");
            secondDataStream.write("more bytes".getBytes());
            secondDataStream.close();
        } catch (IOException e) {
            log.error("Error uploading files to the cloud", e);
        }
    }

    @Override
    public String getCloudAppKey() {
        return "my-cloud-app-key";
    }

    @Override
    public String getServerAppKey() {
        return "my-server-app-key";
    }

    /**
     * Declare what categories of data your app handles.
     */
    @Override
    public Set<AccessScope> getDataAccessScopes() {
        return Stream.of(AccessScope.APP_DATA_OTHER, AccessScope.PRODUCT_DATA_OTHER, AccessScope.MIGRATION_TRACING_IDENTITY)
                .collect(Collectors.toCollection(HashSet::new));
    }
}

Now we need to remove “extends AppCloudMigrationListener” from src/main/java/ru/matveev/alexey/atlassian/tutorial/api/MyPluginComponent.java:

public interface MyPluginComponent
{
    String getName();
}

Now let’s create the src/main/java/ru/matveev/alexey/atlassian/tutorial/ListenerManager.java file. ListenerManager will register and deregister MyAppCloudMigrationListener:

@Slf4j
public class ListenerManager {

    static AppCloudMigrationListener appCloudMigrationListener = null;

    public static void registerListener() {
        appCloudMigrationListener = new MyAppCloudMigrationListener();
        JCMAAccessor.getJCMAGateway().registerListener(appCloudMigrationListener);
        log.error("Listener registered");
    }

    public static void deregisterListener() {
        appCloudMigrationListener = new MyAppCloudMigrationListener();
        JCMAAccessor.getJCMAGateway().deregisterListener(appCloudMigrationListener);
        log.error("Listener deregistered");
    }
}

We have two methods here: to register our MyAppCloudMigrationListener and deregister it. Also we throw a log message upon registering and deregistering this listener.

Now we need to change src/main/java/ru/matveev/alexey/atlassian/tutorial/impl/MyPluginComponentImpl.java to this one:

@Slf4j
@Named ("myPluginComponent")
public class MyPluginComponentImpl implements MyPluginComponent, DisposableBean {

    public MyPluginComponentImpl() {
        if (JCMAAccessor.getJCMAGateway() != null) {
            ListenerManager.registerListener();
        }
        else {
            log.error("Listener not registered");
        }
    }

    public String getName()
    {
        if(null != JCMAAccessor.getJCMAGateway())
        {
            return "myComponent:" + JCMAAccessor.getJCMAGateway().getClass().getName();
        }
        
        return "myComponent";
    }

    @Override
    public void destroy() {
        ListenerManager.deregisterListener();
    }
}

In the constructor we check if JCMA is installed and if installed we register our listener with the ListenerManager class. In the destroy method we deregister our listener with the Listener Manager class. And the getName is left unchanged.

Now let’s run our app

Run app again

If we install our application our app will be enabled and we will have these logs in the log file:

2020-08-31 15:58:42,907+0300 ThreadPoolAsyncTaskExecutor::Thread 64 ERROR admin 958x12356x1 28lz3m 0:0:0:0:0:0:0:1 /rest/plugins/1.0/com.atlassian.jira.migration.jira-migration-plugin-key [r.m.a.a.tutorial.impl.MyPluginComponentImpl] Listener not registered

Which is correct because JCMA is absent. Now let’s install JCMA and then install our app. We will see the following logs:

2020-08-31 16:00:29,104+0300 http-nio-2990-exec-9 ERROR admin 960x12417x1 28lz3m 0:0:0:0:0:0:0:1 /rest/plugins/1.0/com.atlassian.jira.migration.jira-migration-plugin-key [r.m.a.atlassian.tutorial.ListenerManager] Listener registered

The listener has been registered. Well, that is what we expected.

Let’s disable our app, we will see the following message:

2020-08-31 16:01:38,078+0300 http-nio-2990-exec-1 ERROR admin 961x12427x1 28lz3m 0:0:0:0:0:0:0:1 /rest/plugins/1.0/ru.matveev.alexey.atlassian.tutorial.third-party-dependency-tutorial-key [r.m.a.atlassian.tutorial.ListenerManager] Listener deregistered

Again correct. The listener was deregistered.

Now enable our app and disable JCMA:

2020-08-31 16:03:13,050+0300 ThreadPoolAsyncTaskExecutor::Thread 68 ERROR admin 963x12437x1 28lz3m 0:0:0:0:0:0:0:1 /rest/plugins/1.0/ru.matveev.alexey.atlassian.tutorial.third-party-dependency-tutorial-key [r.m.a.a.tutorial.impl.MyPluginComponentImpl] Listener not registered

Again correct. The listener was deregisterd.

Now if you enable JCMA, we expect that our listener should be enabled but it will not be enabled. To fix it we need to create listeners for PluginInstalledEvent and PluginEnabledEvent.

src/main/java/ru/matveev/alexey/atlassian/tutorial/PluginListener.java:

@Slf4j
public class PluginListener implements InitializingBean, DisposableBean {

    private final EventPublisher eventPublisher;

    public PluginListener(@ComponentImport EventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }
    @EventListener
    public void onPluginInstalledEvent(PluginInstalledEvent pluginInstalledEvent) {
        ListenerManager.registerListener();
    }

    @EventListener
    public void onPluginEnabledEvent(PluginEnabledEvent pluginEnabledEvent) {
        ListenerManager.registerListener();
    }


    @Override
    public void destroy() throws Exception {
        this.eventPublisher.unregister(this);
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        this.eventPublisher.register(this);
    }
}

The code is simple. If PluginInstalledEvent and PluginEnabledEvent are triggered we register our listener. And now if we enable JCMA, our listener will be registered.

You can see the code for this part under v.4 tag.

That is all for the article. We managed to enable our app if our dependant app is absent.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: