Accessing Google Documents with Ruby on Rails

I’m building a basic project management system, and one of the requirements is that I want all my project’s documents to be stored and available online, exportable into multiple formats, shared among multiple users who have the ability to work collaboratively on the same document if need be.  Normally, this would take a lot of work to implement – its a hefty list of requirements.  But, this is exactly the functionality that Google Documents offers…wouldn’t it be great to be able to tap into the work done by Google and leverage it for my own site?

The answer is an obvious yes, and now its possible and quite easy thanks to the Google Documents API.  I’ve created a full featured Ruby Gem that implements all of the features available through the Documents API: GDocs4Ruby.

This tutorial will walk through the basic steps of creating a document management system that uses the Google Documents system as the data storage back-end.  I’ll be coding the front-end in Ruby on Rails.

The source for all the examples discussed below is available here.

Basic Requirements

Here’s a list of the basic requirements for the application:

  1. Users can view a list of documents and folders stored in the system.
  2. Each project has a documents folder where all related documents are stored.
  3. Users can upload documents from their computer.
  4. Users can create new documents.
  5. Users can delete documents.
  6. Users can edit the content of the document.
  7. Multiple users can collaboratively edit documents.

It seems like a big list, but luckily this will all be pretty easy to do.

Application Setup

First step is to create a new rails structure, I’ll be calling it ‘GDocs4Ruby_Example’.

> rails gdocs4ruby_example

Next, we’ll create the controller for the application, called Documents.

> script/generate controller Documents

So, if we were creating a normal application, we would create our models at this point. But in this case, because we’ll be storing everything on the Google servers, we’ll use the classes included in the GDocs4Ruby library as our models. They contain similar methods to ActiveRecord classes, so they integrate nicely with Ruby on Rails code.

Setting up the Session
The first thing we need to do is setup the GDocs4Ruby#Session class. This will handle all of the communication with the Google servers, and provide the authentication interface for our user.

To enable everyone to see the same list of files, we’ll want to have a base user that is used to access the Google Documents API.  We could put a local user authentication system on top of this to ensure that only our project staff have access to the system, but the backend interactions will all happen under one Google account.

What we will do is create a ApplicationController method that sets up the Service object every time a request is made, called @account in the examples. Then we’ll add a before_filter to the Documents controller that ensures this setup method is called when we need to work with the Google servers.

require 'GDocs4Ruby'
class ApplicationController < ActionController::Base
  include GDocs4Ruby

  helper :all # include all helpers, all the time
  protect_from_forgery # See ActionController::RequestForgeryProtection for details

  # Scrub sensitive parameters from your log
  # filter_parameter_logging :password

  def setup
    @account = Service.new()
    @account.debug = true
    @account.authenticate(USERNAME, PASSWORD)
  end
end

USERNAME and PASSWORD should be constants containing the credentials for the shared account.

Here’s the code for the Documents controller.

class DocumentsController < ApplicationController
  before_filter :setup
end

Now we have an @account object available whenever we need to interact with the Google servers.

Getting a List of Folders and Documents
The first page we want our users to see is a list of folders and documents, so they can select where to go next.  I’ll use a basic 2 column layout that is familiar to everyone, with the folders on the left and the documents on the right.

First, we’ll get a list of the documents and folders in the controller, then pass that onto the view to display. I’ll also want to be able to reuse this same code for viewing the contents of folders, so I’ll add an if statement that checks to see if a folder_id parameter has been passed, and load the variables accordingly. In the Documents controller, add:

  def index
    if not params[:folder_id]
      #Display all files and root folders
      @documents = @account.files
      @folders = @account.folders.select{|f| !f.parent } #display only root folders
    else
      #Display only files and folders contained by folder_id
      @folder = Folder.find(@account, {:id => params[:folder_id]})
      @documents = @folder.files
      @folders = @folder.folders
    end
  end

If no folder is specified we grab the root files and folders from the @account Service object. Otherwise, we find the specified folder and load the child folders and files.

In the corresponding .erb file:

<% @title = 'Documents' %>
<table>
	<tr>
		<td style='width: 40%; vertical-align: top;'>
			<strong>Folders</strong>
			<ul>
			<% @folders.each do |f| %>
				<li>
					<%= link_to f.title, :action => :index, :folder_id => f.id %> (<%= link_to 'X', {:action => :delete, :folder_id => f.id}, :confirm => 'Are you sure you want to delete this Folder?' %>)
					<%= render :partial => 'folders', :locals => {:folder => f} %>
				</li>
			<% end %>
			</ul>
		</td>
		<td style='vertical-align: top;'>
			<strong>All Documents</strong>
			<%= render :partial => 'file_list' %>
		</td>
	</tr>
