Skip to content

Instantly share code, notes, and snippets.

@philliplongman
Last active March 14, 2023 20:51
Show Gist options
  • Save philliplongman/af64f9eae57f9561f9cf6cb34610e7d3 to your computer and use it in GitHub Desktop.
Save philliplongman/af64f9eae57f9561f9cf6cb34610e7d3 to your computer and use it in GitHub Desktop.

Resourceable module

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.

Usage

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

Module code

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment