001package co.codewizards.cloudstore.rest.client; 002 003import static co.codewizards.cloudstore.core.util.ExceptionUtil.*; 004import static co.codewizards.cloudstore.core.util.Util.*; 005import static java.util.Objects.*; 006 007import java.net.MalformedURLException; 008import java.net.URL; 009import java.util.ArrayList; 010import java.util.Arrays; 011import java.util.LinkedList; 012import java.util.List; 013 014import javax.ws.rs.WebApplicationException; 015import javax.ws.rs.client.Client; 016import javax.ws.rs.client.ClientBuilder; 017import javax.ws.rs.client.Invocation; 018import javax.ws.rs.client.ResponseProcessingException; 019import javax.ws.rs.core.MediaType; 020import javax.ws.rs.core.Response; 021 022import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature; 023import org.slf4j.Logger; 024import org.slf4j.LoggerFactory; 025 026import co.codewizards.cloudstore.core.dto.Error; 027import co.codewizards.cloudstore.core.dto.RemoteException; 028import co.codewizards.cloudstore.core.dto.RemoteExceptionUtil; 029import co.codewizards.cloudstore.core.exception.ApplicationException; 030import co.codewizards.cloudstore.core.util.ExceptionUtil; 031import co.codewizards.cloudstore.rest.client.request.Request; 032import co.codewizards.cloudstore.rest.client.ssl.CallbackDeniedTrustException; 033 034/** 035 * Client for executing REST requests. 036 * <p> 037 * An instance of this class is used to send data to, query data from or execute logic on the server. 038 * <p> 039 * If a series of multiple requests is to be sent to the server, it is recommended to keep an instance of 040 * this class (because it caches resources) and invoke multiple requests with it. 041 * <p> 042 * This class is thread-safe. 043 * @author Marco หงุ่ยตระกูล-Schulze - marco at codewizards dot co 044 */ 045public class CloudStoreRestClient { 046 047 private static final Logger logger = LoggerFactory.getLogger(CloudStoreRestClient.class); 048 049 private final URL url; 050 private String baseURL; 051 052 private final LinkedList<Client> clientCache = new LinkedList<Client>(); 053 054 private ClientBuilder clientBuilder; 055 056 private CredentialsProvider credentialsProvider; 057 058 /** 059 * Get the server's base-URL. 060 * <p> 061 * This base-URL is the base of the <code>CloudStoreREST</code> application. Hence all URLs 062 * beneath this base-URL are processed by the <code>CloudStoreREST</code> application. 063 * <p> 064 * In other words: All repository-names are located directly beneath this base-URL. The special services, too, 065 * are located directly beneath this base-URL. 066 * <p> 067 * For example, if the server's base-URL is "https://host.domain:8443/", then the test-service is 068 * available via "https://host.domain:8443/_test" and the repository with the alias "myrepo" is 069 * "https://host.domain:8443/myrepo". 070 * @return the base-URL. This URL always ends with "/". 071 */ 072 public synchronized String getBaseUrl() { 073 if (baseURL == null) { 074 determineBaseUrl(); 075 } 076 return baseURL; 077 } 078 079 /** 080 * Create a new client. 081 * @param url any URL to the server. Must not be <code>null</code>. 082 * May be the base-URL, any repository's remote-root-URL or any URL within a remote-root-URL. 083 * The base-URL is automatically determined by cutting sub-paths, step by step. 084 */ 085 public CloudStoreRestClient(final URL url, final ClientBuilder clientBuilder) { 086 this.url = requireNonNull(url, "url"); 087 this.clientBuilder = requireNonNull(clientBuilder, "clientBuilder"); 088 } 089 090 /** 091 * Create a new client. 092 * @param url any URL to the server. Must not be <code>null</code>. 093 * May be the base-URL, any repository's remote-root-URL or any URL within a remote-root-URL. 094 * The base-URL is automatically determined by cutting sub-paths, step by step. 095 */ 096 public CloudStoreRestClient(final String url, final ClientBuilder clientBuilder) { 097 try{ 098 this.url = requireNonNull(new URL(url), "url"); 099 } catch (MalformedURLException e){ 100 throw new IllegalStateException("url is invalid", e); 101 } 102 this.clientBuilder = requireNonNull(clientBuilder, "clientBuilder"); 103 } 104 105 private void determineBaseUrl() { 106 acquireClient(); 107 try { 108 final Client client = getClientOrFail(); 109 String url = getHostUrl(); 110 for(String part : getPathParts()){ 111 if(!part.isEmpty()) // part is always empty in first iteration 112 url += part + "/"; 113 final String testUrl = url + "_test"; 114 try { 115 final String response = client.target(testUrl).request(MediaType.TEXT_PLAIN).get(String.class); 116 if ("SUCCESS".equals(response)) { 117 baseURL = url; 118 break; 119 } 120 } catch (final WebApplicationException wax) { 121 doNothing(); 122 } 123 } 124 125 if (baseURL == null) 126 throw new IllegalStateException("baseURL not found!"); 127 } finally { 128 releaseClient(); 129 } 130 } 131 132 private List<String> getPathParts(){ 133 List<String> pathParts = new ArrayList<String>(Arrays.asList(url.getPath().split("/"))); 134 if(pathParts.isEmpty()){ 135 pathParts.add(""); 136 } 137 return pathParts; 138 } 139 140 private String getHostUrl(){ 141 String hostUrl = url.getProtocol() + "://" + url.getHost(); 142 if(url.getPort() != -1){ 143 hostUrl += ":" + url.getPort(); 144 } 145 return hostUrl + "/"; 146 } 147 148 public <R> R execute(final Request<R> request) { 149 requireNonNull(request, "request"); 150 Throwable firstException = null; 151 int retryCounter = 0; // *re*-try: first (normal) invocation is 0, first re-try is 1 152 final int retryMax = 2; // *re*-try: 2 retries means 3 invocations in total 153 while (true) { 154 acquireClient(); 155 try { 156 final long start = System.currentTimeMillis(); 157 158 if (logger.isDebugEnabled()) 159 logger.debug("execute: starting try {} of {}", retryCounter + 1, retryMax + 1); 160 161 try { 162 request.setCloudStoreRestClient(this); 163 final R result = request.execute(); 164 165 if (logger.isDebugEnabled()) 166 logger.debug("execute: invocation took {} ms", System.currentTimeMillis() - start); 167 168 if (result == null && !request.isResultNullable()) 169 throw new IllegalStateException("result == null, but request.resultNullable == false!"); 170 171 return result; 172 } catch (final RuntimeException e) { 173 Throwable exception; 174 try { 175 exception = handleAndRethrowException(e); 176 } catch (Throwable y) { 177 exception = y; 178 } 179 180 final Throwable applicationException = getApplicationException(exception); 181 if (applicationException != null) { // immediately rethrow => no retries! 182 if (applicationException == exception) 183 logger.info("Caught {} => immediately rethrowing.", exception.getClass().getName()); 184 else 185 logger.info("Caught {} wrapped in {} => immediately rethrowing.", applicationException.getClass().getName(), exception.getClass().getName()); 186 187 logger.debug("execute: " + exception, exception); 188 throw throwThrowableAsRuntimeExceptionIfNeeded(exception); 189 } 190 191 if (firstException == null) 192 firstException = exception; 193 194 markClientBroken(); // make sure we do not reuse this client 195 if (++retryCounter > retryMax || !retryExecuteAfterException(exception)) { 196 logger.warn("execute: invocation failed (will NOT retry): " + exception, exception); 197 throw handleAndRethrowException(firstException); // TODO maybe we should make a MultiCauseException?! 198 } 199 logger.warn("execute: invocation failed (will retry): " + exception, exception); 200 201 // Wait a bit before retrying (increasingly longer). 202 try { Thread.sleep(retryCounter * 1000L); } catch (Exception y) { doNothing(); } 203 } 204 } finally { 205 releaseClient(); 206 request.setCloudStoreRestClient(null); 207 } 208 } 209 } 210 211 private Throwable getApplicationException(final Throwable exception) { 212 requireNonNull(exception, "exception"); 213 214 Throwable x = exception; 215 while (x != null) { 216 final ApplicationException appEx = x.getClass().getAnnotation(ApplicationException.class); 217 if (appEx != null) 218 return x; 219 220 x = x.getCause(); 221 } 222 return null; 223 } 224 225 private boolean retryExecuteAfterException(final Throwable x) { 226 // If the user explicitly denied trust, we do not retry, because we don't want to ask the user 227 // multiple times. 228 if (ExceptionUtil.getCause(x, CallbackDeniedTrustException.class) != null) 229 return false; 230 231// final Class<?>[] exceptionClassesCausingRetry = new Class<?>[] { 232// SSLException.class, 233// SocketException.class 234// }; 235// for (final Class<?> exceptionClass : exceptionClassesCausingRetry) { 236// @SuppressWarnings("unchecked") 237// final Class<? extends Throwable> xc = (Class<? extends Throwable>) exceptionClass; 238// if (ExceptionUtil.getCause(x, xc) != null) { 239// logger.warn( 240// String.format("retryExecuteAfterException: Encountered %s and will retry.", xc.getSimpleName()), 241// x); 242// return true; 243// } 244// } 245// return false; 246 return true; 247 } 248 249 public Invocation.Builder assignCredentials(final Invocation.Builder builder) { 250 final CredentialsProvider credentialsProvider = getCredentialsProviderOrFail(); 251 builder.property(HttpAuthenticationFeature.HTTP_AUTHENTICATION_BASIC_USERNAME, credentialsProvider.getUserName()); 252 builder.property(HttpAuthenticationFeature.HTTP_AUTHENTICATION_BASIC_PASSWORD, credentialsProvider.getPassword()); 253 return builder; 254 } 255 256 private final ThreadLocal<ClientRef> clientThreadLocal = new ThreadLocal<ClientRef>(); 257 258 private static class ClientRef { 259 public final Client client; 260 public int refCount = 1; 261 public boolean broken; 262 263 public ClientRef(final Client client) { 264 this.client = requireNonNull(client, "client"); 265 } 266 } 267 268 /** 269 * Acquire a {@link Client} and bind it to the current thread. 270 * <p> 271 * <b>Important: You must {@linkplain #releaseClient() release} the client!</b> Use a try/finally block! 272 * @see #releaseClient() 273 * @see #getClientOrFail() 274 */ 275 private synchronized void acquireClient(){ 276 final ClientRef clientRef = clientThreadLocal.get(); 277 if (clientRef != null) { 278 ++clientRef.refCount; 279 return; 280 } 281 282 Client client = clientCache.poll(); 283 if (client == null) { 284 client = clientBuilder.build(); 285 286 // An authentication is always required. Otherwise Jersey throws an exception. 287 // Hence, we set it to "anonymous" here and set it to the real values for those 288 // requests really requiring it. 289 final HttpAuthenticationFeature feature = HttpAuthenticationFeature.basic("anonymous", ""); 290 client.register(feature); 291 } 292 clientThreadLocal.set(new ClientRef(client)); 293 } 294 295 /** 296 * Get the {@link Client} which was previously {@linkplain #acquireClient() acquired} (and not yet 297 * {@linkplain #releaseClient() released}) on the same thread. 298 * @return the {@link Client}. Never <code>null</code>. 299 * @throws IllegalStateException if there is no {@link Client} bound to the current thread. 300 * @see #acquireClient() 301 */ 302 public Client getClientOrFail() { 303 final ClientRef clientRef = clientThreadLocal.get(); 304 if (clientRef == null) 305 throw new IllegalStateException("acquireClient() not called on the same thread (or releaseClient() already called)!"); 306 307 return clientRef.client; 308 } 309 310 /** 311 * Release a {@link Client} which was previously {@linkplain #acquireClient() acquired}. 312 * @see #acquireClient() 313 */ 314 private synchronized void releaseClient() { 315 final ClientRef clientRef = clientThreadLocal.get(); 316 if (clientRef == null) 317 throw new IllegalStateException("acquireClient() not called on the same thread (or releaseClient() called more often than acquireClient())!"); 318 319 if (--clientRef.refCount == 0) { 320 clientThreadLocal.remove(); 321 322 if (!clientRef.broken) 323 clientCache.add(clientRef.client); 324 } 325 } 326 327 private void markClientBroken() { 328 final ClientRef clientRef = clientThreadLocal.get(); 329 if (clientRef == null) 330 throw new IllegalStateException("acquireClient() not called on the same thread (or releaseClient() called more often than acquireClient())!"); 331 332 clientRef.broken = true; 333 } 334 335 protected RuntimeException handleAndRethrowException(final Throwable x) 336 { 337 Response response = null; 338 if (x instanceof WebApplicationException) 339 response = ((WebApplicationException)x).getResponse(); 340 else if (x instanceof ResponseProcessingException) 341 response = ((ResponseProcessingException)x).getResponse(); 342 343 if (response == null) 344 throw throwThrowableAsRuntimeExceptionIfNeeded(x); 345 346 Error error = null; 347 try { 348 response.bufferEntity(); 349 if (response.hasEntity()) 350 error = response.readEntity(Error.class); 351 352// Commented this out due to log-pollution. We log the error a bit later, anyway. 353// if (error != null && DeferredCompletionException.class.getName().equals(error.getClassName())) 354 logger.trace("handleAndRethrowException: " + x, x); 355// else 356// logger.error("handleAndRethrowException: " + x, x); 357 358 } catch (final Exception y) { 359 logger.error("handleAndRethrowException: " + x, x); 360 logger.error("handleAndRethrowException: " + y, y); 361 } 362 363 if (error != null) { 364 RemoteExceptionUtil.throwOriginalExceptionIfPossible(error); 365 throw new RemoteException(error); 366 } 367 368 throw throwThrowableAsRuntimeExceptionIfNeeded(x); 369 } 370 371 public CredentialsProvider getCredentialsProvider() { 372 return credentialsProvider; 373 } 374 private CredentialsProvider getCredentialsProviderOrFail() { 375 final CredentialsProvider credentialsProvider = getCredentialsProvider(); 376 if (credentialsProvider == null) 377 throw new IllegalStateException("credentialsProvider == null"); 378 return credentialsProvider; 379 } 380 public void setCredentialsProvider(final CredentialsProvider credentialsProvider) { 381 this.credentialsProvider = credentialsProvider; 382 } 383}