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<User, ApiError> result = client.executeWithResult(request);
30 *
31 * // Pattern matching style
32 * result.match(
33 * user -> System.out.println("Success: " + user.getName()),
34 * error -> 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<String, ApiError> 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<User, ApiError> result = client.fluent()
57 * .get("/users/1", User.class);
58 * </code></pre>
59 *
60 * @author guinetik <guinetik@gmail.com>
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 }