001package co.codewizards.cloudstore.ls.rest.client;
002
003import static co.codewizards.cloudstore.core.util.Util.*;
004import static java.util.Objects.*;
005
006import java.util.LinkedList;
007import java.util.List;
008import java.util.concurrent.CopyOnWriteArrayList;
009
010import javax.ws.rs.WebApplicationException;
011import javax.ws.rs.client.Client;
012import javax.ws.rs.client.ClientBuilder;
013import javax.ws.rs.client.Invocation;
014import javax.ws.rs.client.ResponseProcessingException;
015import javax.ws.rs.core.Response;
016
017import org.glassfish.jersey.client.ClientConfig;
018import org.glassfish.jersey.client.ClientProperties;
019import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature;
020import org.slf4j.Logger;
021import org.slf4j.LoggerFactory;
022
023import co.codewizards.cloudstore.core.Uid;
024import co.codewizards.cloudstore.core.concurrent.DeferredCompletionException;
025import co.codewizards.cloudstore.core.config.Config;
026import co.codewizards.cloudstore.core.config.ConfigImpl;
027import co.codewizards.cloudstore.core.dto.Error;
028import co.codewizards.cloudstore.core.dto.RemoteException;
029import co.codewizards.cloudstore.core.dto.RemoteExceptionUtil;
030import co.codewizards.cloudstore.ls.core.LocalServerPropertiesManager;
031import co.codewizards.cloudstore.ls.core.provider.JavaNativeMessageBodyReader;
032import co.codewizards.cloudstore.ls.core.provider.JavaNativeMessageBodyWriter;
033import co.codewizards.cloudstore.ls.rest.client.request.Request;
034
035/**
036 * Client for executing REST requests.
037 * <p>
038 * An instance of this class is used to send data to, query data from or execute logic on the server.
039 * <p>
040 * If a series of multiple requests is to be sent to the server, it is recommended to keep an instance of
041 * this class (because it caches resources) and invoke multiple requests with it.
042 * <p>
043 * This class is thread-safe.
044 * @author Marco หงุ่ยตระกูล-Schulze - marco at codewizards dot co
045 */
046public class LocalServerRestClient {
047
048        private static final Logger logger = LoggerFactory.getLogger(LocalServerRestClient.class);
049
050        private static final int DEFAULT_SOCKET_CONNECT_TIMEOUT = 30 * 1000; // 30 seconds should really be sufficient to connect to localhost
051        private static final int DEFAULT_SOCKET_READ_TIMEOUT = 3 * 60 * 1000; // 3 minutes is more than enough, because we have DelayedMethodInvocationResponse
052
053        /**
054         * The {@code key} for the connection timeout used with {@link Config#getPropertyAsInt(String, int)}.
055         * <p>
056         * The configuration can be overridden by a system property - see {@link Config#SYSTEM_PROPERTY_PREFIX}.
057         */
058        public static final String CONFIG_KEY_SOCKET_CONNECT_TIMEOUT = "localServer.socket.connectTimeout"; //$NON-NLS-1$
059
060        /**
061         * The {@code key} for the read timeout used with {@link Config#getPropertyAsInt(String, int)}.
062         * <p>
063         * The configuration can be overridden by a system property - see {@link Config#SYSTEM_PROPERTY_PREFIX}.
064         */
065        public static final String CONFIG_KEY_SOCKET_READ_TIMEOUT = "localServer.socket.readTimeout"; //$NON-NLS-1$
066
067        private Integer socketConnectTimeout;
068
069        private Integer socketReadTimeout;
070
071        private String baseUrl;
072
073        private final LinkedList<Client> clientCache = new LinkedList<Client>();
074
075        private CredentialsProvider credentialsProvider;
076
077        private static final class Holder {
078                public static final LocalServerRestClient instance = new LocalServerRestClient();
079        }
080
081        public static LocalServerRestClient getInstance() {
082                return Holder.instance;
083        }
084
085        public synchronized Integer getSocketConnectTimeout() {
086                if (socketConnectTimeout == null)
087                        socketConnectTimeout = ConfigImpl.getInstance().getPropertyAsPositiveOrZeroInt(
088                                        CONFIG_KEY_SOCKET_CONNECT_TIMEOUT,
089                                        DEFAULT_SOCKET_CONNECT_TIMEOUT);
090
091                return socketConnectTimeout;
092        }
093        public synchronized void setSocketConnectTimeout(Integer socketConnectTimeout) {
094                if (socketConnectTimeout != null && socketConnectTimeout < 0)
095                        socketConnectTimeout = null;
096
097                this.socketConnectTimeout = socketConnectTimeout;
098        }
099
100        public synchronized Integer getSocketReadTimeout() {
101                if (socketReadTimeout == null)
102                        socketReadTimeout = ConfigImpl.getInstance().getPropertyAsPositiveOrZeroInt(
103                                        CONFIG_KEY_SOCKET_READ_TIMEOUT,
104                                        DEFAULT_SOCKET_READ_TIMEOUT);
105
106                return socketReadTimeout;
107        }
108        public synchronized void setSocketReadTimeout(Integer socketReadTimeout) {
109                if (socketReadTimeout != null && socketReadTimeout < 0)
110                        socketReadTimeout = null;
111
112                this.socketReadTimeout = socketReadTimeout;
113        }
114
115        /**
116         * Get the server's base-URL.
117         * <p>
118         * This base-URL is the base of the <code>LocalServerRest</code> application. Hence all URLs
119         * beneath this base-URL are processed by the <code>LocalServerRest</code> application.
120         * @return the base-URL. This URL always ends with "/".
121         */
122        public synchronized String getBaseUrl() {
123                if (baseUrl == null)
124                        baseUrl = LocalServerPropertiesManager.getInstance().getBaseUrl();
125
126                return baseUrl;
127        }
128
129        /**
130         * Create a new client.
131         */
132        protected LocalServerRestClient() {
133                // The clientId is used for memory management in the server: if a client is closed or disappears, i.e. doesn't
134                // send keep-alives regularly, anymore, the server removes all objectRef-references kept for this client in its ObjectManager.
135                final String clientId = new Uid().toString();
136
137                setCredentialsProvider(new CredentialsProvider() {
138                        @Override
139                        public String getUserName() {
140                                return clientId;
141                        }
142                        @Override
143                        public String getPassword() {
144                                return LocalServerPropertiesManager.getInstance().getPassword();
145                        }
146                });
147        }
148
149//      private static String appendFinalSlashIfNeeded(final String url) {
150//              return url.endsWith("/") ? url : url + "/";
151//      }
152
153        public <R> R execute(final Request<R> request) {
154                requireNonNull(request, "request");
155                RuntimeException firstException = null;
156                int retryCounter = 0; // *re*-try: first (normal) invocation is 0, first re-try is 1
157                final int retryMax = 1; // *re*-try: 1 retries means 2 invocations in total
158                while (true) {
159                        acquireClient();
160                        try {
161                                final long start = System.currentTimeMillis();
162
163                                if (logger.isDebugEnabled())
164                                        logger.debug("execute: starting try {} of {}", retryCounter + 1, retryMax + 1);
165
166                                try {
167                                        request.setLocalServerRestClient(this);
168                                        final R result = request.execute();
169
170                                        if (logger.isDebugEnabled())
171                                                logger.debug("execute: invocation took {} ms", System.currentTimeMillis() - start);
172
173                                        if (result == null && !request.isResultNullable())
174                                                throw new IllegalStateException("result == null, but request.resultNullable == false!");
175
176                                        return result;
177                                } catch (final RuntimeException x) {
178                                        if (firstException == null)
179                                                firstException = x;
180
181                                        final String oldBaseUrl = getBaseUrl();
182                                        baseUrl = null;
183                                        if (!oldBaseUrl.equals(getBaseUrl())) {
184                                                retryCounter = 0; // reset to make sure we really try again with the new URL
185                                                clearClientCache();
186                                        }
187
188                                        markClientBroken(); // make sure we do not reuse this client
189                                        if (++retryCounter > retryMax || !retryExecuteAfterException(x)) {
190                                                logger.warn("execute: invocation failed (will NOT retry): " + x, x);
191                                                handleAndRethrowException(firstException); // TODO maybe we should make a MultiCauseException?!
192                                                throw firstException;
193                                        }
194                                        logger.warn("execute: invocation failed (will retry): " + x, x);
195
196                                        // Wait a bit before retrying (increasingly longer).
197                                        try { Thread.sleep(retryCounter * 1000L); } catch (Exception y) { doNothing(); }
198                                }
199                        } finally {
200                                releaseClient();
201                                request.setLocalServerRestClient(null);
202                        }
203                }
204        }
205
206        private synchronized void clearClientCache() {
207                clientCache.clear();
208        }
209
210        private boolean retryExecuteAfterException(final Exception x) {
211//              final Class<?>[] exceptionClassesCausingRetry = new Class<?>[] {
212//                              SSLException.class,
213//                              SocketException.class
214//              };
215//              for (final Class<?> exceptionClass : exceptionClassesCausingRetry) {
216//                      @SuppressWarnings("unchecked")
217//                      final Class<? extends Throwable> xc = (Class<? extends Throwable>) exceptionClass;
218//                      if (ExceptionUtil.getCause(x, xc) != null) {
219//                              logger.warn(
220//                                              String.format("retryExecuteAfterException: Encountered %s and will retry.", xc.getSimpleName()),
221//                                              x);
222//                              return true;
223//                      }
224//              }
225//              return false;
226                return true;
227        }
228
229        public Invocation.Builder assignCredentials(final Invocation.Builder builder) {
230                final CredentialsProvider credentialsProvider = getCredentialsProviderOrFail();
231                builder.property(HttpAuthenticationFeature.HTTP_AUTHENTICATION_BASIC_USERNAME, credentialsProvider.getUserName());
232                builder.property(HttpAuthenticationFeature.HTTP_AUTHENTICATION_BASIC_PASSWORD, credentialsProvider.getPassword());
233                return builder;
234        }
235
236        private final ThreadLocal<ClientRef> clientThreadLocal = new ThreadLocal<ClientRef>();
237
238        private static class ClientRef {
239                public final Client client;
240                public int refCount = 1;
241                public boolean broken;
242
243                public ClientRef(final Client client) {
244                        this.client = requireNonNull(client, "client");
245                }
246        }
247
248        /**
249         * Acquire a {@link Client} and bind it to the current thread.
250         * <p>
251         * <b>Important: You must {@linkplain #releaseClient() release} the client!</b> Use a try/finally block!
252         * @see #releaseClient()
253         * @see #getClientOrFail()
254         */
255        private synchronized void acquireClient()
256        {
257                final ClientRef clientRef = clientThreadLocal.get();
258                if (clientRef != null) {
259                        ++clientRef.refCount;
260                        return;
261                }
262
263                Client client = clientCache.poll();
264                if (client == null) {
265                        final ClientConfig clientConfig = new ClientConfig(CloudStoreJaxbContextResolver.class);
266                        clientConfig.property(ClientProperties.CONNECT_TIMEOUT, getSocketConnectTimeout()); // must be a java.lang.Integer
267                        clientConfig.property(ClientProperties.READ_TIMEOUT, getSocketReadTimeout()); // must be a java.lang.Integer
268
269                        final ClientBuilder clientBuilder = ClientBuilder.newBuilder().withConfig(clientConfig);
270
271                        clientBuilder.register(JavaNativeMessageBodyReader.class);
272                        clientBuilder.register(JavaNativeMessageBodyWriter.class);
273
274                        for (final Object restComponent : restComponents) {
275                                if (restComponent instanceof Class<?>)
276                                        clientBuilder.register((Class<?>) restComponent);
277                                else
278                                        clientBuilder.register(restComponent);
279                        }
280
281                        client = clientBuilder.build();
282
283                        // An authentication is always required. Otherwise Jersey throws an exception.
284                        // Hence, we set it to "anonymous" here and set it to the real values for those
285                        // requests really requiring it.
286                        final HttpAuthenticationFeature feature = HttpAuthenticationFeature.basic("anonymous", "");
287                        client.register(feature);
288
289//                      configFrozen = true;
290                }
291                clientThreadLocal.set(new ClientRef(client));
292        }
293
294        private final List<Object> restComponents = new CopyOnWriteArrayList<Object>();
295
296        public void registerRestComponent(Object restComponent) {
297                restComponents.add(restComponent);
298        }
299
300        /**
301         * Get the {@link Client} which was previously {@linkplain #acquireClient() acquired} (and not yet
302         * {@linkplain #releaseClient() released}) on the same thread.
303         * @return the {@link Client}. Never <code>null</code>.
304         * @throws IllegalStateException if there is no {@link Client} bound to the current thread.
305         * @see #acquireClient()
306         */
307        public Client getClientOrFail() {
308                final ClientRef clientRef = clientThreadLocal.get();
309                if (clientRef == null)
310                        throw new IllegalStateException("acquireClient() not called on the same thread (or releaseClient() already called)!");
311
312                return clientRef.client;
313        }
314
315        /**
316         * Release a {@link Client} which was previously {@linkplain #acquireClient() acquired}.
317         * @see #acquireClient()
318         */
319        private synchronized void releaseClient() {
320                final ClientRef clientRef = clientThreadLocal.get();
321                if (clientRef == null)
322                        throw new IllegalStateException("acquireClient() not called on the same thread (or releaseClient() called more often than acquireClient())!");
323
324                if (--clientRef.refCount == 0) {
325                        clientThreadLocal.remove();
326
327                        if (!clientRef.broken)
328                                clientCache.add(clientRef.client);
329                }
330        }
331
332        private void markClientBroken() {
333                final ClientRef clientRef = clientThreadLocal.get();
334                if (clientRef == null)
335                        throw new IllegalStateException("acquireClient() not called on the same thread (or releaseClient() called more often than acquireClient())!");
336
337                clientRef.broken = true;
338        }
339
340        public void handleAndRethrowException(final RuntimeException x)
341        {
342                Response response = null;
343                if (x instanceof WebApplicationException)
344                        response = ((WebApplicationException)x).getResponse();
345                else if (x instanceof ResponseProcessingException)
346                        response = ((ResponseProcessingException)x).getResponse();
347
348                if (response == null)
349                        throw x;
350
351                Error error = null;
352                try {
353                        response.bufferEntity();
354                        if (response.hasEntity())
355                                error = response.readEntity(Error.class);
356
357                        if (error != null && DeferredCompletionException.class.getName().equals(error.getClassName()))
358                                logger.debug("handleException: " + x, x);
359                        else
360                                logger.error("handleException: " + x, x);
361
362                } catch (final Exception y) {
363                        logger.error("handleException: " + x, x);
364                        logger.error("handleException: " + y, y);
365                }
366
367                if (error != null) {
368                        RemoteExceptionUtil.throwOriginalExceptionIfPossible(error);
369                        throw new RemoteException(error);
370                }
371
372                throw x;
373        }
374
375        public CredentialsProvider getCredentialsProvider() {
376                return credentialsProvider;
377        }
378        private CredentialsProvider getCredentialsProviderOrFail() {
379                final CredentialsProvider credentialsProvider = getCredentialsProvider();
380                if (credentialsProvider == null)
381                        throw new IllegalStateException("credentialsProvider == null");
382                return credentialsProvider;
383        }
384        public void setCredentialsProvider(final CredentialsProvider credentialsProvider) {
385                this.credentialsProvider = credentialsProvider;
386        }
387}