How to integrate your RubyMotion OS X app with a JSON based web API Nov 22 2013

In our last article on RubyMotion OS X apps, we built a status bar app that fetched data in the background to keep the menu item continually updated and showing the latest information. Our data source was from the local system, fetching the CPU usage statistics. In this article we'll look at how to integrate with a JSON web API, making HTTP calls from your RubyMotion app, processing the response, and showing that in the menu.

You have to commit

The little utility app we'll build will be a very basic and straightforward one to show the latest commits from projects you have access to on GitHub. GitHub has a wonderful JSON API, complete with very good documentation, and for developers, utility apps integrating with GitHub can be very useful indeed. You can follow along with the code in the article, and the app is open source and available in full here too.

Familiar start

By now if you've been following along with the RubyMotion on OS X series of articles, you'll know our starting point. We'll use the template gem from our first article to spin up a new app. Let's fire up a blank status bar app now:

motion create --template=osx-status-bar-app CommitTrackerMenu

Dependencies

First thing we'll do is add the gems we'll need, BubbleWrap, and motion-yaml. Your Gemfile should look something like this:

source 'https://rubygems.org'

gem 'rake'
gem 'bubble-wrap'
gem 'motion-yaml'

Remember to run this when you're done too:

bundle install

Configuration

We'll come to BubbleWrap in a minute, but we're using motion-yaml for the configuration for the app - to load our config file and ready it for use in our code, add the following to your AppDelegate:

class AppDelegate
  CONFIG = YAML::load(NSMutableData.dataWithContentsOfURL(
    NSBundle.mainBundle.URLForResource("config", withExtension: "yml")).to_s)

This finds the config in the root of the resource bundle, and loads the YAML contents into the constant. You'll see an example config file in the GitHub repo for the code, you just need to rename config.yml.example to config.yml and fill in your GitHub personal access token - you can get one by visiting here. You can also amend the timer config to specify how often to check for new updates. It should end up looking something like this:

timer: 30
github_token: TOKEN

GitHub integration

Now, let's work on integration with the API itself - this is where BubbleWrap comes in handy. This gem is a fantastic collection of all sorts of great functionality, nice Ruby wrappers around more complicated Cocoa APIs, shortcuts for often used features, and helpers that take advantage of Ruby language features to provide nicer ways of doing things. It's a veritable Swiss Army Knife for iOS and OS X development.

We'll be using BW::HTTP and BW::JSON to grab the data we need and to parse it. We've separated out our GitHub integration into a separate class to make it easier to read, so here it is in full:

class GitHub
  attr_accessor :token, :user

  def initialize(token, &block)
    @token = token
    self.user do |json|
      @user = json
      block.call
    end
  end

  def user(&block)
    self.get("https://api.github.com/user", &block)
  end

  def events(&block)
    self.get(@user['received_events_url'], &block)
  end

  def get(url, &block)
    BW::HTTP.get(url, {:credentials => {:username => @token, :password => ""}}) do |response|
      block.call(BW::JSON.parse(response.body))
    end
  end
end

There isn't a lot to this, essentially we initialize the client with our personal access token. It immediately uses the token to do a quick check, and load the authenticated user JSON - this way we don't need further user details such as username to make additional calls. It'll call any block passed into that initialization method when that process is complete, allowing you to wait until the client is ready before doing anything else.

We have a method to retrieve events for the user which we'll be using as our data source in the status bar itself in a minute, but you'll see both that and the authenticated user call are wrappers themselves around the get method, which uses the BubbleWrap HTTP methods to easily call to the URL, passing the provided credentials (using the token over basic auth for GitHub means providing the token as the username, and leaving the password blank), and then processing the response body. In this case, we use the BubbleWrap JSON support to parse the body and return an array or hash parsed from the API.

Lastly, you'll notice too that in the events method, we're using something provided by the response from the authenticated user check as the URL (received_events_url returned within @user). The GitHub API helpfully returns other API call URLs within the authenticated user response, which we can then parse and use for further calls. This way, if their API changes and they move or update URLs, our code won't break as it's using the pointer to the right method provided by GitHub themselves, rather than a hard-coded URL!

With that out of the way, we can now focus back on our status bar app. First, we need to setup our menu items, so our applicationDidFinishLaunching method should look like this:

def applicationDidFinishLaunching(notification)
  @app_name = NSBundle.mainBundle.infoDictionary['CFBundleDisplayName']

  @status_menu = NSMenu.new
  @status_menu.delegate = self

  @status_item = NSStatusBar.systemStatusBar.statusItemWithLength(NSVariableStatusItemLength).init
  @status_item.setMenu(@status_menu)
  @status_item.setHighlightMode(true)
  @status_item.setTitle(@app_name)

  @http_state = createMenuItem("Loading...", '')
  @http_state.enabled = false
  @status_menu.addItem @http_state

  @status_menu.addItem createMenuItem("About #{@app_name}", 'orderFrontStandardAboutPanel:')
  @status_menu.addItem createMenuItem("Quit", 'terminate:')

  @unseen = 0
  @github = GitHub.new(CONFIG[:github_token]) do
    self.checkForNewEvents
  end
end

This should be fairly familiar if you've been following along with the previous articles. A few key differences, we're setting the delegate on the status menu to the app delegate itself - we're doing that so we can look out for a specific event, but we'll come back to that a little later.

We're creating a disabled menu item, @http_state, used just to display information on the state of the app, so we can update it to show when we're doing different things. We instantiate it and set it to show "Loading…" to start with.

We then instantiate the GitHub client, and pass in a block that will get called when the initial authenticated user JSON is returned - we need to wait for this, as any subsequent calls won't work until that has completed. We can then kick off our check for new events. This is where we check with our data source, in this case the GitHub API, and it's where the meat of the app lies.

Events

The method that checks for and processes events from the GitHub API does quite a lot, so we'll step through it and explain how it works - if you want to see the entire thing, you can do so here.

def checkForNewEvents
  begin
    @http_state.title = "Working..."
    @github.events do |events|
      @http_state.title = "Processing..."

We start by setting our @http_state menu item to show us as working, and then as soon as we get the response from GitHub, we switch that to processing.

unless events.empty?
  @events.each { |item| @status_menu.removeItem(item) } unless @events.nil?
  @events = []
  @urls = {}
  @last_commits = @commits || []
  @commits = []

Here we make sure that we have something to process, and then we are removing any menu items that we had previously pertaining to events. Each time this runs it'll clear out the event menu items, and recreate them based on the data found. In addition to the variable to track those menu items (@events), we also have a hash to track URLs (@urls), which comes into play when we're handling menu item clicks for these events, as well as a couple of variables to help us track what's new between calls (@commits and @last_commits).

counter = 0
events.reverse.each do |event|
  next unless event["type"] == "PushEvent"
  next if event["payload"].nil? || event["payload"]["commits"].nil?

  commits = event["payload"]["commits"].select { |c| c["distinct"] }

  next if commits.length == 0

We're now going to loop through all the events returned - we do so in reverse order, as we'll be adding each menu item in at a specific index, which will in turn push all of those before it further down. So that we end up with date descending (newest first) order in the end, we reverse things to begin with.

We're skipping over any event that isn't a push event (code being pushed up to a repository), and that doesn't have a payload or commits.

Lastly, we also ignore any commits that aren't distinct - this is a very handy flag provided by GitHub from their API, and by distinct they mean if a commit has been seen in the repo before, i.e. if something was pushed as part of a branch, it'd be distinct, but then merging it into master, it's the same commit, and it is no longer distinct. We only want to be notified of entirely new commits, so we filter out anything that isn't distinct.

actor = event['actor']['login']
repo = event['repo']['name']

commits.each do |commit|
  message = commit['message'].split("\n").first
  message = "#{message[0...30]}..." if message.length > 35
  @urls[counter] = commit['url'].gsub("api.", "").gsub("/repos", "").gsub("/commits", "/commit")
  item = self.createMenuItem("[#{actor}]: #{repo} - #{message}", 'pressEvent:')

Now we loop through the commits we have left to look at, and we'll do a bit of processing on the message and URL. We want to truncate the message to a reasonable uniform length, and we need to mess with the URL a bit as the URL returned from GitHub points to the commit within the API, and we want a URL we can open in a browser to show the commit - the always useful String gsub method comes in handy there as we stash the URL in our @urls hash for looking up later.

We then setup the menu item, similarly to how we've done before. Each item will use the same event handler, which we'll look at shortly. One thing to note is that by specifying pressEvent:, instead of just pressEvent (note the trailing colon), then our event handler will be sent an argument, the sender of the event, in this case the item itself. As we're handling all of the item events with the same event handler method, and using the tag on the item to lookup the URL to show, it'll be quite handy to be sent the clicked item!

    item.tag = counter
    @events << item
    @status_menu.insertItem(item, atIndex: 1)
    counter += 1
    @commits << commit['sha']
  end
