ApiPresenter
REST APIs provide a concise and conventional means of retrieving resources for a client. But in the real world, clients often have additional data requirements beyond the specifically requested resource(s):
- Current user permissions for the returned records, so that the client can intelligently draw its UI (ex: edit/delete buttons).
- Associated data, to mitigate total number of requests (ex: return authors with posts).
ApiPresenter provides both of these things, plus a bit more.
Installation
Add this line to your application's Gemfile:
gem 'api_presenter'
And then execute:
$ bundle
Or install it yourself as:
$ gem install api_presenter
Usage
ApiPresenter is well suited to large, relational systems. We'll use a blog as the usage example for this gem. The blog has the following model structure:
class Category < ActiveRecord::Base
has_many :sub_categories
has_many :posts, through: :sub_categories
end
class SubCategory < ActiveRecord::Base
belongs_to :category
has_many :posts
end
class Post < ActiveRecord::Base
belongs_to :sub_category
belongs_to :creator, class_name: 'User'
belongs_to :publisher, class_name: 'User'
end
class User < ActiveRecord::Base
has_many :created_posts, class_name: 'Post', foreign_key: 'creator_id'
has_many :published_posts, class_name: 'Post', foreign_key: 'publisher_id'
end
Usage examples will be in the context of requesting posts as the primary collection.
0. Generate config file
rails g api_presenter:config
Generate your configuration file. Currently, ApiPresenter allows customization of querystring parameter names for including policies and associated resources (see below). More configuration options to come.
1. Create your Presenter
Generate a presenter class for your ActiveRecord model. The generator will also ensure the presence of an ApplicationApiPresenter
base class for centralized methods.
rails g api_presenter:presenter post
class PostPresenter < ApplicationApiPresenter
def associations_map
{
categories: { associations: { sub_category: :category } },
sub_categories: { associations: :sub_category },
users: { associations: [:creator, :publisher] }
}
end
def policy_methods
[:update, :destroy]
end
# def policy_associations
# :user_profile
# end
end
Presenters can define three opt-in methods:
-
associations_map
Associated resources that you would like to be includable with the primary collection. Consists of the model name as key and the traversal required to preload/load them. In most cases, the value ofassociations
will correspond directly to associations on the primary model. -
policy_methods
A list of Pundit policy methods to resolve for the primary collection if policies are requested. -
policy_associations
Additional associations to preload in order to optimize policies that must traverse asscoiations.
2. Enable your controllers
Include the supplied controller concern at your ApplicationController
level, or on a specific controller. This concern provides the present
method, which can be called on an ActiveRecord::Relation
, an array of records, or even a single record (preloading of associated collections is only performed for relations).
class ApplicationController
include ApiPresenter::Concerns::Presentable
end
class PostsController < ApplicationController
# @example
# GET /posts?include=categories,subCategories,users&policies=true
#
def index
authorize Post
posts = PostQuery.records(current_user, params)
present posts
end
# @example
# GET /posts/:id?include=categories,subCategories,users&policies=true
#
def show
@post = Post.find(params[:id])
authorize @post
present @post
end
end
Controller params are used to tell the presenter what to load. The default param keys are count
, policies
, and include
:
-
count [Boolean]
Pass true if you just want a count of the primary collection. -
policies [Boolean]
Pass true if you want to resolve policies for the primary collection. -
include [String, Array]
A comma-delimited list or array of collection names (camelCase or under_scored) to include with the primary collection.
3. Render the result
After calling the present
method in a controller action, you access your processed collection through the @presenter
instance variable. How you ultimately render the data produced by ApiPresenter is up to you.
@presenter
has the following properties:
-
collection [Array<ActiveRecord::Base>]
The primary collection that was passed into the presenter. Empty if count requested. -
total_count [Integer]
When using Kaminari or another pagination method that defines atotal_count
property, returns unpaginated count. If the primary collection is not anActiveRecord::Relation
, simply returns the number of records. -
included_collection_names [Array<Symbol>]
Convenience method that returns an array of included collection model names. -
included_collections [Hash]
A hash of included collections, consisting of the model name and corresponding records. -
policies [Array<Hash>]
An array of resolved policies for the primary collection.
Here's an example of how you might render your data using JBduiler:
api/posts/index.json.jbuilder
json.posts(@presenter.collection) do |post|
json.partial!(post)
end
json.partial!("api/shared/included_collections_and_meta", presenter: @presenter)
api/posts/show.json.jbuilder
json.post do
json.partial!(@post)
end
json.partial!("api/shared/included_collections_and_meta", presenter: @presenter)
api/shared/included_collections_and_meta
presenter.included_collections.each do |collection_key, collection|
json.set!(collection_key, collection) do |record|
json.partial!(record)
end
end
json.meta do
json.total_count(presenter.total_count)
json.policies presenter.policies
end
4. Output
Using the code above, our call to GET /posts?include=categories,subCategories,users&policies=true
would result in the following JSON:
{
"posts": [
{ "id": 1, "sub_category": 1, "creator_id": 1, "publisher_id": 2, "body": "Lorem dim sum", "published": true },
{ "id": 2, "sub_category": 2, "creator_id": 3, "publisher_id": null, "body": "Lorem dim sum", "published": false }
],
"categories": [
{ "id": 1, "name": "Animals" }
],
"sub_categories": [
{ "id": 1, "category_id": 1, "name": "Lemurs" },
{ "id": 2, "category_id": 1, "name": "Anteaters" }
],
"users": [
{ "id": 1, "name": "Dora" },
{ "id": 2, "name": "Boots" },
{ "id": 3, "name": "Backpack" }
],
"meta": {
"total_count": 2,
"policies": [
{ "post_id": 1, "update": true, "destroy": false },
{ "post_id": 2, "update": true, "destroy": true }
]
}
}
And similarily, for GET /posts/1?include=categories,subCategories,users&policies=true
:
{
"post": { "id": 1, "sub_category": 1, "creator_id": 1, "publisher_id": 2, "body": "Lorem dim sum", "published": true },
"categories": [
{ "id": 1, "name": "Animals" }
],
"sub_categories": [
{ "id": 1, "category_id": 1, "name": "Lemurs" }
],
"users": [
{ "id": 1, "name": "Dora" },
{ "id": 2, "name": "Boots" }
],
"meta": {
"total_count": 1,
"policies": [
{ "post_id": 1, "update": true, "destroy": false }
]
}
}
Advanced Usage
Conditional includes
There are a number of ways you can conditionally include resources, depending, for instance, on user type.
Add conditions inside associations_map
method
class PostPresenter < ApiApplicationPresenter
def associations_map
current_user.admin? ? admin_associations_map : user_associations_map
end
private
def user_associations_map
{
sub_categories: { associations: :sub_category },
users: { associations: [:creator, :publisher] }
}
end
def admin_associations_map
{
categories: { associations: { sub_category: :category } },
sub_categories: { associations: :sub_category },
users: { associations: [:creator, :publisher] }
}
end
end
Use condition
property within association_map
definition
Via inline string
class PostPresenter < ApiPresenter::Base
def associations_map
{
categories: { associations: { sub_category: :category }, condition: 'current_user.admin?' },
sub_categories: { associations: :sub_category },
users: { associations: [:creator, :publisher] }
}
end
end
Via method call
class PostPresenter < ApiPresenter::Base
def associations_map
{
categories: { associations: { sub_category: :category }, condition: :admin? },
sub_categories: { associations: :sub_category },
users: { associations: [:creator, :publisher] }
}
end
private
def admin?
current_user.admin?
end
end
Control it from your policy
class CategoryPolicy < ApplicationPolicy
def index?
user.admin?
end
end
TODO
- Decouple from Pundit
- Make index policy checking on includes optional
- Allow custom collection names
- Add test helper to assert presenter was called for a given controller action
Development
After checking out the repo, run bin/setup
to install dependencies. Then, run rake rspec
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and tags, and push the .gem
file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/uberllama/api_presenter.
License
The gem is available as open source under the terms of the MIT License.