Ruby on Rails, iOS, Git...

We spend a lot of time thinking about these things. If we have something helpful to share, we'll put it here.

Request a free RaddOnline® proposal.

Ruby on Rails: Using Exchange Web Services from a Rails App

Posted by Tim Stephenson, RaddOnline® on Tuesday, March 23, 2010

EWS is a SOAP service for interacting with data stored in an Exchange server. You can use it to get a list of events from the calendar, or a list of emails. You can also create or delete items in exchange. In my app, I wanted to create events on an Exchange calendar when a user was assigned to a scheduled task. I also wanted to delete events, and possibly create new ones, if the user was removed or re-assigned.

I’ll be demonstrating how to use cURL to consume the EWS services. I’ve created a very simple rails project as a sample which is available at GitHub. EWS-Connect

There are also a couple of good gems that you can try:

  • Viewpoint Uses Soap4r and HTTPClient, is a pretty complete client access library for Exchange Web Services in Ruby. Dan Wanek, the author, is very responsive and helpful.
  • ews-api Favors Handsoap over Soap4r.

I didn’t user either gem because both gems depend on HTTPClient and RubyNTLM. I ran into a problem with authentication on the server. I could authenticate using cURL, but never succeeded with HTTPClient. I know that NTLM is supported, and couldn’t find anything specifically wrong to fix, so I decided to use cURL for my implementation.

Creating the Connection With cURL

This part is easy. There is a single cURL command needed, so I created a very simple wrapper class called ExchangeConnection.

class ExchangeConnection

  # This could easily have been included in the ExchangeService class.
  # Having a separate connection class made it easy to mock the connection
  # in the exchange service tests. 

  def initialize(user, password, endpoint)
    @user, @password, @endpoint = user, password, endpoint
  end

  # Uses cURL to connect to the EWS server.
  # Passes the Soap XML document as the data.
  def connect(xml_doc)
    wsdl = `curl -u #{@user}:#{@password} -L #{@endpoint} -d "#{xml_doc.write}" -H "Content-Type:text/xml" --ntlm` 
  end

end

The connect method takes an XML document and uses the -d option to post the document as the data to EWS. The user, password and endpoint variables are initialized when the instance is created. The endpoint is the url to the EWS server. The contents of the response are returned after the connection.

There is a good gem that you can also try for libcurl. See curb on GitHub.

Making a Request

There are two tasks to create an item in EWS.

  1. Format the XML to make the request. Here’s the definition from MSDN
  2. Parse the response, and if it succeeds, get the item id as a reference so that it can be deleted in the future.
def create_event_in_ews(event, target_mailbox = nil, attendee_addresses = [])
    connection = ExchangeConnection.new(APP_CONFIG[:ews_user_name], APP_CONFIG[:ews_user_password], APP_CONFIG[:ews_endpoint])
    begin
      response_doc = REXML::Document.new(connection.connect(create_calendar_item_xml(event, target_mailbox, attendee_addresses)))
      status = REXML::XPath.first(response_doc, '//m:CreateItemResponseMessage').attribute('ResponseClass')
    rescue => e
      @errors << "Uh-oh, there was an XML exception: #{e}." 
      return false
    end

    if status.to_s != "Success" 
      response_code = REXML::XPath.first(response_doc, "//m:ResponseCode").text
      message = REXML::XPath.first(response_doc, "//m:MessageText").text
      @errors << "EWS appointment creation failed. Status: #{status.to_s}. Response code: #{response_code}. #{message}" 
      return false
    end

    calendar_ids = REXML::XPath.match(response_doc, '//t:ItemId')
    calendar_ids.each { |item|
      @appointment_id = item.attribute("Id").to_s
    }
    return true
  end

This method uses REXML to create an XML document from the response. This line does all the work:

response_doc = REXML::Document.new(connection.connect(create_calendar_item_xml(event, target_mailbox, attendee_addresses)))

After the response is returned, it’s just a matter of parsing it for the important bits of information. If all goes well, the id returned from the server is stored in the variable @appointment_id. Else, an error is stored.

Formatting the XML for the Request

Formatting the XML for the request is a little more interesting. I’ve used a helper from the Ruby Cookbook so that the code that creates the doc could be nested in the same way as the XML file itself. This goes into the config/initializers folder.

class REXML::Element
  # Helper to create xml docs with a nested syntax.
  def with_element(*args)
    e = add_element(*args)
    yield e if block_given?
  end
end

The code to create the XML document looks like this.

