package co.codewizards.cloudstore.rest.client; import static co.codewizards.cloudstore.core.util.AssertUtil.*; import static co.codewizards.cloudstore.core.util.Util.*; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedList; import java.util.List; import javax.ws.rs.WebApplicationException; import javax.ws.rs.client.Client; import javax.ws.rs.client.ClientBuilder; import javax.ws.rs.client.Invocation; import javax.ws.rs.client.ResponseProcessingException; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import co.codewizards.cloudstore.core.concurrent.DeferredCompletionException; import co.codewizards.cloudstore.core.dto.Error; import co.codewizards.cloudstore.core.dto.RemoteException; import co.codewizards.cloudstore.core.dto.RemoteExceptionUtil; import co.codewizards.cloudstore.core.util.ExceptionUtil; import co.codewizards.cloudstore.rest.client.request.Request; import co.codewizards.cloudstore.rest.client.ssl.CallbackDeniedTrustException; /** * Client for executing REST requests. * <p> * An instance of this class is used to send data to, query data from or execute logic on the server. * <p> * If a series of multiple requests is to be sent to the server, it is recommended to keep an instance of * this class (because it caches resources) and invoke multiple requests with it. * <p> * This class is thread-safe. * @author Marco หงุ่ยตระกูล-Schulze - marco at codewizards dot co */ public class CloudStoreRestClient { private static final Logger logger = LoggerFactory.getLogger(CloudStoreRestClient.class); private final URL url; private String baseURL; private final LinkedList<Client> clientCache = new LinkedList<Client>(); private ClientBuilder clientBuilder; private CredentialsProvider credentialsProvider; /** * Get the server's base-URL. * <p> * This base-URL is the base of the <code>CloudStoreREST</code> application. Hence all URLs * beneath this base-URL are processed by the <code>CloudStoreREST</code> application. * <p> * In other words: All repository-names are located directly beneath this base-URL. The special services, too, * are located directly beneath this base-URL. * <p> * For example, if the server's base-URL is "https://host.domain:8443/", then the test-service is * available via "https://host.domain:8443/_test" and the repository with the alias "myrepo" is * "https://host.domain:8443/myrepo". * @return the base-URL. This URL always ends with "/". */ public synchronized String getBaseUrl() { if (baseURL == null) { determineBaseUrl(); } return baseURL; } /** * Create a new client. * @param url any URL to the server. Must not be <code>null</code>. * May be the base-URL, any repository's remote-root-URL or any URL within a remote-root-URL. * The base-URL is automatically determined by cutting sub-paths, step by step. */ public CloudStoreRestClient(final URL url, final ClientBuilder clientBuilder) { this.url = assertNotNull(url, "url"); this.clientBuilder = assertNotNull(clientBuilder, "clientBuilder"); } /** * Create a new client. * @param url any URL to the server. Must not be <code>null</code>. * May be the base-URL, any repository's remote-root-URL or any URL within a remote-root-URL. * The base-URL is automatically determined by cutting sub-paths, step by step. */ public CloudStoreRestClient(final String url, final ClientBuilder clientBuilder) { try{ this.url = assertNotNull(new URL(url), "url"); } catch (MalformedURLException e){ throw new IllegalStateException("url is invalid", e); } this.clientBuilder = assertNotNull(clientBuilder, "clientBuilder"); } private void determineBaseUrl() { acquireClient(); try { final Client client = getClientOrFail(); String url = getHostUrl(); for(String part : getPathParts()){ if(!part.isEmpty()) // part is always empty in first iteration url += part + "/"; final String testUrl = url + "_test"; try { final String response = client.target(testUrl).request(MediaType.TEXT_PLAIN).get(String.class); if ("SUCCESS".equals(response)) { baseURL = url; break; } } catch (final WebApplicationException wax) { doNothing(); } } if (baseURL == null) throw new IllegalStateException("baseURL not found!"); } finally { releaseClient(); } } private List<String> getPathParts(){ List<String> pathParts = new ArrayList<String>(Arrays.asList(url.getPath().split("/"))); if(pathParts.isEmpty()){ pathParts.add(""); } return pathParts; } private String getHostUrl(){ String hostUrl = url.getProtocol() + "://" + url.getHost(); if(url.getPort() != -1){ hostUrl += ":" + url.getPort(); } return hostUrl + "/"; } public <R> R execute(final Request<R> request) { assertNotNull(request, "request"); RuntimeException firstException = null; int retryCounter = 0; // *re*-try: first (normal) invocation is 0, first re-try is 1 final int retryMax = 2; // *re*-try: 2 retries means 3 invocations in total while (true) { acquireClient(); try { final long start = System.currentTimeMillis(); if (logger.isDebugEnabled()) logger.debug("execute: starting try {} of {}", retryCounter + 1, retryMax + 1); try { request.setCloudStoreRestClient(this); final R result = request.execute(); if (logger.isDebugEnabled()) logger.debug("execute: invocation took {} ms", System.currentTimeMillis() - start); if (result == null && !request.isResultNullable()) throw new IllegalStateException("result == null, but request.resultNullable == false!"); return result; } catch (final RuntimeException x) { if (firstException == null) firstException = x; markClientBroken(); // make sure we do not reuse this client if (++retryCounter > retryMax || !retryExecuteAfterException(x)) { logger.warn("execute: invocation failed (will NOT retry): " + x, x); handleAndRethrowException(firstException); // TODO maybe we should make a MultiCauseException?! throw firstException; } logger.warn("execute: invocation failed (will retry): " + x, x); // Wait a bit before retrying (increasingly longer). try { Thread.sleep(retryCounter * 1000L); } catch (Exception y) { doNothing(); } } } finally { releaseClient(); request.setCloudStoreRestClient(null); } } } private boolean retryExecuteAfterException(final Exception x) { // If the user explicitly denied trust, we do not retry, because we don't want to ask the user // multiple times. if (ExceptionUtil.getCause(x, CallbackDeniedTrustException.class) != null) return false; // final Class<?>[] exceptionClassesCausingRetry = new Class<?>[] { // SSLException.class, // SocketException.class // }; // for (final Class<?> exceptionClass : exceptionClassesCausingRetry) { // @SuppressWarnings("unchecked") // final Class<? extends Throwable> xc = (Class<? extends Throwable>) exceptionClass; // if (ExceptionUtil.getCause(x, xc) != null) { // logger.warn( // String.format("retryExecuteAfterException: Encountered %s and will retry.", xc.getSimpleName()), // x); // return true; // } // } // return false; return true; } public Invocation.Builder assignCredentials(final Invocation.Builder builder) { final CredentialsProvider credentialsProvider = getCredentialsProviderOrFail(); builder.property(HttpAuthenticationFeature.HTTP_AUTHENTICATION_BASIC_USERNAME, credentialsProvider.getUserName()); builder.property(HttpAuthenticationFeature.HTTP_AUTHENTICATION_BASIC_PASSWORD, credentialsProvider.getPassword()); return builder; } private final ThreadLocal<ClientRef> clientThreadLocal = new ThreadLocal<ClientRef>(); private static class ClientRef { public final Client client; public int refCount = 1; public boolean broken; public ClientRef(final Client client) { this.client = assertNotNull(client, "client"); } } /** * Acquire a {@link Client} and bind it to the current thread. * <p> * <b>Important: You must {@linkplain #releaseClient() release} the client!</b> Use a try/finally block! * @see #releaseClient() * @see #getClientOrFail() */ private synchronized void acquireClient(){ final ClientRef clientRef = clientThreadLocal.get(); if (clientRef != null) { ++clientRef.refCount; return; } Client client = clientCache.poll(); if (client == null) { client = clientBuilder.build(); // An authentication is always required. Otherwise Jersey throws an exception. // Hence, we set it to "anonymous" here and set it to the real values for those // requests really requiring it. final HttpAuthenticationFeature feature = HttpAuthenticationFeature.basic("anonymous", ""); client.register(feature); } clientThreadLocal.set(new ClientRef(client)); } /** * Get the {@link Client} which was previously {@linkplain #acquireClient() acquired} (and not yet * {@linkplain #releaseClient() released}) on the same thread. * @return the {@link Client}. Never <code>null</code>. * @throws IllegalStateException if there is no {@link Client} bound to the current thread. * @see #acquireClient() */ public Client getClientOrFail() { final ClientRef clientRef = clientThreadLocal.get(); if (clientRef == null) throw new IllegalStateException("acquireClient() not called on the same thread (or releaseClient() already called)!"); return clientRef.client; } /** * Release a {@link Client} which was previously {@linkplain #acquireClient() acquired}. * @see #acquireClient() */ private synchronized void releaseClient() { final ClientRef clientRef = clientThreadLocal.get(); if (clientRef == null) throw new IllegalStateException("acquireClient() not called on the same thread (or releaseClient() called more often than acquireClient())!"); if (--clientRef.refCount == 0) { clientThreadLocal.remove(); if (!clientRef.broken) clientCache.add(clientRef.client); } } private void markClientBroken() { final ClientRef clientRef = clientThreadLocal.get(); if (clientRef == null) throw new IllegalStateException("acquireClient() not called on the same thread (or releaseClient() called more often than acquireClient())!"); clientRef.broken = true; } public void handleAndRethrowException(final RuntimeException x) { Response response = null; if (x instanceof WebApplicationException) response = ((WebApplicationException)x).getResponse(); else if (x instanceof ResponseProcessingException) response = ((ResponseProcessingException)x).getResponse(); if (response == null) throw x; Error error = null; try { response.bufferEntity(); if (response.hasEntity()) error = response.readEntity(Error.class); if (error != null && DeferredCompletionException.class.getName().equals(error.getClassName())) logger.debug("handleException: " + x, x); else logger.error("handleException: " + x, x); } catch (final Exception y) { logger.error("handleException: " + x, x); logger.error("handleException: " + y, y); } if (error != null) { RemoteExceptionUtil.throwOriginalExceptionIfPossible(error); throw new RemoteException(error); } throw x; } public CredentialsProvider getCredentialsProvider() { return credentialsProvider; } private CredentialsProvider getCredentialsProviderOrFail() { final CredentialsProvider credentialsProvider = getCredentialsProvider(); if (credentialsProvider == null) throw new IllegalStateException("credentialsProvider == null"); return credentialsProvider; } public void setCredentialsProvider(final CredentialsProvider credentialsProvider) { this.credentialsProvider = credentialsProvider; } }