Using Backbone.js with Barrister RPC

Posted: May 02, 2012

Motivation

I've recently been evaluating backbone.js for possible use on a new project. It's a very nice lightweight UI framework and has a built in way to persist model classes to the server using REST.

Last month I released a JSON-RPC implementation called Barrister RPC which provides an IDL grammar for defining structs and interfaces that your server code implements. One nice thing about JSON-RPC is that it's transport neutral, which means you can originate a call in your UI, send it over HTTP, then route it via some other transport (AMQP, ZMQ, Redis, etc) to the eventual implementation endpoint.

Thanks to Backbone's Backbone.sync function, it's quite easy to use Barrister RPC as the backend for your Backbone model classes.

Implementation

Going with the standard "To-Do" demo, let's say you have this Barrister IDL:

struct Todo {
    id       int     [optional]
    content  string
    order    int
    done     bool
}

interface TodoService {
    // returns Todo with given id
    readTodo(id int) Todo
    
    // creates new Todo and returns generated id
    createTodo(todo Todo) int
    
    // updates Todo and returns id
    updateTodo(todo Todo) int
    
    // deletes Todo. returns true if delete succeeded. if no Todo found, returns false
    deleteTodo(todo Todo) bool
}

You should be able to use an existing Todo Backbone client implementation. For example, the ServiceStack example

The only modification is the addition of a barrister property to the Model class:

window.Todo = Backbone.Model.extend({
  
  // this is the only addition
  barrister: { entity: "Todo", interface: "TodoService", endpoint: "/api/todo" },

  // ... rest of model here..
});

Then add this generic JS code that overrides Backbone.sync:

Backbone.Barrister = {
  // Stores Barrister.Client objects
  //  key: endpoint name (string)
  //  val: Barrister.Client instance
  clients: {},
  
  // Initializes a Barrister client for the given endpoint and passes it to callback()
  // If client for that endpoint already exists, callback is immediately invoked.
  // Otherwise a new Client is created, its contract loaded, and the callback invoked
  // after the contract loads.
  initClient: function(endpoint, callback) {
    var client;
    if (!Backbone.Barrister.clients[endpoint]) {
      client = Barrister.httpClient(endpoint);
      Backbone.Barrister.clients[endpoint] = client;
      return client.loadContract(function() {
        return callback(client);
      });
    } else {
      return callback(Backbone.Barrister.clients[endpoint]);
    }
  },
  
  // Implements Backbone.sync 
  sync: function(method, model, options) {
    var b, param;
    if (model.barrister) {
      b = model.barrister;
      
      // read and delete calls pass in the ID to the call
      // other operations pass the entire model as JSON
      if (method === "read" || method === "delete") {
        param = model[model.idAttribute];
      } else {
        param = model.toJSON();
      }
      
      // Construct a JSON-RPC method as expected by Barrister
      // the interface name and entity are on the Model's 'barrister' property
      //
      // e.g. a 'create' for Todo becomes:  TodoService.createTodo
      //
      method = b.interface + "." + method + b.entity;
      
      // Make the Barrister RPC call.  We're using the client.request
      // function directly, which still provides IDL contract validation
      return Backbone.Barrister.initClient(b.endpoint, function(client) {
        return client.request(method, [param], function(err, result) {
          if (err) {
            return options.error(model, err);
          } else {
            return options.success(model, result);
          }
        });
      });
    } else {
      return options.error(model, "sync: model does not have barrister property set");
    }
  }
  
};

// Bind our sync function, replacing the default Backbone.sync
Backbone.sync = Backbone.Barrister.sync;

How it works

This solution uses a naming convention for the Barrister interface functions based on the entity name and the method passed into the sync function by Backbone. Here's a summary of the mapping:

backbone.js Model function backbone.js method Example Barrister function IDL
model.save() create or update (depending on whether the model's idAttribute is set)
// Return types are up to you
// backbone.js doesn't care (to my knowledge..)
createFoo(f Foo) int
updateFoo(f Foo) int
model.fetch() read readFoo(id int) Foo
model.destroy() delete deleteFoo(id int) bool