/* * This is part of Geomajas, a GIS framework, http://www.geomajas.org/. * * Copyright 2008-2015 Geosparc nv, http://www.geosparc.com/, Belgium. * * The program is available in open source according to the GNU Affero * General Public License. All contributions in this program are covered * by the Geomajas Contributors License Agreement. For full licensing * details, see LICENSE.txt in the project root. */ package org.geomajas.gwt.client.command; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import org.geomajas.annotation.Api; import org.geomajas.command.CommandResponse; import org.geomajas.global.ExceptionCode; import org.geomajas.global.ExceptionDto; import org.geomajas.global.GeomajasConstant; import org.geomajas.gwt.client.GeomajasService; import org.geomajas.gwt.client.GeomajasServiceAsync; import org.geomajas.gwt.client.command.event.DispatchStartedEvent; import org.geomajas.gwt.client.command.event.DispatchStartedHandler; import org.geomajas.gwt.client.command.event.DispatchStoppedEvent; import org.geomajas.gwt.client.command.event.DispatchStoppedHandler; import org.geomajas.gwt.client.command.event.HasDispatchHandlers; import org.geomajas.gwt.client.command.event.TokenChangedEvent; import org.geomajas.gwt.client.command.event.TokenChangedHandler; import org.geomajas.gwt.client.util.Log; import com.google.gwt.core.client.GWT; import com.google.gwt.event.shared.HandlerManager; import com.google.gwt.event.shared.HandlerRegistration; import com.google.gwt.i18n.client.LocaleInfo; import com.google.gwt.user.client.Window; import com.google.gwt.user.client.Window.ClosingEvent; import com.google.gwt.user.client.Window.ClosingHandler; import com.google.gwt.user.client.rpc.AsyncCallback; import com.google.gwt.user.client.rpc.RpcRequestBuilder; import com.google.gwt.user.client.rpc.ServiceDefTarget; /** * The central client side dispatcher for all commands. Use the {@link #execute(GwtCommand, CommandCallback...)} * function to execute an asynchronous command on the server. * <p/> * Set a {@link TokenRequestHandler} to make sure the class automatically makes the user (re) login when needed. * * @author Pieter De Graef * @author Oliver May * @author Joachim Van der Auwera * @since 2.0.0 */ @Api(allMethods = true) public final class GwtCommandDispatcher implements HasDispatchHandlers, CommandExceptionCallback, CommunicationExceptionCallback { private static final String SECURITY_EXCEPTION_CLASS_NAME = "org.geomajas.security.GeomajasSecurityException"; private static final String RANDOM_UNLIKELY_TOKEN = "%t@kén§#"; private static GwtCommandDispatcher instance = new GwtCommandDispatcher(); private final GeomajasServiceAsync service; private final HandlerManager manager = new HandlerManager(this); private final List<Deferred> deferreds; private int nrOfDispatchedCommands; private String locale; private String userToken; private UserDetail userDetail = new UserDetail(); private boolean useLazyLoading; private int lazyFeatureIncludesDefault; private int lazyFeatureIncludesSelect; private int lazyFeatureIncludesAll; private boolean showError; private TokenRequestHandler tokenRequestHandler; private CommandExceptionCallback commandExceptionCallback; private CommunicationExceptionCallback communicationExceptionCallback; // map is not synchronized as this class runs in JavaScript which only has one execution thread private final Map<String, List<RetryCommand>> afterLoginCommands = new HashMap<String, List<RetryCommand>>(); private GwtCommandDispatcher() { locale = LocaleInfo.getCurrentLocale().getLocaleName(); if ("default".equals(locale)) { locale = null; } deferreds = new ArrayList<Deferred>(); service = (GeomajasServiceAsync) GWT.create(GeomajasService.class); setServiceEndPointUrl(GWT.getModuleBaseURL() + "geomajasService"); setUseLazyLoading(true); setShowError(true); Window.addWindowClosingHandler(new ClosingHandler() { public void onWindowClosing(ClosingEvent event) { GwtCommandDispatcher.getInstance().setShowError(false); // Cancel all outstanding requests: for (Deferred deferred : deferreds) { deferred.cancel(); } } }); } // ------------------------------------------------------------------------- // Public methods: // ------------------------------------------------------------------------- /** * Get the only static instance of this class. This should be the object you work with. * * @return singleton instance */ public static GwtCommandDispatcher getInstance() { return instance; } @Override public HandlerRegistration addDispatchStartedHandler(DispatchStartedHandler handler) { return manager.addHandler(DispatchStartedHandler.TYPE, handler); } @Override public HandlerRegistration addDispatchStoppedHandler(DispatchStoppedHandler handler) { return manager.addHandler(DispatchStoppedHandler.TYPE, handler); } /** * The execution function. Executes a server side command. * * @param command * The command to be executed. This command is a wrapper around the actual request object. * @param callback * A <code>CommandCallback</code> function to be executed when the command successfully returns. The * callbacks may implement CommunicationExceptionCallback or CommandExceptionCallback to allow error * handling. * @return deferred object which can be used to add extra callbacks */ public Deferred execute(final GwtCommand command, final CommandCallback... callback) { final Deferred deferred = new Deferred(); for (CommandCallback successCallback : callback) { try { deferred.addCallback(successCallback); } catch (Throwable t) { Log.logError("Command failed on success callback", t); } } return execute(command, deferred); } /** * The execution function. Executes a server side command. * * @param command * The command to be executed. This command is a wrapper around the actual request object. * @param deferred * list of callbacks for the command * @return original deferred object as passed as parameter */ public Deferred execute(final GwtCommand command, final Deferred deferred) { if (!deferreds.contains(deferred)) { deferreds.add(deferred); } command.setLocale(locale); command.setUserToken(userToken); // shortcut, no need to invoke the server if we know the token has expired if (null != userToken && userToken.length() > 0 && afterLoginCommands.containsKey(userToken)) { afterLogin(command, deferred); return deferred; } incrementDispatched(); service.execute(command, new AsyncCallback<CommandResponse>() { public void onFailure(Throwable error) { try { boolean errorHandled = false; for (CommandCallback<?> callback : deferred.getCallbacks()) { if (callback instanceof CommunicationExceptionCallback) { try { ((CommunicationExceptionCallback) callback).onCommunicationException(error); } catch (Throwable t) { Log.logError("Command failed on error callback", t); } errorHandled = true; } } if (!errorHandled && deferred.isLogCommunicationExceptions()) { onCommunicationException(error); } } catch (Throwable t) { if (deferred.isLogCommunicationExceptions()) { Log.logError("Command failed on error callback", t); } } finally { decrementDispatched(); deferreds.remove(deferred); } } public void onSuccess(CommandResponse response) { try { if (response.isError()) { handleError(response); } else { if (!deferred.isCancelled()) { for (CommandCallback callback : deferred.getCallbacks()) { try { callback.execute(response); } catch (Throwable t) { Log.logError("Command failed on success callback", t); } } } } } catch (Throwable t) { Log.logError("Command failed on success callback", t); } finally { decrementDispatched(); deferreds.remove(deferred); } } private void handleError(CommandResponse response) { // Security exceptions which indicate that the token is invalid cause a token request. // Other security exceptions are normally reported, except when then undefined (null or empty) // token is used. For restricted access for the anonymous user, this should also be assigned // a proper token (can be a constant). // This assumes that the undefined token is only used either in combination with the allow all // security service or in a state between logout and login. boolean authenticationFailed = false; for (ExceptionDto exception : response.getExceptions()) { authenticationFailed |= SECURITY_EXCEPTION_CLASS_NAME.equals(exception.getClassName()) && (ExceptionCode.CREDENTIALS_MISSING_OR_INVALID == exception.getExceptionCode() || isUndefinedToken(command.getUserToken())); } if (authenticationFailed && null != tokenRequestHandler) { handleLogin(command, deferred); } else { // normal error handling... boolean errorHandled = false; for (CommandCallback callback : deferred.getCallbacks()) { if (callback instanceof CommandExceptionCallback) { try { ((CommandExceptionCallback) callback).onCommandException(response); errorHandled = true; } catch (Throwable t) { Log.logError("Command failed on error callback", t); } } } // fallback to the default behaviour if (!errorHandled) { try { onCommandException(response); } catch (Throwable t) { Log.logError("Command failed on error callback", t); } } } } }); return deferred; } /** * Add a command and it's callbacks to the list of commands to retry after login. * * @param command * command to retry * @param deferred * callbacks for the command */ private void afterLogin(GwtCommand command, Deferred deferred) { String token = notNull(command.getUserToken()); if (!afterLoginCommands.containsKey(token)) { afterLoginCommands.put(token, new ArrayList<RetryCommand>()); } afterLoginCommands.get(token).add(new RetryCommand(command, deferred)); } /** * Method which forces retry of a command after login. * <p/> * This method assumes the single threaded nature of JavaScript execution for correctness. * * @param command * command which needs to be retried * @param deferred * callbacks for the command */ private void handleLogin(GwtCommand command, Deferred deferred) { final String oldToken = notNull(command.getUserToken()); if (!afterLoginCommands.containsKey(oldToken)) { afterLoginCommands.put(oldToken, new ArrayList<RetryCommand>()); login(oldToken); } afterLogin(command, deferred); } /** * Assure that a string is not null, convert to empty string if it is. * * @param string * string to convert * @return converted string */ private String notNull(String string) { if (null == string) { return ""; } return string; } /** * Check whether this is an "undefined" token. * * @param token * token to test * @return true when token is null or empty string */ private boolean isUndefinedToken(String token) { return null == token || 0 == token.length(); } /** * Default behaviour for handling a communication exception. Shows a warning window to the user. * * @param error * error to report */ public void onCommunicationException(Throwable error) { if (isShowError()) { communicationExceptionCallback.onCommunicationException(error); } } /** * Default behaviour for handling a command execution exception. Shows an exception report to the user. * * @param response * command response with error */ public void onCommandException(CommandResponse response) { if (isShowError()) { commandExceptionCallback.onCommandException(response); } } /** * Is the dispatcher busy ? * * @return true if there are outstanding commands */ public boolean isBusy() { return nrOfDispatchedCommands != 0; } /** * Set the service end point URL to a different value. If pointing to a different context, make sure the * GeomajasController of that context supports this. * * @see org.geomajas.gwt.server.mvc.GeomajasController * * @param url * the new URL */ public void setServiceEndPointUrl(String url) { ServiceDefTarget endpoint = (ServiceDefTarget) service; endpoint.setServiceEntryPoint(url); } /** * Force request a new user token. This is not used for extending the user token, that is handled automatically. */ public void login() { logout(true); login(RANDOM_UNLIKELY_TOKEN); } /** * Force request a new login, the dangling commands for the previous token are retried when logged in. * * @param oldToken * previous token */ private void login(final String oldToken) { tokenRequestHandler.login(new TokenChangedHandler() { public void onTokenChanged(TokenChangedEvent event) { setToken(event.getToken(), event.getUserDetail(), false); List<RetryCommand> retryCommands = afterLoginCommands.remove(oldToken); if (null != retryCommands) { for (RetryCommand retryCommand : retryCommands) { execute(retryCommand.getCommand(), retryCommand.getDeferred()); } } } }); } /** * Logout. Clear the user token. * * @since 2.0.0 */ public void logout() { logout(false); } /** * Logout. Clear the user token. Can indicate that a login is about to follow. * * @param loginPending * is a new login pending? */ private void logout(boolean loginPending) { setToken(null, null, loginPending); } /** * Set the user token, so it can be sent in every command. This is the internal version, used by the token changed * handler. * * @param userToken * user token * @param userDetail * user details * @param loginPending * true if this will be followed by a fresh token change */ private void setToken(String userToken, UserDetail userDetail, boolean loginPending) { boolean changed = !isEqual(this.userToken, userToken); this.userToken = userToken; if (null == userDetail) { userDetail = new UserDetail(); } this.userDetail = userDetail; if (changed) { TokenChangedEvent event = new TokenChangedEvent(userToken, userDetail, loginPending); manager.fireEvent(event); } } /** * Get currently active user authentication token. * * @return authentication token */ public String getUserToken() { return userToken; } /** * Get details for the current user. * <p/> * Object is always not-null, but the entries may be. * * @return user details object */ public UserDetail getUserDetail() { return userDetail; } /** * Add handler which is notified when the user token changes. * * @param handler * token changed handler * @return handler registration */ public HandlerRegistration addTokenChangedHandler(TokenChangedHandler handler) { return manager.addHandler(TokenChangedHandler.TYPE, handler); } /** * Set the login handler which should be used to request aan authentication token. * * @param tokenRequestHandler * login handler */ public void setTokenRequestHandler(TokenRequestHandler tokenRequestHandler) { this.tokenRequestHandler = tokenRequestHandler; } /** * Get the current token request handler. * * @return token request handler */ public TokenRequestHandler getTokenRequestHandler() { return tokenRequestHandler; } /** * Set default command exception callback. * * @param commandExceptionCallback * command exception callback */ public void setCommandExceptionCallback(CommandExceptionCallback commandExceptionCallback) { this.commandExceptionCallback = commandExceptionCallback; } /** * Set default communication exception callback. * * @param communicationExceptionCallback * communication exception callback */ public void setCommunicationExceptionCallback(CommunicationExceptionCallback communicationExceptionCallback) { this.communicationExceptionCallback = communicationExceptionCallback; } /** * Is lazy feature loading enabled ? * * @return true when lazy feature loading is enabled */ public boolean isUseLazyLoading() { return useLazyLoading; } /** * Set lazy feature loading status. * * @param useLazyLoading * lazy feature loading status */ public void setUseLazyLoading(boolean useLazyLoading) { if (useLazyLoading != this.useLazyLoading) { if (useLazyLoading) { lazyFeatureIncludesDefault = GeomajasConstant.FEATURE_INCLUDE_STYLE + GeomajasConstant.FEATURE_INCLUDE_LABEL; lazyFeatureIncludesSelect = GeomajasConstant.FEATURE_INCLUDE_ALL; lazyFeatureIncludesAll = GeomajasConstant.FEATURE_INCLUDE_ALL; } else { lazyFeatureIncludesDefault = GeomajasConstant.FEATURE_INCLUDE_ALL; lazyFeatureIncludesSelect = GeomajasConstant.FEATURE_INCLUDE_ALL; lazyFeatureIncludesAll = GeomajasConstant.FEATURE_INCLUDE_ALL; } } this.useLazyLoading = useLazyLoading; } /** * Get default value for "featureIncludes" when getting features. * * @return default "featureIncludes" value */ public int getLazyFeatureIncludesDefault() { return lazyFeatureIncludesDefault; } /** * Set default value for "featureIncludes" when getting features. * * @param lazyFeatureIncludesDefault * default for "featureIncludes" */ public void setLazyFeatureIncludesDefault(int lazyFeatureIncludesDefault) { setUseLazyLoading(false); this.lazyFeatureIncludesDefault = lazyFeatureIncludesDefault; } /** * Get "featureIncludes" to use when selecting features. * * @return default "featureIncludes" for select commands */ public int getLazyFeatureIncludesSelect() { return lazyFeatureIncludesSelect; } /** * Set default "featureIncludes" for select commands. * * @param lazyFeatureIncludesSelect * default "featureIncludes" for select commands */ public void setLazyFeatureIncludesSelect(int lazyFeatureIncludesSelect) { setUseLazyLoading(false); this.lazyFeatureIncludesSelect = lazyFeatureIncludesSelect; } /** * Value to use for "featureIncludes" when all should be included. * * @return value for "featureIncludes" when all should be included */ public int getLazyFeatureIncludesAll() { return lazyFeatureIncludesAll; } /** * Set "featureIncludes" value when all should be included. * * @param lazyFeatureIncludesAll * "featureIncludes" value when all should be included */ public void setLazyFeatureIncludesAll(int lazyFeatureIncludesAll) { setUseLazyLoading(false); this.lazyFeatureIncludesAll = lazyFeatureIncludesAll; } /** * Should the dispatcher show error messages ? * * @return true if showing error messages, false otherwise */ public boolean isShowError() { return showError; } /** * Sets whether the dispatcher should show error messages. * * @param showError * true if showing error messages, false otherwise */ public void setShowError(boolean showError) { this.showError = showError; } /** * Set the RPC request builder that should be used with the RPC service. * * @param builder * @since 2.4.0 */ public void setRpcRequestBuilder(RpcRequestBuilder builder) { ((ServiceDefTarget) service).setRpcRequestBuilder(builder); } // ------------------------------------------------------------------------- // Protected methods: // ------------------------------------------------------------------------- protected void incrementDispatched() { boolean started = nrOfDispatchedCommands == 0; nrOfDispatchedCommands++; if (started) { manager.fireEvent(new DispatchStartedEvent()); } } protected void decrementDispatched() { nrOfDispatchedCommands--; if (nrOfDispatchedCommands == 0) { manager.fireEvent(new DispatchStoppedEvent()); } } /** * Checks whether 2 objects are equal. Null-safe, 2 null objects are considered equal. * * @param o1 * first object to compare * @param o2 * second object to compare * @return true if object are equal, false otherwise */ private boolean isEqual(Object o1, Object o2) { return o1 == null ? o2 == null : o1.equals(o2); } /** * Representation of a command which needs to be retried later. * * @author Joachim Van der Auwera */ private static class RetryCommand { private final GwtCommand command; private final Deferred deferred; /** * Create data to allow retying the command later. * * @param command * command * @param deferred * callbacks */ public RetryCommand(GwtCommand command, Deferred deferred) { this.command = command; this.deferred = deferred; } /** * Get the GwtCommand which needs to be retried. * * @return command */ public GwtCommand getCommand() { return command; } /** * Get the callbacks for handling the retied command. * * @return callbacks */ public Deferred getDeferred() { return deferred; } } }