|
1 | 1 | package org.opendevstack.apiservice.externalservice.bitbucket.client; |
2 | 2 |
|
| 3 | +import lombok.extern.slf4j.Slf4j; |
3 | 4 | import org.opendevstack.apiservice.externalservice.bitbucket.config.BitbucketServiceConfiguration; |
4 | 5 | import org.opendevstack.apiservice.externalservice.bitbucket.config.BitbucketServiceConfiguration.BitbucketInstanceConfig; |
5 | 6 | import org.opendevstack.apiservice.externalservice.bitbucket.exception.BitbucketException; |
6 | | -import lombok.extern.slf4j.Slf4j; |
7 | 7 | import org.springframework.boot.web.client.RestTemplateBuilder; |
| 8 | +import org.springframework.cache.annotation.CacheEvict; |
| 9 | +import org.springframework.cache.annotation.Cacheable; |
8 | 10 | import org.springframework.http.client.SimpleClientHttpRequestFactory; |
9 | 11 | import org.springframework.stereotype.Component; |
10 | 12 | import org.springframework.web.client.RestTemplate; |
11 | 13 |
|
12 | | -import javax.net.ssl.*; |
| 14 | +import javax.net.ssl.HttpsURLConnection; |
| 15 | +import javax.net.ssl.SSLContext; |
| 16 | +import javax.net.ssl.TrustManager; |
| 17 | +import javax.net.ssl.X509TrustManager; |
13 | 18 | import java.security.KeyManagementException; |
14 | 19 | import java.security.NoSuchAlgorithmException; |
15 | 20 | import java.security.cert.X509Certificate; |
16 | 21 | import java.util.Map; |
17 | | -import java.util.concurrent.ConcurrentHashMap; |
| 22 | +import java.util.Set; |
18 | 23 |
|
19 | 24 | /** |
20 | | - * Factory for creating BitbucketApiClient instances. |
| 25 | + * Factory for creating {@link BitbucketApiClient} instances. |
21 | 26 | * Uses the factory pattern to provide configured clients for different Bitbucket instances. |
22 | 27 | * Clients are cached and reused for efficiency. |
23 | 28 | */ |
24 | 29 | @Component |
25 | 30 | @Slf4j |
26 | 31 | public class BitbucketApiClientFactory { |
27 | | - |
| 32 | + |
28 | 33 | private final BitbucketServiceConfiguration configuration; |
29 | | - private final Map<String, BitbucketApiClient> clientCache; |
30 | 34 | private final RestTemplateBuilder restTemplateBuilder; |
31 | | - |
| 35 | + |
32 | 36 | /** |
33 | | - * Constructor with dependency injection |
34 | | - * |
35 | | - * @param configuration Bitbucket service configuration |
| 37 | + * Constructor with dependency injection. |
| 38 | + * |
| 39 | + * @param configuration Bitbucket service configuration |
36 | 40 | * @param restTemplateBuilder RestTemplate builder for creating HTTP clients |
37 | 41 | */ |
38 | | - public BitbucketApiClientFactory(BitbucketServiceConfiguration configuration, |
| 42 | + public BitbucketApiClientFactory(BitbucketServiceConfiguration configuration, |
39 | 43 | RestTemplateBuilder restTemplateBuilder) { |
40 | 44 | this.configuration = configuration; |
41 | 45 | this.restTemplateBuilder = restTemplateBuilder; |
42 | | - this.clientCache = new ConcurrentHashMap<>(); |
43 | | - |
44 | | - log.info("BitbucketApiClientFactory initialized with {} instance(s)", |
| 46 | + |
| 47 | + log.info("BitbucketApiClientFactory initialized with {} instance(s)", |
45 | 48 | configuration.getInstances().size()); |
46 | 49 | } |
47 | | - |
| 50 | + |
48 | 51 | /** |
49 | | - * Get a BitbucketApiClient for a specific instance |
50 | | - * |
| 52 | + * Resolve the effective instance name. |
| 53 | + * <ul> |
| 54 | + * <li>If the default instance is configured via {@code externalservices.bitbucket.default-instance}, it is returned.</li> |
| 55 | + * <li>Otherwise the first entry of the instances map is returned (insertion order).</li> |
| 56 | + * <li>If no instances are configured at all, a {@link BitbucketException} is thrown.</li> |
| 57 | + * </ul> |
| 58 | + * |
| 59 | + * @return The resolved default instance name (never {@code null}/blank) |
| 60 | + * @throws BitbucketException if no Bitbucket instances are configured |
| 61 | + */ |
| 62 | + public String getDefaultInstanceName() throws BitbucketException { |
| 63 | + |
| 64 | + String defaultInstance = configuration.getDefaultInstance(); |
| 65 | + if (defaultInstance != null && !defaultInstance.isBlank()) { |
| 66 | + return defaultInstance; |
| 67 | + } |
| 68 | + |
| 69 | + Map<String, ?> instances = configuration.getInstances(); |
| 70 | + if (instances == null || instances.isEmpty()) { |
| 71 | + throw new BitbucketException("No Bitbucket instances configured"); |
| 72 | + } |
| 73 | + |
| 74 | + return instances.keySet().iterator().next(); |
| 75 | + } |
| 76 | + |
| 77 | + /** |
| 78 | + * Get a {@link BitbucketApiClient} for a specific instance. |
| 79 | + * If {@code instanceName} is {@code null} or blank, a {@link BitbucketException} is thrown. |
| 80 | + * |
51 | 81 | * @param instanceName Name of the Bitbucket instance |
52 | 82 | * @return Configured BitbucketApiClient |
53 | | - * @throws BitbucketException if the instance is not configured |
| 83 | + * @throws BitbucketException if the instance name is null/blank or not configured |
54 | 84 | */ |
| 85 | + @Cacheable(value = "bitbucketApiClients", key = "#instanceName", |
| 86 | + condition = "#instanceName != null && !#instanceName.isBlank()") |
55 | 87 | public BitbucketApiClient getClient(String instanceName) throws BitbucketException { |
56 | | - // Check cache first |
57 | | - if (clientCache.containsKey(instanceName)) { |
58 | | - log.debug("Returning cached client for instance '{}'", instanceName); |
59 | | - return clientCache.get(instanceName); |
| 88 | + if (instanceName == null || instanceName.isBlank()) { |
| 89 | + throw new BitbucketException( |
| 90 | + String.format("Provide instance name. Available instances: %s", |
| 91 | + configuration.getInstances().keySet())); |
60 | 92 | } |
61 | | - |
62 | | - // Create new client |
| 93 | + |
63 | 94 | BitbucketInstanceConfig instanceConfig = configuration.getInstances().get(instanceName); |
64 | | - |
| 95 | + |
65 | 96 | if (instanceConfig == null) { |
66 | 97 | throw new BitbucketException( |
67 | | - String.format("Bitbucket instance '%s' is not configured. Available instances: %s", |
68 | | - instanceName, configuration.getInstances().keySet()) |
69 | | - ); |
| 98 | + String.format("Bitbucket instance '%s' is not configured. Available instances: %s", |
| 99 | + instanceName, configuration.getInstances().keySet())); |
70 | 100 | } |
71 | | - |
| 101 | + |
72 | 102 | log.info("Creating new BitbucketApiClient for instance '{}'", instanceName); |
73 | | - |
| 103 | + |
74 | 104 | RestTemplate restTemplate = createRestTemplate(instanceConfig); |
75 | | - BitbucketApiClient client = new BitbucketApiClient(instanceName, instanceConfig, restTemplate); |
76 | | - |
77 | | - // Cache the client |
78 | | - clientCache.put(instanceName, client); |
79 | | - |
80 | | - return client; |
| 105 | + return new BitbucketApiClient(instanceName, instanceConfig, restTemplate); |
81 | 106 | } |
82 | | - |
| 107 | + |
83 | 108 | /** |
84 | | - * Get the default client (first configured instance) |
85 | | - * |
86 | | - * @return BitbucketApiClient for the first configured instance |
| 109 | + * Get the default client, as determined by {@code externalservices.bitbucket.default-instance}. |
| 110 | + * Falls back to the first configured instance when {@code default-instance} is not set. |
| 111 | + * |
| 112 | + * @return BitbucketApiClient for the default instance |
87 | 113 | * @throws BitbucketException if no instances are configured |
88 | 114 | */ |
89 | | - public BitbucketApiClient getDefaultClient() throws BitbucketException { |
90 | | - if (configuration.getInstances().isEmpty()) { |
91 | | - throw new BitbucketException("No Bitbucket instances configured"); |
92 | | - } |
93 | | - |
94 | | - String firstInstanceName = configuration.getInstances().keySet().iterator().next(); |
95 | | - log.debug("Using default instance: '{}'", firstInstanceName); |
96 | | - |
97 | | - return getClient(firstInstanceName); |
| 115 | + @Cacheable(value = "bitbucketApiClients", key = "'default'") |
| 116 | + public BitbucketApiClient getClient() throws BitbucketException { |
| 117 | + String defaultInstanceName = getDefaultInstanceName(); |
| 118 | + BitbucketInstanceConfig instanceConfig = configuration.getInstances().get(defaultInstanceName); |
| 119 | + RestTemplate restTemplate = createRestTemplate(instanceConfig); |
| 120 | + |
| 121 | + return new BitbucketApiClient(defaultInstanceName, instanceConfig, restTemplate); |
98 | 122 | } |
99 | | - |
| 123 | + |
100 | 124 | /** |
101 | | - * Get all available instance names |
102 | | - * |
| 125 | + * Get all available instance names. |
| 126 | + * |
103 | 127 | * @return Set of configured instance names |
104 | 128 | */ |
105 | | - public java.util.Set<String> getAvailableInstances() { |
| 129 | + public Set<String> getAvailableInstances() { |
106 | 130 | return configuration.getInstances().keySet(); |
107 | 131 | } |
108 | | - |
| 132 | + |
109 | 133 | /** |
110 | | - * Check if an instance is configured |
111 | | - * |
| 134 | + * Check if an instance is configured. |
| 135 | + * |
112 | 136 | * @param instanceName Name of the instance to check |
113 | 137 | * @return true if configured, false otherwise |
114 | 138 | */ |
115 | 139 | public boolean hasInstance(String instanceName) { |
116 | 140 | return configuration.getInstances().containsKey(instanceName); |
117 | 141 | } |
118 | | - |
| 142 | + |
119 | 143 | /** |
120 | | - * Clear the client cache (useful for testing or when configuration changes) |
| 144 | + * Clear the client cache (useful for testing or when configuration changes). |
121 | 145 | */ |
| 146 | + @CacheEvict(value = "bitbucketApiClients", allEntries = true) |
122 | 147 | public void clearCache() { |
123 | 148 | log.info("Clearing BitbucketApiClient cache"); |
124 | | - clientCache.clear(); |
125 | 149 | } |
126 | 150 |
|
127 | 151 | /** |
128 | | - * Create a configured RestTemplate for a Bitbucket instance |
129 | | - * |
| 152 | + * Create a configured RestTemplate for a Bitbucket instance. |
| 153 | + * |
130 | 154 | * @param config Configuration for the instance |
131 | 155 | * @return Configured RestTemplate |
132 | 156 | */ |
133 | 157 | private RestTemplate createRestTemplate(BitbucketInstanceConfig config) { |
134 | 158 | RestTemplate restTemplate = restTemplateBuilder.build(); |
135 | 159 |
|
136 | | - // Set timeouts using SimpleClientHttpRequestFactory |
137 | | - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); |
138 | | - requestFactory.setConnectTimeout(config.getConnectionTimeout()); |
139 | | - requestFactory.setReadTimeout(config.getReadTimeout()); |
140 | | - restTemplate.setRequestFactory(requestFactory); |
141 | | - |
142 | | - // Configure SSL if trustAllCertificates is enabled |
143 | 160 | if (config.isTrustAllCertificates()) { |
144 | 161 | log.warn("Trust all certificates is enabled for Bitbucket connection. " + |
145 | 162 | "This should only be used in development environments!"); |
146 | | - configureTrustAllCertificates(restTemplate); |
| 163 | + restTemplate.setRequestFactory(createTrustAllRequestFactory(config)); |
| 164 | + } else { |
| 165 | + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); |
| 166 | + requestFactory.setConnectTimeout(config.getConnectionTimeout()); |
| 167 | + requestFactory.setReadTimeout(config.getReadTimeout()); |
| 168 | + restTemplate.setRequestFactory(requestFactory); |
147 | 169 | } |
148 | 170 |
|
149 | 171 | return restTemplate; |
150 | 172 | } |
151 | | - |
| 173 | + |
152 | 174 | /** |
153 | | - * Configure RestTemplate to trust all SSL certificates |
154 | | - * WARNING: This should only be used in development environments |
155 | | - * |
156 | | - * @param restTemplate RestTemplate to configure |
| 175 | + * Create a {@link SimpleClientHttpRequestFactory} that trusts all SSL certificates |
| 176 | + * <b>only for this specific RestTemplate</b>, without modifying the JVM-wide defaults. |
| 177 | + * <p> |
| 178 | + * WARNING: This should only be used in development environments. |
| 179 | + * |
| 180 | + * @param config Instance configuration (for timeouts) |
| 181 | + * @return A request factory whose connections skip SSL verification |
157 | 182 | */ |
158 | 183 | @SuppressWarnings({"java:S4830", "java:S1186"}) // Intentionally disabling SSL validation for development |
159 | | - private void configureTrustAllCertificates(RestTemplate restTemplate) { |
| 184 | + private SimpleClientHttpRequestFactory createTrustAllRequestFactory(BitbucketInstanceConfig config) { |
160 | 185 | try { |
161 | 186 | TrustManager[] trustAllCerts = new TrustManager[]{ |
162 | 187 | new X509TrustManager() { |
163 | 188 | public X509Certificate[] getAcceptedIssuers() { |
164 | 189 | return new X509Certificate[0]; |
165 | 190 | } |
166 | | - // Intentionally empty - trusting all certificates for development environments |
167 | 191 | public void checkClientTrusted(X509Certificate[] certs, String authType) { |
168 | 192 | // No validation performed - development only |
169 | 193 | } |
170 | | - // Intentionally empty - trusting all certificates for development environments |
171 | 194 | public void checkServerTrusted(X509Certificate[] certs, String authType) { |
172 | 195 | // No validation performed - development only |
173 | 196 | } |
174 | 197 | } |
175 | 198 | }; |
176 | | - |
| 199 | + |
177 | 200 | SSLContext sslContext = SSLContext.getInstance("TLS"); |
178 | 201 | sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); |
179 | | - |
180 | | - HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.getSocketFactory()); |
181 | | - // Intentionally disabling hostname verification for development environments |
182 | | - HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true); |
183 | | - |
| 202 | + |
| 203 | + final javax.net.ssl.SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); |
| 204 | + final javax.net.ssl.HostnameVerifier trustAllHostnames = (hostname, session) -> true; |
| 205 | + |
| 206 | + // Override prepareConnection so SSL settings apply only to this RestTemplate |
| 207 | + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory() { |
| 208 | + @Override |
| 209 | + protected void prepareConnection(java.net.HttpURLConnection connection, String httpMethod) throws java.io.IOException { |
| 210 | + if (connection instanceof HttpsURLConnection httpsConnection) { |
| 211 | + httpsConnection.setSSLSocketFactory(sslSocketFactory); |
| 212 | + httpsConnection.setHostnameVerifier(trustAllHostnames); |
| 213 | + } |
| 214 | + super.prepareConnection(connection, httpMethod); |
| 215 | + } |
| 216 | + }; |
| 217 | + requestFactory.setConnectTimeout(config.getConnectionTimeout()); |
| 218 | + requestFactory.setReadTimeout(config.getReadTimeout()); |
| 219 | + return requestFactory; |
| 220 | + |
184 | 221 | } catch (NoSuchAlgorithmException | KeyManagementException e) { |
185 | | - log.error("Failed to configure SSL trust all certificates", e); |
| 222 | + log.error("Failed to configure SSL trust all certificates, falling back to default factory", e); |
| 223 | + SimpleClientHttpRequestFactory fallback = new SimpleClientHttpRequestFactory(); |
| 224 | + fallback.setConnectTimeout(config.getConnectionTimeout()); |
| 225 | + fallback.setReadTimeout(config.getReadTimeout()); |
| 226 | + return fallback; |
186 | 227 | } |
187 | 228 | } |
188 | 229 | } |
0 commit comments