View Javadoc
1   package com.guinetik.rr.http;
2   
3   import com.guinetik.rr.RocketRestOptions;
4   import com.guinetik.rr.request.RequestSpec;
5   import com.guinetik.rr.result.ApiError;
6   import com.guinetik.rr.result.Result;
7   import org.slf4j.Logger;
8   import org.slf4j.LoggerFactory;
9   
10  import javax.net.ssl.SSLContext;
11  
12  /**
13   * HTTP client using the Result pattern for exception-free error handling.
14   *
15   * <p>This client wraps any {@link RocketClient} and converts exception-based errors into
16   * {@link com.guinetik.rr.result.Result} objects, enabling functional-style error handling.
17   *
18   * <h2>Benefits</h2>
19   * <ul>
20   *   <li>No exceptions to catch - errors are values</li>
21   *   <li>Compile-time enforcement of error handling</li>
22   *   <li>Functional composition with map, flatMap, fold</li>
23   * </ul>
24   *
25   * <h2>Basic Usage</h2>
26   * <pre class="language-java"><code>
27   * FluentHttpClient client = new FluentHttpClient("https://api.example.com");
28   *
29   * Result&lt;User, ApiError&gt; result = client.executeWithResult(request);
30   *
31   * // Pattern matching style
32   * result.match(
33   *     user -&gt; System.out.println("Success: " + user.getName()),
34   *     error -&gt; System.err.println("Error: " + error.getMessage())
35   * );
36   *
37   * // Or check and extract
38   * if (result.isSuccess()) {
39   *     User user = result.getValue();
40   * }
41   * </code></pre>
42   *
43   * <h2>Functional Composition</h2>
44   * <pre class="language-java"><code>
45   * // Transform success value
46   * Result&lt;String, ApiError&gt; name = result.map(User::getName);
47   *
48   * // Provide default on error
49   * User userOrDefault = result.getOrElse(defaultUser);
50   * </code></pre>
51   *
52   * <h2>Via RocketRest</h2>
53   * <pre class="language-java"><code>
54   * RocketRest client = new RocketRest(config);
55   *
56   * Result&lt;User, ApiError&gt; result = client.fluent()
57   *     .get("/users/1", User.class);
58   * </code></pre>
59   *
60   * @author guinetik &lt;guinetik@gmail.com&gt;
61   * @see RocketClient
62   * @see com.guinetik.rr.result.Result
63   * @see com.guinetik.rr.RocketRest#fluent()
64   * @since 1.0.0
65   */
66  public class FluentHttpClient implements RocketClient {
67  
68      private static final Logger logger = LoggerFactory.getLogger(FluentHttpClient.class);
69  
70      private final RocketClient delegate;
71      private String baseUrl;
72      private final RocketRestOptions clientOptions;
73  
74      /**
75       * Creates a new FluentHttpClient with the specified base URL.
76       *
77       * @param baseUrl The base URL for all requests
78       */
79      public FluentHttpClient(String baseUrl) {
80          this(baseUrl, new RocketRestOptions());
81      }
82  
83      /**
84       * Creates a new FluentHttpClient with the specified base URL and client options.
85       *
86       * @param baseUrl       The base URL for all requests
87       * @param clientOptions The client options
88       */
89      public FluentHttpClient(String baseUrl, RocketRestOptions clientOptions) {
90          this.baseUrl = baseUrl;
91          this.clientOptions = clientOptions != null ? clientOptions : new RocketRestOptions();
92          this.delegate = new DefaultHttpClient(baseUrl, this.clientOptions);
93      }
94  
95      /**
96       * Creates a new FluentHttpClient that delegates to the specified RocketClient.
97       *
98       * @param delegate The RocketClient to delegate requests to
99       * @param baseUrl The base URL for all requests
100      * @param clientOptions The client options
101      */
102     public FluentHttpClient(RocketClient delegate, String baseUrl, RocketRestOptions clientOptions) {
103         this.delegate = delegate;
104         this.baseUrl = baseUrl;
105         this.clientOptions = clientOptions != null ? clientOptions : new RocketRestOptions();
106     }
107 
108     @Override
109     public void configureSsl(SSLContext sslContext) {
110         delegate.configureSsl(sslContext);
111     }
112 
113     @Override
114     public void setBaseUrl(String baseUrl) {
115         this.baseUrl = baseUrl;
116     }
117 
118     /**
119      * Gets the client options.
120      *
121      * @return The client options
122      */
123     public RocketRestOptions getClientOptions() {
124         return clientOptions;
125     }
126 
127     /**
128      * Gets the base URL.
129      *
130      * @return The base URL
131      */
132     public String getBaseUrl() {
133         return baseUrl;
134     }
135 
136     /**
137      * Executes a request and returns a Result object containing either the response or an error.
138      * This method is the primary API for executing requests in a functional way without exceptions.
139      *
140      * @param <Req>       The type of the request body
141      * @param <Res>       The type of the response
142      * @param requestSpec The request specification
143      * @return A Result object containing either the response or an error
144      */
145     public <Req, Res> Result<Res, ApiError> executeWithResult(RequestSpec<Req, Res> requestSpec) {
146         try {
147             // Validate absolute URLs
148             if (isAbsoluteUrl(requestSpec.getEndpoint()) && 
149                 !baseUrl.trim().isEmpty() && 
150                 !baseUrl.equals("/")) {
151                 return Result.failure(ApiError.configError(
152                     "Cannot use absolute URL '" + requestSpec.getEndpoint() + "' with base URL '" + baseUrl + 
153                     "'. Either use a relative path or set base URL to empty string."
154                 ));
155             }
156             
157             // Delegate to the underlying client to execute the request
158             Res response = delegate.execute(requestSpec);
159             return Result.success(response);
160         } catch (RocketRestException e) {
161             // Convert exception to appropriate ApiError
162             ApiError error = convertExceptionToApiError(e);
163             return Result.failure(error);
164         } catch (Exception e) {
165             // Handle unexpected exceptions
166             return Result.failure(ApiError.networkError("Unexpected error: " + e.getMessage()));
167         }
168     }
169 
170     @Override
171     public <Req, Res> Res execute(RequestSpec<Req, Res> requestSpec) throws RocketRestException {
172         // Bridge to the exception-based API
173         Result<Res, ApiError> result = executeWithResult(requestSpec);
174         if (result.isSuccess()) {
175             return result.getValue();
176         } else {
177             ApiError error = result.getError();
178             throw new RocketRestException(
179                     error.getMessage(),
180                     error.getStatusCode(),
181                     error.getResponseBody()
182             );
183         }
184     }
185     
186     /**
187      * Checks if a URL is absolute.
188      *
189      * @param url The URL to check
190      * @return true if the URL is absolute, false otherwise
191      */
192     private boolean isAbsoluteUrl(String url) {
193         return url.startsWith("http://") || url.startsWith("https://");
194     }
195     
196     /**
197      * Converts a RocketRestException to an appropriate ApiError.
198      *
199      * @param e The exception to convert
200      * @return An ApiError representing the exception
201      */
202     private ApiError convertExceptionToApiError(RocketRestException e) {
203         // Special handling for CircuitBreakerOpenException
204         if (e instanceof CircuitBreakerOpenException) {
205             CircuitBreakerOpenException cbException = (CircuitBreakerOpenException) e;
206             return ApiError.circuitOpenError(e.getMessage());
207         }
208         
209         int statusCode = e.getStatusCode();
210         String body = e.getResponseBody();
211         
212         // Determine the error type based on the status code
213         if (statusCode == HttpConstants.StatusCodes.UNAUTHORIZED) {
214             return ApiError.authError(e.getMessage(), statusCode, body);
215         } else if (statusCode >= HttpConstants.StatusCodes.CLIENT_ERROR_MIN && 
216                    statusCode < HttpConstants.StatusCodes.SERVER_ERROR_MIN) {
217             return ApiError.httpError(e.getMessage(), statusCode, body);
218         } else if (statusCode >= HttpConstants.StatusCodes.SERVER_ERROR_MIN) {
219             return ApiError.httpError(e.getMessage(), statusCode, body);
220         } else if (e.getCause() instanceof java.io.IOException) {
221             return ApiError.networkError(e.getMessage());
222         } else {
223             // Default to unknown error
224             return ApiError.httpError(e.getMessage(), statusCode, body);
225         }
226     }
227 }