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}