View Javadoc
1   package com.guinetik.rr.api;
2   
3   import com.guinetik.rr.RocketRestConfig;
4   import com.guinetik.rr.RocketRestOptions;
5   import com.guinetik.rr.auth.TokenExpiredException;
6   import com.guinetik.rr.http.RocketClient;
7   import com.guinetik.rr.http.RocketHeaders;
8   import com.guinetik.rr.http.RocketRestException;
9   import com.guinetik.rr.request.RequestSpec;
10  import org.slf4j.Logger;
11  import org.slf4j.LoggerFactory;
12  
13  import java.util.Map;
14  
15  /**
16   * Abstract base class for API clients providing common HTTP request handling.
17   *
18   * <p>This class serves as the foundation for all API client implementations in RocketRest.
19   * It provides:
20   * <ul>
21   *   <li>Automatic token refresh on expiration</li>
22   *   <li>Configurable retry logic with exponential backoff</li>
23   *   <li>Request/response timing and logging</li>
24   *   <li>Header management including authentication</li>
25   * </ul>
26   *
27   * <p>Concrete implementations include:
28   * <ul>
29   *   <li>{@link DefaultApiClient} - Synchronous blocking requests</li>
30   *   <li>{@link AsyncApiClient} - Asynchronous requests with {@link java.util.concurrent.CompletableFuture}</li>
31   *   <li>{@link FluentApiClient} - Functional style with {@link com.guinetik.rr.result.Result} pattern</li>
32   * </ul>
33   *
34   * <h2>Request Execution Flow</h2>
35   * <pre class="language-java"><code>
36   * // 1. Create request specification
37   * RequestSpec&lt;Void, User&gt; request = RequestBuilder.get("/users/1")
38   *     .responseType(User.class)
39   *     .build();
40   *
41   * // 2. Execute with automatic retry and timing
42   * User user = client.execute(request);
43   * </code></pre>
44   *
45   * <h2>Configuration</h2>
46   * <pre class="language-java"><code>
47   * // Configure client options
48   * client.configure(RocketRestOptions.RETRY_ENABLED, true);
49   * client.configure(RocketRestOptions.MAX_RETRIES, 5);
50   * client.configure(RocketRestOptions.LOG_REQUEST_BODY, true);
51   * </code></pre>
52   *
53   * @author guinetik &lt;guinetik@gmail.com&gt;
54   * @see DefaultApiClient
55   * @see AsyncApiClient
56   * @see FluentApiClient
57   * @since 1.0.0
58   */
59  public abstract class AbstractApiClient {
60      protected final Logger logger = LoggerFactory.getLogger(this.getClass());
61  
62      protected String baseUrl;
63      protected final RocketRestConfig config;
64      protected final RocketRestOptions options;
65      protected RocketClient httpClient;
66  
67      /**
68       * Creates a new API client.
69       *
70       * @param baseUrl    The base URL for API requests.
71       * @param config     The RocketRest configuration.
72       * @param httpClient The HTTP client implementation to use.
73       */
74      protected AbstractApiClient(String baseUrl, RocketRestConfig config, RocketClient httpClient) {
75          this.baseUrl = baseUrl;
76          this.config = config;
77          this.httpClient = httpClient;
78  
79          // Initialize options with defaults from config if available
80          if (config != null && config.getDefaultOptions() != null) {
81              // Create a new ClientOptions and copy values from the config's default options
82              this.options = new RocketRestOptions();
83              RocketRestOptions defaultOptions = config.getDefaultOptions();
84  
85              // Copy all default options to this client's options
86              for (String key : defaultOptions.getKeys()) {
87                  Object value = defaultOptions.getRaw(key);
88                  if (value != null) {
89                      this.options.set(key, value);
90                  }
91              }
92  
93              logger.debug("Initialized client with default options from config");
94          } else {
95              this.options = new RocketRestOptions();
96          }
97      }
98  
99      /**
100      * Method to handle token refresh.
101      * Refreshes the authentication token if needed using the configured auth strategy.
102      */
103     protected void refreshToken() {
104         if (config != null && config.getAuthStrategy() != null && config.getAuthStrategy().needsTokenRefresh()) {
105             logger.debug("Refreshing authentication token");
106             boolean refreshed = config.getAuthStrategy().refreshCredentials();
107             if (refreshed) {
108                 logger.debug("Credentials refreshed successfully");
109             } else {
110                 logger.warn("Credential refresh failed");
111             }
112         }
113     }
114 
115 
116     /**
117      * Sets a client option value.
118      *
119      * @param key   The option key.
120      * @param value The option value.
121      * @return This client instance for method chaining.
122      */
123     public AbstractApiClient configure(String key, Object value) {
124         options.set(key, value);
125         logger.debug("Set configuration option: {} = {}", key, value);
126         return this;
127     }
128 
129     /**
130      * Executes the given request specification and returns the response.
131      *
132      * @param <Req>       The type of the request.
133      * @param <Res>       The type of the response.
134      * @param requestSpec The request specification.
135      * @return The response object.
136      */
137     public <Req, Res> Res execute(RequestSpec<Req, Res> requestSpec) {
138         if (options.getBoolean(RocketRestOptions.TIMING_ENABLED, true)) {
139             return executeWithTiming(requestSpec);
140         }
141         return executeWithRetry(requestSpec, options.getInt(RocketRestOptions.MAX_RETRIES, 1));
142     }
143 
144     /**
145      * Executes a request with timing measurement if enabled.
146      *
147      * @param <Req>       The type of the request.
148      * @param <Res>       The type of the response.
149      * @param requestSpec The specification of the request to be executed.
150      * @return The response object.
151      */
152     protected <Req, Res> Res executeWithTiming(RequestSpec<Req, Res> requestSpec) {
153         long startTime = System.currentTimeMillis();
154         try {
155             return executeWithRetry(requestSpec, options.getInt(RocketRestOptions.MAX_RETRIES, 1));
156         } finally {
157             long duration = System.currentTimeMillis() - startTime;
158             if (options.getBoolean(RocketRestOptions.LOGGING_ENABLED, true)) {
159                 logger.info("Request completed in {}ms: {} {}", duration,
160                         requestSpec.getMethod(), requestSpec.getEndpoint());
161             }
162         }
163     }
164 
165     /**
166      * Executes an API request with retry logic. If the request fails due to a token expiration,
167      * it will attempt to refresh the token and retry the request up to a maximum number of retries.
168      *
169      * @param <Req>       The type of the request.
170      * @param <Res>       The type of the response.
171      * @param requestSpec The specification of the request to be executed.
172      * @param retriesLeft The number of retries remaining.
173      * @return The response object.
174      * @throws ApiException if the request fails after all retries are exhausted.
175      */
176     protected <Req, Res> Res executeWithRetry(RequestSpec<Req, Res> requestSpec, int retriesLeft) {
177         try {
178             // If logging is enabled, log the request
179             if (options.getBoolean(RocketRestOptions.LOGGING_ENABLED, true)) {
180                 logRequest(requestSpec);
181             }
182             // If we have auth headers, apply them early, at the requestspec level
183             if(config.getAuthStrategy() != null) {
184                 config.getAuthStrategy().applyAuthHeaders(requestSpec.getHeaders());
185             }
186             // Delegate to the HTTP client implementation
187             return httpClient.execute(requestSpec);
188         } catch (TokenExpiredException e) {
189             if (retriesLeft > 0 && options.getBoolean(RocketRestOptions.RETRY_ENABLED, true)) {
190                 logger.debug("Token expired, attempting refresh. Retries left: {}", retriesLeft);
191                 refreshToken();
192                 config.getAuthStrategy().applyAuthHeaders(requestSpec.getHeaders());
193                 // Apply delay if configured
194                 long retryDelay = options.getLong(RocketRestOptions.RETRY_DELAY, 0);
195                 if (retryDelay > 0) {
196                     try {
197                         Thread.sleep(retryDelay);
198                     } catch (InterruptedException ie) {
199                         Thread.currentThread().interrupt();
200                         throw new ApiException("Retry interrupted", ie);
201                     }
202                 }
203                 return executeWithRetry(requestSpec, retriesLeft - 1);
204             }
205             logger.error("Token refresh failed after maximum retries");
206             throw new ApiException("Token refresh failed after maximum retries", e);
207         } catch (com.guinetik.rr.http.CircuitBreakerOpenException e) {
208             // Don't wrap CircuitBreakerOpenException, just rethrow it
209             logger.warn("Circuit breaker is open", e);
210             throw e;
211         } catch (RocketRestException e) {
212             logger.error("HTTP client error", e);
213             throw new ApiException("Failed to execute request", e);
214         }
215     }
216 
217     /**
218      * Logs information about the request if logging is enabled.
219      *
220      * @param <Req>       The type of the request.
221      * @param <Res>       The type of the response.
222      * @param requestSpec The request specification.
223      */
224     protected <Req, Res> void logRequest(RequestSpec<Req, Res> requestSpec) {
225         logger.info("Executing {} request to: {}", requestSpec.getMethod(), requestSpec.getEndpoint());
226 
227         if (options.getBoolean(RocketRestOptions.LOG_REQUEST_BODY, false) && requestSpec.getBody() != null) {
228             // In real implementation, should be careful about logging sensitive information
229             logger.debug("Request body: {}", requestSpec.getBody());
230         }
231     }
232 
233     /**
234      * Creates a map of HTTP headers, including default headers, authorization headers,
235      * and any custom headers provided.
236      *
237      * @param customHeaders A map of custom headers to include in the request.
238      * @return A complete map of headers for the request.
239      */
240     protected RocketHeaders createHeaders(Map<String, String> customHeaders) {
241         // Start with default JSON headers
242         RocketHeaders headers = RocketHeaders.defaultJson();
243 
244         // Add auth headers using the auth strategy
245         if (config != null && config.getAuthStrategy() != null) {
246             // Use the newer applyAuthHeaders method which has a better implementation
247             headers = config.getAuthStrategy().applyAuthHeaders(headers);
248         }
249 
250         // Add custom headers
251         if (customHeaders != null) {
252             for (Map.Entry<String, String> entry : customHeaders.entrySet()) {
253                 headers.set(entry.getKey(), entry.getValue());
254             }
255         }
256 
257         return headers;
258     }
259 
260 
261     public void setBaseUrl(String baseUrl){
262         this.baseUrl = baseUrl;
263         this.config.setServiceUrl(baseUrl);
264         this.httpClient.setBaseUrl(baseUrl);
265     }
266 }