AbstractApiClient.java
package com.guinetik.rr.api;
import com.guinetik.rr.RocketRestConfig;
import com.guinetik.rr.RocketRestOptions;
import com.guinetik.rr.auth.TokenExpiredException;
import com.guinetik.rr.http.RocketClient;
import com.guinetik.rr.http.RocketHeaders;
import com.guinetik.rr.http.RocketRestException;
import com.guinetik.rr.request.RequestSpec;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
/**
* Abstract base class for API clients providing common HTTP request handling.
*
* <p>This class serves as the foundation for all API client implementations in RocketRest.
* It provides:
* <ul>
* <li>Automatic token refresh on expiration</li>
* <li>Configurable retry logic with exponential backoff</li>
* <li>Request/response timing and logging</li>
* <li>Header management including authentication</li>
* </ul>
*
* <p>Concrete implementations include:
* <ul>
* <li>{@link DefaultApiClient} - Synchronous blocking requests</li>
* <li>{@link AsyncApiClient} - Asynchronous requests with {@link java.util.concurrent.CompletableFuture}</li>
* <li>{@link FluentApiClient} - Functional style with {@link com.guinetik.rr.result.Result} pattern</li>
* </ul>
*
* <h2>Request Execution Flow</h2>
* <pre class="language-java"><code>
* // 1. Create request specification
* RequestSpec<Void, User> request = RequestBuilder.get("/users/1")
* .responseType(User.class)
* .build();
*
* // 2. Execute with automatic retry and timing
* User user = client.execute(request);
* </code></pre>
*
* <h2>Configuration</h2>
* <pre class="language-java"><code>
* // Configure client options
* client.configure(RocketRestOptions.RETRY_ENABLED, true);
* client.configure(RocketRestOptions.MAX_RETRIES, 5);
* client.configure(RocketRestOptions.LOG_REQUEST_BODY, true);
* </code></pre>
*
* @author guinetik <guinetik@gmail.com>
* @see DefaultApiClient
* @see AsyncApiClient
* @see FluentApiClient
* @since 1.0.0
*/
public abstract class AbstractApiClient {
protected final Logger logger = LoggerFactory.getLogger(this.getClass());
protected String baseUrl;
protected final RocketRestConfig config;
protected final RocketRestOptions options;
protected RocketClient httpClient;
/**
* Creates a new API client.
*
* @param baseUrl The base URL for API requests.
* @param config The RocketRest configuration.
* @param httpClient The HTTP client implementation to use.
*/
protected AbstractApiClient(String baseUrl, RocketRestConfig config, RocketClient httpClient) {
this.baseUrl = baseUrl;
this.config = config;
this.httpClient = httpClient;
// Initialize options with defaults from config if available
if (config != null && config.getDefaultOptions() != null) {
// Create a new ClientOptions and copy values from the config's default options
this.options = new RocketRestOptions();
RocketRestOptions defaultOptions = config.getDefaultOptions();
// Copy all default options to this client's options
for (String key : defaultOptions.getKeys()) {
Object value = defaultOptions.getRaw(key);
if (value != null) {
this.options.set(key, value);
}
}
logger.debug("Initialized client with default options from config");
} else {
this.options = new RocketRestOptions();
}
}
/**
* Method to handle token refresh.
* Refreshes the authentication token if needed using the configured auth strategy.
*/
protected void refreshToken() {
if (config != null && config.getAuthStrategy() != null && config.getAuthStrategy().needsTokenRefresh()) {
logger.debug("Refreshing authentication token");
boolean refreshed = config.getAuthStrategy().refreshCredentials();
if (refreshed) {
logger.debug("Credentials refreshed successfully");
} else {
logger.warn("Credential refresh failed");
}
}
}
/**
* Sets a client option value.
*
* @param key The option key.
* @param value The option value.
* @return This client instance for method chaining.
*/
public AbstractApiClient configure(String key, Object value) {
options.set(key, value);
logger.debug("Set configuration option: {} = {}", key, value);
return this;
}
/**
* Executes the given request specification and returns the response.
*
* @param <Req> The type of the request.
* @param <Res> The type of the response.
* @param requestSpec The request specification.
* @return The response object.
*/
public <Req, Res> Res execute(RequestSpec<Req, Res> requestSpec) {
if (options.getBoolean(RocketRestOptions.TIMING_ENABLED, true)) {
return executeWithTiming(requestSpec);
}
return executeWithRetry(requestSpec, options.getInt(RocketRestOptions.MAX_RETRIES, 1));
}
/**
* Executes a request with timing measurement if enabled.
*
* @param <Req> The type of the request.
* @param <Res> The type of the response.
* @param requestSpec The specification of the request to be executed.
* @return The response object.
*/
protected <Req, Res> Res executeWithTiming(RequestSpec<Req, Res> requestSpec) {
long startTime = System.currentTimeMillis();
try {
return executeWithRetry(requestSpec, options.getInt(RocketRestOptions.MAX_RETRIES, 1));
} finally {
long duration = System.currentTimeMillis() - startTime;
if (options.getBoolean(RocketRestOptions.LOGGING_ENABLED, true)) {
logger.info("Request completed in {}ms: {} {}", duration,
requestSpec.getMethod(), requestSpec.getEndpoint());
}
}
}
/**
* Executes an API request with retry logic. If the request fails due to a token expiration,
* it will attempt to refresh the token and retry the request up to a maximum number of retries.
*
* @param <Req> The type of the request.
* @param <Res> The type of the response.
* @param requestSpec The specification of the request to be executed.
* @param retriesLeft The number of retries remaining.
* @return The response object.
* @throws ApiException if the request fails after all retries are exhausted.
*/
protected <Req, Res> Res executeWithRetry(RequestSpec<Req, Res> requestSpec, int retriesLeft) {
try {
// If logging is enabled, log the request
if (options.getBoolean(RocketRestOptions.LOGGING_ENABLED, true)) {
logRequest(requestSpec);
}
// If we have auth headers, apply them early, at the requestspec level
if(config.getAuthStrategy() != null) {
config.getAuthStrategy().applyAuthHeaders(requestSpec.getHeaders());
}
// Delegate to the HTTP client implementation
return httpClient.execute(requestSpec);
} catch (TokenExpiredException e) {
if (retriesLeft > 0 && options.getBoolean(RocketRestOptions.RETRY_ENABLED, true)) {
logger.debug("Token expired, attempting refresh. Retries left: {}", retriesLeft);
refreshToken();
config.getAuthStrategy().applyAuthHeaders(requestSpec.getHeaders());
// Apply delay if configured
long retryDelay = options.getLong(RocketRestOptions.RETRY_DELAY, 0);
if (retryDelay > 0) {
try {
Thread.sleep(retryDelay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new ApiException("Retry interrupted", ie);
}
}
return executeWithRetry(requestSpec, retriesLeft - 1);
}
logger.error("Token refresh failed after maximum retries");
throw new ApiException("Token refresh failed after maximum retries", e);
} catch (com.guinetik.rr.http.CircuitBreakerOpenException e) {
// Don't wrap CircuitBreakerOpenException, just rethrow it
logger.warn("Circuit breaker is open", e);
throw e;
} catch (RocketRestException e) {
logger.error("HTTP client error", e);
throw new ApiException("Failed to execute request", e);
}
}
/**
* Logs information about the request if logging is enabled.
*
* @param <Req> The type of the request.
* @param <Res> The type of the response.
* @param requestSpec The request specification.
*/
protected <Req, Res> void logRequest(RequestSpec<Req, Res> requestSpec) {
logger.info("Executing {} request to: {}", requestSpec.getMethod(), requestSpec.getEndpoint());
if (options.getBoolean(RocketRestOptions.LOG_REQUEST_BODY, false) && requestSpec.getBody() != null) {
// In real implementation, should be careful about logging sensitive information
logger.debug("Request body: {}", requestSpec.getBody());
}
}
/**
* Creates a map of HTTP headers, including default headers, authorization headers,
* and any custom headers provided.
*
* @param customHeaders A map of custom headers to include in the request.
* @return A complete map of headers for the request.
*/
protected RocketHeaders createHeaders(Map<String, String> customHeaders) {
// Start with default JSON headers
RocketHeaders headers = RocketHeaders.defaultJson();
// Add auth headers using the auth strategy
if (config != null && config.getAuthStrategy() != null) {
// Use the newer applyAuthHeaders method which has a better implementation
headers = config.getAuthStrategy().applyAuthHeaders(headers);
}
// Add custom headers
if (customHeaders != null) {
for (Map.Entry<String, String> entry : customHeaders.entrySet()) {
headers.set(entry.getKey(), entry.getValue());
}
}
return headers;
}
public void setBaseUrl(String baseUrl){
this.baseUrl = baseUrl;
this.config.setServiceUrl(baseUrl);
this.httpClient.setBaseUrl(baseUrl);
}
}