Revisiting a Rails project from when I started developing, I wanted to figure out how to really DRY my controllers out—without making them unreadable by doing half/all of the work in a before_action
. (I've used CanCanCan's load_resource
in the past. That and some other gems I looked at are a little too DRY for what I want.)
So I wrote this module to encapsulate the business logic of finding the record and loading the params data within an Accessor
object. It's not meant to cover all cases, but I'm pretty happy with it.
I have it set up with controller tests in a scaffolded app here.
You add it to your controllers...
class ApplicationController < ActionController::Base
extend Resourceable
end
And then for a user
model that has one profile
like this...
# config/routes.rb
Rails.application.routes.draw do
resources :users do
resource :profile
end
end
You write controllers like this (along with the responders
gem):
class UsersController < ApplicationController
access_resource :users, columns: [:email, :password, :password_confirmation]
def index
users
end
def show
user
end
def new
user
end
def create
user.save
respond_with user
end
end
class ProfilesController < ApplicationController
# singleton resource accessed by its parent's id
# because its own will never be in the params
access_resource :profile, key: :user_id, columns: [
:username, :picture, :gender, :birthday, :location
]
def edit
profile
end
def update
profile.save
respond_with profile, location: -> { user_path(profile.user) }
end
end
require "resourceable/accessor"
# Module to DRY controllers by encapsulating finding/initializing ActiveRecord
# resources and assigning params data in a PORO. ::access_resource is called at
# the top of the controller for every resource you want to access, and two
# memoized getter methods are metaprogramatically defined for each. When
# called, they generate an Accessor object that returns the correct object,
# loaded with the params, but not saved.
module Resourceable
private
def access_resource(*models, **options)
# Accepts a list of ActiveRecord models as symbols, strings, or constants.
# Can also accept a single model and the following keyword options:
#
# columns:
# The columns you want to permit access to in this context.
# When the resource is loaded, data for these columns will be
# automatically assigned using strong params.
# decorator:
# For use with the Draper gem. When true, return a the class wrapped in
# a decorator. A decorator class can be specified, instead, if named
# differently than the resource.
# key:
# The db column to use for finding the resource in this context.
#
# Examples:
# access_resources :users, :accounts
# access_resource Profile, key: :user_id, decorator: true,
# columns: [:username, :picture]
#
define_access_methods_for(models.pop, options) if options.present?
models.each { |model| define_access_methods_for model }
end
alias access_resources access_resource
def define_access_methods_for(resource, options = {})
resource = resource.to_s.underscore.singularize
define_memoized_getter_for_collection resource, options
define_memoized_getter_for_single_record resource, options
end
def define_memoized_getter_for_collection(resource, options = {})
# Generate a memoized getter method to return a collection of ActiveRecord
# objects. The resource & options args will be hard-coded into the method.
#
# The generated method will accept a block for chaining ActiveRecord
# methods. params & block will be called when the method is called.
#
# example usage:
# def index
# users { |u| u.limit(5) }
# end
#
define_method resource.pluralize.to_sym do |&scope|
instance_variable_get("@#{resource.pluralize}") || begin
accessor = Accessor.new(resource, options, params, &scope)
instance_variable_set "@#{resource.pluralize}", accessor.collection
end
end
end
def define_memoized_getter_for_single_record(resource, options = {})
# Generate a memoized getter method to return a single ActiveRecord
# object, loaded with data from params. The resource & options args
# will be hard-coded into the method.
#
# params will be called when the method is called.
#
# example usage:
# def show
# user
# end
#
define_method resource.to_sym do
instance_variable_get("@#{resource}") || begin
accessor = Accessor.new(resource, options, params)
instance_variable_set "@#{resource}", accessor.load_resource
end
end
end
end
# app/controllers/concerns/resourceable/accessor.rb
module Resourceable
# PORO to encapsulate the business logic of finding/initializing ActiveRecord
# objects and loading data from params. Returned records are not saved.
class Accessor
attr_reader :resource, :permitted_columns
def initialize(resource, options, params, &scope)
@resource = resource.to_s.underscore.singularize
@klass = resource.camelize.constantize
@decorator = options[:decorator]
@key = options[:key]
@permitted_columns = options[:columns]
@params = params
@scope = scope
end
def collection
scope ? scope.yield(klass) : klass.all
end
def load_resource
loaded = existing_resource || new_resource
decorate(loaded).tap{ |r| r.assign_attributes updated_attributes }
end
private
attr_reader :decorator, :key, :klass, :params, :scope
def existing_resource
klass.find_by(identifier) unless new_or_create_action?
end
def new_resource
klass.new identifier
end
def new_or_create_action?
params[:action].in? %w(new create)
end
def identifier
key ? { key => params[key] } : { id: resource_id_from_params }
end
def resource_id_from_params
params["#{resource}_id"] || (params[:id] if eponymous_controller?) || nil
end
def eponymous_controller?
params[:controller] == resource.pluralize
end
def updated_attributes
params[resource].present? ? resource_params : {}
end
def resource_params
params.require(resource).permit(associations, permitted_columns)
end
def associations
(klass.column_names - [key.to_s]).select { |col| col.end_with? "_id" }
end
def decorate(record)
case decorator
when nil, false
record
when true
record.decorate
else
decorator.to_s.camelize.constantize.new record
end
end
end
end