Skip to content
Original file line number Diff line number Diff line change
@@ -1,188 +1,229 @@
package org.opendevstack.apiservice.externalservice.bitbucket.client;

import lombok.extern.slf4j.Slf4j;
import org.opendevstack.apiservice.externalservice.bitbucket.config.BitbucketServiceConfiguration;
import org.opendevstack.apiservice.externalservice.bitbucket.config.BitbucketServiceConfiguration.BitbucketInstanceConfig;
import org.opendevstack.apiservice.externalservice.bitbucket.exception.BitbucketException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

import javax.net.ssl.*;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.X509Certificate;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.Set;

/**
* Factory for creating BitbucketApiClient instances.
* Factory for creating {@link BitbucketApiClient} instances.
* Uses the factory pattern to provide configured clients for different Bitbucket instances.
* Clients are cached and reused for efficiency.
*/
@Component
@Slf4j
public class BitbucketApiClientFactory {

private final BitbucketServiceConfiguration configuration;
private final Map<String, BitbucketApiClient> clientCache;
private final RestTemplateBuilder restTemplateBuilder;

/**
* Constructor with dependency injection
*
* @param configuration Bitbucket service configuration
* Constructor with dependency injection.
*
* @param configuration Bitbucket service configuration
* @param restTemplateBuilder RestTemplate builder for creating HTTP clients
*/
public BitbucketApiClientFactory(BitbucketServiceConfiguration configuration,
public BitbucketApiClientFactory(BitbucketServiceConfiguration configuration,
RestTemplateBuilder restTemplateBuilder) {
this.configuration = configuration;
this.restTemplateBuilder = restTemplateBuilder;
this.clientCache = new ConcurrentHashMap<>();

log.info("BitbucketApiClientFactory initialized with {} instance(s)",

log.info("BitbucketApiClientFactory initialized with {} instance(s)",
configuration.getInstances().size());
}

/**
* Get a BitbucketApiClient for a specific instance
*
* Resolve the effective instance name.
* <ul>
* <li>If the default instance is configured via {@code externalservices.bitbucket.default-instance}, it is returned.</li>
* <li>Otherwise the first entry of the instances map is returned (insertion order).</li>
* <li>If no instances are configured at all, a {@link BitbucketException} is thrown.</li>
* </ul>
*
* @return The resolved default instance name (never {@code null}/blank)
* @throws BitbucketException if no Bitbucket instances are configured
*/
public String getDefaultInstanceName() throws BitbucketException {

String defaultInstance = configuration.getDefaultInstance();
if (defaultInstance != null && !defaultInstance.isBlank()) {
return defaultInstance;
}

Map<String, ?> instances = configuration.getInstances();
if (instances == null || instances.isEmpty()) {
throw new BitbucketException("No Bitbucket instances configured");
}

return instances.keySet().iterator().next();
}

/**
* Get a {@link BitbucketApiClient} for a specific instance.
* If {@code instanceName} is {@code null} or blank, a {@link BitbucketException} is thrown.
*
* @param instanceName Name of the Bitbucket instance
* @return Configured BitbucketApiClient
* @throws BitbucketException if the instance is not configured
* @throws BitbucketException if the instance name is null/blank or not configured
*/
@Cacheable(value = "bitbucketApiClients", key = "#instanceName",
condition = "#instanceName != null && !#instanceName.isBlank()")
public BitbucketApiClient getClient(String instanceName) throws BitbucketException {
// Check cache first
if (clientCache.containsKey(instanceName)) {
log.debug("Returning cached client for instance '{}'", instanceName);
return clientCache.get(instanceName);
if (instanceName == null || instanceName.isBlank()) {
throw new BitbucketException(
String.format("Provide instance name. Available instances: %s",
configuration.getInstances().keySet()));
}

// Create new client

BitbucketInstanceConfig instanceConfig = configuration.getInstances().get(instanceName);

if (instanceConfig == null) {
throw new BitbucketException(
String.format("Bitbucket instance '%s' is not configured. Available instances: %s",
instanceName, configuration.getInstances().keySet())
);
String.format("Bitbucket instance '%s' is not configured. Available instances: %s",
instanceName, configuration.getInstances().keySet()));
}

log.info("Creating new BitbucketApiClient for instance '{}'", instanceName);

RestTemplate restTemplate = createRestTemplate(instanceConfig);
BitbucketApiClient client = new BitbucketApiClient(instanceName, instanceConfig, restTemplate);

// Cache the client
clientCache.put(instanceName, client);

return client;
return new BitbucketApiClient(instanceName, instanceConfig, restTemplate);
}

/**
* Get the default client (first configured instance)
*
* @return BitbucketApiClient for the first configured instance
* Get the default client, as determined by {@code externalservices.bitbucket.default-instance}.
* Falls back to the first configured instance when {@code default-instance} is not set.
*
* @return BitbucketApiClient for the default instance
* @throws BitbucketException if no instances are configured
*/
public BitbucketApiClient getDefaultClient() throws BitbucketException {
if (configuration.getInstances().isEmpty()) {
throw new BitbucketException("No Bitbucket instances configured");
}

String firstInstanceName = configuration.getInstances().keySet().iterator().next();
log.debug("Using default instance: '{}'", firstInstanceName);

return getClient(firstInstanceName);
@Cacheable(value = "bitbucketApiClients", key = "'default'")
public BitbucketApiClient getClient() throws BitbucketException {
String defaultInstanceName = getDefaultInstanceName();
BitbucketInstanceConfig instanceConfig = configuration.getInstances().get(defaultInstanceName);
RestTemplate restTemplate = createRestTemplate(instanceConfig);

return new BitbucketApiClient(defaultInstanceName, instanceConfig, restTemplate);
}

/**
* Get all available instance names
*
* Get all available instance names.
*
* @return Set of configured instance names
*/
public java.util.Set<String> getAvailableInstances() {
public Set<String> getAvailableInstances() {
return configuration.getInstances().keySet();
}

/**
* Check if an instance is configured
*
* Check if an instance is configured.
*
* @param instanceName Name of the instance to check
* @return true if configured, false otherwise
*/
public boolean hasInstance(String instanceName) {
return configuration.getInstances().containsKey(instanceName);
}

/**
* Clear the client cache (useful for testing or when configuration changes)
* Clear the client cache (useful for testing or when configuration changes).
*/
@CacheEvict(value = "bitbucketApiClients", allEntries = true)
public void clearCache() {
log.info("Clearing BitbucketApiClient cache");
clientCache.clear();
}

/**
* Create a configured RestTemplate for a Bitbucket instance
*
* Create a configured RestTemplate for a Bitbucket instance.
*
* @param config Configuration for the instance
* @return Configured RestTemplate
*/
private RestTemplate createRestTemplate(BitbucketInstanceConfig config) {
RestTemplate restTemplate = restTemplateBuilder.build();

// Set timeouts using SimpleClientHttpRequestFactory
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(config.getConnectionTimeout());
requestFactory.setReadTimeout(config.getReadTimeout());
restTemplate.setRequestFactory(requestFactory);

// Configure SSL if trustAllCertificates is enabled
if (config.isTrustAllCertificates()) {
log.warn("Trust all certificates is enabled for Bitbucket connection. " +
"This should only be used in development environments!");
configureTrustAllCertificates(restTemplate);
restTemplate.setRequestFactory(createTrustAllRequestFactory(config));
} else {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(config.getConnectionTimeout());
requestFactory.setReadTimeout(config.getReadTimeout());
restTemplate.setRequestFactory(requestFactory);
}

return restTemplate;
}

/**
* Configure RestTemplate to trust all SSL certificates
* WARNING: This should only be used in development environments
*
* @param restTemplate RestTemplate to configure
* Create a {@link SimpleClientHttpRequestFactory} that trusts all SSL certificates
* <b>only for this specific RestTemplate</b>, without modifying the JVM-wide defaults.
* <p>
* WARNING: This should only be used in development environments.
*
* @param config Instance configuration (for timeouts)
* @return A request factory whose connections skip SSL verification
*/
@SuppressWarnings({"java:S4830", "java:S1186"}) // Intentionally disabling SSL validation for development
private void configureTrustAllCertificates(RestTemplate restTemplate) {
private SimpleClientHttpRequestFactory createTrustAllRequestFactory(BitbucketInstanceConfig config) {
try {
TrustManager[] trustAllCerts = new TrustManager[]{
new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
// Intentionally empty - trusting all certificates for development environments
public void checkClientTrusted(X509Certificate[] certs, String authType) {
// No validation performed - development only
}
// Intentionally empty - trusting all certificates for development environments
public void checkServerTrusted(X509Certificate[] certs, String authType) {
// No validation performed - development only
}
}
};

SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAllCerts, new java.security.SecureRandom());

HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.getSocketFactory());
// Intentionally disabling hostname verification for development environments
HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true);


final javax.net.ssl.SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();
final javax.net.ssl.HostnameVerifier trustAllHostnames = (hostname, session) -> true;

// Override prepareConnection so SSL settings apply only to this RestTemplate
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory() {
@Override
protected void prepareConnection(java.net.HttpURLConnection connection, String httpMethod) throws java.io.IOException {
if (connection instanceof HttpsURLConnection httpsConnection) {
httpsConnection.setSSLSocketFactory(sslSocketFactory);
httpsConnection.setHostnameVerifier(trustAllHostnames);
}
super.prepareConnection(connection, httpMethod);
}
};
requestFactory.setConnectTimeout(config.getConnectionTimeout());
requestFactory.setReadTimeout(config.getReadTimeout());
return requestFactory;

} catch (NoSuchAlgorithmException | KeyManagementException e) {
log.error("Failed to configure SSL trust all certificates", e);
log.error("Failed to configure SSL trust all certificates, falling back to default factory", e);
SimpleClientHttpRequestFactory fallback = new SimpleClientHttpRequestFactory();
fallback.setConnectTimeout(config.getConnectionTimeout());
fallback.setReadTimeout(config.getReadTimeout());
return fallback;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,25 @@
@Data
public class BitbucketServiceConfiguration {

/**
* Optional name of the default Bitbucket instance.
* When set, {@code BitbucketApiClientFactory#getClient()} will use this instance.
* If not set, the first entry in the instances map is used as default.
*/
private String defaultInstance;

/**
* Map of Bitbucket instances with instance name as key and configuration as value.
* Example:
* externalservice:
* bitbucket:
* instances:
* dev:
* base-url: https://bitbucket.dev.example.com
* base-url: "https://bitbucket.dev.example.com"
* username: admin
* password: password123
* prod:
* base-url: https://bitbucket.example.com
* base-url: "https://bitbucket.example.com"
* username: admin
* password: secret
*/
Expand All @@ -39,7 +46,7 @@ public class BitbucketServiceConfiguration {
@Data
public static class BitbucketInstanceConfig {
/**
* The base URL of the Bitbucket server (e.g., https://bitbucket.example.com)
* The base URL of the Bitbucket server (e.g., "https://bitbucket.example.com")
*/
private String baseUrl;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ public interface BitbucketService extends ExternalService {
*/
boolean branchExists(String instanceName, String projectKey, String repositorySlug, String branchName) throws BitbucketException;

/**
* Check if a project exists in a specific Bitbucket instance.
*
* @param instanceName Name of the Bitbucket instance
* @param projectKey Project key (e.g., "PROJ")
* @return true if the project exists, false if it does not exist
* @throws BitbucketException if the check fails due to a non-functional error
* (e.g., Bitbucket unreachable, bad credentials, network errors).
* A non-existent project is NOT surfaced as an exception — it returns false.
*/
boolean projectExists(String instanceName, String projectKey) throws BitbucketException;

/**
* Get all available Bitbucket instance names
*
Expand Down
Loading