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}