Decaf Sucks Launch Countdown: Finishing the API
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!