</table>

Following DRY practices, I’ve seperated the common display code for folders and files into two partials: ‘folders’ and ‘file_list’.

Here’s the ‘folders’ partial:

<ul>
	<% folder.sub_folders.each do |f| %>
	<li>
		<%= link_to f.title, :action => :index, :folder_id => f.id %> (<%= link_to 'X', {:action => :delete, :folder_id => f.id}, :confirm => 'Are you sure you want to delete this Folder?' %>)
		<%= render :partial => 'folders', :locals => {:folder => f} %>
	</li>
	<% end %>
</ul>

and the ‘file_list’ partial:

<ul>
	<% @documents.each do |doc| %>
	<li>
		<% if not doc.viewed %>
			<strong>
		<% end %>
		<%= link_to doc.title, :action => :view, :doc_id => doc.id %> <em><%= doc.type.capitalize %></em>(<%= link_to 'X', {:action => :delete, :doc_id => doc.id}, :confirm => 'Are you sure you want to delete this file?' %>)
		<% if not doc.viewed %>
			</strong>
		<% end %>
	</li>
	<% end %>
</ul>

Basically, these display the folders and files within <ul> tags, and add links to drill down into folders and view the details for files.
Now, if you run the code and view ‘/documents/’ in your browser, you should see a list of files and folders.

Viewing File Details
The next step is to implement a basic file view. When a user clicks on a file, we want them to be able to view the metadata for the file (i.e. title and author and updated date), download a copy of the file in different formats, and manage the file’s folders and user permissions. This will all be done through the ‘view’ page.
First, add a new method to the Documents controller:

  def view
    @document = BaseObject.find(@account, {:id => params[:doc_id]})
  end

This takes the passed parameter ‘doc_id’ and loads the Document. Because we don’t know which type of file this is necessarily (i.e. Document or Spreadsheet) we’ll use the BaseObject#find method.
For the view.html.erb file, add:

<% @title = 'View Document Info' %>
<p><%= button_to_function 'Edit', "window.location = '#{url_for(:action => :edit, :doc_id => @document.id)}'" %> <%= button_to_function 'Edit in iFrame', "window.location = '#{url_for(:action => :edit_iframe, :doc_id => @document.id)}'" %>
<p><strong>Title:</strong> <%= @document.title %></p>
<p><strong>Author:</strong> <%= @document.author_name %> (<%= @document.author_email %>)</p>
<p><strong>Type:</strong> <%= @document.type %></p>
<p><strong>Published: </strong><%= Time.parse(@document.published).strftime("%m/%d/%Y") %></p>
<p><strong>Updated: </strong><%= Time.parse(@document.updated).strftime("%m/%d/%Y") %></p>

<h3>Folders</h3>
<% form_for '', :url => {:action => :update_doc_folder, :doc_id => @document.id, :do => 'add'} do %>
<p><%= select_tag :folder, options_for_select(@account.folders.select{|f| !@document.folders.include?(f.title) }.collect{|f| [f.title, f.id]}) %> <%= submit_tag 'Add to Folder' %></p>
<% end %>
<ul>
	<% @document.folders.each do |f| %>
		<li><%= f %> (<%= link_to 'X', {:action => :update_doc_folder, :doc_id => @document.id, :do => 'delete', :folder => f} %>)</li>
	<% end %>
</ul>
<h3>Download As:</h3>
<%= render :partial => @document.type+"_download" %>

<h3>Permissions</h3>
<strong>Add User</strong>
<% form_for '', :url => {:action => :add_user, :doc_id => @document.id} do %>
<p>Email: <%= text_field_tag :user %> Role: <%= select_tag :role, options_for_select(['writer', 'reader']) %> <%= submit_tag 'Save' %>
<% end %>
<ul>
<% @document.access_rules.each do |a| %>
	<li><%= a.user %> - <%= a.role %> <% if a.role != 'owner' %>(<%= link_to 'X', {:action => :remove_user, :user => a.user, :doc_id => @document.id}, :confirm => 'Are you sure you want to remove this user?' %>)<% end %></li>
<% end %>
</ul>

There are four main sections of the view: file metadata, download links, folders and user permissions.

For the file metadata, we display the title, type, author and published/updated dates. This is all loaded automatically by the Document object.

For the download links, we need to provide different download types depending on the document type. In other words, Google provides different types of file exports for each Document, Presentation or Spreadsheet. So a document can be exported as RTF, while a spreadsheet can be exported as XLS. So we’ll use a partial to display the allowed download types for each file, for example ‘document_download’. Here’s the example of the document partial:

<%= link_to 'Doc', :action => :download, :doc_id => @document.id, :type => 'doc' %>
<%= link_to 'HTML', :action => :download, :doc_id => @document.id, :type => 'html' %>
<%= link_to 'ODT', :action => :download, :doc_id => @document.id, :type => 'odt' %>
<%= link_to 'PDF', :action => :download, :doc_id => @document.id, :type => 'pdf' %>
<%= link_to 'PNG', :action => :download, :doc_id => @document.id, :type => 'png' %>
<%= link_to 'RTF', :action => :download, :doc_id => @document.id, :type => 'rtf' %>
<%= link_to 'TXT', :action => :download, :doc_id => @document.id, :type => 'txt' %>
<%= link_to 'ZIP', :action => :download, :doc_id => @document.id, :type => 'zip' %>

and the download method in the controller looks like this:

  def download
    @document = BaseObject.find(@account, {:id => CGI::unescape(params[:doc_id])})
    send_data @document.get_content(params[:type]), :disposition => 'inline', :filename => "#{@document.title}.#{params[:type]}"
  end

To display the folders the document is contained in, we simply access the folders attribute of the document object and iterate through the returned array.

Its a similar procedure to display the users who have access to the document; we iterate through the array returned by the access_rules method. Each object in the array is a GData4Ruby#AccessRule that contains a username and role. The roles for Google Documents may either be ‘writer’ or ‘reader’ depending on whether user can edit the file.

Editing a File
There are two ways you can edit a Google Documents file:

  1. Download the raw HTML and use a WISYWIG text editor, like TinyMCE, to edit the HTML, then save the edited HTML back to Google. This is a great solution for documents, however it won’t work for presentations or Google Spreadsheets.
  2. For spreadsheets and presentations, display the file as an embedded iframe, allowing you to use Google’s UI for editing the file. The upside of this approach is that you get all the great benefits of using the Google interface, and are able to edit collaboratively with other users. The downside is that all users (at least with version 2.0 of the API) have to be specifically added as ‘writers’ to the file.

There are two links at the top of the view page that provide access to these two editing methods. There is a great helper method for generating the iframe, BaseObject#to_iframe, that you can use to create the editing window.  Here is the view code for grabbing the Document content to edit (as HTML):

<% form_for '', :url => {:action => :save_content, :doc_id => @document.id} do %>
<p><%= text_area_tag 'content', @document.get_content('html'), :size => '50x10' %></p>
<%= submit_tag 'Save Content' %>
<% end %>

and for saving the metadata and content back to Google:

  def save
    @document = BaseObject.find(@account, {:id => CGI::unescape(params[:doc_id])})
    @document.title = params[:title]
    @document.save
    redirect_to :action => :view, :doc_id => @document.id
  end

  def save_content
    @document = BaseObject.find(@account, {:id => CGI::unescape(params[:doc_id])})
    @document.put_content(params[:content])
    redirect_to :action => :view, :doc_id => @document.id
  end

Uploading New Content
Now that the file interface is done, we want to be able to existing content to our Google account, i.e. upload a file. There are two cases where this can be used:

  1. Uploading a new file
  2. Uploading new content to an existing file

Since these are essentially the same functions, we’ll combine it into a single method in the Document controller.

  def send_upload
    if params[:doc_id]
      doc = BaseObject.find(@account, {:id => params[:doc_id]})
      doc.content = params[:upload_file].read
      doc.content_type = File.extname(params[:upload_file].original_filename).gsub(".", "")
      if doc.save
        flash[:notice] = 'File successfully uploaded'
      else
        flash[:warning] = 'Could not upload file!'
      end
      redirect_to :action => :view, :doc_id => doc.id and return
    else
      file = BaseObject.new(@account)
      file.title = params[:upload_file].original_filename.gsub(/\.\w.*/, "")
      file.content = params[:upload_file].read
      file.content_type = File.extname(params[:upload_file].original_filename).gsub(".", "")
      if file.save
        flash[:notice] = 'File successfully uploaded'
      else
        flash[:warning] = 'Could not upload file!'
      end
      redirect_to :action => :index and return
    end
  end

First, we want to check to see if a document ID is passed, in which case we’ll know we want to pass the new content to an existing file. We do this by setting the ‘content’ and ‘content_type’ attributes for the Document with the passed params.
Otherwise, we create a new Document and set the ‘content’ and ‘content_type’ attributes, and save the file.

Deleting a File or Folder
Deleting a file is simple and should be familiar now:

  def delete
    obj = nil
    if params[:doc_id]
      obj = BaseObject.find(@account, {:id => params[:doc_id]})
    elsif params[:folder_id]
      obj = Folder.find(@account, {:id => params[:folder_id]})
    end
    if obj and obj.delete
      flash[:notice] = 'Successfully deleted!'
    else
      flash[:notice] = "Error deleting!"
    end
    redirect_to request.referer
  end

