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