It’s time for something new

After 14 years we’re hanging up our keyboards. Our team is joining the lovely people at Culture Amp, where we’ll be helping build a better world of work.

Icelab

Decaf Sucks Launch Countdown: Finishing the API

By Tim Riley30 Jul 2011

This week passed without me even touching the codebase for the iPhone app. Don’t worry, though, because I actually made a major step forward by completing the Decaf Sucks API. Last week I built the API actions for reading information, and this week I had to solve the problem of providing authenticated API actions for Decaf Sucks users, which are authenticated only using Facebook and Twitter’s OAuth systems.

Authenticating via the API

The answer to this (at least for now) is to use a two-step authentication process for the iPhone app. Firstly, the iPhone app will have the same OAuth consumer tokens and secrets as the web app. When a user wants to get started on the iPhone app, they’ll follow the standard Twitter or Facebook OAuth process, which ends with the iPhone app acquiring a valid access token for that user (connected to the registered “Decaf Sucks” app on either Facebook or Twitter). The iPhone app will then POST the authentication provider and access token to /account.json in the Decaf Sucks API:

{
  "provider": "facebook",
  "access_token": "xxx"
}

At this point, the web app will find the Decaf Sucks user associated with that Facebook account, or create a new one, and return the user’s details along with a custom Decaf Sucks API key:

{
  "api_key": "cd47dcac36f1081f3c23b2bb3d386b47",
  "id": 223,
  "slug": "223-tariley",
  "login": "tariley",
  "name": "Tim Riley",
  "picture_url": "https://graph.facebook.com/tariley/picture?type=square",
  "account_url": "http://facebook.com/tariley"
}

This unique, non-colliding API key is generated automatically when the user is created:

class User < ActiveRecord::Base
  after_create :generate_api_key

  protected

  def generate_api_key
    update_attribute(:api_key, Digest::MD5.hexdigest("#{ActiveSupport::SecureRandom.hex(10)}-#{DateTime.now.to_s}-#{self.id}"))
  end
end

The iPhone app will store this API key and use it to authenticate itself for any subsequent Decaf Sucks API requests that create or modify data. The API will ask for the token via HTTP basic authentication on these requests, much like the Highrise and Campaign Monitor APIs.

Creating Reviews

The API is RESTful, as you would expect, and uses JSON to pass data back and forth. Creating a review is as simple as POSTing to /reviews.json along with a Content-Type: application/json header and JSON formatted attributes in the request body:

{
  "name": "Lonsdale Street Roasters",
  "lat": -35.274862,
  "lng": 149.132495,
  "rating": 10,
  "body": "The reason I get out of bed every morning. Outstanding coffee."
}

That’s it! The web app will take care of connecting your review to any matching cafe at that location, or will create a new one for you. In response, you get the full details of your review back:

{
  "id": 1463,
  "slug": "1463-lonsdale-street-roasters",
  "body": "The reason I get out of bed every morning. Outstanding coffee.",
  "rating": 10,
  "created_at": "2011-07-30T05:59:24Z",
  "updated_at": "2011-07-30T05:59:24Z",
  "reviewer": {
    "id": 223,
    "slug": "223-tariley",
    "login": "tariley",
    "name": "Tim Riley",
    "picture_url": "https://graph.facebook.com/tariley/picture?type=square",
    "account_url": "http://facebook.com/tariley"
  },
  "cafe": {
    "id": 624,
    "slug": "624-lonsdale-street-roasters",
    "name": "Lonsdale Street Roasters",
    "simplified_name": "Lonsdale Street Roasters",
    "address": "7 Lonsdale St, Braddon ACT 2612, Australia",
    "lat": "-35.27493",
    "lng": "149.132115",
    "rating": 8,
    "reviews_count": 12,
    "created_at": "2010-11-24T22:43:22Z",
    "last_reviewed_at": "2011-07-30T05:59:24Z",
    "latest_review": {
      "id": 1463,
      "slug": "1463-lonsdale-street-roasters",
      "body": "The reason I get out of bed every morning. Outstanding coffee.",
      "rating": 10,
      "created_at": "2011-07-30T05:59:24Z",
      "updated_at": "2011-07-30T05:59:24Z",
      "reviewer": {
        "id": 223,
        "slug": "223-tariley",
        "login": "tariley",
        "name": "Tim Riley",
        "picture_url": "https://graph.facebook.com/tariley/picture?type=square",
        "account_url": "http://facebook.com/tariley"
      }
    }
  }
}

