View Javadoc
1   package com.guinetik.rr.http;
2   
3   import com.guinetik.rr.RocketRestOptions;
4   import com.guinetik.rr.auth.TokenExpiredException;
5   import com.guinetik.rr.json.JsonObjectMapper;
6   import com.guinetik.rr.request.RequestSpec;
7   import com.guinetik.rr.util.ResponseLogger;
8   import com.guinetik.rr.util.StreamUtils;
9   import org.slf4j.Logger;
10  import org.slf4j.LoggerFactory;
11  
12  import javax.net.ssl.HttpsURLConnection;
13  import javax.net.ssl.SSLContext;
14  import java.io.IOException;
15  import java.io.InputStream;
16  import java.io.OutputStream;
17  import java.net.HttpURLConnection;
18  import java.net.URL;
19  import java.nio.charset.StandardCharsets;
20  import java.util.HashMap;
21  import java.util.Map;
22  import java.util.Optional;
23  import java.util.StringJoiner;
24  
25  /**
26   * Default implementation of {@link RocketClient} using Java's built-in {@link java.net.HttpURLConnection}.
27   *
28   * <p>This client provides HTTP request execution without external dependencies, making it
29   * suitable for environments where third-party HTTP libraries cannot be used. It supports
30   * all standard HTTP methods, custom headers, SSL/TLS configuration, and JSON serialization.
31   *
32   * <h2>Features</h2>
33   * <ul>
34   *   <li>Zero external HTTP dependencies (uses java.net.HttpURLConnection)</li>
35   *   <li>Automatic JSON serialization/deserialization via Jackson</li>
36   *   <li>SSL/TLS support with custom certificate configuration</li>
37   *   <li>Configurable timeouts and request options</li>
38   *   <li>Query parameter encoding and URL building</li>
39   *   <li>Comprehensive error handling with status codes</li>
40   * </ul>
41   *
42   * <h2>Direct Usage</h2>
43   * <pre class="language-java"><code>
44   * // Create a client
45   * DefaultHttpClient client = new DefaultHttpClient("https://api.example.com");
46   *
47   * // Build and execute a request
48   * RequestSpec&lt;Void, User&gt; request = RequestBuilder.&lt;Void, User&gt;get("/users/1")
49   *     .responseType(User.class)
50   *     .build();
51   *
52   * User user = client.execute(request);
53   * </code></pre>
54   *
55   * <h2>With Custom Options</h2>
56   * <pre class="language-java"><code>
57   * RocketRestOptions options = new RocketRestOptions();
58   * options.set(RocketRestOptions.LOGGING_ENABLED, true);
59   * options.set(RocketRestOptions.LOG_RESPONSE_BODY, true);
60   *
61   * DefaultHttpClient client = new DefaultHttpClient(
62   *     "https://api.example.com",
63   *     options
64   * );
65   * </code></pre>
66   *
67   * <h2>Note</h2>
68   * <p>For most use cases, prefer using {@link com.guinetik.rr.RocketRest} facade or
69   * {@link RocketClientFactory} instead of instantiating this class directly.
70   *
71   * @author guinetik &lt;guinetik@gmail.com&gt;
72   * @see RocketClient
73   * @see RocketClientFactory
74   * @see com.guinetik.rr.RocketRest
75   * @since 1.0.0
76   */
77  public class DefaultHttpClient implements RocketClient {
78  
79      private static final Logger logger = LoggerFactory.getLogger(DefaultHttpClient.class);
80  
81      private String baseUrl;
82      private final RocketRestOptions clientOptions;
83      private SSLContext sslContext;
84  
85      /**
86       * Creates a new DefaultHttpClient with the specified base URL.
87       *
88       * @param baseUrl The base URL for all requests
89       */
90      public DefaultHttpClient(String baseUrl) {
91          this(baseUrl, new RocketRestOptions());
92      }
93  
94      /**
95       * Creates a new DefaultHttpClient with the specified base URL and client options.
96       *
97       * @param baseUrl       The base URL for all requests
98       * @param clientOptions The client options
99       */
100     public DefaultHttpClient(String baseUrl, RocketRestOptions clientOptions) {
101         this.baseUrl = baseUrl.endsWith(HttpConstants.Url.PATH_SEPARATOR) ?
102                 baseUrl : baseUrl + HttpConstants.Url.PATH_SEPARATOR;
103         this.clientOptions = clientOptions != null ? clientOptions : new RocketRestOptions();
104     }
105 
106     @Override
107     public void configureSsl(SSLContext sslContext) {
108         this.sslContext = sslContext;
109     }
110 
111     @Override
112     public void setBaseUrl(String baseUrl) {
113         this.baseUrl = baseUrl;
114     }
115 
116     /**
117      * Gets the client options.
118      *
119      * @return The client options
120      */
121     public RocketRestOptions getClientOptions() {
122         return clientOptions;
123     }
124 
125     @Override
126     public <Req, Res> Res execute(RequestSpec<Req, Res> requestSpec) throws RocketRestException {
127         try {
128             String fullUrl = buildFullUrl(requestSpec);
129             HttpURLConnection connection = configureConnection(fullUrl, requestSpec);
130             setRequestBody(connection, requestSpec);
131 
132             return executeRequest(connection, requestSpec);
133         } catch (TokenExpiredException e) {
134             throw e;
135         } catch (Exception e) {
136             if (e instanceof RocketRestException) {
137                 throw (RocketRestException) e;
138             }
139             throw new RocketRestException(HttpConstants.Errors.EXECUTE_REQUEST, e);
140         }
141     }
142 
143     /**
144      * Builds the full URL including endpoint and query parameters.
145      * Validates that absolute URLs are not used with a non-empty base URL.
146      *
147      * @throws RocketRestException if an absolute URL is used with a non-empty base URL
148      */
149     private String buildFullUrl(RequestSpec<?, ?> requestSpec) throws RocketRestException {
150         String endpoint = requestSpec.getEndpoint();
151 
152         // Check if the endpoint is an absolute URL
153         boolean isAbsoluteUrl = endpoint.startsWith("http://") || endpoint.startsWith("https://");
154 
155         // If baseUrl is not empty/blank and the endpoint is absolute, throw exception
156         if (isAbsoluteUrl && !baseUrl.trim().isEmpty() && !baseUrl.equals("/")) {
157             throw new RocketRestException(
158                 "Cannot use absolute URL '" + endpoint + "' with base URL '" + baseUrl +
159                 "'. Either use a relative path or set base URL to empty string.",
160                 400,
161                 null);
162         }
163 
164         // If the endpoint is absolute, use it directly
165         if (isAbsoluteUrl) {
166             String fullUrl = endpoint;
167             if (!requestSpec.getQueryParams().isEmpty()) {
168                 fullUrl += buildQueryString(requestSpec.getQueryParams());
169             }
170             return fullUrl;
171         }
172 
173         // Handle relative endpoints
174         if (endpoint.startsWith(HttpConstants.Url.PATH_SEPARATOR)) {
175             endpoint = endpoint.substring(1);
176         }
177 
178         String fullUrl = baseUrl + endpoint;
179 
180         if (!requestSpec.getQueryParams().isEmpty()) {
181             fullUrl += buildQueryString(requestSpec.getQueryParams());
182         }
183 
184         return fullUrl;
185     }
186 
187     /**
188      * Configures the HttpURLConnection with proper settings.
189      */
190     private <Req, Res> HttpURLConnection configureConnection(String fullUrl, RequestSpec<Req, Res> requestSpec)
191             throws IOException {
192         URL url = new URL(fullUrl);
193         HttpURLConnection connection = (HttpURLConnection) url.openConnection();
194         // Configure SSL if needed
195         if (connection instanceof HttpsURLConnection && sslContext != null) {
196             ((HttpsURLConnection) connection).setSSLSocketFactory(sslContext.getSocketFactory());
197         }
198         // Set timeouts
199         connection.setConnectTimeout(HttpConstants.Timeouts.DEFAULT_CONNECT_TIMEOUT);
200         connection.setReadTimeout(HttpConstants.Timeouts.DEFAULT_READ_TIMEOUT);
201         // Configure method
202         connection.setRequestMethod(requestSpec.getMethod());
203         // Set headers
204         setRequestHeaders(connection, requestSpec);
205         return connection;
206     }
207 
208     /**
209      * Sets the request headers on the connection.
210      */
211     private <Req, Res> void setRequestHeaders(HttpURLConnection connection, RequestSpec<Req, Res> requestSpec) {
212         RocketHeaders headers = requestSpec.getHeaders();
213         // Set all headers on the connection
214         headers.asMap().forEach(connection::setRequestProperty);
215     }
216 
217     /**
218      * Sets the request body if applicable.
219      */
220     private <Req, Res> void setRequestBody(HttpURLConnection connection, RequestSpec<Req, Res> requestSpec)
221             throws IOException {
222         boolean hasBody = requestSpec.getBody() != null && isMethodWithBody(requestSpec.getMethod());
223 
224         if (hasBody) {
225             connection.setDoOutput(true);
226             String jsonBody;
227 
228             // If the body is already a String, use it directly
229             if (requestSpec.getBody() instanceof String) {
230                 jsonBody = (String) requestSpec.getBody();
231             } else {
232                 // Otherwise convert to JSON
233                 jsonBody = JsonObjectMapper.toJsonString(requestSpec.getBody());
234             }
235 
236             try (OutputStream os = connection.getOutputStream()) {
237                 byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8);
238                 os.write(input, 0, input.length);
239             }
240         }
241     }
242 
243     /**
244      * Checks if the HTTP method supports a request body.
245      */
246     private boolean isMethodWithBody(String method) {
247         return method.equals(HttpConstants.Methods.POST) ||
248                 method.equals(HttpConstants.Methods.PUT) ||
249                 method.equals(HttpConstants.Methods.PATCH);
250     }
251 
252     /**
253      * Executes the configured request and processes the response.
254      */
255     private <Req, Res> Res executeRequest(HttpURLConnection connection, RequestSpec<Req, Res> requestSpec)
256             throws IOException, RocketRestException {
257         if (getClientOptions().getBoolean(RocketRestOptions.LOGGING_ENABLED, true)) {
258             logger.debug("Executing request: {} {}", connection.getRequestMethod(), connection.getURL());
259         }
260         int statusCode = connection.getResponseCode();
261         Map<String, String> responseHeaders = extractResponseHeaders(connection);
262         // Log raw response
263         ResponseLogger.logRawResponse(statusCode, responseHeaders, clientOptions);
264 
265         // Check for token expired
266         if (statusCode == HttpConstants.StatusCodes.UNAUTHORIZED) {
267             throw new TokenExpiredException(HttpConstants.Errors.TOKEN_EXPIRED);
268         }
269 
270         // Handle response based on status code
271         if (statusCode >= HttpConstants.StatusCodes.SUCCESS_MIN &&
272                 statusCode < HttpConstants.StatusCodes.SUCCESS_MAX) {
273             return handleSuccessResponse(connection, requestSpec);
274         } else {
275             handleErrorResponse(connection, statusCode);
276             // This line is never reached as handleErrorResponse always throws an exception
277             return null;
278         }
279     }
280 
281     /**
282      * Extracts response headers from the connection.
283      */
284     private Map<String, String> extractResponseHeaders(HttpURLConnection connection) {
285         Map<String, String> responseHeaders = new HashMap<>();
286         // Extract all headers
287         for (int i = 0; ; i++) {
288             String headerName = connection.getHeaderFieldKey(i);
289             String headerValue = connection.getHeaderField(i);
290             if (headerName == null && headerValue == null) {
291                 break;
292             }
293             if (headerName != null) {
294                 responseHeaders.put(headerName, headerValue);
295             }
296         }
297         return responseHeaders;
298     }
299 
300     /**
301      * Handles a successful HTTP response.
302      */
303     private <Req, Res> Res handleSuccessResponse(HttpURLConnection connection, RequestSpec<Req, Res> requestSpec)
304             throws IOException {
305 
306         // Handle void response
307         if (requestSpec.getResponseType() == Void.class) {
308             return null;
309         }
310 
311         // Read and process response body
312         try (InputStream is = connection.getInputStream()) {
313             String responseString = StreamUtils.readInputStreamAsString(is);
314 
315             // Log response body if enabled
316             ResponseLogger.logResponseBody(responseString, clientOptions);
317 
318             // Special case for String.class - return the raw response string
319             if (requestSpec.getResponseType() == String.class) {
320                 @SuppressWarnings("unchecked")
321                 Res result = (Res) responseString;
322                 return result;
323             }
324 
325             // Parse response to the requested type
326             return JsonObjectMapper.jsonToObject(responseString, requestSpec.getResponseType());
327         }
328     }
329 
330     /**
331      * Handles an HTTP error response.
332      * Always throws an exception with the error details.
333      *
334      * @throws RocketRestException Always thrown with error details
335      */
336     private void handleErrorResponse(HttpURLConnection connection, int statusCode) throws RocketRestException {
337         // Get error details from the error stream
338         String errorBody = Optional.ofNullable(connection.getErrorStream())
339                 .map(is -> {
340                     try {
341                         return StreamUtils.readInputStreamAsString(is);
342                     } catch (IOException e) {
343                         logger.warn("Error reading error stream", e);
344                         return null;
345                     } finally {
346                         try {
347                             is.close();
348                         } catch (IOException e) {
349                             // Ignore close errors
350                         }
351                     }
352                 })
353                 .orElse(null);
354 
355         // Log error response body if enabled
356         ResponseLogger.logResponseBody(errorBody, this.getClientOptions());
357 
358         throw new RocketRestException(
359                 HttpConstants.Errors.REQUEST_FAILED + statusCode,
360                 statusCode,
361                 errorBody
362         );
363     }
364 
365     /**
366      * Builds a query string from a map of parameters.
367      *
368      * @param params The query parameters
369      * @return The formatted query string
370      */
371     private String buildQueryString(Map<String, String> params) {
372         if (params.isEmpty()) {
373             return "";
374         }
375         StringJoiner sj = new StringJoiner(
376                 HttpConstants.Url.QUERY_SEPARATOR,
377                 HttpConstants.Url.QUERY_PREFIX,
378                 "");
379 
380         params.forEach((key, value) ->
381                 sj.add(key + HttpConstants.Url.PARAM_EQUALS + encodeParam(value))
382         );
383         return sj.toString();
384     }
385 
386     /**
387      * Encodes a URL parameter.
388      *
389      * @param param The parameter to encode
390      * @return The encoded parameter
391      */
392     private String encodeParam(String param) {
393         try {
394             return java.net.URLEncoder.encode(param, HttpConstants.Encoding.UTF8);
395         } catch (Exception e) {
396             logger.warn(HttpConstants.Errors.ENCODE_PARAM, param, e);
397             return param;
398         }
399     }
400 }