mavenlink / brainstem

The Brainstem gem provides a framework for converting ActiveRecord objects into a great JSON API. Brainstem Presenters allow easy application of user-requested sorts, filters, and association loads, allowing for simpler implementations, fewer requests, and smaller responses.
MIT License
203 stars 14 forks source link

If you're upgrading from an older version of Brainstem, please see Upgrading From The Pre 1.0 Brainstem and the rest of this README.

Brainstem

Gitter

Build Status

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.

Why Brainstem?

Watch our talk about Brainstem from RailsConf 2013

Installation

Add this line to your application's Gemfile:

gem 'brainstem'

Usage

Make a Presenter

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 lib/api/v1/widget_presenter.rb:

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 applies a lambda.
      filter :location_name, :string, items: [:sf, :la] do |scope, location_name|
        scope.joins(:locations).where("locations.name = ?", location_name)
      end

      # Filter with an overridable default. This will run on every request,
      # passing in `bool` as `false` unless a user has specified otherwise.
      filter :include_legacy_widgets, :boolean, default: false do |scope, bool|
        bool ? scope : scope.without_legacy_widgets
      end

      # The top-level JSON key in which these presented records will be returned.
      # This is optional and defaults to the model's table name.
      brainstem_key :widgets

      # Specify the fields to be present in the returned JSON.
      fields do
        field :name, :string,
              info: "the Widget's name"
        field :legacy, :boolean,
              info: "true for legacy Widgets, false otherwise",
              via: :legacy?
        field :longform_description, :string,
              info: "feature-length description of this Widget",
              optional: true
        field :aliases, :array,
              item_type: :string,
              info: "the differnt aliases for the widget"
        field :updated_at, :datetime,
              info: "the time of this Widget's last update"
        field :created_at, :datetime,
              info: "the time at which this Widget was created"

        # Fields can be nested under non-evaluable parent fields where the nested fields
        # are evaluated with the presented model.
        fields :permissions, :hash do |permissions_field|

          # Since the permissions parent field is not evaluable, the can_edit? method is
          # evaluated with the presented Widget model.
          permissions_field.field :can_edit, :boolean,
                                  via: :can_edit?,
                                  info: "Indicates if the user can edit the widget"
        end

        # Specify nested fields within an evaluable parent block field. A parent block field
        # is evaluable only if one of the following options :via, :dynamic or :lookup is specified.
        # The nested fields are evaluated with the value of the parent.
        fields :tags, :array,
               item_type: :hash,
               info: "The tags for the given category",
               dynamic: -> (widget) { widget.tags } do |tag|

          # The name method will be evaluated with each tag model returned by the the parent block.
          tag.field :name, :string,
                    info: "Name of the assigned tag"
        end

        fields :primary_category, :hash,
               via: :primary_category,
               info: "The primary category of the widget" do |category|

          # The title method will be evaluated with each category model returned by the parent block.
          category.field :title, :string,
                         info: "The title of the category"
        end
      end

      # Associations can be included by providing include=association_name in the URL.
      # IDs for belongs_to associations will be returned for free if they're native
      # columns on the model, otherwise the user must explicitly request associations
      # to avoid unnecessary loads.
      associations do
        association :features, Feature,
                    info: "features associated with this Widget"
        association :location, Location,
                    info: "the location of this Widget"
      end
    end
  end
end

Setup your Controller

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: brainstem_present("widgets") { Widgets.visible_to(current_user) }
  end

  def show
    widget = Widget.find(params[:id])
    render json: brainstem_present_object(widget)
  end

  def create
    # Note: you are in charge of sanitizing params[brainstem_model_name], likely with strong parameters.
    widget = Widget.new(params[brainstem_model_name])
    if widget.save
      render json: brainstem_present_object(widget)
    else
      render json: brainstem_model_error(widget), status: :unprocessable_entity
    end
  end
end

The Brainstem::ControllerMethods concern provides:

Controller Best Practices

We recommend that your base API controller look something like the following.

