Google Drive SDK

Example Drive App: DrEdit for Java

DrEdit is a sample Google Drive app written in Java using the Google App Engine. It is a text editor capable of editing files with the MIME types text/* and extensions such as .txt, .html etc. that are stored in a user's Google Drive. This article describes the complete application to help you in your integrations.

Setting up this sample application

Setting up DrEdit requires using Google App Engine and performing some configuration. Follow the instructions in the repository's README to set up the application.

Features

DrEdit uses these two types of endpoints:

  • User interface at /

    The user interface is rendered including HTML, JavaScript, and CSS. Requests to this endpoint are handled by the StartPageServlet class.

  • AJAX service to communicate with Drive at /svc, /about and /user

    Drive API requests are made in response to GET, PUT, and POST requests and return JSON data. Requests to this endpoint are handled by the FileServlet class.

DrEdit implements two Google Drive use cases:

  1. Create a new file from the Google Drive UI
  2. Open a file from the Google Drive UI

The flow for both use cases is similar. A user is redirected to DrEdit after selecting DrEdit from the Create menu or context menu of a file with a registered MIME type.

This redirect takes place with two important parts of data as query parameters:

  1. An authorization code, capable of being exchanged for an access token to access the user's data.
  2. A state object describing the user's action, i.e. which file(s) and which action (open, create) to perform.

DrEdit responds by performing the required action on the passed files using the authorization credentials. The authorization credentials are stored in a session for future use by AJAX requests from the user interface.

Authorization

In order for DrEdit to be able to make calls to the Drive API, an authorized API client must be created. The first requirement of using the Google API Java Client Library is to get the credentials of the user to authorize the client. Getting the user's permission is what enables the Drive client to make requests to the Drive API on behalf of the user.

These credentials can be loaded from:

  1. A code passed from the Google Drive UI
  2. The user's session

The action that DrEdit is performing determines from where credentials need to be loaded. In cases where the user has been redirected from the Drive user interface, an authorization code is always provided and DrEdit always uses it. In cases where the user is making AJAX calls from the user interface, the credentials are loaded from a session.

Setting up the client ID, client secret, and other OAuth 2.0 parameters

Google Drive applications need these three values for authorization:

  1. Client ID
  2. Client secret
  3. List of valid redirect URIs

The client ID and client secret for an application are created when an application is registered in the Google APIs Console and the OAuth 2.0 client IDs are generated. These can be viewed in API Access tab of a project.

DrEdit stores these settings in the file war/WEB-INF/client_secrets.json. When downloading DrEdit, a file named client_secrets.json.example is provided. This example file must be copied to client_secrets.json, and modified to reflect an application's own client ID, client secret, and list of valid redirect URIs. Developers normally do not need to change the auth_uri or token_uri parameters.

{
  "web": {
    "client_id": "abc123456789.apps.googleusercontent.com",
    "client_secret": "def987654321",
    "redirect_uris": ["https://my-dredit-instance.appspot.com"],
    "auth_uri": "https://accounts.google.com/o/oauth2/auth",
    "token_uri": "https://accounts.google.com/o/oauth2/token"
  }
}

Authorizing a code passed from the Google Drive UI

After a user chooses DrEdit to open or create a file, the user's browser is redirected to one of DrEdit's registered redirect URIs (set in the Google APIs Console), with an OAuth 2.0 authorization code attached as the code query parameter in the URI. The given authorization code is scoped specifically to open the single file (Open With) or to create any new file (Create New), and must be exchanged for an access token with the OAuth 2.0 web server flow.

To exchange the authorization code for an access token and refresh token, DrEdit uses the CredentialManager.retrieve method.

/**
 * Retrieves a new access token by exchanging the given code with OAuth2
 * end-points.
 * @param code Exchange code.
 * @return A credential object.
 */
public Credential retrieve(String code) {
  try {
    GoogleTokenResponse response = new GoogleAuthorizationCodeTokenRequest(
        transport,
        jsonFactory,
        clientSecrets.getWeb().getClientId(),
        clientSecrets.getWeb().getClientSecret(),
        code,
        clientSecrets.getWeb().getRedirectUris().get(0)).execute();
    return buildEmpty().setAccessToken(response.getAccessToken());
  } catch (IOException e) {
    new RuntimeException("An unknown problem occured while retrieving token");
  }
  return null;
}

Once these tokens are obtained, they are used to fetch the user profile information from the Userinfo service. This profile information is stored along with the user's authorization credentials in the current session for later user.

 try {
  Userinfo about = service.userinfo().get().execute();
  String id = about.getId();
  credentialManager.save(id, credential);
  req.getSession().setAttribute(KEY_SESSION_USERID, id);
} catch (IOException e) {
  throw new RuntimeException("Can't handle the OAuth2 callback, " +
      "make sure that code is valid.");
}

Loading credentials from the session

At the beginning of each request to DrEdit, DrEdit attempts to load credentials from the current user's session and the Google App Engine data store. The CredentialMediator class handles the current session. Since the HttpServletRequest object is in scope of CredentialManager, it is possible to manipulate the session of the active user.

/**
 * Returns the credentials of the user in the session. If user is not in the
 * session, returns null.
 * @param req   Request object.
 * @param resp  Response object.
 * @return      Credential object of the user in session or null.
 */
protected Credential getCredential(HttpServletRequest req,
    HttpServletResponse resp) {
  String userId = (String) req.getSession().getAttribute(KEY_SESSION_USERID);
  if (userId != null) {
    return credentialManager.get(userId);
  }
  return null;
};

Storing and retrieving credentials from the database

Google Drive applications only receive a long-lived refresh token during the very first exchange of an authorization code. This refresh token is used to request new access tokens after a short-lived access token expires, and it must be stored in the application's database to be retrieved every time the user returns to the app.

DrEdit relies on the Google App Engine data store to store access token and refresh token pairs associated with a Google user ID.

The CredentialManager class provides some methods that store, retrieve, and delete tokens from the Google App Engine Datastore.

/**
 * Returns credentials of the given user, returns null if there are none.
 * @param userId The id of the user.
 * @return A credential object or null.
 */
public Credential get(String userId) {
  Credential credential = buildEmpty();
  if (credentialStore.load(userId, credential)) {
    return credential;
  }
  return null;
}

/**
 * Saves credentials of the given user.
 * @param userId The id of the user.
 * @param credential A credential object to save.
 */
public void save(String userId, Credential credential) {
  credentialStore.store(userId, credential);
}

Failing to authorize

There are a number of points at which the authorization process can fail while retrieving credentials. You can experience problems:

  • When no refresh token has been found.
  • When a code exchange has failed, this redirects the user back to login page.

If no refresh token is found, the user is redirected to the OAuth 2.0 authorization endpoint so that they can reauthorize and return to DrEdit. The other two exceptions are not handled, because in a production environment, behavior may differ.

Putting together the pieces: getting a complete set of credentials for every request

For each request, the servlet initializes a Drive service instance and authenticates it with the user's stored OAuth 2.0 credentials.

/**
 * Returns the credentials of the user in the session. If user is not in the
 * session, returns null.
 * @param req   Request object.
 * @param resp  Response object.
 * @return      Credential object of the user in session or null.
 */
protected Credential getCredential(HttpServletRequest req,
    HttpServletResponse resp) {
  String userId = (String) req.getSession().getAttribute(KEY_SESSION_USERID);
  if (userId != null) {
    return credentialManager.get(userId);
  }
  return null;
};

/**
 * Build and return a Drive service object based on given request parameters.
 * @param credential User credentials.
 * @return Drive service object that is ready to make requests, or null if
 *         there was a problem.
 */
protected Drive getDriveService(Credential credential) {
  return new Drive.Builder(TRANSPORT, JSON_FACTORY, credential).build();
}

If user is not in the session, the servlet redirects to / for the login page or responds with HTTP 401 for AJAX calls.

/**
 * Redirects to OAuth2 consent page if user is not logged in.
 * @param req   Request object.
 * @param resp  Response object.
 */
protected void loginIfRequired(HttpServletRequest req,
    HttpServletResponse resp) {
  Credential credential = getCredential(req, resp);
  if (credential == null) {
    // redirect to authorization url
    try {
      resp.sendRedirect(credentialManager.getAuthorizationUrl());
    } catch (IOException e) {
      throw new RuntimeException("Can't redirect to auth page");
    }
  }
}
// ...
try {
  Userinfo about = service.userinfo().get().execute();
  sendJson(resp, about);
} catch (GoogleJsonResponseException e) {
  if (e.getStatusCode() == 401) {
    // The user has revoked our token or it is otherwise bad.
    // Delete the local copy so that their next page load will recover.
    deleteCredential(req, resp);
    sendGoogleJsonResponseError(resp, e);
  }
}

Making authenticated API requests

Once the access token has been retrieved, it is used to authorize and authenticate the Drive service that will be used for making API requests.

/**
 * Build and return a Drive service object based on given request parameters.
 * @param credential User credentials.
 * @return Drive service object that is ready to make requests, or null if
 *         there was a problem.
 */
protected Drive getDriveService(Credential credential) {
  return new Drive.Builder(TRANSPORT, JSON_FACTORY, credential).build();
}

When an authorized and authenticated Drive service is available, making requests to the Drive API takes a single line of code. Doing so is described in the next section.

Handling HTTP requests

This section describes how DrEdit handles HTTP requests for operations such as creating and opening new files and rendering the user interface.

Requests to the user interface

The user interface is provided as a single HTML template which is used for both opening (Open with) and creating new files. Once loaded, the user interface loads any data required.

The text editing component uses the Ace Editor, an editor with syntax highlighting and configurable key bindings. It is used in DrEdit as a form field with advanced editing features.

Users may arrive at the / endpoint from two separate sources:

  1. Drive User interface, along with Drive State
  2. By directly accessing the URL, with no Drive State

Specifically, the user interface is provided by the StartPageServlet class, which does nothing more than check authorization and display the JSP page to render the user interface.

Loading the Drive state

DrEdit contains a helper class for parsing and loading the Drive state. The state parameter is a JSON string containing two important properties:

  1. The action to be performed, create or open in the action field
  2. The file IDs (if any) to perform the action on in the ids field

DrEdit uses this state to determine how it should behave. If file ids are provided they are loaded immediately after the user interface is loaded.

// Deserialize the state in order to specify some values to the DrEdit
// JavaScript client below.
String stateParam = req.getParameter("state");
if (stateParam != null) {
  State state = new State(stateParam);
  if (state.ids != null && state.ids.size() > 0) {
    resp.sendRedirect("/#/edit/" + state.ids.toArray()[0]);
  } else if (state.parentId != null) {
    resp.sendRedirect("/#/create/" + state.parentId);
  }
}

Rendering the user interface

The user interface is rendered in response to GET requests. The Drive state is loaded and the 'index.jsp' template is rendered. There is no communication with the Drive API at this stage, but credentials are checked.

The StartPageServlet renders the same view for both the Open with and the Create New scenarios, the only difference being the value of the ids property, which contains the file id(s) that have been sent from the Drive user interface when opening existing files.

Requests to the Files service

The /svc endpoint allows three HTTP methods.

  • GET: Fetches file metadata and content from Google Drive.
  • POST: Creates new files in Google Drive.
  • PUT: Updates existing files in Google Drive.

Data is always returned as JSON with the Content-Type application/json, but the JSON data returned varies with each request type. GET requests receive a response that is a JSON representation of a File. POST and PUT requests receive a response that is a JSON-encoded file ID.

Representing file data in transit between the user interface and /svc endpoint

A model class is provided to simplify JSON serialization and deserialization of file objects. This class combines a representation of File objects, which only represent the metadata of a file, and the file's content, into a single JSON object.

Fetching file metadata and content from Google Drive

The file is retrieved in two steps. First, the metadata is fetched using the files.get method, passing the file_id that was sent in the initial request. Second, the file content is fetched by making a simple authorized GET request to the file's downloadUrl. Both metadata and file content are used to instantiate a ClientFile object that is then serialized into JSON and returned to the user.

/**
 * Given a {@code file_id} URI parameter, return a JSON representation
 * of the given file.
 */
@Override
public void doGet(HttpServletRequest req, HttpServletResponse resp)
    throws IOException {
  Drive service = getDriveService(getCredential(req, resp));
  String fileId = req.getParameter("file_id");

  if (fileId == null) {
    sendError(resp, 400, "The `file_id` URI parameter must be specified.");
    return;
  }

  File file = null;
  try {
    file = service.files().get(fileId).execute();
  } catch (GoogleJsonResponseException e) {
    if (e.getStatusCode() == 401) {
      // The user has revoked our token or it is otherwise bad.
      // Delete the local copy so that their next page load will recover.
      deleteCredential(req, resp);
      sendGoogleJsonResponseError(resp, e);
      return;
    }
  }

  if (file != null) {
    String content = downloadFileContent(service, file);
    if (content == null) {
      content = "";
    }
    sendJson(resp, new ClientFile(file, content));
  } else {
    sendError(resp, 404, "File not found");
  }
}

The file content is retrieved by the FileServlet.downloadFileContent helper method:

/**
 * Download the content of the given file.
 *
 * @param service Drive service to use for downloading.
 * @param file File metadata object whose content to download.
 * @return String representation of file content.  String is returned here
 *         because this app is setup for text/plain files.
 * @throws IOException Thrown if the request fails for whatever reason.
 */
private String downloadFileContent(Drive service, File file)
    throws IOException {
  GenericUrl url = new GenericUrl(file.getDownloadUrl());
  HttpResponse response = service.getRequestFactory().buildGetRequest(url)
      .execute();
  try {
    return new Scanner(response.getContent()).useDelimiter("\\A").next();
  } catch (java.util.NoSuchElementException e) {
    return "";
  }
}

Saving files

