Note: Version 2.3.x will be the last series of releases that support Rails 3.x and Ruby 1.8/1.9. Starting with version 3.0, Decent Exposure will only support Rails 4.0 and above, and Ruby 2.0 and above.
Rails controllers are the sweaty armpit of every rails app. This is due, in large part, to the fact that they expose their instance variables directly to their views. This means that your instance variables are your interface... and that you've broken encapsulation. Instance variables are meant to be private, for Science's sake!
What decent_exposure
proposes is that you go from this:
class Controller
def new
@person = Person.new(params[:person])
end
def create
@person = Person.new(params[:person])
if @person.save
redirect_to(@person)
else
render :new
end
end
def edit
@person = Person.find(params[:id])
end
def update
@person = Person.find(params[:id])
if @person.update_attributes(params[:person])
redirect_to(@person)
else
render :edit
end
end
end
To something like this:
class Controller
expose(:person)
def create
if person.save
redirect_to(person)
else
render :new
end
end
def update
if person.save
redirect_to(person)
else
render :edit
end
end
end
And your views from this:
@person.email
To simply this:
person.email
In your forms, instead of this:
= form_for @person do |f|
...
To this:
= form_for person do |f|
...
decent_exposure
makes it easy to define named methods that are made available
to your views and which memoize the resultant values. It also tucks away the
details of the common fetching, initializing and updating of resources and
their parameters.
That's neat and all, but the real advantage comes when it's time to refactor
(because you've encapsulated now). What happens when you need to scope your
Person
resource from a Company
? Which implementation isolates those changes
better? In that particular example, decent_exposure
goes one step farther and
will handle the scoping for you (with a smidge of configuration) while still
handling all that repetitive initialization, as we'll see next.
Even if you decide not to use decent_exposure
, do yourself a favor and stop
using instance variables in your views. Your code will be cleaner and easier to
refactor as a result. If you want to learn more about this approach, I've
expanded on my thoughts in the article A Diatribe on Maintaining State.
Well, no it won't lessen your carbon footprint, but it does take a lot of cues from what's going on around it...
decent_exposure
will build the requested object in one of a couple of ways
depending on what the params
make available to it. At its simplest, when an
id
is present in the params
hash, decent_exposure
will attempt to find a
record. In absence of params[:id]
decent_exposure
will try to build a new
record.
Once the object has been obtained, it attempts to set the attributes of the
resulting object. Thus, a newly minted person
instance will get any
attributes set that've been passed along in params[:person]
. When you
interact with person
in your create action, just call save on it and handle
the valid/invalid branch. Let's revisit our previous example:
class Controller
expose(:person)
def create
if person.save
redirect_to(person)
else
render :new
end
end
end
Behind the scenes, decent_exposure
has essentially done this:
person.attributes = params[:person]
In Rails, this assignment is actually a merge with the current attributes and
it marks attributes as dirty as you would expect. This is why you're simply
able to call save
on the person
instance in the create action, rather than
the typical update_attributes(params[:person])
.
An Aside
Did you notice there's no new
action? Yeah, that's because we don't need it.
More often than not actions that respond to GET
requests are just setting up
state. Since we've declared an interface to our state and made it available to
the view (a.k.a. the place where we actually want to access it), we just let
Rails do it's magic and render the new
view, lazily evaluating person
when
we actually need it.
A Caveat
Rails conveniently responds with a 404 if you get a record not found in the
controller. Since we don't find the object until we're in the view in this
paradigm, we get an ugly ActionView::TemplateError
instead. If this is
problematic for you, consider using the expose!
method to circumvent lazy
evaluation and eagerly evaluate whilst still in the controller.
In an effort to make the examples below a bit less magical, we'll offer a
simplified explanation for how the exposed resource would be queried for
(assuming you are using ActiveRecord
).
expose(:person)
Query Explanation
id present? |
Query |
true |
Person.find(params[:id]) |
false |
Person.new(params[:person]) |
expose(:people)
Query Explanation
Query |
Person.scoped |
Want to scope your queries to ensure object hierarchy? decent_exposure
automatically scopes singular forms of a resource from a plural form where
they're defined:
expose(:people)
expose(:person)
Query Explanation
id present? |
Query |
true |
Person.scoped.find(params[:id]) |
false |
Person.scoped.new(params[:person]) |
How about a more realistic scenario where the object hierarchy specifies something useful, like only finding people in a given company:
expose(:company)
expose(:people, ancestor: :company)
expose(:person)
Query Explanation
person id present? |
Query |
true |
Company.find(params[:company_id]).people.find(params[:id]) |
false |
Company.find(params[:company_id]).people.new(params[:person]) |
decent_exposure
is a configurable beast. Let's take a look at some of the
things you can do:
Specify the model name:
expose(:company, model: :enterprisey_company)
Specify the parameter accessor method:
expose(:company, params: :company_params)
Specify the finder method:
expose(:article, finder: :find_by_slug)
Specify the parameter key to use to fetch the object:
expose(:article, finder_parameter: :slug)
There are times when one action in a controller is different from the rest of the actions. Rather than putting conditional logic in your exposure block, a better approach is the use the controller's setter methods:
expose(:articles) { current_user.articles }
expose(:article)
def index
self.articles = Article.all
end
While we try to make things as easy for you as possible, sometimes you just
need to go off the beaten path. For those times, expose
takes a block which
it lazily evaluates and returns the result of when called. So for instance:
expose(:environment) { Rails.env }
This block is evaluated and the memoized result is returned whenever you call
environment
.
If you don't want to go too far off the beaten path, the value of the default exposure can be easily obtained inside of your custom block. The block will receive a proxy object that you can use to lazily evaluate the default decent_exposure logic. For example:
expose(:articles) {|default| default.limit(10) }
This allows you to customize your exposures, without having to redo all of the built-in logic decent_exposure gives you out of the box.
For the times when custom behavior is needed for resource finding,
decent_exposure
provides a base class for extending. For example, if
scoping a resource from current_user
is not an option, but you'd like
to verify a resource's relationship to the current_user
, you can use a
custom strategy like the following:
class VerifiableStrategy < DecentExposure::Strategy
delegate :current_user, :to => :controller
def resource
instance = model.find(params[:id])
if current_user != instance.user
raise ActiveRecord::RecordNotFound
end
instance
end
end
You would then use your custom strategy in your controller:
expose(:post, strategy: VerifiableStrategy)
The API only necessitates you to define resource
, but provides some
common helpers to access common things, such as the params
hash. For
everything else, you can delegate to controller
, which is the same as
self
in the context of a normal controller action.
For most things, you'll be able to pass a few configuration options and get
the desired behavior. For changes you want to affect every call to expose
in
a controller or controllers inheriting from it (e.g. ApplicationController
,
if you need to change the behavior for all your controllers), you can define
an decent_configuration
block:
class ApplicationController < ActionController::Base
decent_configuration do
strategy MongoidStrategy
end
end
A decent_configuration
block without a :name
argument is considered the
"default" configuration for that controller (and it's ancestors). All things
considered, you probably only want to change the strategy in a default.
Nonetheless, you can pass any configuration option you can to an individual
exposure to the decent_configuration
block.
If you don't want a specific configuration to affect every exposure in the given controller, you can give it a name like so:
class ArticleController < ApplicationController
decent_configuration(:sluggable) do
finder :find_by_slug
finder_parameter :slug
end
end
And opt into it like so:
expose(:article, config: :sluggable)
If you're using Rails 4 or strong_parameters, add the following to your ApplicationController:
class ApplicationController < ActionController::Base
decent_configuration do
strategy DecentExposure::StrongParametersStrategy
end
end
Then, when you'd like parameters to be assigned to a model, add the
attributes
option to your exposure:
class FooController < ApplicationController
expose(:foo, attributes: :foo_params)
private
def foo_params
params.require(:foo).permit(:bar, :baz)
end
end
In the example above, foo_params
will only be called on a PUT, POST or
PATCH request.
Controller testing remains trivially easy. The shift is that you now set expectations on methods rather than instance variables. With RSpec, this mostly means avoiding assign
and assigns
.
describe CompaniesController do
describe "GET index" do
# this...
it "assigns @companies" do
company = Company.create
get :index
assigns(:companies).should eq([company])
end
# becomes this
it "exposes companies" do
company = Company.create
get :index
controller.companies.should eq([company])
end
end
end
View specs follow a similar pattern:
describe "people/index.html.erb" do
# this...
it "lists people" do
assign(:people, [ mock_model(Person, name: 'John Doe') ])
render
rendered.should have_content('John Doe')
end
# becomes this
it "lists people" do
view.stub(people: [ mock_model(Person, name: 'John Doe') ])
render
rendered.should have_content('John Doe')
end
end