es.iti.wakamiti.api.util.http.HttpClient Maven / Gradle / Ivy
The newest version!
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package es.iti.wakamiti.api.util.http;
import com.fasterxml.jackson.databind.JsonNode;
import es.iti.wakamiti.api.WakamitiAPI;
import es.iti.wakamiti.api.WakamitiException;
import es.iti.wakamiti.api.util.JsonUtils;
import es.iti.wakamiti.api.util.WakamitiLogger;
import org.apache.commons.lang3.SerializationUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import static es.iti.wakamiti.api.util.JsonUtils.json;
import static es.iti.wakamiti.api.util.MapUtils.entry;
import static es.iti.wakamiti.api.util.MapUtils.map;
import static es.iti.wakamiti.api.util.PathUtil.encodeURI;
import static es.iti.wakamiti.api.util.StringUtils.format;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.join;
import static org.apache.commons.text.StringEscapeUtils.escapeEcmaScript;
public abstract class HttpClient> implements HttpClientInterface {
private static final long serialVersionUID = 674371982367L;
private static final Logger LOGGER = WakamitiLogger.forClass(WakamitiAPI.class);
/**
* Retry on all exceptions that inherits from IOException:
*
* - {@link java.net.http.HttpTimeoutException}
* - {@link java.net.http.HttpConnectTimeoutException}
* - {@link java.nio.channels.ClosedChannelException}
*
*/
private static final Predicate DEFAULT_RETRY_ON_THROWABLE =
ex -> ex instanceof IOException;
/**
* A default number of maximum retries on both types on-response and on-throwable
*/
private static final int DEFAULT_MAX_ATTEMPTS = 5;
/**
* When a retry on-response exceeded, then throw an exception by default.
*/
private static final boolean DEFAULT_THROW_WHEN_RETRY_ON_RESPONSE_EXCEEDED = true;
private static final Map.Entry HTTP_VERSION =
entry(java.net.http.HttpClient.Version.HTTP_2, "HTTP/2");
private static ExecutorService executor = executor();
private static final java.net.http.HttpClient.Builder CLIENT = java.net.http.HttpClient.newBuilder()
.executor(executor)
.version(HTTP_VERSION.getKey())
.followRedirects(java.net.http.HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(20));
protected final Map finalQueryParams = new LinkedHashMap<>();
protected final Map finalPathParams = new LinkedHashMap<>();
protected final Map finalHeaders = new LinkedHashMap<>();
protected final Map queryParams = new LinkedHashMap<>();
protected final Map pathParams = new HashMap<>();
protected final Map headers = new LinkedHashMap<>();
private final URL baseUrl;
protected transient JsonNode body;
private final AtomicInteger attempts = new AtomicInteger(DEFAULT_MAX_ATTEMPTS);
private transient Consumer>> postCall = response -> {
};
protected HttpClient(URL baseUrl) {
this.baseUrl = baseUrl;
finalHeaders.putAll(map("Content-Type", "application/json", "Accept", "application/json"));
}
private static void renewExecutor() {
if (executor.isShutdown()) {
executor = executor();
CLIENT.executor(executor);
}
}
private static ExecutorService executor() {
return Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 10);
}
public SELF postCall(Consumer>> postCall) {
this.postCall = postCall;
return self();
}
@Override
public SELF queryParam(String name, Object value) {
queryParams.put(name, value);
return self();
}
@Override
public SELF pathParam(String name, Object value) {
pathParams.put(name, value);
return self();
}
@Override
public SELF header(String name, Object value) {
headers.put(name, value);
return self();
}
public SELF basicAuth(String username, String password) {
finalHeaders.put(AUTHORIZATION, "Basic " +
Base64.getEncoder().encodeToString((username + ":" + password).getBytes()));
return self();
}
public SELF bearerAuth(String token) {
finalHeaders.put(AUTHORIZATION, "Bearer " + token);
return self();
}
@Override
public SELF body(String body) {
this.body = Optional.ofNullable(body).filter(StringUtils::isNotBlank).map(JsonUtils::json).orElse(null);
return self();
}
private HttpRequest buildRequest(String method, String path) {
finalPathParams.forEach(pathParams::putIfAbsent);
finalQueryParams.forEach(queryParams::putIfAbsent);
finalHeaders.forEach(headers::putIfAbsent);
URI uri = uri(path);
if (!queryParams.isEmpty()) {
uri = URI.create(uri.toASCIIString() + "?" + join(queryParams.entrySet(), "&"));
}
HttpRequest.Builder builder = HttpRequest.newBuilder(uri)
.method(method,
Optional.ofNullable(body)
.map(JsonNode::toString)
.map(HttpRequest.BodyPublishers::ofString)
.orElse(HttpRequest.BodyPublishers.noBody()));
headers.forEach((k, v) -> builder.header(k, Objects.toString(v)));
return builder.build();
}
private URI uri(String path) {
try {
path = format(path, pathParams);
String encoded = encodeURI(path.startsWith("/") ? path : "/" + path);
return URI.create(baseUrl.toString().replaceAll("/$", "") + encoded);
} catch (NoSuchFieldException e) {
throw new WakamitiException("Cannot determine uri for path: {}", path, e);
}
}
protected HttpRequest buildPost(String uri) {
return buildRequest("POST", uri);
}
protected HttpRequest buildGet(String uri) {
return buildRequest("GET", uri);
}
protected HttpRequest buildPut(String uri) {
return buildRequest("PUT", uri);
}
protected HttpRequest buildPatch(String uri) {
return buildRequest("PATCH", uri);
}
protected HttpRequest buildDelete(String uri) {
return buildRequest("DELETE", uri);
}
protected HttpRequest buildOptions(String uri) {
return buildRequest("OPTIONS", uri);
}
protected HttpRequest buildHead(String uri) {
return buildRequest("HEAD", uri);
}
protected HttpRequest buildContent(String uri) {
return buildRequest("CONTENT", uri);
}
protected HttpRequest buildTrace(String uri) {
return buildRequest("TRACE", uri);
}
@Override
public HttpResponse> post(String uri) {
return send(buildPost(uri));
}
@Override
public HttpResponse> get(String uri) {
return send(buildGet(uri));
}
@Override
public HttpResponse> put(String uri) {
return send(buildPut(uri));
}
@Override
public HttpResponse> patch(String uri) {
return send(buildPatch(uri));
}
@Override
public HttpResponse> delete(String uri) {
return send(buildDelete(uri));
}
@Override
public HttpResponse> options(String uri) {
return send(buildOptions(uri));
}
@Override
public HttpResponse> head(String uri) {
return send(buildHead(uri));
}
@Override
public HttpResponse> content(String uri) {
return send(buildContent(uri));
}
@Override
public HttpResponse> trace(String uri) {
return send(buildTrace(uri));
}
protected HttpResponse> send(HttpRequest request) {
try {
if (LOGGER.isTraceEnabled()) {
LOGGER.trace("HTTP call => {} ", stringify(request));
}
renewExecutor();
HttpResponse> response = CLIENT.build().send(request, asJSON());
if (LOGGER.isTraceEnabled()) {
LOGGER.trace("HTTP response => {}", stringify(response));
}
postCall.accept(response);
return response;
} catch (IOException e) {
throw new WakamitiException(e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new WakamitiException(e);
}
}
@Override
public CompletableFuture>> postAsync(String uri) {
return sendAsync(buildPost(uri));
}
@Override
public CompletableFuture>> getAsync(String uri) {
return sendAsync(buildGet(uri));
}
@Override
public CompletableFuture>> putAsync(String uri) {
return sendAsync(buildPut(uri));
}
@Override
public CompletableFuture>> patchAsync(String uri) {
return sendAsync(buildPatch(uri));
}
@Override
public CompletableFuture>> deleteAsync(String uri) {
return sendAsync(buildDelete(uri));
}
@Override
public CompletableFuture>> optionsAsync(String uri) {
return sendAsync(buildOptions(uri));
}
@Override
public CompletableFuture>> headAsync(String uri) {
return sendAsync(buildHead(uri));
}
@Override
public CompletableFuture>> contentAsync(String uri) {
return sendAsync(buildContent(uri));
}
@Override
public CompletableFuture>> traceAsync(String uri) {
return sendAsync(buildTrace(uri));
}
private CompletableFuture>> sendAsync(HttpRequest request) {
renewExecutor();
attempts.incrementAndGet();
return CLIENT.build()
.sendAsync(request, asJSON())
.thenApply(response -> {
if (LOGGER.isTraceEnabled()) {
LOGGER.trace("HTTP call => {} {}HTTP response => {} ", stringify(response.request()),
System.lineSeparator() + System.lineSeparator(), stringify(response));
}
return response;
})
.thenApply(response -> {
if (response.statusCode() >= 500) {
return attemptRetry(response, null);
} else {
return CompletableFuture.completedFuture(response);
}
})
.exceptionally(ex -> {
// All internal exceptions are wrapped by `CompletionException`
if (DEFAULT_RETRY_ON_THROWABLE.test(ex.getCause())) {
return attemptRetry(null, ex);
} else {
return CompletableFuture.failedFuture(ex);
}
})
.thenCompose(Function.identity())
.thenApply(response -> {
postCall.accept(response);
return response;
});
}
/**
* It tries to invoke the request again if there is any remaining attempt, or handle the situation
* when a threshold of maximum attempts was exceeded.
*
* @param response a failed response or NULL.
* @param throwable a thrown exception or NULL.
* @return a new completable future with a next attempt, or a failed response/exception in a case
* of exceeded attempts.
*/
private CompletableFuture>> attemptRetry(
HttpResponse> response, Throwable throwable) {
if (attempts.get() < DEFAULT_MAX_ATTEMPTS) {
LOGGER.warn("Retrying: attempt={} path={}", attempts.get() + 1, response.request().uri());
return CompletableFuture.supplyAsync(() -> sendAsync(response.request()), executor)
.thenCompose(Function.identity());
} else {
return handleRetryExceeded(response, throwable);
}
}
/**
* Defines the handler for an exceeded retry attempts. If the last attempt failed because of
* an exception then throw it immediately. However, if the attempt failed on a regular response and
* status code, them there are two possible behaviors based on the property {@link #DEFAULT_THROW_WHEN_RETRY_ON_RESPONSE_EXCEEDED }.
*
* - TRUE when {@link #DEFAULT_MAX_ATTEMPTS} is exceeded then an exception is thrown
* - FALSE when {@link #DEFAULT_MAX_ATTEMPTS} is exceeded then the latest {@link HttpResponse}
* is returned
*
*
* @param response the very latest response object
* @return a new completable future with a completed or failed state
* depending on {@link #DEFAULT_THROW_WHEN_RETRY_ON_RESPONSE_EXCEEDED }
*/
private CompletableFuture>> handleRetryExceeded(
HttpResponse> response, Throwable throwable) {
if (throwable != null || DEFAULT_THROW_WHEN_RETRY_ON_RESPONSE_EXCEEDED) {
Throwable ex = throwable == null
? new RuntimeException("Retries exceeded: status-code=" + response.statusCode())
: throwable;
return CompletableFuture.failedFuture(ex);
} else {
return CompletableFuture.completedFuture(response);
}
}
private String stringify(HttpRequest request) {
return System.lineSeparator() +
"Request method:\t" + request.method() + System.lineSeparator() +
"Request URI:\t" + request.uri() + System.lineSeparator() +
"Query params:\t" + stringify(queryParams) + System.lineSeparator() +
"Path params:\t" + stringify(pathParams) + System.lineSeparator() +
"Headers:\t\t" + stringify(headers) + System.lineSeparator() +
"Body:\t\t\t" +
Optional.ofNullable(body).map(JsonNode::toPrettyString)
.map(j -> System.lineSeparator() + j)
.orElse("");
}
private String stringify(Map params) {
if (params.isEmpty()) {
return "";
} else {
return join(params.entrySet(), System.lineSeparator() + "\t\t\t\t");
}
}
private String stringify(HttpResponse> response) {
return System.lineSeparator() +
HTTP_VERSION.getValue() + " " + response.statusCode() + System.lineSeparator() +
response.headers().map().entrySet().stream()
.map(e -> e.getKey() + ": " + join(e.getValue(), "; "))
.collect(Collectors.joining(System.lineSeparator())) +
response.body().map(JsonNode::toPrettyString)
.map(str -> System.lineSeparator() + System.lineSeparator() + str).orElse("");
}
protected SELF newRequest() {
this.body = null;
this.queryParams.clear();
this.pathParams.clear();
this.headers.clear();
return copy();
}
private HttpResponse.BodyHandler> asJSON() {
return response -> HttpResponse.BodySubscribers.mapping(
HttpResponse.BodySubscribers.ofString(StandardCharsets.UTF_8), str -> {
try {
return Optional.of(json(str));
} catch (Exception e) {
if (!isBlank(str)) {
return Optional.of(json("{\"message\":\"" + escapeEcmaScript(str) + "\"}"));
}
return Optional.empty();
}
});
}
public SELF copy() {
SELF clone = SerializationUtils.clone(self()).postCall(postCall);
Optional.ofNullable(body).map(Objects::toString).ifPresent(clone::body);
return clone;
}
public void close() {
executor.shutdown();
}
}
© 2015 - 2024 Weber Informatics LLC | Privacy Policy