Brainstem is designed to power rich APIs in Rails. The Brainstem gem provides a presenter library that handles converting ActiveRecord objects into structured JSON and a set of API abstractions that allow users to request sorts, filters, and association loads, allowing for simpler implementations, fewer requests, and smaller responses.
- Separate business and presentation logic with Presenters.
- Version your Presenters for consistency as your API evolves.
- Expose end-user selectable filters and sorts.
- Whitelist your existing scopes to act as API filters for your users.
- Allow users to side-load multiple objects, with their associations, in a single request, reducing the number of requests needed to get the job done. This is especially helpful for building speedy mobile applications.
- Prevent data duplication by pulling associations into top-level hashes, easily indexable by ID.
- Easy integration with Backbone.js. "It's like Ember Data for Backbone.js!"
Please watch our talk about Brainstem from RailsConf 2013.
Add this line to your application's Gemfile:
gem 'brainstem'
Create a class that inherits from Brainstem::Presenter, named after the model that you want to present, and preferrably versioned in a module. For example:
module Api
module V1
class WidgetPresenter < Brainstem::Presenter
presents "Widget"
# Available sort orders to expose through the API
sort_order :updated_at, "widgets.updated_at"
sort_order :created_at, "widgets.created_at"
# Default sort order to apply
default_sort_order "updated_at:desc"
# Optional filter that delegates to the Widget model :popular scope,
# which should take one argument of true or false.
filter :popular
# Optional filter that applies a lambda.
filter :location_name do |scope, location_name|
scope.joins(:locations).where("locations.name = ?", location_name)
end
# Filter with an overridable default that runs on all requests.
filter :include_legacy_widgets, :default => false do |scope, bool|
bool ? scope : scope.without_legacy_widgets
end
# Return a ruby hash that can be converted to JSON
def present(widget)
{
:name => widget.name,
:legacy => widget.legacy?,
:updated_at => widget.updated_at,
:created_at => widget.created_at,
# Associations can be included by request
:features => association(:features),
:location => association(:location)
}
end
end
end
end
Once you've created a presenter like the one above, pass requests through from your controller.
class Api::WidgetsController < ActionController::Base
include Brainstem::ControllerMethods
def index
render :json => present("widgets") { Widget.visible_to(current_user) }
end
end
The scope passed to present
could contain any starting conditions that you'd like. Requests can have includes, filters, and sort orders.
GET /api/widgets.json?include=features&order=popularity:desc&location_name=san+francisco
Responses will look like the following:
{
# Total number of results that matched the query.
count: 5,
# A lookup table to top-level keys. Necessary
# because some objects can have associations of
# the same type as themselves.
results: [
{ key: "widgets", id: "2" },
{ key: "widgets", id: "10" }
],
# Serialized models with any requested associations, keyed by ID.
widgets: {
"10": {
id: "10",
name: "disco ball",
feature_ids: ["5"],
popularity: 85,
location_id: "2"
},
"2": {
id: "2",
name: "flubber",
feature_ids: ["6", "12"],
popularity: 100,
location_id: "2"
}
},
features: {
"5": { id: "5", name: "shiny" },
"6": { id: "6", name: "bouncy" },
"12": { id: "12", name: "physically impossible" }
}
}
You may want to setup an initializer in config/initializers/brainstem.rb
like the following:
Brainstem.default_namespace = :v1
module Api
module V1
module Helper
def current_user
# However you get your current user.
end
end
end
end
Brainstem::Presenter.helper(Api::V1::Helper)
require 'api/v1/widget_presenter'
require 'api/v1/feature_presenter'
require 'api/v1/location_presenter'
# ...
# Or you could do something like this:
# Dir[Rails.root.join("lib/api/v1/*_presenter.rb").to_s].each { |p| require p }
In Rails 3 it was acceptable to write scopes like this: scope :popular, where(:popular => true)
. This was deprecated in Rails 4 in preference of scopes that include a callable object: scope :popular, lambda { where(:popular) => true }
.
If your scope does not take any parameters, this can cause a problem with Brainstem if you use a filter that delegates to that scope in your presenter. (e.g., filter :popular
). The preferable way to handle this is to write a Brainstem scope that delegates to your model scope:
filter :popular { |scope| scope.popular }
--
For more detailed examples, please see the documentation for methods on Brainstem::Presenter
and our detailed Rails example application.
APIs presented with Brainstem are just JSON APIs, so they can be consumed with just about any language. As Brainstem evolves, we hope that people will contribute consumption libraries in various languages.
{
results: [
{ key: "widgets", id: "2" }, { key: "widgets", id: "10" }
],
widgets: {
"10": {
id: "10",
name: "disco ball",
…
Brainstem returns objects as top-level hashes and provides a results
array of key
and id
objects for finding the returned data in those hashes. The reason that we use the results
array is two-fold: 1st) it provides order outside of the serialized objects so that we can provide objects keyed by ID, and 2nd) it allows for polymorphic responses and for objects that have associations of their own type (like posts and replies or tasks and sub-tasks).
Brainstem includes some spec helpers for controller specs. In order to use them, you need to include Brainstem in your controller specs by adding the following to spec/support/brainstem.rb
or in your spec/spec_helper.rb
:
require 'brainstem/test_helpers'
RSpec.configure do |config|
config.include Brainstem::TestHelpers, type: :controller
end
Now you are ready to use the brainstem_data
method.
# Assume user is the model and name is an attribute
# Selecting an item from a collection by it's id
expect(brainstem_data.users.by_id(235).name).to eq('name')
# Getting an array of all ids of in a collection without map
expect(brainstem_data.users.ids).to include(1)
# Accessing the keys of a collection
expect(brainstem_data.users.first.keys).to =~ %w(id name email address)
# Using standard array methods on a collection to get by index
expect(brainstem_data.users.first.name).to eq('name')
expect(brainstem_data.users[2].name).to eq('name')
An alternate syntax for readability might be:
describe 'brainstem_data' do
subject { brainstem_data }
its('users.ids') { should include(1) }
end
If you're already using Backbone.js, integrating with a Brainstem API is super simple. Just use the Brainstem.js gem (or its JavaScript contents) to access your relational Brainstem API from JavaScript.
- Fork Brainstem or Brainstem.js
- Create your feature branch (
git checkout -b my-new-feature
) - Commit your changes (
git commit -am 'Added some feature'
) - Push to the branch (
git push origin my-new-feature
) - Create new Pull Request (
git pull-request
)
Brainstem and Brainstem.js were created by Mavenlink, Inc. and are available under the MIT License.