# Takes an event and creates the XML to create a calendar item.
  # Soap schema information can be found at:
  # http://msdn.microsoft.com/en-us/library/aa564690.aspx
  # Params:
  # * event - Should have a name, location, description and start and end times.
  # * target_mailbox - The email address for the calendar the event should go to. If nil, it will go on the calendar of the user the app logs in with.
  # * attendees - An array of email addresses that will be added as attendees.
  def create_calendar_item_xml(event, target_mailbox = nil, attendee_addresses = [])

    doc = REXML::Document.new
    doc.with_element('soap:Envelope', envelope_data) do |envelope|
      envelope.with_element('soap:Body') do |body|
        body.with_element('CreateItem', create_item_data(attendee_addresses.length > 0)) do |create_item|

          create_item.with_element('SavedItemFolderId') do |saved_item_folder_id|
            # Will add the event to the calendar of the user that the app logs in as.
            if target_mailbox.blank?
              saved_item_folder_id.add_element('t:DistinguishedFolderId', {'Id' => "calendar"})
            else

              # This adds an event to to the calendar specified by the mailbox.
              # Schema info: http://msdn.microsoft.com/en-us/library/aa580808.aspx
              # In order to succeed, the user who owns the target mail box must grant
              # permission to the user that the app logs in as.
              saved_item_folder_id.with_element('t:DistinguishedFolderId', xmlns_types.merge({'Id' => "calendar"})) do |distinguished_folder_id|
                distinguished_folder_id.with_element("Mailbox") do |mailbox|
                  mailbox.with_element("EmailAddress") do |email|
                    email.add_text(target_mailbox)
                  end
                end
              end # end distinguished folder id
            end #if target_mailbox.blank?
          end

          # The items block in the XML
          create_item.with_element('Items') do |items|

              items.with_element("t:CalendarItem", xmlns_types) do |t_calendar_item|
                subject = t_calendar_item.add_element("Subject")
                subject.add_text(event.name.blank? ? "Rails EWS Test" : event.name)

                body = t_calendar_item.add_element("Body", {'BodyType' => "Text"})
                body.add_text(event.description)

                reminder_is_set = t_calendar_item.add_element("ReminderIsSet")
                reminder_is_set.add_text("true")

                reminder_minutes_before_start =  t_calendar_item.add_element("ReminderMinutesBeforeStart")
                reminder_minutes_before_start.add_text("60")

                event_start =  t_calendar_item.add_element("Start")
                event_start.add_text(event.start_datetime.strftime("%Y-%m-%dT%H:%M:%S"))

                event_end = t_calendar_item.add_element("End")
                event_end.add_text(event.end_datetime.strftime("%Y-%m-%dT%H:%M:%S"))

                all_day = t_calendar_item.add_element("IsAllDayEvent")
                all_day.add_text(event.all_day?.to_s)

                status = t_calendar_item.add_element("LegacyFreeBusyStatus")
                # There are several options that can be used for the status. 
                # * Free
                # * Busy
                # * OOF - Out of office - etc.
                status.add_text("Busy")

                location = t_calendar_item.add_element("Location")
                location.add_text(event.location) unless event.location.blank?
                # If you have attendees, you would add them here.
                # If the SendToNone option is seleceted, then it seems to ignore attendees anyway.
                t_calendar_item.with_element("RequiredAttendees") do |attendees|
                  attendee_addresses.each do |email_address|
                    attendees.with_element("Attendee") do |attendee|
                      attendee.with_element("Mailbox") do |mailbox|
                        mailbox.with_element("EmailAddress") do |email|
                          email.add_text(email_address)
                        end
                      end
                    end
                  end # attendee_address.each
                end # end of attendees
              end # end of calendar item

          end # items
        end # body
      end # envelope
    end # doc
    return doc
  end

This looks scarier than it is. I’m using REXML to create an XML document formatted to the specifications provided by Microsoft. There are two interesting things in this method regarding mailboxes.

  1. The SavedItemFolderId can be left alone to add the event to the calendar of the user that the application logged in as. Or you can target a specific mailbox. In this example, the target_mailbox defaults to nil. The default behavior is to use the application’s user. Just pass an email address if you want it to go into another user’s calendar. The only caveat is that the target person must grant permission to the application’s user before events can be placed on the target user’s calendar. EWS will return an error message that the mailbox can not be found if permission has not been granted. See this section of code: create_item.with_element('SavedItemFolderId')
  2. You can include required attendees and each of them will get an invitation to the event. See: t_calendar_item.with_element("RequiredAttendees") If you pass an array of email addresses in the “attendee_addresses” parameter, each of them will be added as RequiredAttendees, and each will receive an invitation.

Take a look at the sample application to see all of the details, and how to delete events using EWS. You can download the complete sample and browse all the code at GitHub. EWS-Connect

Here’s the ExchangeService class.

You can also run the tests without an EWS server. The connections are all mocked out. If you’d like to run tests against a real server, just modify one of the tests and pass the correct credentials.