// Copyright 2012 Google Inc. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.google.collide.client.communication; import com.google.collide.client.bootstrap.BootstrapSession; import com.google.collide.client.communication.MessageFilter.MessageRecipient; import com.google.collide.client.status.StatusManager; import com.google.collide.client.status.StatusMessage; import com.google.collide.client.status.StatusMessage.MessageType; import com.google.collide.client.util.logging.Log; import com.google.collide.dto.InvalidXsrfTokenServerError; import com.google.collide.dto.RoutingTypes; import com.google.collide.dto.ServerError; import com.google.collide.dto.ServerError.FailureReason; import com.google.collide.dto.client.DtoClientImpls; import com.google.collide.dto.client.DtoClientImpls.EmptyMessageImpl; import com.google.collide.dto.client.DtoUtils; import com.google.collide.dtogen.client.RoutableDtoClientImpl; import com.google.collide.dtogen.shared.ClientToServerDto; import com.google.collide.dtogen.shared.ServerToClientDto; import com.google.collide.json.client.Jso; import com.google.collide.json.shared.JsonStringMap; import com.google.collide.json.shared.JsonStringMap.IterationCallback; import com.google.collide.shared.FrontendConstants; import com.google.collide.shared.util.JsonCollections; import com.google.collide.shared.util.StringUtils; import com.google.common.annotations.VisibleForTesting; import com.google.gwt.core.client.Scheduler; import com.google.gwt.core.client.Scheduler.RepeatingCommand; import com.google.gwt.http.client.Request; import com.google.gwt.http.client.RequestBuilder; import com.google.gwt.http.client.RequestCallback; import com.google.gwt.http.client.RequestException; import com.google.gwt.http.client.Response; /** * The Servlet APIs for the Collide server. * * See {@package com.google.collide.dto} for data objects. * */ public class FrontendRestApi { private static FailureReason getFailureReason(Response response, ServerError responseData) { switch (response.getStatusCode()) { case Response.SC_OK: return null; case Response.SC_UNAUTHORIZED: if (responseData != null) { return responseData.getFailureReason(); } return FailureReason.UNAUTHORIZED; // TODO: Make this a server dto error. case Response.SC_CONFLICT: return FailureReason.STALE_CLIENT; case Response.SC_NOT_FOUND: if (responseData != null) { return responseData.getFailureReason(); } return FailureReason.UNKNOWN; case Response.SC_NOT_IMPLEMENTED: if (responseData != null) { return responseData.getFailureReason(); } return FailureReason.UNKNOWN; default: return FailureReason.SERVER_ERROR; } } /** * Encapsulates a servlet API that documents the message types sent to the * frontend, and the optional message types returned as a response. * * @param <REQ> The outgoing message type. * @param <RESP> The incoming message type. */ public static interface Api<REQ extends ClientToServerDto, RESP extends ServerToClientDto> { public void send(REQ msg); public void send(REQ msg, final ApiCallback<RESP> callback); public void send(REQ msg, int retries, final RetryCallback<RESP> callback); } /** * Callback interface for making requests to a frontend API. */ public interface ApiCallback<T extends ServerToClientDto> extends MessageRecipient<T> { /** * Message didn't come back OK. * * @param reason the reason for the failure, should not be null */ void onFail(FailureReason reason); } /** * Production implementation of the Api. Sends XmlHttpRequests. * * Visible so that the {@code MockFrontendApi.MockApi} can inherit it, which * in turn is so that {@link #send(ClientToServerDto, int, RetryCallback)} can * be inherited and correctly be mocked as the constituent individual sends. */ @VisibleForTesting public class ApiImpl<REQ extends ClientToServerDto, RESP extends ServerToClientDto> implements Api<REQ, RESP> { private final String url; @VisibleForTesting protected ApiImpl(String url) { this.url = url; } /** * @return the url */ public String getUrl() { return url; } /** * Calls this Api passing in the specified message. If there is a response * that comes back, it will be dispatched on the MessageFilter. */ @Override public void send(REQ msg) { send(msg, null); } /** * Calls this Api passing in the specified message. * * NOTE: Responses will be dispatch on the supplied callback and NOT on the * MessageFilter (unless the callback is null). */ @Override public void send(REQ msg, final ApiCallback<RESP> callback) { doRequest(msg, new RequestCallback() { @Override public void onError(Request request, Throwable exception) { Log.error(FrontendRestApi.class, "Failed: " + exception); if (callback != null) { callback.onFail(FailureReason.COMMUNICATION_ERROR); } } @Override public void onResponseReceived(Request request, Response response) { if (response.getStatusCode() == Response.SC_OK) { try { // If the frontend doesn't write something back to the stream, // invoke the callback with an empty message. if (response.getText() == null || response.getText().equals("")) { if (callback != null) { @SuppressWarnings("unchecked") RESP emptyMessage = (RESP) EmptyMessageImpl.make(); callback.onMessageReceived(emptyMessage); } return; } ServerToClientDto responseData = (ServerToClientDto) Jso.deserialize(response.getText()); String action = "?"; try { if (callback != null) { action = "invoking callback"; @SuppressWarnings("unchecked") RESP message = (RESP) responseData; callback.onMessageReceived(message); } else { action = "dispatching message on MessageFilter"; Log.info(FrontendRestApi.class, "dispatching: " + response.getText()); getMessageFilter().dispatchMessage(responseData); } } catch (Exception e) { Log.error(FrontendRestApi.class, "Exception when " + action + ": " + response.getText(), e); } } catch (Exception e) { Log.warn( FrontendRestApi.class, "Failed to deserialize JSON response: " + response.getText()); } } else { if (callback != null) { ServerError responseData = null; if (!StringUtils.isNullOrEmpty(response.getText())) { try { responseData = DtoUtils.parseAsDto(response.getText(), RoutingTypes.SERVERERROR, RoutingTypes.INVALIDXSRFTOKENSERVERERROR); } catch (Exception e) { Log.error( FrontendRestApi.class, "Exception when deserializing " + response.getText(), e); } } FailureReason error = getFailureReason(response, responseData); if (recoverer != null) { // TODO: Instead of just terminating retry here. // We should instead refactor the callback's onFail semantics to also take in // "what attempts at failure handling have already been attempted" and make the // leaves do something intelligent wrt to handling the final failure. boolean tryAgain = recoverer.handleFailure(FrontendRestApi.this, error, responseData); if (tryAgain) { // For auto-retry handlers, this will issue another XHR retry. callback.onFail(error); } } else { callback.onFail(error); } } else { Log.warn(FrontendRestApi.class, "Failed: " + response.getStatusText()); } } } }); } @Override public void send(final REQ msg, int retries, final RetryCallback<RESP> callback) { final Countdown countdown = new Countdown(retries); send(msg, new ApiCallback<RESP>() { @Override public void onFail(FailureReason reason) { if (FailureReason.UNAUTHORIZED != reason && countdown.canTryAgain()) { /* * If the failure is due to an authorization issue, there is no * reason to retry the request. */ final ApiCallback<RESP> apiCallback = this; final RepeatingCommand cmd = new RepeatingCommand() { @Override public boolean execute() { send(msg, apiCallback); return false; } }; callback.onRetry(countdown.getRetryCount(), countdown.delayToTryAgain(), cmd); Scheduler.get().scheduleFixedDelay(cmd, countdown.delayToTryAgain()); } else { callback.onFail(reason); } } @Override public void onMessageReceived(RESP message) { callback.onMessageReceived(message); } }); } private void doRequest(REQ msg, RequestCallback internalCallback) { final RequestBuilder requestBuilder = new RequestBuilder(RequestBuilder.POST, getUrl()); customHeaders.iterate(new IterationCallback<String>() { @Override public void onIteration(String header, String value) { requestBuilder.setHeader(header, value); } }); try { RoutableDtoClientImpl messageImpl = (RoutableDtoClientImpl) msg; requestBuilder.sendRequest(messageImpl.serialize(), internalCallback); } catch (RequestException e1) { Log.error(FrontendRestApi.class, e1.getMessage()); } } } /** * Callback with built-in retry notification, so it can put up a "trying again * in N seconds" notice if it wants to, or try again early. */ public abstract static class RetryCallback<T extends ServerToClientDto> implements ApiCallback< T> { /** * Called when a given fails, with information about the next time it will * be tried and a handle to early if desired. * * @param count a count of the number of retries so far * @param milliseconds time between "now" and the next execution * @param retryCmd an already-scheduled {@link RepeatingCommand}, whose * {@link RepeatingCommand#execute()} method could be called early if * desired. */ protected void onRetry(int count, int milliseconds, RepeatingCommand retryCmd) { // by default, do nothing. Subclasses may opt to provide status feedback, // or trigger retryCmd to try again before the regularly scheduled time. } } private static class Countdown { int retriesLeft; int retriesDone; private Countdown(int retries) { this.retriesLeft = retries; this.retriesDone = 0; } /** * Decrements the retry counter, and returns {@code true} only if more * retries are allowed. */ private boolean canTryAgain() { retriesLeft--; retriesDone++; return retriesLeft > 0; } /** * Returns milliseconds of delay before next . */ private int delayToTryAgain() { return 2000 * retriesDone * retriesDone; } private int getRetryCount() { return retriesDone; } } ///////////////////////////////// // BEGIN AVAILABLE FRONTEND APIS ///////////////////////////////// /* * If one were to consider running this as a hosted service, with affordances for branch switching * and project management. You would probably need APIs that looked like the following ;) -> */ // /** // * Send a keep-alive for the client in a workspace. // */ // public final Api<KeepAlive, EmptyMessage> KEEP_ALIVE = makeApi("/workspace/act/KeepAlive"); // // public final Api<ClientToServerDocOp, ServerToClientDocOps> MUTATE_FILE = // makeApi("/workspace/act/MutateFile"); // // /** // * Lets a client re-synchronize with the server's version of a file after // * being offline or missing a doc op broadcast. // */ // public final Api<RecoverFromMissedDocOps, RecoverFromMissedDocOpsResponse> // RECOVER_FROM_MISSED_DOC_OPS = makeApi("/workspace/act/RecoverFromMissedDocOps"); // // /** // * Leave a workspace. // */ // public final Api<LeaveWorkspace, EmptyMessage> LEAVE_WORKSPACE = // makeApi("/workspace/act/LeaveWorkspace"); // // /** // * Enter a workspace. // */ // public final Api<EnterWorkspace, EnterWorkspaceResponse> ENTER_WORKSPACE = // makeApi("/workspace/act/EnterWorkspace"); // // /** // * Get the workspace file tree and associated meta data like conflicts and the // * tango version number for the file tree. // */ // public final Api<GetFileTree, GetFileTreeResponse> // GET_FILE_TREE = makeApi("/workspace/act/GetFileTree"); // // /** // * Get a subdirectory. Just the subtree rooted at that path. No associated // * meta data. // */ // public final Api<GetDirectory, GetDirectoryResponse> // GET_DIRECTORY = makeApi("/workspace/act/GetDirectory"); // // /** // * Get the directory listing and any conflicts. // */ // public final Api<GetFileContents, GetFileContentsResponse> // GET_FILE_CONTENTS = makeApi("/workspace/mgmt/GetFileContents"); // // /** // * Get the sync state. // */ // public final Api<GetSyncState, GetSyncStateResponse> // GET_SYNC_STATE = makeApi("/workspace/mgmt/GetSyncState"); // // /** // * Sync from the parent workspace. // */ // public final Api<Sync, EmptyMessage> SYNC = makeApi("/workspace/mgmt/Sync"); // // /** // * Undo the most recent sync. // */ // public final Api<UndoLastSync, EmptyMessage> UNDO_LAST_SYNC = // makeApi("/workspace/mgmt/UndoLastSync"); // // /** // * Submit to the parent workspace. // */ // public final Api<Submit, SubmitResponse> SUBMIT = makeApi("/workspace/mgmt/Submit"); // // /** // * Archives a workspace. // */ // public final Api<SetWorkspaceArchiveState, SetWorkspaceArchiveStateResponse> ARCHIVE_WORKSPACE = // makeApi("/workspace/mgmt/setWorkspaceArchiveState"); // // /** // * Creates a project. // */ // public final Api<CreateProject, CreateProjectResponse> CREATE_PROJECT = // makeApi("/project/create"); // // /** // * Creates a workspace. // */ // public final Api<CreateWorkspace, CreateWorkspaceResponse> CREATE_WORKSPACE = // makeApi("/workspace/mgmt/create"); // // /** // * Gets a list of revisions for a particular file // */ // public final Api<GetFileRevisions, GetFileRevisionsResponse> GET_FILE_REVISIONS = // makeApi("/workspace/mgmt/getFileRevisions"); // // /** // * Gets a diff of a particular file. // */ // public final Api<GetFileDiff, GetFileDiffResponse> GET_FILE_DIFF = // makeApi("/workspace/mgmt/getFileDiff"); // // /** // * Notify the frontend that a tree conflict has been resolved. // */ // public final Api<ResolveTreeConflict, ResolveTreeConflictResponse> RESOLVE_TREE_CONFLICT = // makeApi("/workspace/mgmt/resolveTreeConflict"); // // /** // * Notify the frontend that a conflict chunk has been resolved. // */ // public final Api<ResolveConflictChunk, ConflictChunkResolved> RESOLVE_CONFLICT_CHUNK = // makeApi("/workspace/mgmt/resolveConflictChunk"); // // /** // * Retrieves code errors for a file. // */ // public final Api<CodeErrorsRequest, CodeErrors> GET_CODE_ERRORS = // makeApi("/workspace/code/CodeErrorsRequest"); // // /** // * Retrieves code parsing results. // */ // public final Api<CodeGraphRequest, CodeGraphResponse> GET_CODE_GRAPH = // makeApi("/workspace/code/CodeGraphRequest"); // // /** // * Gets a list of the templates that might seed new projects // */ // public final Api<GetTemplates, GetTemplatesResponse> GET_TEMPLATES = // makeApi("/project/getTemplates"); // // /** // * Loads a template into a workspace // */ // public final Api<LoadTemplate, LoadTemplateResponse> LOAD_TEMPLATE = // makeApi("/workspace/mgmt/loadTemplate"); // // /** // * Gets a list of the files that have changes in the workspace. // */ // public final Api<GetWorkspaceChangeSummary, GetWorkspaceChangeSummaryResponse> // GET_WORKSPACE_CHANGE_SUMMARY = makeApi("/workspace/mgmt/getWorkspaceChangeSummary"); // // /** // * Gets info about a specific set of workspaces. // */ // public final Api<GetWorkspace, GetWorkspaceResponse> GET_WORKSPACES = // makeApi("/workspace/mgmt/get"); // // /** // * Gets information about a project. // */ // public final Api<GetProjectById, GetProjectByIdResponse> GET_PROJECT_BY_ID = // makeApi("/project/getById"); // // /** // * Gets information about a user's projects. // */ // public final Api<EmptyMessage, GetProjectsResponse> GET_PROJECTS_FOR_USER = // makeApi("/project/getForUser"); // // public final Api<SetActiveProject, EmptyMessage> SET_ACTIVE_PROJECT = // makeApi("/settings/setActiveProject"); // // public final Api<SetProjectHidden, EmptyMessage> SET_PROJECT_HIDDEN = // makeApi("/settings/setProjectHidden"); // // /** // * Gets the information about the project that owns a particular workspace. // */ // public final Api<GetOwningProject, GetOwningProjectResponse> GET_OWNING_PROJECT = // makeApi("/project/getFromWsId"); // // /** // * Request to be a project member. // */ // public final Api<RequestProjectMembership, EmptyMessage> REQUEST_PROJECT_MEMBERSHIP = // makeApi("/project/requestProjectMembership"); // // /** // * Gets the list of project members, and users who requested project // * membership. // */ // public final Api<GetProjectMembers, GetProjectMembersResponse> GET_PROJECT_MEMBERS = // makeApi("/project/getMembers"); // // /** // * Gets the list of workspace members. // */ // public final Api<GetWorkspaceMembers, GetWorkspaceMembersResponse> GET_WORKSPACE_MEMBERS = // makeApi("/workspace/mgmt/getMembers"); // // /** // * Gets the list of workspace participants. // */ // public final Api<GetWorkspaceParticipants, GetWorkspaceParticipantsResponse> // GET_WORKSPACE_PARTICIPANTS = makeApi("/workspace/act/getParticipants"); // // /** // * Add members to a project. // */ // public final Api<AddProjectMembers, AddMembersResponse> ADD_PROJECT_MEMBERS = // makeApi("/project/addProjectMembers"); // // /** // * Add members to a workspace. // */ // public final Api<AddWorkspaceMembers, AddMembersResponse> ADD_WORKSPACE_MEMBERS = // makeApi("/workspace/mgmt/addWorkspaceMembers"); // // /** // * Set the project role for a single user. // */ // public final Api<SetProjectRole, SetRoleResponse> SET_PROJECT_ROLE = // makeApi("/project/setProjectRole"); // // /** // * Set the workspace role for a single user. // */ // public final Api<SetWorkspaceRole, SetRoleResponse> SET_WORKSPACE_ROLE = // makeApi("/workspace/mgmt/setWorkspaceRole"); // // /** // * Log an exception to the server and potentially receive an unobfuscated // * response. // */ // public final Api<LogFatalRecord, LogFatalRecordResponse> LOG_REMOTE = // makeApi("/logging/logFatal"); // // /** Sends an ADD_FILE, ADD_DIR, COPY, MOVE, or DELETE tree mutation. */ // public final Api<WorkspaceTreeUpdate, WorkspaceTreeUpdateResponse> MUTATE_WORKSPACE_TREE = // makeApi("/workspace/mgmt/mutateTree"); // // // TODO: this may want to move to browser channel instead, for // // search-as-you-type streaming. No sense to it yet until we have a real // // backend, though. // public final Api<Search, SearchResponse> SEARCH = makeApi("/workspace/mgmt/search"); // // /** Updates the name and summary of a project. */ // public final Api<UpdateProject, EmptyMessage> UPDATE_PROJECT = makeApi("/project/updateProject"); // // /** Requests that we get updated information about a workspace. */ // public final Api<UpdateWorkspace, EmptyMessage> UPDATE_WORKSPACE = // makeApi("/workspace/mgmt/updateWorkspace"); // // /** Requests that we get updated information about a workspace's run targets. */ // public final Api<UpdateWorkspaceRunTargets, EmptyMessage> UPDATE_WORKSPACE_RUN_TARGETS = // makeApi("/workspace/mgmt/updateWorkspaceRunTargets"); // // public final Api<GetUserAppEngineAppIds, GetUserAppEngineAppIdsResponse> // GET_USER_APP_ENGINE_APP_IDS = makeApi("/settings/getUserAppEngineAppIds"); // // public final Api<BeginUploadSession, EmptyMessage> BEGIN_UPLOAD_SESSION = // makeApi("/uploadcontrol/beginUploadSession"); // // public final Api<EndUploadSession, EmptyMessage> END_UPLOAD_SESSION = // makeApi("/uploadcontrol/endUploadSession"); // // public final Api<RetryAlreadyTransferredUpload, EmptyMessage> RETRY_ALREADY_TRANSFERRED_UPLOAD = // makeApi("/uploadcontrol/retryAlreadyTransferredUpload"); // // public final Api<EmptyMessage, GetStagingServerInfoResponse> GET_MIMIC_INFO = // makeApi("/settings/getStagingServerInfo"); // // public final Api<SetStagingServerAppId, EmptyMessage> SET_MIMIC_APP_ID = // makeApi("/settings/setStagingServerAppId"); // // public final Api<GetDeployInformation, GetDeployInformationResponse> GET_DEPLOY_INFORMATION = // makeApi("/workspace/mgmt/getDeployInformation"); // // public final Api<UpdateUserWorkspaceMetadata, EmptyMessage> UPDATE_USER_WORKSPACE_METADATA = // makeApi("/settings/updateUserWorkspaceMetadata"); // // public final Api<RecoverFromDroppedTangoInvalidation, RecoverFromDroppedTangoInvalidationResponse> // RECOVER_FROM_DROPPED_INVALIDATION = makeApi("/payload/recover"); // // /** // * Deploy a workspace. // */ // public final Api<DeployWorkspace, EmptyMessage> DEPLOY_WORKSPACE = // makeApi("/workspace/act/DeployWorkspace"); /////////////////////////////// // END AVAILABLE FRONTEND APIS /////////////////////////////// /** * Generic mechanism for dealing with failed XHRs and responding to them in * some sensible fashion. */ public interface XhrFailureHandler { /** * Returns whether or not the Client should continue retrying RPCs. */ boolean handleFailure(FrontendRestApi api, FailureReason failure, ServerError responseData); } /** * Creates a FrontendApi and initializes it. */ public static FrontendRestApi create(MessageFilter messageFilter, final StatusManager statusManager) { // Make a new FrontendApi with a failure recoverer that knows how to deal // with XSRF token recovery. FrontendRestApi frontendApi = new FrontendRestApi(messageFilter, new XhrFailureHandler() { @Override public boolean handleFailure(FrontendRestApi api, FailureReason failure, ServerError errorDto) { switch (failure) { case INVALID_XSRF_TOKEN: // Update our XSRF token. InvalidXsrfTokenServerError xsrfError = (InvalidXsrfTokenServerError) errorDto; BootstrapSession.getBootstrapSession().setXsrfToken(xsrfError.getNewXsrfToken()); api.initCustomHeaders(); return true; case CLIENT_FRONTEND_VERSION_SKEW: // Display a message to the user that he needs to reload the client. StatusMessage skewMsg = new StatusMessage(statusManager, MessageType.LOADING, "A new version of Collide is available. Please Reload."); skewMsg.setDismissable(false); skewMsg.addAction(StatusMessage.RELOAD_ACTION); skewMsg.fireDelayed(500); return false; case NOT_LOGGED_IN: // Display a message to the user that he needs to reload the client. StatusMessage loginMsg = new StatusMessage(statusManager, MessageType.LOADING, "You have been signed out. Please reload to sign in."); loginMsg.setDismissable(true); loginMsg.addAction(StatusMessage.RELOAD_ACTION); loginMsg.fireDelayed(500); return false; default: // Allow the RPC retry logic to proceed. return true; } } }); frontendApi.initCustomHeaders(); return frontendApi; } private final MessageFilter messageFilter; private final JsonStringMap<String> customHeaders = JsonCollections.createMap(); private final XhrFailureHandler recoverer; public FrontendRestApi(MessageFilter messageFilter) { this(messageFilter, null); } public FrontendRestApi(MessageFilter messageFilter, XhrFailureHandler recoverer) { this.messageFilter = messageFilter; this.recoverer = recoverer; } private void initCustomHeaders() { addCustomHeader(FrontendConstants.CLIENT_BOOTSTRAP_ID_HEADER, BootstrapSession.getBootstrapSession().getActiveClientId()); addCustomHeader( FrontendConstants.XSRF_TOKEN_HEADER, BootstrapSession.getBootstrapSession().getXsrfToken()); addCustomHeader(FrontendConstants.CLIENT_VERSION_HASH_HEADER, DtoClientImpls.CLIENT_SERVER_PROTOCOL_HASH); } /** * Adds a custom header which is appended to every api request. */ public void addCustomHeader(String header, String value) { customHeaders.put(header, value); } protected MessageFilter getMessageFilter() { return messageFilter; } /** * Makes an API given the URL. * * @param <REQ> the request object * @param <RESP> the response object */ protected <REQ extends ClientToServerDto, RESP extends ServerToClientDto> Api<REQ, RESP> makeApi( String url) { return new ApiImpl<REQ, RESP>(url); } }