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&lt;String&gt; 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}