ApacheWpRestClient.java
package io.github.evisentin.wordpress.rest.client.adapter.apache;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.evisentin.wordpress.rest.client.adapter.apache.discovery.ApiUrlDiscoveryHelper;
import io.github.evisentin.wordpress.rest.client.adapter.apache.interceptors.AuthenticationInterceptor;
import io.github.evisentin.wordpress.rest.client.adapter.apache.interceptors.WpErrorInterceptor;
import io.github.evisentin.wordpress.rest.client.adapter.apache.modules.*;
import io.github.evisentin.wordpress.rest.client.domain.WpBaseRestClient;
import io.github.evisentin.wordpress.rest.client.domain.api.*;
import io.github.evisentin.wordpress.rest.client.domain.auth.WpAuthenticationStrategy;
import io.github.evisentin.wordpress.rest.client.domain.configuration.SslConfiguration;
import io.github.evisentin.wordpress.rest.client.domain.configuration.TimeoutConfiguration;
import lombok.NonNull;
import lombok.SneakyThrows;
import org.apache.hc.client5.http.config.ConnectionConfig;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder;
import org.apache.hc.core5.http.HttpRequestInterceptor;
import org.apache.hc.core5.http.HttpResponseInterceptor;
import org.apache.hc.core5.util.Timeout;
import javax.net.ssl.SSLContext;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
/**
* Apache HttpClient-based implementation of {@link WpBaseRestClient}.
*
* <p>
* This client provides a synchronous wrapper around the WordPress REST API using
* {@link org.apache.hc.client5.http.impl.classic.CloseableHttpClient} as the underlying HTTP transport.
*
* <p>
* It supports CRUD operations and paginated queries for core WordPress resources such as posts, categories, and tags.
*
* <p>
* The client is configured with an authentication strategy and automatically registers internal interceptors for:
* <ul>
* <li>Authentication ({@link AuthenticationInterceptor})</li>
* <li>WordPress-specific error handling ({@link WpErrorInterceptor})</li>
* </ul>
*
* <p>
* Optional {@link SslConfiguration} and {@link TimeoutConfiguration} can be provided
* to customize TLS behaviour, connection management, and request timeouts.
*
* <b>Thread Safety</b>
*
* <p>
* Instances of this class are thread-safe and intended to be reused.
*
* @see WpBaseRestClient
* @see AuthenticationInterceptor
* @see WpErrorInterceptor
*/
public class ApacheWpRestClient extends WpBaseRestClient {
private final CategoryAPIs categoryAPIs;
private final CommentAPIs commentAPIs;
private final MediaAPIs mediaPIs;
private final PageAPIs pageAPIs;
private final PageRevisionAPIs pageRevisionAPIs;
private final PostAPIs postAPIs;
private final PostRevisionAPIs postRevisionAPIs;
private final PostStatusAPIs postStatusAPIs;
private final PostTypeAPIs postTypesAPIs;
private final TagAPIs tagAPIs;
private final TaxonomyAPIs taxonomyAPIs;
/**
* Creates a new {@code ApacheWpRestClient}.
*
* <p>
* The client is initialized with the given base URL and authentication strategy, and backed by an Apache
* {@link CloseableHttpClient} configured with internal authentication and error-handling interceptors.
*
* <p>
* If a {@link SslConfiguration} is provided, it is used to configure the underlying TLS strategy. If {@code null},
* the default JVM SSL configuration is used.
*
* <p>
* If a {@link TimeoutConfiguration} is provided, it is applied to the HTTP client, including connection, response,
* and connection request timeouts. If {@code null}, default HttpClient settings are used.
*
* @param baseUrl
* the base URL of the WordPress instance (must not be {@code null})
* @param authenticationStrategy
* the authentication strategy used to sign requests (must not be {@code null})
* @param sslConfiguration
* optional SSL configuration; may be {@code null}
* @param timeoutConfiguration
* optional timeout configuration; may be {@code null}
* @param requestInterceptors
* additional request interceptors to register with the underlying HTTP client; may be {@code null} or
* empty
* @param responseInterceptors
* additional response interceptors to register with the underlying HTTP client; may be {@code null} or
* empty
*
* @throws NullPointerException
* if {@code baseUrl} or {@code authenticationStrategy} is {@code null}
* @throws IllegalStateException
* if the provided {@link SslConfiguration} is invalid
*/
ApacheWpRestClient(final @NonNull String baseUrl,
final @NonNull WpAuthenticationStrategy authenticationStrategy,
final SslConfiguration sslConfiguration,
final TimeoutConfiguration timeoutConfiguration,
final List<HttpRequestInterceptor> requestInterceptors,
final List<HttpResponseInterceptor> responseInterceptors) {
ObjectMapper mapper = new ObjectMapper();
mapper.findAndRegisterModules();
final CloseableHttpClient authHttpClient = buildAuthHttpClient(sslConfiguration, timeoutConfiguration);
String apiUrl = ApiUrlDiscoveryHelper.resolveApiUrl(authHttpClient, baseUrl);
final HttpClientBuilder httpClientBuilder = HttpClients.custom();
httpClientBuilder.addRequestInterceptorFirst(new AuthenticationInterceptor(authenticationStrategy, authHttpClient, apiUrl));
httpClientBuilder.addResponseInterceptorFirst(new WpErrorInterceptor());
emptyIfNull(requestInterceptors).forEach(httpClientBuilder::addRequestInterceptorLast);
emptyIfNull(responseInterceptors).forEach(httpClientBuilder::addResponseInterceptorLast);
applySslConfigurationIfPresent(httpClientBuilder, sslConfiguration);
applyTimeoutConfigurationIfPresent(httpClientBuilder, timeoutConfiguration);
CloseableHttpClient httpClient = httpClientBuilder.build();
categoryAPIs = new CategoryApiClientModule(apiUrl, httpClient, mapper);
commentAPIs = new CommentApiClientModule(apiUrl, httpClient, mapper);
mediaPIs = new MediaApiClientModule(apiUrl, httpClient, mapper);
pageAPIs = new PageApiClientModule(apiUrl, httpClient, mapper);
pageRevisionAPIs = new PageRevisionApiClientModule(apiUrl, httpClient, mapper);
postAPIs = new PostApiClientModule(apiUrl, httpClient, mapper);
postRevisionAPIs = new PostRevisionApiClientModule(apiUrl, httpClient, mapper);
postStatusAPIs = new PostStatusApiClientModule(apiUrl, httpClient, mapper);
postTypesAPIs = new PostTypeApiClientModule(apiUrl, httpClient, mapper);
tagAPIs = new TagApiClientModule(apiUrl, httpClient, mapper);
taxonomyAPIs = new TaxonomyApiClientModule(apiUrl, httpClient, mapper);
}
@Override
public CategoryAPIs categories() {
return categoryAPIs;
}
@Override
public CommentAPIs comments() {
return commentAPIs;
}
@Override
public MediaAPIs media() {
return mediaPIs;
}
@Override
public PageRevisionAPIs pageRevisions() {
return pageRevisionAPIs;
}
@Override
public PageAPIs pages() {
return pageAPIs;
}
@Override
public PostRevisionAPIs postRevisions() {
return postRevisionAPIs;
}
@Override
public PostStatusAPIs postStatuses() {
return postStatusAPIs;
}
@Override
public PostTypeAPIs postTypes() {
return postTypesAPIs;
}
@Override
public PostAPIs posts() {
return postAPIs;
}
@Override
public TagAPIs tags() {
return tagAPIs;
}
@Override
public TaxonomyAPIs taxonomies() {
return taxonomyAPIs;
}
private static void applySslConfigurationIfPresent(final HttpClientBuilder httpClientBuilder,
final SslConfiguration sslConfiguration) {
if (sslConfiguration != null) {
failOnInvalidConfiguration(sslConfiguration);
final SSLContext sslContext = createSslContext(sslConfiguration);
final var tlsStrategyBuilder = ClientTlsStrategyBuilder.create()
.setSslContext(sslContext);
if (sslConfiguration.getHostnameVerifier() != null) {
tlsStrategyBuilder.setHostnameVerifier(sslConfiguration.getHostnameVerifier());
}
final var connectionManager = PoolingHttpClientConnectionManagerBuilder.create()
.setTlsSocketStrategy(tlsStrategyBuilder.buildClassic())
.build();
httpClientBuilder.setConnectionManager(connectionManager);
}
}
private static void applyTimeoutConfigurationIfPresent(final HttpClientBuilder httpClientBuilder,
final TimeoutConfiguration config) {
if (config == null) {
return;
}
final RequestConfig requestConfig = getRequestConfig(config);
final PoolingHttpClientConnectionManager connectionManager = getPoolingHttpClientConnectionManager(config);
httpClientBuilder
.setConnectionManager(connectionManager)
.setDefaultRequestConfig(requestConfig);
}
private static CloseableHttpClient buildAuthHttpClient(final SslConfiguration sslConfiguration,
final TimeoutConfiguration timeoutConfiguration) {
final HttpClientBuilder authClientBuilder = HttpClients.custom();
applySslConfigurationIfPresent(authClientBuilder, sslConfiguration);
applyTimeoutConfigurationIfPresent(authClientBuilder, timeoutConfiguration);
return authClientBuilder.build();
}
@SneakyThrows
private static SSLContext createSslContext(final SslConfiguration sslConfiguration) {
final SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(
null,
new javax.net.ssl.TrustManager[]{sslConfiguration.getTrustManager()},
new java.security.SecureRandom()
);
return sslContext;
}
private static <T> List<T> emptyIfNull(final List<T> list) {
return Optional.ofNullable(list).orElseGet(Collections::emptyList);
}
private static void failOnInvalidConfiguration(final SslConfiguration sslConfiguration) {
if (sslConfiguration.getTrustManager() == null) {
throw new IllegalStateException("SSL configuration requires a trustManager");
}
}
private static PoolingHttpClientConnectionManager getPoolingHttpClientConnectionManager(final TimeoutConfiguration config) {
ConnectionConfig.Builder connBuilder = ConnectionConfig.custom();
Optional.ofNullable(config.getConnectTimeout())
.map(Timeout::of)
.ifPresent(connBuilder::setConnectTimeout);
ConnectionConfig connectionConfig = connBuilder.build();
return PoolingHttpClientConnectionManagerBuilder.create()
.setDefaultConnectionConfig(connectionConfig)
.build();
}
private static RequestConfig getRequestConfig(final TimeoutConfiguration config) {
RequestConfig.Builder builder = RequestConfig.custom();
Optional.ofNullable(config.getReadTimeout())
.map(Timeout::of)
.ifPresent(builder::setResponseTimeout);
Optional.ofNullable(config.getConnectionRequestTimeout())
.map(Timeout::of)
.ifPresent(builder::setConnectionRequestTimeout);
return builder.build();
}
}