package tc.oc.api.http; import java.io.IOException; import java.io.OutputStream; import java.lang.reflect.Type; import java.net.MalformedURLException; import java.net.URL; 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 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) { final GenericUrl url; try { url = new GenericUrl(new URL(config.getBaseUrl(), path)); } catch(MalformedURLException e) { throw new IllegalArgumentException(e.getMessage()); } // NOTE: Serialization must happen synchronously, because getter methods may not be thread-safe final HttpContent httpContent = content == null ? null : new Content(gson.toJson(content)); final 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); } } } }