Hi, welcome to Word of Mike, my little corner of the internet. I am a Software/Web Developer working in North Yorkshire. I mainly write about programming but my other passion is politics so beware. (click to hide)

2013-06-07 13:22:29 UTC

Lightweight, Flexible Sinatra API

As part of learning Backbone.js I set up a simple API to interact with. I chose to use Sinatra and SQLite with DataMapper ORM, but with a few changes the code could easily run with a different ORM.

Edit: the whole API is here on GitHub if you're interested.

I designed the API itself to just be a proxy for accessing objects, so it needn't know what models lie within, it just accepts your word for it.

The routing itself uses a bit of regex magic to split the request URI up into its constituent parts, here it is in full:

# get any arbitrary object or collection and return it as json
get %r{\/api((\/[a-zA-Z]+(\/[\d]+)?)+)} do
  urn = params[:captures].first.scan(/([\w]+)(?:\/([\d]+))*/)
  find(urn).try(:to_json) || 404

# create object
post %r{\/api((\/[a-zA-Z]+(\/[\d]+)?)*\/[a-zA-Z]+)} do
  assocs = params[:captures].first.scan(/([\w]+)(?:\/([\d]+))*/)
  post_data = JSON.parse(request.body.read)
  (res = create(post_data, assocs)).try(:to_json, only: :id)

# delete object
delete %r{\/api((\/[a-zA-Z]+(\/[\d]+)?)+)} do
  urn = params[:captures].first.scan(/([\w]+)(?:\/([\d]+))*/)
  (obj = find(urn)).nil? ? 404 : obj.destroy

Firstly, the get route accepts almost anything, have a play around with it if you like. The idea being that it can return a collection /foos, or a single item /foos/1. Within that route definition we break up the URI into its parts using scan(), pairing the model names with the identifiers. The result of the scan on the example URI in the previous link is:

2.0.0p0 :009 > "/foos/1234/bars/12/baz".scan(/([\w]+)(?:\/([\d]+))*/)
 => [["foos", "1234"], ["bars", "12"], ["baz", nil]]

This gets passed to our find() method which does all the hard work:

# Find a datamapper object from a urn e.g. /foos/1/bars/1, foos/2/bars, etc
def find(urn = [], obj = nil)
  res_type, res_id = urn.shift
  return false if !Models.const_defined?(res_type.classify)
  if obj.nil?
    obj = Models.const_get(res_type.classify).all
    obj = obj.send(res_type)
  obj = obj.get(res_id) unless res_id.nil?
  return false if obj.nil?
  urn_to_obj(urn, obj) unless urn.empty?

This is a recursive method which pulls the pairs of models/ids from our array, checks they're really models using const_defined?() and starts to locate what we're after. Given our example that we're using, it will recursively build up the following method chain:

obj = Foo.all.get(1234).bars.get(12).baz

obj will be returned to where we called our the method and converted to json and returned to the browser. It's also worth noting that I've isolated my models in a module called Models, so that Models.constants only consists of my DataMapper models that are exposed to the API. I don't want the public to be able to access every constant in my Sinatra app.

The method for create is largely similar:

def create(params, assocs)
  obj = nil
  assocs.each do |res_type, res_id|
    return false if !Models.const_defined?(res_type.classify)
    if !res_id.nil?
      params["#{res_type.singularize}_id"] = res_id
      obj = Models.const_get(res_type.classify).create(params)
  !obj.nil? and obj.valid? ? obj : false

This method takes the post data and the parsed URI as assocs, because it contains associations that we want to include in our new object. For example, posting a new object at foos/1/bar would typically mean we want a new Bar object that has a foreign key foo_id of 1. It appends the post data with the association stuff then creates and returns the new object if it's not nil and valid.

That's basically it, I haven't even implemented a put method yet but you get the idea. It's working perfectly fine for my little application so far, but I imagine it would come unstuck with a bit of prodding in the wrong places. The advantage of doing it this way is I rarely even have to spare a thought for integrating my app with my database, backbone creates the calls and my API handles anything that is thrown at it.