We recently had the opportunity to create a note in Evernote through their Evernote API. It was a little different from using other APIs because Evernote uses Thrift instead of XML/JSON-RPC like many other web applications.

As with any integration, there are a few gotchas with this one. In particular, we found the Ruby-oriented documentation on the web a little lacking. Other than that, here were the major challenges:

  • We like to simulate a user being redirected and authorizing the app in our integration tests, but getting a session to work as if a user were driving it was tricky
  • The API has a very Java-like feel, which wasn’t a natural fit for Ruby idioms
  • The contents of notes need to be wrapped in special XML, which is documented but isn’t the first thing you find when Googling.
  • Exceptions thrown from the API are cryptic at first, until you know how to get the info you want out of them.

Without further ado, below is the full source code:

require 'oauth'
require 'evernote-thrift'


#this is for testing.  in production, it's www.evernote.com
base = "https://sandbox.evernote.com" 
# given to you by Evernote
key = "developer-key-from-evernote" 
# given to you by Evernote
secret = "developer-secret-from-evernote" 
# needs to be a valid path in your application
callback = "http://www.pollen.io/evernote-oauth-callback" 
oauth = OAuth::Consumer.new( key,secret,{:site=>base,
                      :authorize_path => "/OAuth.action",
                      :access_token_path=>"/oauth",
                      :request_token_path=>"/oauth"})
#this lets you see raw wire calls
oauth.http.set_debug_output($stdout) 

#this callback is not ignored and is actually used
request_token = oauth.get_request_token(:oauth_callback=>callback) 
puts request_token.inspect

#pretend like the user logged in and pushed the "Authorize" button
oauth_verifier = pretend_to_authorize_as_user(request_token)
#end of pretending like the user logged in

#now, finally, get the access_token you wanted and then use it to your heart's content
access_token = request_token.get_access_token(:oauth_verifier=>oauth_verifier)
puts access_token.inspect

# you need to store the url for the note store when you get it in the access_token.
#this can differ by user, so don't just use the same one for everybody
note_store_url = access_token.params['edam_noteStoreUrl']
access_token_str = access_token.token

#Here is where we create an actual note.
#  This will all feel familiar for someone with a Java background, but if you've
#   only ever used Ruby it's going to feel really weird.
note = Evernote::EDAM::Type::Note.new
my_content = "Hello, this is a note from Pollen.io.  Please check us out if you need enterprise-grade cloud integration"
#This is a special gotcha.  The content of your note needs to be wrapped in this special xml
note.content = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><!DOCTYPE en-note SYSTEM \"http://xml.evernote.com/pub/enml2.dtd\"><en-note><![CDATA[#{my_content}]]></en-note>"
note.title = "I love Evernote"
note.tagNames = ['pollen.io','evernote']
noteStoreTransport = Thrift::HTTPClientTransport.new(note_store_url)
noteStoreProtocol = Thrift::BinaryProtocol.new(noteStoreTransport)
noteStore = Evernote::EDAM::NoteStore::NoteStore::Client.new(noteStoreProtocol)     
begin 
  note = noteStore.createNote(access_token_str, note)
rescue Evernote::EDAM::Error::EDAMUserException => e
  #the exceptions that come back from Evernote are hard to read, but really important to keep track of
  msg = "Caught an exception from Evernote trying to create a note.  #{translate_error(e)}"
  raise msg
end      

We omitted a few functions above for clarity, so here they are for completeness sake.

First, the function to simulate a user being redirected to Evernote, logging in, and pushing the “Authorize” button. Of course, in a real app, you’d actually redirect a real user there instead, but as I said we like to simulate this in integration tests.


def store_cookies(response)
  rv = {}
  cookies = response.get_fields("Set-Cookie")
  if cookies
    cookies.each do |cookie|
      real_cookie = cookie.split('; ')[0]
      key, value = real_cookie.split("=")
      rv[key] = real_cookie #yes, setting the whole cookie and not just the value
    end
  end
  rv
end