When the user clicks Save from the DrEdit user interface, an AJAX request is made from the user interface to the server. This request is either:

  • POST: indicating that a new file is to be created.
  • PUT: indicating that an existing file is to be updated, or

These methods match standard REST semantics, and are convenient because both can be represented as a single URL which handles different HTTP methods.

The data is sent as JSON containing the following fields:

  • title: Title of the file.
  • description: Description of the file.
  • content: Content of the file, i.e., the content from the text editor.
  • file_id: File ID of the file, or an empty string if this is a new file.
  • parents: Collection containing the ID of the folder to save new files to.
  • labels: Map containing the boolean indicating if the file has been starred.

Both PUT and POST methods return a JSON response that contains the file ID of the saved or created file. The file ID is stored in the user interface, and is sent along with future requests.

Saving new files

The POST method is used to save newly created files. A files.insert call is made to create the new file and set the metadata.

/**
 * Create a new file given a JSON representation, and return the JSON
 * representation of the created file.
 */
@Override
public void doPost(HttpServletRequest req, HttpServletResponse resp)
    throws IOException {
  Drive service = getDriveService(getCredential(req, resp));
  ClientFile clientFile = new ClientFile(req.getReader());
  File file = clientFile.toFile();

  if (!clientFile.content.equals("")) {
    file = service.files().insert(file,
        ByteArrayContent.fromString(clientFile.mimeType, clientFile.content))
        .execute();
  } else {
    file = service.files().insert(file).execute();
  }
  sendJson(resp, file.getId());
}

Updating files

The process for updating a file is similar to that of creating one. File updates are made using an HTTP PUT instead of an HTTP POST.

Unlike when creating files, the files.update method of the API is used, and this requires the file ID to be passed as the file_id parameter.

/**
 * Update a file given a JSON representation, and return the JSON
 * representation of the created file.
 */
@Override
public void doPut(HttpServletRequest req, HttpServletResponse resp)
    throws IOException {
  boolean newRevision = req.getParameter("newRevision").equals(Boolean.TRUE);
  Drive service = getDriveService(getCredential(req, resp));
  ClientFile clientFile = new ClientFile(req.getReader());
  File file = clientFile.toFile();
  // If there is content we update the given file
  if (clientFile.content != null) {
    file = service.files().update(clientFile.resource_id, file,
        ByteArrayContent.fromString(clientFile.mimeType, clientFile.content))
        .setNewRevision(newRevision).execute();
  } else { // If there is no content we patch the metadata only
    file = service.files()
        .patch(clientFile.resource_id, file)
        .setNewRevision(newRevision)
        .execute();
  }
  sendJson(resp, file.getId());
}

Responding with the file ID

Both creating new files and updating existing files return a JSON response containing the file ID of the file that has been created or modified. The user interface can use this file ID to ensure that future saves are to the same file, and that a new file is not created each time.

Personalizing the user interface

Additional services are provided to customize the user interface for the authorized user. This has the benefit of providing a familiar and personalized experience for the user.

About service

The /about endpoint allows one HTTP method.

  • GET: Fetches information about the authorized user and their account.

This service returns information about the currently authorized user and settings for their account. This service fetches and returns the JSON resource representation described in about.get.

/**
 * Returns a JSON representation of Drive's user's About feed.
 */
@Override
public void doGet(HttpServletRequest req, HttpServletResponse resp)
    throws IOException {
  Drive service = getDriveService(getCredential(req, resp));
  try {
    About about = service.about().get().execute();
    sendJson(resp, about);
  } catch (GoogleJsonResponseException e) {
    if (e.getStatusCode() == 401) {
      // The user has revoked our token or it is otherwise bad.
      // Delete the local copy so that their next page load will recover.
      deleteCredential(req, resp);
      sendGoogleJsonResponseError(resp, e);
    }
  }
}

User service

The /user endpoint allows one HTTP method.

  • GET: Fetches information about the user

This service returns information about the authenticated user using the UserInfo API.

/**
 * Returns a JSON representation of the user's profile.
 */
@Override
public void doGet(HttpServletRequest req, HttpServletResponse resp)
    throws IOException {
  Oauth2 service = getOauth2Service(getCredential(req, resp));
  try {
    Userinfo about = service.userinfo().get().execute();
    sendJson(resp, about);
  } catch (GoogleJsonResponseException e) {
    if (e.getStatusCode() == 401) {
      // The user has revoked our token or it is otherwise bad.
      // Delete the local copy so that their next page load will recover.
      deleteCredential(req, resp);
      sendGoogleJsonResponseError(resp, e);
    }
  }
}

Additional resources

Authentication required

You need to be signed in with Google+ to do that.

Signing you in...

Google Developers needs your permission to do that.