module Api
  module V1
    class ApiController < ApplicationController
      include Brainstem::ControllerMethods

      before_filter :api_authenticate

      rescue_from StandardError, with: :server_error
      rescue_from Brainstem::SearchUnavailableError, with: :search_unavailable
      rescue_from ActiveRecord::RecordNotDestroyed, with: :record_not_destroyed
      rescue_from ActiveRecord::RecordNotFound,
                  ActionController::RoutingError, with: :page_not_found

      private

      def api_authenticate
        # Implement your authentication here.  We recommend Doorkeeper.
      end

      def server_error(exception)
        render json: brainstem_system_error("A server error has occurred."), status: 500
      end

      def search_unavailable
        render json: brainstem_system_error('Search is currently unavailable'), status: 503
      end

      def page_not_found
        render json: brainstem_system_error('Record not found'), status: 404
      end

      def record_not_destroyed
        render json: brainstem_model_error("Could not delete the #{brainstem_model_name.humanize.downcase.singularize}"), status: :unprocessable_entity
      end
    end
  end
end

Setup Rails to Load Brainstem

To configure Brainstem for development and production, we do the following:

1) We add lib to our Rails autoload_paths in application.rb with config.autoload_paths += "#{config.root}/lib"

2) We setup an initializer in config/initializers/brainstem.rb, similar to the following:

# In order to support live code reload in the development environment, we
# register a `to_prepare` callback. This # runs once in production (before the
# first request) and whenever a file has changed in development.
Rails.application.config.to_prepare do
  # Forget all Brainstem configuration.
  Brainstem.reset!

  # Set the current default API namespace.
  Brainstem.default_namespace = :v1

  # (Optional) Utilize MySQL's [FOUND_ROWS()](https://dev.mysql.com/doc/refman/5.7/en/information-functions.html#function_found-rows) 
  # functionality to avoid issuing a new query to calculate the record count, 
  # which has the potential to up to double the response time of the endpoint.
  Brainstem.mysql_use_calc_found_rows = true 

  # (Optional) Load a default base helper into all presenters. You could use
  # this to bring in a concept like `current_user`.  # While not necessarily the
  # best approach, something like http://stackoverflow.com/a/11670283 can
  # currently be used to # access the requesting user inside of a Brainstem
  # presenter. We hope to clean this up by allowing a user to be passed in #
  # when presenting in the future.
  module ApiHelper
    def current_user
      Thread.current[:current_user]
    end
  end
  Brainstem::Presenter.helper(ApiHelper)

  # Load the presenters themselves.
  Dir[Rails.root.join("lib/api/v1/*_presenter.rb").to_s].each { |presenter_path| require_dependency(presenter_path) }
end

Make an API request

The scope passed to brainstem_present can contain any starting scope conditions that you'd like. Requests can have includes, filters, and sort orders specified in the params and automatically parsed by Brainstem.

GET /api/widgets.json?include=features&order=created_at:desc&location_name=san+francisco

Responses will look like the following:

{
  # Total number of results that matched the query.
  count: 5,

  # Information about the request and response.
  meta: {
    # Total number of results that matched the query.
    count: 5,

    # Current page returned in the response.
    page_number: 1,

    # Total number pages available.
    page_count: 1,

    # Number of results per page.
    page_size: 20,
  },

  # A lookup table to top-level keys. Necessary
  # because some objects can have associations of
  # the same type as themselves. Also helps to
  # support polymorphic requests.
  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" }
  }
}

Valid URL params

Brainstem parses the request params and supports the following:

--

The brainstem executable

The brainstem executable provided with the gem is at the moment used only to generate API docs from the command line, but you can verify which commands are available simply by running:

bundle exec brainstem

This will give you a list of all available commands. Additional help is available for each command, and can be found by passing the command the help flag, i.e.:

bundle exec brainstem generate --help

--

API Documentation

Currently, Brainstem supports generation of documentation in the following formats:

The generate command

Running bundle exec brainstem generate [ARGS] will generate the documentation extracted from your properly annotated presenters and controllers.

Note that this does not, at present, remove existing docs that may be present from a previous generation, so it is recommended that you use this executable as part of a large shell script that empties your directory and regenerates over top of it if you expect much churn.