Supporting the API

All of the controllers behind the API use decent_exposure to manage the loading and manipulation of the models. This keeps them nice and tidy. Here’s part of the ReviewsController as an example:

class Api::V1::ReviewsController < Api::V1::BaseController
  expose(:reviews) { Review.newest.page(params[:page]).per(per_page_amount(10)) }
  expose(:review)

  before_filter :require_api_key, :only => [:create, :update, :destroy]
  before_filter :must_own_review, :only => [:update, :destroy]

  def create
    review.user = api_user
    review.save
    respond_with(review)
  end
end

Because all of the controllers use decent_exposure, I could create a default exposure in just one place that handles the loading of the model attributes from JSON in the request bodies. In particular, it means we don’t have to require the model attributes to be nested in a hash underneath the model name:

class Api::V1::BaseController < ActionController::Base
  respond_to :json

  self.responder = Decafsucks::ApiResponder

  # Change the default_exposure to support attributes that aren't nested under the object's name
  # (This is the same as the default exposure from decent_exposure except for the params.except() lines)
  default_exposure do |name|
    collection = name.to_s.pluralize
    if respond_to?(collection) && collection != name.to_s && send(collection).respond_to?(:scoped)
      proxy = send(collection)
    else
      proxy = name.to_s.classify.constantize
    end

    if id = params["#{name}_id"] || params[:id]
      proxy.find(id).tap do |r|
        r.attributes = params.except(:controller, :action, :format) unless request.get?
      end
    else
      proxy.new(params.except(:controller, :action, :format))
    end
  end
end

You’ll also notice above that all the controllers use Decafsucks::ApiResponder to return the models at the end of each API call. I created this responder so that we could return error messages along the same lines as the GitHub v3 API. Here’s an example of the response to a review that was posted without a body:

{
  "message": "Validation failed",
  "errors": [
    {
      "resource": "Review",
      "field": "body",
      "code": "missing_field"
    }
  ]
}

The responder extends the default ActionController::Responder to add this behaviour, and also allows me to use a create.rep template for the nicely formatted JSON that is returned when there are no errors (I wrote about these representative_view templates last week). Here’s the responder:

module Decafsucks
  class ApiResponder < ActionController::Responder
    def to_format
      if !get? && has_errors?
        api_behavior(nil)
      else
        super
      end
    end

    def api_behavior(error)
      if !get? && has_errors?
        errors_hash = {
          'message' => 'Validation failed',
          'errors' => error_messages
        }

        display errors_hash, :status => :unprocessable_entity
      else
        super
      end
    end

    protected

    def error_messages
      errors = []

      resource.errors.each_pair do |attr, messages|
        messages.each do |message|
          errors << {
            'resource'  => resource.class.model_name,
            'field'     => attr,
            'code'      => codified_error_message(message)
          }
        end
      end

      errors
    end

    def codified_error_message(message)
      if message == "can't be blank"
        'missing_field'
      else
        'invalid'
      end
    end
  end
end

Potential Improvements

Overall, I’m happy with how the API has come together. I’m quite appreciative of all the many well-established and well-documented APIs that already exist (such as GitHub, the 37signals apps, Gowalla and Campaign Monitor). It’s definitely been useful to check these out and use them as inspiration for building our own thing.

The biggest area for improvement that I see is in how we handle the authentication. Eschewing our own authentication system has been great so far. It allowed us to build the initial app across a single Rails Rumble weekend, and has been helpful for getting new users involved with minimal fuss. It’s only now that we’re building an API that it brings in some complications.

For our initial requirements – to support our iPhone app only – the authentication process I described above will work just fine. In fact, supporting 3rd-party access would work right now by exposing a user’s API key in the web interface and have them use that for any API calls, but that feels too clunky. Ideally, we would have some sort of OAuth support in our own web app that proxies or redirects to the right parts of the Facebook or Twitter OAuth processes when appropriate. Do you have any ideas about this? Let us know.

Where Next?

Now that the API is done, it’s time to get back into building the iPhone app. Time to start investigating Twitter & Facebook OAuth libraries for Cocoa Touch apps. I’ll be back next week to share the results!