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<Void, User> 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 <guinetik@gmail.com>
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 }