Customizing behavior

While options can be passed on the command line, this can complicate the invocation, especially when the desired settings are often specific to the project and do not often change.

As a result, it is possible to specify options through an initializer in your application that will be used in the absence of command-line flags. Thus, configuration precedence is in the following order:

  1. Command-line flags;
  2. Initializer settings;
  3. Built-in defaults.

To see a list of the available command-line options, run bundle exec brainstem generate --help.

To see a list of the available initializer settings, view lib/brainstem/api_docs.rb. You can configure these in your initializers just by setting them:

# config/initializers/brainstem.rb

Brainstem::ApiDocs.tap do |config|
  config.write_path = "/path/to/output"
end

Annotating an API

Presenters / Data Models

By and large, Presenters are self-documenting: simply using them as intended will yield a panoply of data.

Docstrings

All common methods that do not explicitly take a description take an :info option, which allows for the specification of an explanatory documentation string.

As a general rule of thumb, methods that are not used within a block tend to accept :info strings, and those used within a block tend to have their own description argument.

For example:

class MyPresenter < Brainstem::Presenter
  sort_order :cost, info: "Sorts by cost" do |scope, direction|
    scope.reorder("myobjects.cost #{direction}")
  end
end

The methods that take an :info option include:

The following do not accept documentation:

Nodoc

The following methods accept a :nodoc boolean option, which indicates that the documentation should be suppressed for this particular entry:

Additional Documentables

In addition to the above, there are three additional methods in the DSL designed primarily for documentation:

Example
class PostsPresenter < Brainstem::Presenter
  presents Post

  # Hide the entire presenter
  #
  # nodoc!

  # If we temporarily want to disable the custom title, and just display
  # 'Posts', we can add a 'nodoc' option set to true.
  #
  # title "Blog Posts", nodoc: true

  title "Blog Posts"

  description <<-MARKDOWN.strip_heredoc
    The blog post is the primary entity in the blog, which represents a single
    post by one of our authors.
  MARKDOWN

  associations do
    association :author, User,
                info: "the author of the post"

    # Temporarily disable documenting this relationship as we revamp the
    # editorial system:
    association :editor, User,
                info: "the editor of the post",
                nodoc: true
  end
end

Controllers

The configuration for a controller takes place inside the brainstem_params block, e.g.:

class PostsController < ApiController
  include Brainstem::Concerns::ControllerDSL

  brainstem_params do   
    title "Posts"
  end
end
Action Contexts

Configuration that is specified within the root level of the brainstem_params block is applied to the entire controller, and every action within the controller. This is referred to as the 'default' context, because it is used as the default for all actions. This lets you specify common defaults for all actions, as well as a title and description for the controller, which, along with an annotation of nodoc!, are not inherited by the actions.

Each action has its own action context, and the documentation is smart enough to know that what you want to document for the index action is likely not what you'd like to document for the show action, but you are also likely to have your create and update methods be very similar.

You can define an action context and place any configuration inside this context, and it will keep the documentation isolated to that specific action:

brainstem_params do
  valid :global_controller_param, :string,
        info: "A trivial example of a param that applies to all actions."

  actions :index do
    # This adds a `blog_id` param to just the `index` action.
    valid :blog_id, :integer,
          info: "The id of the blog to which this post belongs"
  end

  actions :create, :update do
    # This will add an `id` param to both `create` and `update` actions.
    valid :id, :integer,
          info: "The id of the blog post"
  end
end

Action contexts, like the default context, are inherited from the parent controller. So it is often possible to express common setup in the more abstract controllers, like so:

class ApiController
  brainstem_params do
    actions :destroy do
      presents nil
    end
  end
end

class PostsController << ApiController; end

In this example, PostsController will list no presenter for its destroy method as it inherits this from ApiController.

It is important to specify everything at the most specific level possible. Action contexts have a higher priority than defaults, and will fall back to the action context of the parent controller before they check the default of the child controller. It's therefore recommended that your documentation be kept in action contexts as much as possible.

title / description / nodoc!

