001/* 002 * Copyright © 2025 CUI-OpenSource-Software (info@cuioss.de) 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package de.cuioss.http.client.handler; 017 018import de.cuioss.http.client.HttpLogMessages; 019import de.cuioss.tools.logging.CuiLogger; 020import de.cuioss.tools.string.MoreStrings; 021import lombok.AccessLevel; 022import lombok.Builder; 023import lombok.EqualsAndHashCode; 024import lombok.Getter; 025import lombok.ToString; 026import org.jspecify.annotations.Nullable; 027 028import javax.net.ssl.SSLContext; 029import java.io.IOException; 030import java.net.MalformedURLException; 031import java.net.URI; 032import java.net.URISyntaxException; 033import java.net.URL; 034import java.net.http.HttpClient; 035import java.net.http.HttpRequest; 036import java.net.http.HttpResponse; 037import java.time.Duration; 038import java.util.regex.Pattern; 039 040/** 041 * Secure HTTP client wrapper providing simplified HTTP request execution with robust SSL handling. 042 * 043 * <p>This class provides a builder-based wrapper around Java's {@link HttpClient} that simplifies 044 * HTTP request configuration and execution while ensuring secure defaults for SSL/TLS connections. 045 * It handles common HTTP client setup patterns and provides consistent timeout and SSL management.</p> 046 * 047 * <h3>Design Principles</h3> 048 * <ul> 049 * <li><strong>Security First</strong> - Automatic secure SSL context creation for HTTPS</li> 050 * <li><strong>Builder Pattern</strong> - Fluent API for easy configuration</li> 051 * <li><strong>Immutable</strong> - Thread-safe after construction</li> 052 * <li><strong>Fail Fast</strong> - Validates configuration at build time</li> 053 * </ul> 054 * 055 * <h3>Security Features</h3> 056 * <ul> 057 * <li><strong>Automatic SSL Context</strong> - Creates secure SSL contexts when not provided</li> 058 * <li><strong>TLS Version Control</strong> - Uses {@link SecureSSLContextProvider} for modern TLS versions</li> 059 * <li><strong>URL Validation</strong> - Validates URI format and convertibility at build time</li> 060 * <li><strong>Timeout Protection</strong> - Configurable timeouts prevent resource exhaustion</li> 061 * </ul> 062 * 063 * <h3>Usage Examples</h3> 064 * <pre> 065 * // Basic HTTPS request 066 * HttpHandler handler = HttpHandler.builder() 067 * .uri("https://api.example.com/users") 068 * .connectionTimeoutSeconds(5) 069 * .readTimeoutSeconds(10) 070 * .build(); 071 * 072 * // Execute GET request 073 * HttpResponse<String> response = handler.executeGetRequest(); 074 * if (response.statusCode() == 200) { 075 * String body = response.body(); 076 * // Process response 077 * } 078 * 079 * // Custom SSL context 080 * SSLContext customSSL = mySecureSSLProvider.getSSLContext(); 081 * HttpHandler secureHandler = HttpHandler.builder() 082 * .uri("https://secure.example.com/api") 083 * .sslContext(customSSL) 084 * .build(); 085 * 086 * // URI object 087 * URI apiEndpoint = URI.create("https://example.com/api/v1/data"); 088 * HttpHandler uriHandler = HttpHandler.builder() 089 * .uri(apiEndpoint) 090 * .build(); 091 * </pre> 092 * 093 * <h3>Configuration Contract</h3> 094 * <ul> 095 * <li><strong>URI Validation</strong> - URI must be valid and convertible to URL (checked at build time)</li> 096 * <li><strong>HTTPS SSL Context</strong> - Automatically created if not provided for HTTPS URIs</li> 097 * <li><strong>Timeout Defaults</strong> - Uses 10 seconds for both connection and read timeouts if not specified</li> 098 * <li><strong>URL Scheme Detection</strong> - Automatically handles URLs with or without explicit schemes</li> 099 * </ul> 100 * 101 * <h3>Thread Safety</h3> 102 * <p>HttpHandler instances are immutable and thread-safe after construction. The underlying 103 * {@link HttpClient} is also thread-safe and can be used concurrently from multiple threads.</p> 104 * 105 * <h3>Error Handling</h3> 106 * <ul> 107 * <li><strong>Build-time Validation</strong> - {@link IllegalStateException} for invalid URIs or configuration</li> 108 * <li><strong>Runtime Exceptions</strong> - {@link IOException} for network errors, {@link InterruptedException} for thread interruption</li> 109 * </ul> 110 * 111 * @since 1.0 112 * @see HttpClient 113 * @see SecureSSLContextProvider 114 * @see HttpStatusFamily 115 */ 116@EqualsAndHashCode 117@ToString 118@Builder(builderClassName = "HttpHandlerBuilder", access = AccessLevel.PRIVATE) 119public final class HttpHandler { 120 121 private static final CuiLogger LOGGER = new CuiLogger(HttpHandler.class); 122 123 /** 124 * Pre-compiled pattern for detecting URLs with scheme. 125 * Matches RFC 3986 scheme format: scheme:remainder 126 */ 127 private static final Pattern URL_SCHEME_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9+.-]*:.*"); 128 129 public static final int DEFAULT_CONNECTION_TIMEOUT_SECONDS = 10; 130 public static final int DEFAULT_READ_TIMEOUT_SECONDS = 10; 131 132 @Getter private final URI uri; 133 @Getter private final URL url; 134 @Getter private final @Nullable SSLContext sslContext; 135 @Getter private final int connectionTimeoutSeconds; 136 @Getter private final int readTimeoutSeconds; 137 private final HttpClient httpClient; 138 139 // Constructor for HTTP URIs (no SSL context needed) 140 private HttpHandler(URI uri, URL url, int connectionTimeoutSeconds, int readTimeoutSeconds) { 141 this.uri = uri; 142 this.url = url; 143 this.sslContext = null; 144 this.connectionTimeoutSeconds = connectionTimeoutSeconds; 145 this.readTimeoutSeconds = readTimeoutSeconds; 146 147 // Create the HttpClient for HTTP 148 this.httpClient = HttpClient.newBuilder() 149 .connectTimeout(Duration.ofSeconds(connectionTimeoutSeconds)) 150 .build(); 151 } 152 153 // Constructor for HTTPS URIs (SSL context required) 154 private HttpHandler(URI uri, URL url, SSLContext sslContext, int connectionTimeoutSeconds, int readTimeoutSeconds) { 155 this.uri = uri; 156 this.url = url; 157 this.sslContext = sslContext; 158 this.connectionTimeoutSeconds = connectionTimeoutSeconds; 159 this.readTimeoutSeconds = readTimeoutSeconds; 160 161 // Create the HttpClient for HTTPS 162 this.httpClient = HttpClient.newBuilder() 163 .connectTimeout(Duration.ofSeconds(connectionTimeoutSeconds)) 164 .sslContext(sslContext) 165 .build(); 166 } 167 168 public static HttpHandlerBuilder builder() { 169 return new HttpHandlerBuilder(); 170 } 171 172 /** 173 * Creates a pre-configured {@link HttpRequest.Builder} for the URI contained in this handler. 174 * The builder is configured with the read timeout from this handler. 175 * 176 * @return A pre-configured {@link HttpRequest.Builder} 177 */ 178 public HttpRequest.Builder requestBuilder() { 179 return HttpRequest.newBuilder() 180 .uri(uri) 181 .timeout(Duration.ofSeconds(readTimeoutSeconds)); 182 } 183 184 /** 185 * Creates a pre-configured {@link HttpHandlerBuilder} with the same configuration as this handler. 186 * The builder is configured with the connection timeout, read timeout and sslContext from this handler. 187 * 188 * <p>This method allows creating a new builder based on the current handler's configuration, 189 * which can be used to create a new handler with modified URL.</p> 190 * 191 * @return A pre-configured {@link HttpHandlerBuilder} with the same timeouts as this handler 192 */ 193 public HttpHandlerBuilder asBuilder() { 194 return builder() 195 .connectionTimeoutSeconds(connectionTimeoutSeconds) 196 .readTimeoutSeconds(readTimeoutSeconds) 197 .sslContext(sslContext); 198 } 199 200 /** 201 * Pings the URI using the HEAD method and returns the HTTP status code. 202 * 203 * @return The HTTP status code family, or {@link HttpStatusFamily#UNKNOWN} if an error occurred 204 */ 205 // HttpClient implements AutoCloseable in Java 17 but doesn't need to be closed 206 @SuppressWarnings("try") 207 public HttpStatusFamily pingHead() { 208 return pingWithMethod("HEAD", HttpRequest.BodyPublishers.noBody()); 209 } 210 211 /** 212 * Pings the URI using the GET method and returns the HTTP status code. 213 * 214 * @return The HTTP status code family, or {@link HttpStatusFamily#UNKNOWN} if an error occurred 215 */ 216 // HttpClient implements AutoCloseable in Java 17 but doesn't need to be closed 217 @SuppressWarnings("try") 218 public HttpStatusFamily pingGet() { 219 return pingWithMethod("GET", HttpRequest.BodyPublishers.noBody()); 220 } 221 222 /** 223 * Pings the URI using the specified HTTP method and returns the HTTP status code. 224 * 225 * @param method The HTTP method to use (e.g., "HEAD", "GET") 226 * @param bodyPublisher The body publisher to use for the request 227 * @return The HTTP status code family, or {@link HttpStatusFamily#UNKNOWN} if an error occurred 228 */ 229 // HttpClient implements AutoCloseable in Java 17 but doesn't need to be closed 230 @SuppressWarnings("try") 231 private HttpStatusFamily pingWithMethod(String method, HttpRequest.BodyPublisher bodyPublisher) { 232 try { 233 HttpClient client = createHttpClient(); 234 HttpRequest request = requestBuilder() 235 .method(method, bodyPublisher) 236 .build(); 237 238 HttpResponse<Void> response = client.send(request, HttpResponse.BodyHandlers.discarding()); 239 return HttpStatusFamily.fromStatusCode(response.statusCode()); 240 } catch (IOException e) { 241 LOGGER.warn(e, HttpLogMessages.WARN.HTTP_PING_IO_ERROR.format(uri, e.getMessage())); 242 return HttpStatusFamily.UNKNOWN; 243 } catch (InterruptedException e) { 244 Thread.currentThread().interrupt(); 245 LOGGER.warn(HttpLogMessages.WARN.HTTP_PING_INTERRUPTED.format(uri, e.getMessage())); 246 return HttpStatusFamily.UNKNOWN; 247 } catch (IllegalArgumentException | SecurityException e) { 248 LOGGER.warn(e, HttpLogMessages.WARN.HTTP_PING_ERROR.format(uri, e.getMessage())); 249 return HttpStatusFamily.UNKNOWN; 250 } 251 } 252 253 /** 254 * Gets the configured {@link HttpClient} for making HTTP requests. 255 * The HttpClient is created once during construction and reused for all requests, 256 * improving performance by leveraging connection pooling. 257 * 258 * @return A configured {@link HttpClient} with the SSL context and connection timeout 259 */ 260 public HttpClient createHttpClient() { 261 return httpClient; 262 } 263 264 /** 265 * Builder for creating {@link HttpHandler} instances. 266 */ 267 public static class HttpHandlerBuilder { 268 private @Nullable URI uri; 269 private @Nullable URL url; 270 private @Nullable String urlString; 271 private @Nullable SSLContext sslContext; 272 private @Nullable SecureSSLContextProvider secureSSLContextProvider; 273 private @Nullable Integer connectionTimeoutSeconds; 274 private @Nullable Integer readTimeoutSeconds; 275 276 /** 277 * Sets the URI as a string. 278 * 279 * @param uriString The string representation of the URI. 280 * Must not be null or empty. 281 * @return This builder instance. 282 * @throws IllegalArgumentException if the URI string is null, empty, or malformed 283 * (thrown during the {@link #build()} method execution, 284 * not by this setter method) 285 */ 286 public HttpHandlerBuilder uri(String uriString) { 287 this.urlString = uriString; 288 return this; 289 } 290 291 /** 292 * Sets the URI directly. 293 * <p> 294 * Note: If both URI and URL are set, the URI takes precedence. 295 * </p> 296 * 297 * @param uri The URI to be used for HTTP requests. 298 * Must not be null. 299 * @return This builder instance. 300 */ 301 public HttpHandlerBuilder uri(URI uri) { 302 this.uri = uri; 303 return this; 304 } 305 306 /** 307 * Sets the URL as a string. 308 * <p> 309 * Note: This method is provided for backward compatibility. 310 * Consider using {@link #uri(String)} instead. 311 * </p> 312 * 313 * @param urlString The string representation of the URL. 314 * Must not be null or empty. 315 * @return This builder instance. 316 * @throws IllegalArgumentException if the URL string is null, empty, or malformed 317 * (thrown during the {@link #build()} method execution, 318 * not by this setter method) 319 */ 320 public HttpHandlerBuilder url(String urlString) { 321 this.urlString = urlString; 322 return this; 323 } 324 325 /** 326 * Sets the URL directly. 327 * <p> 328 * Note: This method is provided for backward compatibility. 329 * Consider using {@link #uri(URI)} instead. 330 * </p> 331 * <p> 332 * If both URI and URL are set, the URI takes precedence. 333 * </p> 334 * 335 * @param url The URL to be used for HTTP requests. 336 * Must not be null. 337 * @return This builder instance. 338 */ 339 public HttpHandlerBuilder url(URL url) { 340 this.url = url; 341 return this; 342 } 343 344 /** 345 * Sets the SSL context to use for HTTPS connections. 346 * <p> 347 * If not set, a default secure SSL context will be created. 348 * </p> 349 * 350 * @param sslContext The SSL context to use. 351 * @return This builder instance. 352 */ 353 public HttpHandlerBuilder sslContext(@Nullable SSLContext sslContext) { 354 this.sslContext = sslContext; 355 return this; 356 } 357 358 /** 359 * Sets the TLS versions configuration. 360 * 361 * @param secureSSLContextProvider The TLS versions configuration to use. 362 * @return This builder instance. 363 */ 364 public HttpHandlerBuilder tlsVersions(@Nullable SecureSSLContextProvider secureSSLContextProvider) { 365 this.secureSSLContextProvider = secureSSLContextProvider; 366 return this; 367 } 368 369 /** 370 * Sets the connection timeout in seconds for HTTP requests. 371 * <p> 372 * If not set, a default timeout of 10 seconds will be used. 373 * </p> 374 * 375 * @param connectionTimeoutSeconds The connection timeout in seconds. 376 * Must be positive. 377 * @return This builder instance. 378 */ 379 public HttpHandlerBuilder connectionTimeoutSeconds(int connectionTimeoutSeconds) { 380 this.connectionTimeoutSeconds = connectionTimeoutSeconds; 381 return this; 382 } 383 384 /** 385 * Sets the read timeout in seconds for HTTP requests. 386 * <p> 387 * If not set, a default timeout of 10 seconds will be used. 388 * </p> 389 * 390 * @param readTimeoutSeconds The read timeout in seconds. 391 * Must be positive. 392 * @return This builder instance. 393 */ 394 public HttpHandlerBuilder readTimeoutSeconds(int readTimeoutSeconds) { 395 this.readTimeoutSeconds = readTimeoutSeconds; 396 return this; 397 } 398 399 /** 400 * Builds a new {@link HttpHandler} instance with the configured parameters. 401 * 402 * @return A new {@link HttpHandler} instance. 403 * @throws IllegalArgumentException If any parameter is invalid. 404 */ 405 public HttpHandler build() { 406 // Resolve the URI from the provided inputs 407 resolveUri(); 408 409 // Validate connection timeout 410 int actualConnectionTimeoutSeconds = connectionTimeoutSeconds != null ? 411 connectionTimeoutSeconds : DEFAULT_CONNECTION_TIMEOUT_SECONDS; 412 if (actualConnectionTimeoutSeconds <= 0) { 413 throw new IllegalArgumentException("Connection timeout must be positive"); 414 } 415 416 // Validate read timeout 417 int actualReadTimeoutSeconds = readTimeoutSeconds != null ? 418 readTimeoutSeconds : DEFAULT_READ_TIMEOUT_SECONDS; 419 if (actualReadTimeoutSeconds <= 0) { 420 throw new IllegalArgumentException("Read timeout must be positive"); 421 } 422 423 // Convert the URI to a URL 424 // Note: URI.toURL() is deprecated but all alternatives (URL constructors) are also deprecated. 425 // We suppress the warning since we need to create a URL for backward compatibility. 426 if (uri == null) { 427 throw new IllegalArgumentException("URI cannot be null"); 428 } 429 430 URL verifiedUrl; 431 try { 432 verifiedUrl = uri.toURL(); 433 } catch (MalformedURLException | IllegalArgumentException | NullPointerException e) { 434 throw new IllegalStateException("Failed to convert URI to URL: " + uri, e); 435 } 436 437 // Use appropriate constructor based on scheme 438 if ("https".equalsIgnoreCase(uri.getScheme())) { 439 // For HTTPS, create or validate SSL context 440 SecureSSLContextProvider actualSecureSSLContextProvider = secureSSLContextProvider != null ? 441 secureSSLContextProvider : new SecureSSLContextProvider(); 442 SSLContext secureContext = actualSecureSSLContextProvider.getOrCreateSecureSSLContext(sslContext); 443 return new HttpHandler(uri, verifiedUrl, secureContext, actualConnectionTimeoutSeconds, actualReadTimeoutSeconds); 444 } else { 445 // For HTTP, no SSL context needed 446 return new HttpHandler(uri, verifiedUrl, actualConnectionTimeoutSeconds, actualReadTimeoutSeconds); 447 } 448 } 449 450 /** 451 * Resolves the URI from the provided inputs. 452 * Priority: 1. uri, 2. url, 3. urlString 453 */ 454 private void resolveUri() { 455 // If URI is already set, use it 456 if (uri != null) { 457 return; 458 } 459 460 // If URL is set, convert it to URI 461 if (url != null) { 462 try { 463 uri = url.toURI(); 464 return; 465 } catch (URISyntaxException e) { 466 throw new IllegalArgumentException("Invalid URL: " + url, e); 467 } 468 } 469 470 // If urlString is set, convert it to URI 471 if (!MoreStrings.isBlank(urlString)) { 472 // Check if the URL has a scheme, if not prepend https:// 473 String urlToUse = urlString; 474 if (!URL_SCHEME_PATTERN.matcher(urlToUse).matches()) { 475 LOGGER.debug(() -> "URL missing scheme, prepending https:// to %s".formatted(urlString)); 476 urlToUse = "https://" + urlToUse; 477 } 478 479 uri = URI.create(urlToUse); 480 return; 481 } 482 483 // If we get here, no valid URI source was provided 484 throw new IllegalArgumentException("URI must not be null or empty."); 485 } 486 487 } 488}