Skip to content

Instantly share code, notes, and snippets.

@robsonkades
Created October 6, 2025 01:16
Show Gist options
  • Save robsonkades/0dcbe88f295a272c2993e7dbc8a8c1c0 to your computer and use it in GitHub Desktop.
Save robsonkades/0dcbe88f295a272c2993e7dbc8a8c1c0 to your computer and use it in GitHub Desktop.
import org.springframework.web.client.RestClient;
public record WebServiceClient(RestClient restClient, CloseableResources closeableResources) {
}
import org.springframework.http.ResponseEntity;
import java.util.concurrent.CompletableFuture;
public interface WebService {
CompletableFuture<ResponseEntity<String>> post(String url, String body);
CompletableFuture<ResponseEntity<String>> get(String uri);
}
import io.github.robsonkades.sefaz.ssl.RegisterBundle;
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.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.DefaultClientTlsStrategy;
import org.apache.hc.client5.http.ssl.NoopHostnameVerifier;
import org.apache.hc.core5.http.io.SocketConfig;
import org.apache.hc.core5.pool.PoolConcurrencyPolicy;
import org.apache.hc.core5.pool.PoolReusePolicy;
import org.apache.hc.core5.reactor.ssl.SSLBufferMode;
import org.apache.hc.core5.util.Timeout;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.time.Duration;
import java.util.function.Consumer;
/**
* Fábrica responsável por criar e configurar instâncias de {@link RestClient}
* e seus recursos subjacentes (HttpClient e ConnectionManager) com suporte a TLS,
* pooling de conexões, timeouts e integração com {@link SslBundles} do Spring Boot.
*
* <p>Principais características:</p>
* <ul>
* <li>Configuração explícita de timeouts de socket, conexão e leitura.</li>
* <li>Gerenciamento de conexões com {@link PoolingHttpClientConnectionManager}.
* Define limites de conexões totais e por rota, além de política de
* expurgo de conexões ociosas.</li>
* <li>Suporte a TLS 1.2 e 1.3 a partir de um {@link SslBundle} nomeado.</li>
* <li>Criação de {@link WebServiceClient} contendo o {@link RestClient}
* e os recursos "fecháveis" para encerramento adequado.</li>
* <li>Resultado de criação cacheado por nome do bundle SSL via {@link Cacheable}.</li>
* </ul>
*
* <p>Thread-safety: Esta classe é um {@link Component} Spring com dependências imutáveis e
* não mantém estado mutável; pode ser utilizada de forma segura entre múltiplas threads.</p>
*/
@Component
public class RestClientFactory {
/**
* Timeout de socket (inatividade na camada de leitura/escrita) utilizado pelo {@link SocketConfig}.
* Valor padrão: 60 segundos.
*/
public static final Timeout SO_TIMEOUT = Timeout.ofSeconds(60);
/**
* Timeout máximo aguardando uma conexão do pool para o {@link RequestConfig}.
* Valor padrão: 10 segundos.
*/
public static final Timeout REQUEST_CONFIG_CONNECTION_REQUEST_TIMEOUT = Timeout.ofSeconds(10);
/**
* Timeout de resposta do request no {@link RequestConfig} (tempo total aguardando a resposta).
* Valor padrão: 60 segundos.
*/
public static final Timeout REQUEST_CONFIG_RESPONSE_TIMEOUT = Timeout.ofSeconds(60);
/**
* Timeout de conexão (handshake TCP) aplicado na request factory do Spring.
* Valor padrão: 5 segundos.
*/
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
/**
* Timeout para aguardar uma conexão disponível no pool aplicado na request factory do Spring.
* Valor padrão: 10 segundos.
*/
private static final Duration CONNECTION_REQUEST_TIMEOUT = Duration.ofSeconds(10);
/**
* Timeout de leitura (entre bytes) aplicado na request factory do Spring.
* Valor padrão: 60 segundos.
*/
private static final Duration READ_TIMEOUT = Duration.ofSeconds(60);
/**
* Número máximo de conexões totais no pool do HttpClient.
* Valor padrão: 100.
*/
public static final int MAX_CONN_TOTAL = 20;
/**
* Número máximo de conexões por rota (host) no pool do HttpClient.
* Valor padrão: 20.
*/
public static final int MAX_CONN_PER_ROUTE = 5;
/**
* Configuração de socket padrão utilizada pelo pool de conexões.
* Ativa TCP no-delay, mantém keep-alive e reuso de endereço, além do {@link #SO_TIMEOUT}.
*/
public static final SocketConfig SOCKET_CONFIG = SocketConfig.custom()
.setTcpNoDelay(true)
.setSoTimeout(SO_TIMEOUT)
.setSoReuseAddress(true)
.setSoKeepAlive(true)
.build();
/**
* Configuração de request padrão aplicada ao {@link CloseableHttpClient}.
* Inclui timeouts de espera por conexão do pool e de resposta.
*/
public static final RequestConfig REQUEST_CONFIG = RequestConfig.custom()
.setConnectionRequestTimeout(REQUEST_CONFIG_CONNECTION_REQUEST_TIMEOUT)
.setResponseTimeout(REQUEST_CONFIG_RESPONSE_TIMEOUT)
.build();
/** Builder do Spring para criação de {@link RestClient}. */
private final RestClient.Builder restClientBuilder;
/** Registro de bundles SSL configurados na aplicação. */
private final RegisterBundle registerBundle;
/**
* Cria a fábrica a partir do builder de {@link RestClient} e do registro de {@link SslBundles}.
*
* @param restClientBuilder builder base para configuração do RestClient
* @param registerBundle registro de bundles SSL (certificados/chaves) nomeados
*/
public RestClientFactory(RestClient.Builder restClientBuilder, RegisterBundle registerBundle, SslBundles sslBundles) {
this.restClientBuilder = restClientBuilder;
this.registerBundle = registerBundle;
}
/**
* Cria um {@link WebServiceClient} configurado para um bundle SSL específico.
* O resultado é armazenado em cache com a chave igual ao nome do bundle, evitando recriação
* de recursos dispendiosos como o pool de conexões e o HttpClient.
*
* @param bundleName nome do {@link SslBundle} a ser utilizado (deve existir em {@link SslBundles})
* @return instância de {@link WebServiceClient} com {@link RestClient} e os recursos fecháveis
*/
@Cacheable(value = "sefaz", key = "#bundleName")
public WebServiceClient create(String bundleName) {
CloseableResources closeableResources = closeableResources(bundleName);
RestClient restClient = restClientBuilder
.apply(fromHttpClient(closeableResources.httpClient()))
.build();
return new WebServiceClient(restClient, closeableResources);
}
/**
* Adapta um {@link CloseableHttpClient} para o {@link RestClient.Builder} do Spring,
* configurando a {@link HttpComponentsClientHttpRequestFactory} com os timeouts
* {@link #CONNECT_TIMEOUT}, {@link #CONNECTION_REQUEST_TIMEOUT} e {@link #READ_TIMEOUT}.
*
* @param httpClient cliente HTTP já configurado
* @return função que aplica a request factory ao builder do RestClient
*/
public Consumer<RestClient.Builder> fromHttpClient(CloseableHttpClient httpClient) {
return (builder) -> {
HttpComponentsClientHttpRequestFactory rf = new HttpComponentsClientHttpRequestFactory(httpClient);
rf.setConnectTimeout(CONNECT_TIMEOUT);
rf.setConnectionRequestTimeout(CONNECTION_REQUEST_TIMEOUT);
rf.setReadTimeout(READ_TIMEOUT);
builder.requestFactory(rf);
};
}
/**
* Cria os recursos fecháveis necessários para execução de chamadas HTTP com TLS.
* Isso inclui:
* <ul>
* <li>Construção de uma estratégia TLS utilizando o {@link SslBundle} informado
* (protocolos suportados: TLSv1.2 e TLSv1.3, verificação de host desabilitada via {@link NoopHostnameVerifier}).</li>
* <li>Criação de um {@link PoolingHttpClientConnectionManager} com {@link #SOCKET_CONFIG},
* limites de conexões ({@link #MAX_CONN_TOTAL}, {@link #MAX_CONN_PER_ROUTE}) e estratégia TLS.</li>
* <li>Criação de um {@link CloseableHttpClient} que usa o connection manager,
* aplica {@link #REQUEST_CONFIG}.</li>
* </ul>
*
* @param bundleName nome do {@link SslBundle} carregado a partir de {@link SslBundles}
* @return contêiner com o {@link CloseableHttpClient} e o {@link PoolingHttpClientConnectionManager}
* para permitir fechamento explícito quando não forem mais necessários
* @throws IllegalArgumentException se o bundle não existir em {@link SslBundles}
*/
public CloseableResources closeableResources(String bundleName) {
SslBundle bundle = registerBundle.getSslBundle(bundleName);
DefaultClientTlsStrategy tlsStrategy = new DefaultClientTlsStrategy(
bundle.createSslContext(),
new String[]{"TLSv1.2", "TLSv1.3"},
null,
SSLBufferMode.DYNAMIC,
NoopHostnameVerifier.INSTANCE);
PoolingHttpClientConnectionManager connectionManager =
PoolingHttpClientConnectionManagerBuilder.create()
.setTlsSocketStrategy(tlsStrategy)
.setPoolConcurrencyPolicy(PoolConcurrencyPolicy.STRICT)
.setConnPoolPolicy(PoolReusePolicy.FIFO)
.setDefaultSocketConfig(SOCKET_CONFIG)
.setMaxConnTotal(MAX_CONN_TOTAL)
.setMaxConnPerRoute(MAX_CONN_PER_ROUTE)
.build();
CloseableHttpClient httpClient = HttpClients.custom()
.setConnectionManager(connectionManager)
.setDefaultRequestConfig(REQUEST_CONFIG)
.setConnectionManagerShared(true)
.build();
return new CloseableResources(httpClient, connectionManager);
}
}
import org.springframework.stereotype.Component;
@Component
public class ConsultaProtocolo extends AbstractWebService {
protected ConsultaProtocolo(RestClientFactory restClientBuilder) {
super(restClientBuilder);
}
@Override
protected String resolverBundleName() {
return "test";
}
}
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.Closeable;
import java.io.IOException;
public record CloseableResources(CloseableHttpClient httpClient, PoolingHttpClientConnectionManager connectionManager) implements Closeable {
private static final Logger log = LoggerFactory.getLogger(CloseableResources.class);
@Override
public void close() throws IOException {
if (httpClient != null) {
log.debug("Closing http client");
httpClient.close();
}
if (connectionManager != null) {
log.debug("Closing connection manager");
connectionManager.close();
}
}
}
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestClient;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public abstract class AbstractWebService implements WebService {
private static final ExecutorService VIRTUAL_THREAD_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor();
private static final Pattern SOAP_BODY_PATTERN = Pattern.compile(
"<(?:\\w+:)?Body[^>]*>(.*?)</(?:\\w+:)?Body>",
Pattern.DOTALL | Pattern.CASE_INSENSITIVE
);
private final RestClientFactory restClientBuilder;
protected AbstractWebService(RestClientFactory restClientBuilder) {
this.restClientBuilder = restClientBuilder;
}
public static String wrapWithSoapEnvelope(String xmlBody) {
return """
<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<soap:Body>
%s
</soap:Body>
</soap:Envelope>""".formatted(xmlBody.trim());
}
public static String unwrapSoapEnvelope(String soapResponse) {
if (soapResponse == null || soapResponse.isBlank()) return "";
Matcher matcher = SOAP_BODY_PATTERN.matcher(soapResponse);
if (matcher.find()) {
return matcher.group(1).trim();
}
return soapResponse.trim();
}
@Override
public CompletableFuture<ResponseEntity<String>> post(String uri, String body) {
return CompletableFuture.supplyAsync(() -> {
WebServiceClient webServiceClient = restClientBuilder.create(resolverBundleName());
return webServiceClient.restClient()
.post()
.uri(uri)
.body(body)
.contentType(MediaType.parseMediaType("application/soap+xml"))
.accept(MediaType.parseMediaType("application/soap+xml"))
.retrieve()
.toEntity(String.class);
}, VIRTUAL_THREAD_EXECUTOR);
}
@Override
public CompletableFuture<ResponseEntity<String>> get(String uri) {
RestClient restClient = restClientBuilder.create(resolverBundleName()).restClient();
return CompletableFuture.supplyAsync(() -> restClient
.get()
.uri(uri)
.retrieve()
.toEntity(String.class), VIRTUAL_THREAD_EXECUTOR);
}
public void test() {
restClientBuilder.create(resolverBundleName()).restClient();
}
protected abstract String resolverBundleName();
}
import com.github.benmanes.caffeine.cache.Caffeine;
import io.github.robsonkades.sefaz.newwb.WebServiceClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.io.IOException;
import java.time.Duration;
@Configuration
@EnableCaching
public class CacheConfig {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.registerCustomCache("sefaz",
Caffeine.newBuilder()
.recordStats()
.maximumSize(1000)
.softValues()
.expireAfterWrite(Duration.ofMinutes(5))
.evictionListener((key, value, cause) -> {
logger.info("Eviction of cache {} has been cleared", key);
})
.removalListener((key, value, cause) -> {
String safeValue = (value != null ? value.getClass().getName() : "null");
logger.info("Removed key: {}, valueType: {}, cause: {}", key, safeValue, cause);
if (value instanceof WebServiceClient webServiceClient) {
try {
webServiceClient.closeableResources().close();
} catch (IOException ignored) {
}
}
}).build());
// --- Cache 2: Para outra funcionalidade (ex: dados de usuários) ---
// Política: Tamanho máximo de 500, expira 10 minutos após a escrita (bom para dados que mudam com frequência).
String usuariosSpec = "maximumSize=500,expireAfterWrite=600s";
Caffeine<Object, Object> keystore = Caffeine.from(usuariosSpec);
cacheManager.registerCustomCache("keystore", keystore.build());
// --- Cache 3: Um cache com configuração padrão do Caffeine ---
// Se você não especificar uma spec, ele usará as configurações default.
// É útil ter um cache genérico.
cacheManager.registerCustomCache("truststore", Caffeine.newBuilder().build());
// Opcional: Se você quiser que o CacheManager conheça os nomes dos caches
// de antemão, você pode usar setCacheNames. Isso pode ser útil para algumas
// implementações, mas registerCustomCache já é suficiente.
// cacheManager.setCacheNames(List.of("sefazClients", "dadosDeUsuarios", "cacheGenerico"));
return cacheManager;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment