Hi everyone,
We’ve finally succeeded in building a JAR that runs across Confluence 7 to 9. It took us approximately 95 person-hours, and it was quite a challenging process. This thread was invaluable in resolving some issues, so I’d like to share some key learnings from our experience.
Key Challenge: The Rest Api
The biggest hurdle we faced was adapting our REST APIs. Different versions of Confluence export different versions of Jackson, which caused significant issues since our application also relies heavily on Jackson internally. To resolve this, we chose to package Jackson directly into our app instead of importing it via OSGi. A similar approach was necessary for Jakarta Validation.
To achieve compatibility across Confluence versions 7 to 9, we decided to move away from some of the “Magic.” Instead of relying on implicit serialization, deserialization, and validation by Confluence/Spring, we now receive the request body as an InputStream
and handle serialization explicitly using our own packaged Jackson. We also replaced the @AdminOnly
annotation for security checks with explicit code, moving this logic to a dedicated bean. This is necessary because confluence 7 and 9 need different imports for AdminOnly. Although these changes might not be considered as elegant, it has significantly improved our code’s maintainability, debuggability, and portability.
Approach to Making the REST APIs Compatible Across Versions
1. Explicit Import-Package
Declarations
-
We made all Import-Package
statements explicit in our configuration. By avoiding large wildcard imports, we ensured that only the desired classes were included in the classpath. Specifically, we avoided importing anything related to Jackson or Jakarta Validation.
-
Here is our Import-Package
statement for reference:
<plugin>
<groupId>com.atlassian.maven.plugins</groupId>
<artifactId>confluence-maven-plugin</artifactId>
<version>9.0.2</version>
<extensions>true</extensions>
<configuration>
[...]
<Import-Package>
com.atlassian.confluence.api.model.content.id,
com.atlassian.upm.api.license.entity,
com.atlassian.bandana,
com.atlassian.bonnie,
com.atlassian.confluence.api.model.content,
com.atlassian.confluence.api.service.accessmode,
com.atlassian.confluence.api.service.content,
com.atlassian.confluence.api.service.exceptions,
com.atlassian.confluence.api.service.search,
com.atlassian.confluence.content,
com.atlassian.confluence.core,
com.atlassian.confluence.event.events.content.comment,
com.atlassian.confluence.importexport.resource,
com.atlassian.confluence.labels,
com.atlassian.confluence.pages,
com.atlassian.confluence.pages.persistence.dao,
com.atlassian.confluence.pages.thumbnail,
com.atlassian.confluence.plugins.conversion.api,
com.atlassian.confluence.renderer.radeox.macros,
com.atlassian.confluence.search,
com.atlassian.confluence.search.service,
com.atlassian.confluence.search.v2,
com.atlassian.confluence.search.v2.query,
com.atlassian.confluence.search.v2.sort,
com.atlassian.confluence.security,
com.atlassian.confluence.setup.bandana,
com.atlassian.confluence.setup.settings,
com.atlassian.confluence.spaces,
com.atlassian.confluence.user,
com.atlassian.confluence.util,
com.atlassian.confluence.util.i18n,
com.atlassian.confluence.util.velocity,
com.atlassian.confluence.xhtml.api,
com.atlassian.event.api,
com.atlassian.plugin,
com.atlassian.plugin.web,
com.atlassian.renderer.util,
com.atlassian.sal.api.transaction,
com.atlassian.sal.api.user,
com.atlassian.scheduler,
com.atlassian.spring.container,
com.atlassian.plugin.spring.scanner.annotation.export,
com.atlassian.plugin.spring.scanner.annotation.imports,
com.atlassian.upm.api.license,
com.atlassian.upm.api.util,
com.atlassian.user,
com.atlassian.user.search,
com.atlassian.user.search.page,
com.atlassian.user.search.query,
javax.net,
javax.net.*,
javax.imageio,
javax.servlet,
javax.servlet.*,
javax.ws.*,
javax.xml,
javax.xml.*,
javax.swing,
org.xml.*,
org.springframework.beans,
org.springframework.beans.factory,
org.springframework.beans.factory.annotation,
org.springframework.cglib.core,
org.springframework.cglib.proxy,
org.springframework.cglib.reflect,
org.springframework.stereotype,
org.springframework.context.annotation,
org.springframework.web.bind.annotation,
com.opensymphony.xwork2.conversion.impl;resolution:=optional,
org.springframework.osgi.*;resolution:="optional",
org.eclipse.gemini.blueprint.*;resolution:="optional",
org.osgi.framework,
org.slf4j
</Import-Package>
<!-- Ensure plugin is spring powered -->
<Spring-Context>*</Spring-Context>
</configuration>
</plugin>
2. Shadowing Jackson and Jakarta Validation
-
We packaged Jackson and Jakarta Validation directly into our app, effectively shadowing the versions provided by Confluence:
<dependencies>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>7.0.5.Final</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.17.2</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>2.17.2</version>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<version>3.0.2</version>
</dependency>
</dependencies>
-
Additionally, we had to explicitly list Jakarta and Jackson dependencies as exceptions in our confluence-maven-plugin
configuration to bypass the packaging restrictions:
<plugin>
<groupId>com.atlassian.maven.plugins</groupId>
<artifactId>confluence-maven-plugin</artifactId>
<version>9.0.2</version>
<extensions>true</extensions>
<configuration>
[...]
<banningExcludes>
<exclude>com.fasterxml.jackson.core:jackson-core</exclude>
<exclude>com.fasterxml.jackson.core:jackson-databind</exclude>
<exclude>com.fasterxml.jackson.core:jackson-annotations</exclude>
<exclude>jakarta.validation:jakarta.validation-api</exclude>
</banningExcludes>
[...]
</configuration>
</plugin>
3. Validation Handling
- We created helper beans to handle validation and authorization. Instead of using annotations like
@Valid
, validation is now handled through a dedicated Validator
class:
import lombok.Getter;
import java.io.Serializable;
import java.util.List;
public class ValidatorException extends Exception {
@Getter private final List<String> errors;
@Getter private final Serializable entity;
public ValidatorException(List<String> errors, Serializable entity) {
super("validation failed");
this.entity = entity;
this.errors = errors;
}
}
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.ValidatorFactory;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Set;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
@Provider
public class ValidatorExceptionHandler implements ExceptionMapper<ValidatorException> {
@Override
public Response toResponse(ValidatorException e){
String message = "Validation failed";
return Response
.status(400)
.entity(HttpError.builder()
.statusCode(400)
.message(message)
.errors(e.getErrors())
.build()
).build();
}
}
public class Validator {
public void validate(Serializable entity) throws ValidatorException {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
jakarta.validation.Validator validator = factory.getValidator();
Set<ConstraintViolation<Object>> violations = validator.validate(entity);
if (!violations.isEmpty()) {
ArrayList<String> messages = new ArrayList<>();
for (ConstraintViolation<Object> violation: violations) {
messages.add(violation.getMessage());
}
throw new ValidatorException(messages, entity);
}
}
}
4. Authorization Handling
- Similarly, we implemented explicit checks for authorization. The
AdminChecker
bean allows us to check permissions manually, replacing @AdminOnly
:
public class AuthenticationRequiredException extends SecurityException {
public AuthenticationRequiredException() {
super("Client must be authenticated to access this resource.");
}
}
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
import java.util.Collections;
@Provider
public class AuthenticationRequiredExceptionHandler implements ExceptionMapper<AuthenticationRequiredException> {
@Override
public Response toResponse(AuthenticationRequiredException e){
return Response
.status(401)
.entity(HttpError.builder()
.statusCode(401)
.message(e.getMessage())
.errors(Collections.singletonList("Authentication required"))
.build()
).build();
}
}
public class AuthorisationException extends SecurityException {
public AuthorisationException(String message) {
super(message);
}
}
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
import java.util.Collections;
@Provider
public class AuthorisationExceptionHandler implements ExceptionMapper<AuthorisationException> {
@Override
public Response toResponse(AuthorisationException e){
return Response
.status(403)
.entity(HttpError.builder()
.statusCode(403)
.message(e.getMessage())
.errors(Collections.singletonList("Authorisation required"))
.build()
).build();
}
}
import com.atlassian.user.User;
import com.atlassian.confluence.security.Permission;
import com.atlassian.confluence.security.PermissionManager;
import com.atlassian.confluence.user.AuthenticatedUserThreadLocal;
import com.atlassian.confluence.user.ConfluenceUser;
import com.atlassian.confluence.user.UserAccessor;
public class AdminChecker {
private final UserAccessor userAccessor;
private final PermissionManager permissionManager;
public AdminChecker(UserAccessor userAccessor, PermissionManager permissionManager) {
this.userAccessor = userAccessor;
this.permissionManager = permissionManager;
}
public void check() {
ConfluenceUser user = AuthenticatedUserThreadLocal.get();
if (user == null) {
throw new AuthenticationRequiredException();
}
if (!isAdmin(user)) {
throw new AuthorisationException("You must be a system administrator to access this resource.");
}
}
private boolean isAdmin(ConfluenceUser user) {
// Check user has system admin permission.
// We have to cast ConfluenceUser to User here to access the old method that is available in Confluence 7/8.
return permissionManager.hasPermission((User)user, Permission.ADMINISTER, PermissionManager.TARGET_SYSTEM);
}
}
5. Updated REST Resource
-
Our REST resources have been updated to perform explicit serialization, validation, and permission checks:
import com.fasterxml.jackson.core.JacksonException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import javax.inject.Inject;
import javax.ws.rs.Consumes;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.io.IOException;
import java.io.InputStream;
@Path("/configuration")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class ConfigurationRest {
private final Validator validator;
private final AdminChecker adminChecker;
@Inject
public ConfigurationRest(Validator validator, AdminChecker adminChecker) {
this.validator = validator;
this.adminChecker = adminChecker;
}
@PUT
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public Response putConfiguration(InputStream requestBody) throws ValidatorException, JacksonException, IOException {
adminChecker.check(); // Explicit permission check
// Explicit deserialization
ObjectMapper mapper = new ObjectMapper();
Configuration configuration = mapper.readValue(requestBody, Configuration.class);
// Explicit validation
validator.validate(configuration);
// Explicit serialization
byte[] data = new ObjectMapper().writeValueAsBytes(configuration);
return Response.ok(data).build();
}
}
I realize we are quite late to the party but maybe this helps somebody else.
The changes we made have made our code more verbose but also more predictable and portable, especially across major platform versions. We believe the trade-off of replacing implicit behavior with explicit code will pay off in the long run, particularly in terms of maintainability and debugging. As it is robust against changing versions of libraries and renaming of packages.
Update
We encountered an edge case issue with hibernate-validator and jakarta-validation, so we decided to use Apache BVal as an alternative. We chose BVal 2.x because it is still compatible with JDK 8, which is the minum requirement for Confluence 7, while the 3.x version requires JDK 11. BVal 2.x continues to use javax.validation, which fits our needs without significant code changes.
To implement this, replace all imports of jakarta.validation.* with javax.validation.* and change the dependencies for hibernate-validator and jakarta-validation to:
<dependency>
<groupId>org.apache.bval</groupId>
<artifactId>bval-jsr</artifactId>
<version>2.0.6</version>
</dependency>
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>
Or keep using jakarta-validation with BVal 3.x if you don’t need to be compatible with JDK 8.
I would recommend using BVal over hibernate-validation as it is much simpler and build for simple bean validation and therefore much less likely to cause issues.