HttpExecutor.java
package com.bonitasoft.processbuilder.execution;
import com.bonitasoft.processbuilder.enums.RestAuthenticationType;
import com.bonitasoft.processbuilder.enums.RestContentType;
import com.bonitasoft.processbuilder.records.RestAuthConfig;
import com.bonitasoft.processbuilder.records.RestServiceRequest;
import com.bonitasoft.processbuilder.records.RestServiceResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* HTTP execution engine with connection pooling, OAuth2 token caching, and Bonita session management.
* <p>
* Extracted from RestServiceExecutor to be shared between REST Extension and custom connector.
* This is now an instantiable class (not static) to allow proper lifecycle management,
* but uses shared static clients and caches for connection pooling.
* </p>
*/
public final class HttpExecutor {
private static final Logger LOGGER = LoggerFactory.getLogger(HttpExecutor.class);
private static final int DEFAULT_TIMEOUT_MS = 30_000;
private static final int MAX_TIMEOUT_MS = 300_000;
private static final Pattern BONITA_API_PATTERN = Pattern.compile(
"^(https?://[^/]+)/bonita/API/.*", Pattern.CASE_INSENSITIVE);
private static final String BONITA_LOGIN_PATH = "/bonita/loginservice";
private static final String JSESSIONID_COOKIE = "JSESSIONID";
private static final String BONITA_API_TOKEN_COOKIE = "X-Bonita-API-Token";
private static final String BONITA_API_TOKEN_HEADER = "X-Bonita-API-Token";
// Shared HttpClient instances for connection pooling
private static final HttpClient SECURE_CLIENT;
private static final HttpClient INSECURE_CLIENT;
// OAuth2 token cache (cacheKey -> CachedToken)
private static final ConcurrentHashMap<String, CachedToken> TOKEN_CACHE = new ConcurrentHashMap<>();
// Bonita session cache (baseUrl:user -> CachedSession)
private static final ConcurrentHashMap<String, CachedSession> BONITA_SESSION_CACHE = new ConcurrentHashMap<>();
static {
SECURE_CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(DEFAULT_TIMEOUT_MS))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
HttpClient insecure;
try {
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, new TrustManager[]{new TrustAllManager()}, new java.security.SecureRandom());
insecure = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(DEFAULT_TIMEOUT_MS))
.followRedirects(HttpClient.Redirect.NORMAL)
.sslContext(sslContext)
.build();
} catch (Exception e) {
insecure = SECURE_CLIENT;
}
INSECURE_CLIENT = insecure;
}
public HttpExecutor() {}
/**
* Executes a REST service request.
*
* @param request The REST service request configuration
* @return The REST service response
*/
public RestServiceResponse execute(RestServiceRequest request) {
long startTime = System.currentTimeMillis();
String requestUrl = request.buildFullUrl();
try {
LOGGER.info("Executing REST request: {} {}", request.method(), requestUrl);
HttpClient client = request.verifySsl() ? SECURE_CLIENT : INSECURE_CLIENT;
HttpRequest.Builder httpRequestBuilder = HttpRequest.newBuilder()
.uri(URI.create(requestUrl))
.timeout(Duration.ofMillis(
Math.min(request.timeoutMs() > 0 ? request.timeoutMs() : DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)));
// Apply headers
Map<String, String> allHeaders = request.buildAllHeaders();
allHeaders.forEach(httpRequestBuilder::header);
// Handle Bonita session-based authentication (JSESSIONID + CSRF token)
if (request.auth() != null && isBasicAuth(request.auth()) && isBonitaApiUrl(requestUrl)) {
LOGGER.debug("Detected Bonita API URL, using session-based authentication");
CachedSession session = getBonitaSession(requestUrl, request.auth(), request.verifySsl());
if (session != null) {
httpRequestBuilder.header("Cookie", JSESSIONID_COOKIE + "=" + session.sessionId());
if (session.apiToken() != null && !session.apiToken().isEmpty()) {
httpRequestBuilder.header(BONITA_API_TOKEN_HEADER, session.apiToken());
}
}
}
// Handle OAuth2 authentication
if (request.auth() != null && isOAuth2Auth(request.auth())) {
String token = getOAuth2Token(request.auth());
if (token != null) {
httpRequestBuilder.header("Authorization", "Bearer " + token);
}
}
// Set HTTP method and body
HttpRequest.BodyPublisher bodyPublisher = request.hasBody()
? HttpRequest.BodyPublishers.ofString(request.body())
: HttpRequest.BodyPublishers.noBody();
switch (request.method()) {
case GET -> httpRequestBuilder.GET();
case POST -> httpRequestBuilder.POST(bodyPublisher);
case PUT -> httpRequestBuilder.PUT(bodyPublisher);
case DELETE -> httpRequestBuilder.DELETE();
case PATCH -> httpRequestBuilder.method("PATCH", bodyPublisher);
case HEAD -> httpRequestBuilder.method("HEAD", HttpRequest.BodyPublishers.noBody());
case OPTIONS -> httpRequestBuilder.method("OPTIONS", HttpRequest.BodyPublishers.noBody());
default -> httpRequestBuilder.method(request.method().name(), bodyPublisher);
}
HttpResponse<String> response = client.send(
httpRequestBuilder.build(),
HttpResponse.BodyHandlers.ofString());
long executionTime = System.currentTimeMillis() - startTime;
Map<String, String> responseHeaders = new HashMap<>();
response.headers().map().forEach((key, values) -> {
if (!values.isEmpty()) {
responseHeaders.put(key, values.get(0));
}
});
RestContentType contentType = determineContentType(
response.headers().firstValue("Content-Type").orElse("application/json"));
LOGGER.info("REST request completed: {} {} -> {} in {}ms",
request.method(), requestUrl, response.statusCode(), executionTime);
return RestServiceResponse.success(
response.statusCode(), responseHeaders, response.body(),
contentType, executionTime, requestUrl);
} catch (Exception e) {
long executionTime = System.currentTimeMillis() - startTime;
LOGGER.error("REST request failed: {} {} - {}", request.method(), requestUrl, e.getMessage(), e);
return RestServiceResponse.fromException(e, executionTime, requestUrl);
}
}
// ========================================================================
// Authentication helpers
// ========================================================================
private boolean isOAuth2Auth(RestAuthConfig auth) {
RestAuthenticationType type = auth.getAuthType();
return type == RestAuthenticationType.OAUTH2_CLIENT_CREDENTIALS
|| type == RestAuthenticationType.OAUTH2_PASSWORD;
}
private boolean isBasicAuth(RestAuthConfig auth) {
return auth.getAuthType() == RestAuthenticationType.BASIC;
}
private boolean isBonitaApiUrl(String url) {
return BONITA_API_PATTERN.matcher(url).matches();
}
private String extractBonitaBaseUrl(String url) {
Matcher matcher = BONITA_API_PATTERN.matcher(url);
return matcher.matches() ? matcher.group(1) : null;
}
private CachedSession getBonitaSession(String requestUrl, RestAuthConfig auth, boolean verifySsl) {
String baseUrl = extractBonitaBaseUrl(requestUrl);
if (baseUrl == null) return null;
String username = "";
if (auth instanceof RestAuthConfig.BasicAuth basicAuth) {
username = basicAuth.username();
}
String cacheKey = baseUrl + ":" + username;
CachedSession cached = BONITA_SESSION_CACHE.get(cacheKey);
if (cached != null && !cached.isExpired()) {
LOGGER.debug("Using cached Bonita session for {}", baseUrl);
return cached;
}
try {
CachedSession session = loginToBonita(baseUrl, auth, verifySsl);
if (session != null) {
BONITA_SESSION_CACHE.put(cacheKey, session);
LOGGER.info("Bonita session obtained and cached for {}", baseUrl);
}
return session;
} catch (Exception e) {
LOGGER.error("Failed to login to Bonita at {}: {}", baseUrl, e.getMessage(), e);
return null;
}
}
private CachedSession loginToBonita(String baseUrl, RestAuthConfig auth, boolean verifySsl) throws Exception {
if (!(auth instanceof RestAuthConfig.BasicAuth basicAuth)) {
LOGGER.error("Bonita login requires Basic auth config");
return null;
}
String loginUrl = baseUrl + BONITA_LOGIN_PATH;
LOGGER.info("Logging in to Bonita at: {}", loginUrl);
String loginBody = "username=" + encode(basicAuth.username())
+ "&password=" + encode(basicAuth.password())
+ "&redirect=false";
HttpClient client = verifySsl ? SECURE_CLIENT : INSECURE_CLIENT;
HttpRequest loginRequest = HttpRequest.newBuilder()
.uri(URI.create(loginUrl))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(loginBody))
.timeout(Duration.ofSeconds(30))
.build();
HttpResponse<String> response = client.send(loginRequest, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200 || response.statusCode() == 204) {
List<String> cookies = response.headers().allValues("Set-Cookie");
String sessionId = null;
String apiToken = null;
for (String cookie : cookies) {
if (cookie.startsWith(JSESSIONID_COOKIE + "=")) {
sessionId = cookie.split(";")[0].substring(JSESSIONID_COOKIE.length() + 1);
}
if (cookie.startsWith(BONITA_API_TOKEN_COOKIE + "=")) {
apiToken = cookie.split(";")[0].substring(BONITA_API_TOKEN_COOKIE.length() + 1);
}
}
if (sessionId != null) {
if (apiToken == null) {
LOGGER.warn("Bonita login succeeded but no X-Bonita-API-Token cookie found. "
+ "POST/PUT/DELETE requests may fail with CSRF 403.");
}
return new CachedSession(sessionId, apiToken,
System.currentTimeMillis() + 25 * 60 * 1000);
}
LOGGER.warn("Bonita login returned {} but no JSESSIONID cookie found", response.statusCode());
} else {
LOGGER.error("Bonita login failed with status {}: {}", response.statusCode(), response.body());
}
return null;
}
private String getOAuth2Token(RestAuthConfig auth) {
if (auth instanceof RestAuthConfig.OAuth2ClientCredentials oauth2) {
String cacheKey = "cc:" + oauth2.clientId() + ":" + oauth2.tokenUrl();
CachedToken cached = TOKEN_CACHE.get(cacheKey);
if (cached != null && !cached.isExpired()) {
LOGGER.debug("Using cached OAuth2 token for {}", oauth2.clientId());
return cached.value;
}
try {
String token = requestOAuth2ClientCredentialsToken(oauth2);
if (token != null) {
TOKEN_CACHE.put(cacheKey,
new CachedToken(token, System.currentTimeMillis() + 55 * 60 * 1000));
}
return token;
} catch (Exception e) {
LOGGER.error("Failed to get OAuth2 token: {}", e.getMessage(), e);
return null;
}
}
return null;
}
private String requestOAuth2ClientCredentialsToken(RestAuthConfig.OAuth2ClientCredentials config) throws Exception {
LOGGER.info("Requesting OAuth2 Client Credentials token from: {}", config.tokenUrl());
String body = config.getTokenRequestBody();
Map<String, String> tokenHeaders = config.getTokenRequestHeaders();
HttpRequest.Builder reqBuilder = HttpRequest.newBuilder()
.uri(URI.create(config.tokenUrl()))
.POST(HttpRequest.BodyPublishers.ofString(body))
.timeout(Duration.ofSeconds(30));
tokenHeaders.forEach(reqBuilder::header);
HttpResponse<String> response = SECURE_CLIENT.send(reqBuilder.build(), HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 200 && response.statusCode() < 300) {
String responseBody = response.body();
// Simple JSON parsing to avoid Jackson dependency in the critical path
int tokenStart = responseBody.indexOf("\"access_token\"");
if (tokenStart >= 0) {
int valueStart = responseBody.indexOf(":", tokenStart) + 1;
int valueEnd = responseBody.indexOf(",", valueStart);
if (valueEnd < 0) valueEnd = responseBody.indexOf("}", valueStart);
String tokenValue = responseBody.substring(valueStart, valueEnd).trim();
if (tokenValue.startsWith("\"")) {
tokenValue = tokenValue.substring(1, tokenValue.length() - 1);
}
LOGGER.info("OAuth2 token obtained successfully");
return tokenValue;
}
}
LOGGER.error("Failed to get OAuth2 token. Status: {}, Response: {}", response.statusCode(), response.body());
return null;
}
// ========================================================================
// Utility
// ========================================================================
private static RestContentType determineContentType(String contentTypeHeader) {
if (contentTypeHeader == null) return RestContentType.JSON;
String lower = contentTypeHeader.toLowerCase();
if (lower.contains("json")) return RestContentType.JSON;
if (lower.contains("xml")) return RestContentType.XML;
if (lower.contains("form-urlencoded")) return RestContentType.FORM_URLENCODED;
if (lower.contains("text/plain")) return RestContentType.TEXT_PLAIN;
if (lower.contains("text/html")) return RestContentType.TEXT_HTML;
return RestContentType.JSON;
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
public static void clearTokenCache() { TOKEN_CACHE.clear(); }
public static void clearSessionCache() { BONITA_SESSION_CACHE.clear(); }
// ========================================================================
// Cache records
// ========================================================================
private record CachedToken(String value, long expiresAt) {
boolean isExpired() { return System.currentTimeMillis() >= expiresAt; }
}
private record CachedSession(String sessionId, String apiToken, long expiresAt) {
boolean isExpired() { return System.currentTimeMillis() >= expiresAt; }
}
private static class TrustAllManager implements X509TrustManager {
@Override public void checkClientTrusted(X509Certificate[] chain, String authType) {}
@Override public void checkServerTrusted(X509Certificate[] chain, String authType) {}
@Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
}
}