#here we are pretending to be the user and submitting the login form as if we were them.  In reality, you'd redirect
# the user to the url and then wait for Freshbooks to redirect them back to you.
def pretend_to_authorize_as_user(request_token)
  net = Net::HTTP.new("sandbox.evernote.com", 443)
  net.use_ssl = true
  net.verify_mode = OpenSSL::SSL::VERIFY_NONE
  net.set_debug_output $stdout 
  net.read_timeout = 5
  net.open_timeout = 5
  
  #force a session to start
  # this doesn't seem to work unless you have a session
  start_request = Net::HTTP::Post.new("https://sandbox.evernote.com/Login.action")
  start_response = net.start do |http|
     http.request(start_request)
  end
  cookies = store_cookies(start_response)
  #extract the session id from the cookies
  session_id = cookies['JSESSIONID'].split('=')[1]
  
  #now login as if you were that user
  login_params = {:username=>'a-test-user-name',
                  :password=>'a-test-user-password',
                  :login=>'Sign In',
                  :targetUrl=>CGI.escape("/OAuth.action?oauth_token=#{request_token.token}")}
  login_request = Net::HTTP::Post.new("https://sandbox.evernote.com/Login.action;jsessionid=#{session_id}")
  login_request.set_form_data(login_params)
  login_request.add_field("Cookie", cookies.values.join("; "))
  login_request.add_field("Cookie", "JSESSIONID=#{@session_id}") if @session_id
  login_response = net.start do |http|
     http.request(login_request)
  end
  cookies = cookies.merge(store_cookies(login_response))
  
  #now actually push the "Authorize" button
  authorize_params = {:authorize=>"Authorize", :oauth_token=>request_token.token}
  authorize_request = Net::HTTP::Post.new("https://sandbox.evernote.com/OAuth.action")
  authorize_request.set_form_data(authorize_params)
  authorize_request.add_field("Cookie", cookies.values.join("; "))
  authorize_request.add_field("Cookie", "JSESSIONID=#{@session_id}") if @session_id
  authorize_response = net.start do |http|
     http.request(authorize_request)
  end
  #now, finally, extract what we came here for in the first place, the access token
  location = authorize_response['location'].scan(/oauth_verifier=(\d*\w*)/)
  oauth_verifier = location[0][0]
  oauth_verifier
end

Next, our error handling code, so you can see how to understand what happened when something goes wrong:

# see: http://www.ruby-doc.org/gems/docs/e/evernote-1.2.0/Evernote/EDAM/Error/EDAMErrorCode.html
# see: http://www.ruby-doc.org/gems/docs/e/evernote-1.2.0/Evernote/EDAM/Error/EDAMUserException.html
def translate_error(e)
  error_name = "unknown"
  case e.errorCode
  when Evernote::EDAM::Error::EDAMErrorCode::AUTH_EXPIRED
    error_name = "AUTH_EXPIRED"
  when Evernote::EDAM::Error::EDAMErrorCode::BAD_DATA_FORMAT
    error_name = "BAD_DATA_FORMAT"
  when Evernote::EDAM::Error::EDAMErrorCode::DATA_CONFLICT
    error_name = "DATA_CONFLICT"
  when Evernote::EDAM::Error::EDAMErrorCode::DATA_REQUIRED
    error_name = "DATA_REQUIRED"
  when Evernote::EDAM::Error::EDAMErrorCode::ENML_VALIDATION
    error_name = "ENML_VALIDATION"
  when Evernote::EDAM::Error::EDAMErrorCode::INTERNAL_ERROR
    error_name = "INTERNAL_ERROR"
  when Evernote::EDAM::Error::EDAMErrorCode::INVALID_AUTH
    error_name = "INVALID_AUTH"
  when Evernote::EDAM::Error::EDAMErrorCode::LIMIT_REACHED
    error_name = "LIMIT_REACHED"
  when Evernote::EDAM::Error::EDAMErrorCode::PERMISSION_DENIED
    error_name = "PERMISSION_DENIED"
  when Evernote::EDAM::Error::EDAMErrorCode::QUOTA_REACHED
    error_name = "QUOTA_REACHED"
  when Evernote::EDAM::Error::EDAMErrorCode::SHARD_UNAVAILABLE
    error_name = "SHARD_UNAVAILABLE"
  when Evernote::EDAM::Error::EDAMErrorCode::UNKNOWN
    error_name = "UNKNOWN"
  when Evernote::EDAM::Error::EDAMErrorCode::VALID_VALUES
    error_name = "VALID_VALUES"
  when Evernote::EDAM::Error::EDAMErrorCode::VALUE_MAP
    error_name = "VALUE_MAP"
  end
  rv = "Error code was: #{error_name}[#{e.errorCode}] and parameter: [#{e.parameter}]"  
end

[UPDATE]: Anja Skrba of Croatia has translated this page into Serbo-Croatian. Thanks Anja!