ProjectAres/API/api/src/main/java/tc/oc/api/http/HttpClient.java

308 lines
13 KiB
Java

package tc.oc.api.http;
import java.io.IOException;
import java.io.OutputStream;
import java.lang.reflect.Type;
import java.time.Duration;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.logging.ConsoleHandler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import javax.annotation.Nullable;
import javax.inject.Inject;
import javax.inject.Singleton;
import com.google.api.client.http.AbstractHttpContent;
import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.HttpBackOffIOExceptionHandler;
import com.google.api.client.http.HttpContent;
import com.google.api.client.http.HttpMethods;
import com.google.api.client.http.HttpRequest;
import com.google.api.client.http.HttpRequestFactory;
import com.google.api.client.http.HttpResponse;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.util.ExponentialBackOff;
import com.google.common.base.Charsets;
import com.google.common.reflect.TypeToken;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import com.google.gson.Gson;
import com.google.gson.JsonParseException;
import tc.oc.api.connectable.Connectable;
import tc.oc.api.config.ApiConstants;
import tc.oc.api.exceptions.ApiException;
import tc.oc.api.exceptions.Conflict;
import tc.oc.api.exceptions.Forbidden;
import tc.oc.api.exceptions.NotFound;
import tc.oc.api.exceptions.UnprocessableEntity;
import tc.oc.api.message.types.Reply;
import tc.oc.api.serialization.JsonUtils;
import tc.oc.commons.core.concurrent.ExecutorUtils;
import tc.oc.commons.core.logging.Loggers;
import static com.google.common.base.Preconditions.checkNotNull;
@Singleton
public class HttpClient implements Connectable {
private static final Duration SHUTDOWN_TIMEOUT = Duration.ofSeconds(10);
protected final Logger logger;
private final HttpClientConfiguration config;
private final ListeningExecutorService executorService;
private final JsonUtils jsonUtils;
private final Gson gson;
private final HttpRequestFactory requestFactory;
@Inject HttpClient(Loggers loggers, HttpClientConfiguration config, JsonUtils jsonUtils, Gson gson) {
this.logger = loggers.get(getClass());
this.config = checkNotNull(config, "config");
this.jsonUtils = jsonUtils;
this.gson = gson;
ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("API HTTP Executor").build();
if (config.getThreads() > 0) {
this.executorService = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(config.getThreads()));
} else {
this.executorService = MoreExecutors.listeningDecorator(Executors.newCachedThreadPool(threadFactory));
}
this.requestFactory = this.createRequestFactory();
// By default the google http client is very noisy on http errors,
// spitting out a long stack trace every time one try of a request
// fails. Disabling the parent handlers (removes default console
// handling) and adding our own handler that will only show the short
// message makes the error output much more manageable.
// See HttpRequest#986 for the offending line and HttpTransport#81 for
// the logger definition (it's package private so we can't access it
// directly).
final Logger httpLogger = Logger.getLogger(HttpTransport.class.getName());
httpLogger.setUseParentHandlers(false);
httpLogger.addHandler(new ConsoleHandler() {
@Override
public void publish(LogRecord record) {
String message = record.getMessage();
if (record.getThrown() != null) message += ": " + record.getThrown().toString();
HttpClient.this.logger.log(record.getLevel(), message);
}
});
}
private HttpRequestFactory createRequestFactory() {
return new NetHttpTransport().createRequestFactory(request -> {
request.setConnectTimeout(HttpClient.this.config.getConnectTimeout());
request.setReadTimeout(HttpClient.this.config.getReadTimeout());
request.setNumberOfRetries(HttpClient.this.config.getRetries());
request.setIOExceptionHandler(new HttpBackOffIOExceptionHandler(new ExponentialBackOff.Builder().build()));
});
}
public String getBaseUrl() {
return this.config.getBaseUrl();
}
public ListenableFuture<?> get(String path, HttpOption... options) {
return get(path, (TypeToken) null, options);
}
public <T> ListenableFuture<T> get(String path, @Nullable Class<T> returnType, HttpOption...options) {
return request(HttpMethods.GET, path, null, returnType, options);
}
public ListenableFuture<?> get(String path, @Nullable Type returnType, HttpOption...options) {
return request(HttpMethods.GET, path, null, returnType, options);
}
public <T> ListenableFuture<T> get(String path, @Nullable TypeToken<T> returnType, HttpOption...options) {
return request(HttpMethods.GET, path, null, returnType, options);
}
public ListenableFuture<?> post(String path, @Nullable Object content, HttpOption...options) {
return post(path, content, (TypeToken) null, options);
}
public <T> ListenableFuture<T> post(String path, @Nullable Object content, @Nullable Class<T> returnType, HttpOption...options) {
return request(HttpMethods.POST, path, content, returnType, options);
}
public ListenableFuture<?> post(String path, @Nullable Object content, @Nullable Type returnType, HttpOption...options) {
return request(HttpMethods.POST, path, content, returnType, options);
}
public <T> ListenableFuture<T> post(String path, @Nullable Object content, @Nullable TypeToken<T> returnType, HttpOption...options) {
return request(HttpMethods.POST, path, content, returnType, options);
}
public ListenableFuture<?> put(String path, @Nullable Object content, HttpOption...options) {
return put(path, content, (TypeToken) null, options);
}
public <T> ListenableFuture<T> put(String path, @Nullable Object content, @Nullable Class<T> returnType, HttpOption...options) {
return request(HttpMethods.PUT, path, content, returnType, options);
}
public ListenableFuture<?> put(String path, @Nullable Object content, @Nullable Type returnType, HttpOption...options) {
return request(HttpMethods.PUT, path, content, returnType, options);
}
public <T> ListenableFuture<T> put(String path, @Nullable Object content, @Nullable TypeToken<T> returnType, HttpOption...options) {
return request(HttpMethods.PUT, path, content, returnType, options);
}
protected <T> ListenableFuture<T> request(String method, String path, @Nullable Object content, @Nullable Class<T> returnType, HttpOption...options) {
return request(method, path, content, returnType == null ? null : TypeToken.of(returnType), options);
}
protected ListenableFuture<?> request(String method, String path, @Nullable Object content, @Nullable Type returnType, HttpOption...options) {
return request(method, path, content, returnType == null ? null : TypeToken.of(returnType), options);
}
protected <T> ListenableFuture<T> request(String method, String path, @Nullable Object content, @Nullable TypeToken<T> returnType, HttpOption...options) {
// NOTE: Serialization must happen synchronously, because getter methods may not be thread-safe
final HttpContent httpContent = content == null ? null : new Content(gson.toJson(content));
GenericUrl url = new GenericUrl(this.getBaseUrl() + path);
HttpRequest request;
try {
request = requestFactory.buildRequest(method, url, httpContent).setThrowExceptionOnExecuteError(false);
} catch (IOException e) {
this.getLogger().log(Level.SEVERE, "Failed to build request to " + url.toString(), e);
return Futures.immediateFailedFuture(e);
}
request.getHeaders().set("X-OCN-Version", String.valueOf(ApiConstants.PROTOCOL_VERSION));
request.getHeaders().setAccept("application/json");
for(HttpOption option : options) {
switch(option) {
case INFINITE_RETRY:
request.setNumberOfRetries(Integer.MAX_VALUE);
break;
case NO_RETRY:
request.setNumberOfRetries(0);
break;
}
}
return this.executorService.submit(new RequestCallable<T>(request, returnType, options));
}
@Override
public void connect() throws IOException {
// Nothing to do
}
@Override
public void disconnect() throws IOException {
ExecutorUtils.shutdownImpatiently(executorService, logger, SHUTDOWN_TIMEOUT);
}
protected Logger getLogger() {
return logger;
}
private class Content extends AbstractHttpContent {
final String json;
protected Content(String json) {
super("application/json");
this.json = json;
}
@Override
public void writeTo(OutputStream out) throws IOException {
out.write(json.getBytes(Charsets.UTF_8));
}
}
private class RequestCallable<T> implements Callable<T> {
private final HttpRequest request;
private final @Nullable Content content;
private final TypeToken<T> returnType;
private StackTraceElement[] callSite;
public RequestCallable(HttpRequest request, @Nullable TypeToken<T> returnType, HttpOption...options) {
this.request = checkNotNull(request, "http request");
this.content = (Content) request.getContent();
this.returnType = returnType;
this.callSite = new Exception().getStackTrace();
}
@Override
public T call() throws Exception {
try {
if(logger.isLoggable(Level.FINE)) {
logger.fine("Request " + request.getRequestMethod() + " " + request.getUrl() +
(content == null ? " (no content)" : "\n" + content.json));
}
final HttpResponse response = this.request.execute();
final String json = response.parseAsString();
if(logger.isLoggable(Level.FINE)) {
logger.fine("Response " + response.getStatusCode() +
" " + response.getStatusMessage() +
"\n" + jsonUtils.prettify(json));
}
if(response.getStatusCode() < 300) {
// We need a 200 in order to return a document
if(returnType == null) return null;
try {
return gson.fromJson(json, returnType.getType());
} catch (JsonParseException e) {
throw new ApiException("Parsing of " + returnType + " failed at [" + jsonUtils.errorContext(json, returnType.getType()) + "]", e, callSite);
}
} else if(response.getStatusCode() < 400 && returnType == null) {
// 3XX is a successful response only if no response document is expected
return null;
} else {
Reply reply;
// Error response might be a Reply, even if that is not the return type
try {
reply = gson.fromJson(json, Reply.class);
} catch(JsonParseException ex) {
reply = null;
}
final String message;
if(reply != null && reply.error() != null) {
// If we have a Reply somehow, use the included error message
message = reply.error();
} else {
// Otherwise, make a generic message
message = "HTTP " + response.getStatusCode() + " " + response.getStatusMessage();
}
final ApiException apiException;
switch(response.getStatusCode()) {
case 403: apiException = new Forbidden(message, reply); break;
case 404: apiException = new NotFound(message, reply); break;
case 409: apiException = new Conflict(message, reply); break;
case 422: apiException = new UnprocessableEntity(message, reply); break;
default: apiException = new ApiException(message, reply); break;
}
throw apiException;
}
} catch(ApiException e) {
e.setCallSite(callSite);
throw e;
} catch (Throwable e) {
final String message = "Unhandled exception submitting request to " + this.request.getUrl();
logger.log(Level.SEVERE, message, e);
throw new ApiException(message, e, callSite);
}
}
}
}