Introduction

This document describes how to setup synchronization between a Rails application and Google Calendar. I am using the Google Calendar API v3 with OAuth2 and a service account to programmatically access and manage the calendar data.

Create the service account and its credentials

The service account will do the communication between the application and Google Calendar (through the v3 API). This account will at a later stage be used to impersonate the calendar user (in the same domain), so that all calendar updates appear to come from the calendar user, instead of the service account.

  1. Sign in to the account of which you want to share the Google Calendar.
  2. Visit the Google Developers Console
  3. Set up your project if you haven't already done so and enable the 'Google Calendar API'.
  4. Select your project and go to "APIs & auth", make sure "Google Calender API" is enabled.
  5. Click on 'credentials'.
  6. Click on "Create new client ID". Specify that your application type is service account, and proceed with the setup of the service account

You should now have

  • Client ID (xxx.apps.googleusercontent.com)
  • email address (xxx@developer.gserviceaccount.com)
  • A private key file

Share your calendar with the service account

Your service account will need to have access to the Google Calendar of the calendar user, to do this:

  1. Sign in to the account of which you want to share the Google Calendar.
  2. Visit Google Calendar
  3. Click on settings/calendars and 'edit settings' of the calendar you want to share
  4. In the field 'Share with specific people' add the developer email address: xxxxxx@developer.gserviceaccount.com
  5. select proper permissions and click on save.

Delegate domain-wide authority to your service account

If you skip this step, all events created by your service account will show the service accounts email address as the creator. To fix this, it needs to be granted access to the Google Apps domain’s user data that you want to access. The following tasks have to be performed by an administrator of the Google Apps domain:

  1. Go to your Google Apps domain’s Admin console.
  2. Select Security from the list of controls. If you don't see Security listed, select More controls from the gray bar at the bottom of the page, then select Security from the list of controls.
  3. Select Advanced settings from the list of options.
  4. Select Manage third party OAuth Client access in the Authentication section.
  5. In the Client name field enter the service account's Client ID.
  6. In the One or More API Scopes field enter the list of scopes that your application should be granted access to (https://www.googleapis.com/auth/calendar)
  7. Click the Authorize button.

Setup your rails environment

  1. Install the gem 'google-api-client'
  2. Copy private key of the service account to a folder outside your app/ and set proper permissions
  3. Our app EventR uses this mechanism, here is the relevant code (in the form of a ActiveSupprt::Concern module and using Rails 4.1 config/secrets.yml)

config/secrets.yml
development: &development
  calendarId: 'your calendar ID here. This can most of the times be set to: primary'
  service_account_email: 'xxx@developer.gserviceaccount.com'
  impersonate_user_email: 'replaceWithEmailYouWantToImpersonate@email.com'
  key_file: <%= "#{ENV['HOME']}/.eventr/<your key here>" %>
  key_secret: 'private-key-secret'

test:
  <<: *development

integration:
  <<: *development

production:
  <<: *development


app/models/concerns/calendar.rb
module Calendar
  require 'google/api_client'
  extend ActiveSupport::Concern

  API_VERSION = 'v3'
  CACHED_API_FILE = "calendar-#{API_VERSION}.cache"
  CALENDAR_ID = Rails.application.secrets.calendarId

  def gcal_event_insert
    params = {
      calendarId: CALENDAR_ID
    }
    result = client.execute(
      :api_method => calendar.events.insert,
      :parameters => params,
      :body_object => convert_to_gcal_event
    )
    logger.debug(result.data.to_yaml)
    result
  end

  def gcal_event_update
    params = {
      calendarId: CALENDAR_ID,
      eventId: self.gcal_id
    }
    result = client.execute(
      :api_method => calendar.events.update,
      :parameters => params,
      :body_object => convert_to_gcal_event
    )
    logger.debug(result.data.to_yaml)
  end

  def gcal_event_delete
    params = {
      calendarId: CALENDAR_ID,
      eventId: self.gcal_id
    }
    result = client.execute(
      :api_method => calendar.events.delete,
      :parameters => params
    )
    logger.debug(result.data.to_yaml)
  end

private
  def convert_to_gcal_event
    event = {
      'summary' => self.name,
      'description' => self.description,
      'start' => {
         'dateTime' => self.tstart
      },
      'end' => {
         'dateTime' => self.tend
      },
      'location' => get_event_location,
      'extendedProperties' => {
        'private' => {
          'id' => self.id
        }
      }
    }
  end

  def get_event_location
    [self.location.try(:name),
      self.location.try(:address),
      self.location.try(:city),
      self.location.try(:country)].compact.join(", ")
  end

  def init_client
    @client = Google::APIClient.new(:application_name => 'EventR', :application_version => '1.0.0')

    # Load our credentials for the service account
    key = Google::APIClient::KeyUtils.load_from_pkcs12(Rails.application.secrets.key_file, Rails.application.secrets.key_secret)
    @client.authorization = Signet::OAuth2::Client.new(
      :token_credential_uri => 'https://accounts.google.com/o/oauth2/token',
      :audience => 'https://accounts.google.com/o/oauth2/token',
      :scope => 'https://www.googleapis.com/auth/calendar',
      :issuer => Rails.application.secrets.service_account_email,
      :person => Rails.application.secrets.impersonate_user_email,
      :signing_key => key)

    # Request a token for our service account
    @client.authorization.fetch_access_token!
    @client
  end

  def init_calendar
    @calendar = nil
    # Load cached discovered API, if it exists. This prevents retrieving the
    # discovery document on every run, saving a round-trip to the discovery service.
    if File.exists? CACHED_API_FILE
      File.open(CACHED_API_FILE) do |file|
        @calendar = Marshal.load(file)
      end
    else
      @calendar = @client.discovered_api('calendar', API_VERSION)
      File.open(CACHED_API_FILE, 'w') do |file|
        Marshal.dump(@calendar, file)
      end
    end
  end

  def client
    @client ||= init_client
  end

  def calendar
    @calendar ||= init_calendar
  end

end