Any of these can be used inside an action context as well.

class BlogPostsController < ApiController
  brainstem_params do

    # Make the displayed title of this controller "Posts"
    title "Posts"

    # Fall back to 'BlogPostsController' for a title
    title "Posts", nodoc: true

    # Show description
    description "Access blog posts through these endpoints."

    # Hide description
    description "...", nodoc: true

    # Do not document this controller or any of its endpoints!
    nodoc!

    actions :index do
      # Set the title of this action
      title "Listing blog posts"
      description "..."
    end

    actions :show do
      # Do not display this action.
      nodoc!
    end

  end
end
valid / model_params
class BlogPostsController < ApiController
  brainstem_params do

    # Add an `:category_id` param to all actions in this controller / children:
    valid :category_id, :integer,
          info: "(required) the category's ID"

    # Do not document this additional field.
    valid :lang, :string,
          info: "(optional) the language of the requested post",
          nodoc: true

    actions :show do
      # Declare a nested param under the `brainstem_model_name` root key,
      # i.e. `params[:blog_post][:id]`):
      model_params do |post|
        post.valid :id, :integer,
                   info: "the id of the post", required: true
      end
    end

    actions :create do
      model_params :post do |params|
        params.valid :message, :string,
                     info: "the message of the post",
                     required: true

        params.valid :viewable_by, :array,          
                     item_type: :integer,
                     info: "an array of user ids that can access the post"

        # Declare a nested param with an explicit root key:, i.e. `params[:rating][...]`
        model_params :rating do |rating_param|
          rating_param.valid :stars, :integer,
                             info: "the rating of the post"
        end

        # Declare nested array params with an explicit key:, i.e. `params[:replies][0][...]`
        params.valid :replies, :array,
                     item_type: :hash,
                     info: "an array of reply params that can be created along with the post" do |reply_params|
          reply_params.valid :message, :string,
                             info: "the message of the post"
          reply_params.valid :replier_id, :integer,
                             info: "the ID of the user"
          ...
        end
      end
    end

    actions :share do
      # Declare a nested param with an explicit root key:, i.e. `params[:share][...]`
      model_param :share do
        # ...
      end
    end

    def self.param_root
      :widgets
    end

    actions :update do
      # Declare a dynamic root key, i.e. `params[:widgets][:id]`
      model_params(-> (controller_klass) { controller_class.param_root } do |p|
        p.valid :id #, ...
      end
    end
  end
end
presents
class BlogPostsController < ApiController
  brainstem_params do
    # Includes a link to the presenter for `BlogPost` in each action.
    presents BlogPost
  end
response

Allows documenting custom responses on endpoints. These are only applicable to action contexts.

class ContactsController < ApiController
  brainstem_params do
    actions :index do
      response :hash do
        field :count, :integer,
              info: "Total count of contacts"

        fields :contacts, :array,
               item_type: :hash,
               info: "Array of contact details" do

          field :full_name, :string,
                info: "Full name of the contact"

          field :email_address, :string,
                info: "Email address of the contact"
        end
      end
    end
  end
Specific to Open API Specification 2.0 generation
tag / tag_groups

These are applicable only to the root context.

brainstem_params do

  # The `tag` configuration allows grouping of all endpoints
  # in a controller under the same group
  tag "Adopt a Pet"

  # The `tag_group` configuration introduces another level of nesting
  # and allows grouping multiple controllers under a specific group
  tag_groups "Dogs", "Cats"
end
consumes / produces / security / external_doc / schemes / deprecated

Any of these can be used inside an action context.

class PetsController < ApiController
  brainstem_params do

    # A list of default MIME types, endpoints on this controller can consume.
    consumes "application/xml", "application/json"

    # A list of default MIME types, endpoints on this controller can produce.
    produces "application/xml"

    # A declaration of which security schemes are applied to endpoints on this controller.
    security []

    # The default transfer protocols for endpoints on this controller. 
    schemes "https", "http"

    # Additional external documentation
    external_doc description: 'External Doc',
                 url: 'www.google.com'

    # Declares endpoints on this controller to be deprecated.
    deprecated true

    actions :update do

      # Overriden MIME types the endpoints can consume.
      consumes "application/json"

      # A list of default MIME types the endpoints can produce.
      produces "application/json"

      # Security schemes for this endpoint.
      security { "petstore_auth" => [ "write:pets" ] }

      # Transfer protocols applicable to this endpoint.
      schemes "https"

      # External documentation for the endpoint.
      external_doc description: 'Stock Market News',
                   url: 'www.google.com/finance'

      # Overrides the deprecated value set on the root context.
      deprecated false
    end
  end
end
operation_id

The operation_id configuration can only be used within an action context.

class BlogPostsController < ApiController
  brainstem_params do
    actions :show do

      # Unique string used to identify the operation. 
      operation_id "getBlogByID"
    end
  end
end

Extending and Customizing the API Documentation

For more information on extending and customizing the API documentation, please see the API Doc Generator developer documentation.

--

For more detailed examples, please see the rest of this README and our detailed Rails example application.

Consuming a Brainstem API

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 client libraries in many languages.

Existing libraries:

The Brainstem Results Array

{
  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).

Testing your Brainstem API

We recommend writing specs for your Presenters and validating them with the Brainstem::PresenterValidator. Here is an example RSpec shared behavior that you might want to use:

shared_examples_for "a Brainstem api presenter" do |presenter_class|
  it 'passes Brainstem::PresenterValidator' do
    validator = Brainstem::PresenterValidator.new(presenter_class)
    validator.valid?
    validator.should be_valid, "expected a valid presenter, got: #{validator.errors.full_messages}"
  end
end

And then use it in your presenter specs (e.g., in spec/lib/api/v1/widget_presenter_spec.rb:

require 'spec_helper'

describe Api::V1::WidgetPresenter do
  it_should_behave_like "a Brainstem api presenter", described_class

  describe 'presented fields' do
    let(:loaded_associations) { { } }
    let(:user_requested_associations) { %w[features location] }
    let(:model) { some_widget } # load from a fixture or create with a factory
    let(:presented_data) {
      # `present_model` will return the representation of a single model. As an optional
      # side effect, it will store any requested associations in the Hash provided
      # to `load_associations_into`.
      described_class.new.present_model(model, user_requested_associations,
                                        load_associations_into: loaded_associations)
    }

    describe 'attributes' do
      it 'presents the attributes' do
        presented_data['name'].should == model.name
      end

      describe 'something conditional on the presenter' do
        describe 'for widgets with this behavior' do
          let(:model) { widget_with_permissions }

          it 'should be true' do
            presented_data['conditional_thing'].should be_truthy
          end
        end

        describe 'for widgets without this behavior' do
          let(:model) { widget_without_permissions }

          it 'should be missing' do
            presented_data.should_not have_key('conditional_thing')
          end
        end
      end
    end

    describe 'associations' do
      it 'should load the associations' do
        presented_data
        loaded_associations.keys.should == %w[features location]
      end
    end
  end
end

You can also write a spec that validates all presenters simultaniously by calling Brainstem.presenter_collection.validate!.


Brainstem also 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.

# Access the request results:
expect(brainstem_data.results.first.name).to eq('name')

# View the resulting IDs
expect(brainstem_data.results.ids).to eq(['1', '2', '3'])

Selecting an item from a top-level collection by it's id
expect(brainstem_data.users.by_id(235).name).to eq('name')

# Accessing the keys of presented model
expect(brainstem_data.results.first.keys).to =~ %w(id name email address)

Upgrading from the pre-1.0 Brainstem

If you're upgrading from the previous version of Brainstem to 1.0, there are some key changes that you'll want to know about:

Advanced Topics

The presenter DSL

Brainstem provides a rich DSL for building presenters. This section details the methods available to you.

A note on Rails 4 Style Scopes

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 }

Contributing

  1. Fork Brainstem or Brainstem.js
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Added some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create new Pull Request (git pull-request)

License

Brainstem and Brainstem.js were created by Mavenlink, Inc. and are available under the MIT License.