end

Here we set a tag on the item, and this is where our counter comes in - we can only use an integer for the tag, but we need a reference to our URL for use in the event handler. So we've used the counter as a numeric pointer to the URL in our URLs hash (@urls), and we set it on the item so we can match the two together. We are then keeping track of our event menu item (so we can remove it from the menu next time we re-process), and then insert the item into the menu at the same index for each item. We're doing it at index 1 instead of index 0 so as to always leave the @http_state menu item at the top of the menu as the others get pushed down with each new item added.

We then increment our counter for the next commit, and stash the unique SHA reference for the commit in @commits so we can compare which commits we've seen this time with last time for working out which commits are new.

  @unseen += (@commits - @last_commits).length
  self.showUnseenCommits
end

Here we use our arrays of the commit references (@commits for this time, @last_commits for the last time around) to deduce how many new commits there are in this run, and add that to our unseen total tracking variable, @unseen. The method to show those we'll get into below.

    @http_state.title = "Ready"
  end
rescue
  @http_state.title = "Error, retrying again shortly"

If everything finishes as expected, we set the @http_state menu item to show things as ready for the next call - and if we experienced an error, we make that clear too.

  ensure
    NSTimer.scheduledTimerWithTimeInterval(CONFIG[:timer],
      target: self,
      selector: 'checkForNewEvents',
      userInfo: nil,
      repeats: false)
  end
end

Whatever happens though, the ensure part of the begin/rescue/ensure block will always end by triggering the next call. We're using our timer configuration to schedule the same method to be called again. We do this rather than simply call it once at the beginning but with repeats set to true, because the call itself and subsequent processing will take a variable amount of time. For the most part it's very quick, but network slowdowns, poor connections, or timeouts could cause it to take a bit longer. We want our delay between calls to be just that - between calls, not between the time each call is kicked off, regardless of result. From the time our processing ends, to the time it runs again, will be the value we've configured.

Mark all as read

We have a couple of steps left to flesh out. Above we called showUnseenCommits after incrementing how many unseen commits there were - it's a fairly simple update to the title of the main system menu item for our app:

def showUnseenCommits
  @status_item.setTitle("#{@unseen.zero? ? 'No' : @unseen} new #{@unseen == 1 ? 'commit' : 'commits'}")
end

When we click on the menu, it'd be good to clear the message, sort of a mark all as read. This is the reason we set the delegate for the status menu to be the app delegate itself - luckily we can easily handle the event of the menu opening (in response to the click) by implementing menuWillOpen, and in there we can clear the message by setting @unseen to zero and updating the status menu text:

def menuWillOpen(menu)
  @unseen = 0
  self.showUnseenCommits
end

Click to view

Lastly, when we click on an item, it'd be great to go to that commit on GitHub for viewing. We've already stashed the URLs when processing the events above, and so we now just need to lookup the particular URL, which we can do with the numeric tag property on the item. That's our pointer into the URLs hash, @urls, and so our event handler looks like this:

def pressEvent(item)
  NSWorkspace.sharedWorkspace.openURL(NSURL.URLWithString(@urls[item.tag]))
end

NSWorkspace.sharedWorkspace.openURL is a nice way to use your system default way of opening a URL, i.e. your default browser. Easy!

You can now fire up the app to see it all working together.

Wrapping up - questions?

And that's about it - much like last time, we've built a status bar app that runs in the background, updating from a data source. This time though we used HTTP calls and JSON processing to interact with a 3rd party API to make the data a bit more interesting, we used YAML for file configuration, we used a timer instead of a background thread to periodically check for new data, and we dynamically updated our menu list based on the data we brought back, bringing even greater interactivity into our fairly basic app. Remember you can check out all the code here.

In our next few articles we'll look at integrating notifications within our apps to make notifying the user of a change more prominent and useful, and we'll also start to explore custom user interfaces, to improve the experience for users of your Mac OS X apps, both for status bar apps as we've been focusing on thus far, but also for window based apps.

Hopefully you're enjoying my articles on RubyMotion development - as always, let me know your thoughts, comments and questions below, or contact me on Twitter @ejdraper!

technicalrubymotioncodekickcode