Here, we check for either a file or folder id, grab the referenced file/folder and delete it if it exists.

Access Rules
To enable a different user to access a document, we need to add an Access Rule. This requires a user id, usually an email address, and a role, either ‘writer’ or ‘viewer’. We’ll need three methods to handle adding, updating and removing access rules:

  def add_user
    @document = BaseObject.find(@account, {:id => CGI::unescape(params[:doc_id])})
    @document.add_access_rule(params[:user], params[:role])
    redirect_to :action => :view, :doc_id => @document.id
  end

  def update_user
    @document = BaseObject.find(@account, {:id => CGI::unescape(params[:doc_id])})
    @document.update_access_rule(params[:user], params[:role])
    redirect_to :action => :view, :doc_id => @document.id
  end

  def remove_user
    @document = BaseObject.find(@account, {:id => CGI::unescape(params[:doc_id])})
    @document.remove_access_rule(params[:user])
    redirect_to :action => :view, :doc_id => @document.id
  end

These are pretty straightforward and make use of the related BaseObject functions for managing access rules.

Conclusion
And that’s it! Once you get all of it tied together, you’ll have a working document management system that stores content on Google servers, but is completely managed through your own website.

  • Share/Bookmark

Related Posts

6 Responses to “Accessing Google Documents with Ruby on Rails”


  • Could use out of the box, getting exceptions when requiring ‘gdata4ruby’, failing looking for base.rb – put a bogus file there – loaded fine..

    features work ok but only supports a specific set of files. Google docs now supports uploading of any file , jpg, png dmg, zip etc

    Tried to upload using BaseObject but no luck, couldnt find the right header content type I think.. any updates planned? thanks a bunch

  • Hey Chris, thanks for the note. Which version of the gem are you using? Thanks,

    -Mike

  • Hi Chris,

    Thanks again for this awesome gem!

    I made it through most of the implementation, but am having trouble getting get_content to actually work.

    gdoc.get_content(‘html’)

    Returns:
    GData4Ruby::HTTPRequestFailed:

    Not Found

    Not Found
    Error 404

    from /Users/chuboy/.gem/ruby/1.8/gems/gdata4ruby-0.1.5/lib/gdata4ruby/base.rb:126:in `do_request’
    from /Users/chuboy/.gem/ruby/1.8/gems/gdata4ruby-0.1.5/lib/gdata4ruby/base.rb:94:in `send_request’
    from /Users/chuboy/.gem/ruby/1.8/gems/gdocs4ruby-0.1.2/lib/gdocs4ruby/document.rb:81:in `get_content’
    from (irb):33
    from :0

    Any ideas why this would be the case?

  • I realized the problem:

    In gdocs4ruby-0.1.2/lib/gdocs4ruby/document.rb:81

    ret = service.send_request(GData4Ruby::Request.new(:get, EXPORT_URI, nil, nil, {“docId” => @id,”exportFormat” => type}))

    @id in this case actually needs to be:
    1hCi8Xs4ECEn0Pc_kR8ftRc9NnZtvDO0rioQP2c-11QA

    Right now, @id has a “document:” prefix to it:
    document:1hCi8Xs4ECEn0Pc_kR8ftRc9NnZtvDO0rioQP2c-11QA

    I think you will need to parse this out to make it work.

  • Thanks for the bug report Conrad. I’ll add the fix to the next release. Thanks!

  • Mike,
    Thanks for a great gem! I’m really enjoying all of the work you put into it. I am having a problem with getting folders to list though. This is being run in a script, not a rails application. I am requiring ‘rubygems’ and ‘gdocs4ruby’ at the beginning.

    I am using ruby 1.8.7 (2009-06-12 patchlevel 174) [universal-darwin10.0]
    Gem version 1.3.5

    My error begins with
    /System/Library/Frameworks/Ruby.framework/Versions/1.8/usr/lib/ruby/1.8/net/protocol.rb:135:in `sysread’: Connection reset by peer (Errno::ECONNRESET)

    Files list fine, but if I run
    folders = service.folders
    I get an error… bizarre.

    I noticed in the base_object.rb file in your hash declaration for FEEDS you declare the feed for folder listings to be
    :folder => “http://docs.google.com/feeds/folders/private/full/” (line 85ish)
    Google’s protocal guide at http://code.google.com/apis/documents/docs/3.0/developers_guide_protocol.html#RetrievingFolderList
    indicates the url might be
    http://docs.google.com/feeds/default/private/full/-/folder

    I’m not sure if this could be a contributing factor to my problem.

    Thank you :)
    Whit

Leave a Reply