Search code examples
ruby-on-railsrubydryabstractionruby-on-rails-5.1

Rails 5: Abstracting actions out of controllers


RoR 5.1.4. My application is backed by MongoDB via Mongoid, and many of the models have similar handling. I'm trying to make it as data-driven as possible, which means querying Mongoid for the models and Rails for the routes, neither of which can be done comfortably in initialisers because of eager loading and routes not yet having been set up. The schema information (controller/model relationships, model fields, etc., are stored in a singleton location, and making that happen and be accessible from class and instance environments has been a PITA. However, that's background.

Most of the controllers support two upload actions, one via form file specification and one via request content body. I'm using webupload and upload actions for these respectively, and since they're always handled the same (differences are determined by examining the aforementioned schema info), I want to abstract those two action methods, and their related before/after/.. relationship definitions, into a separate module.

A controller concern won't do it OOTB because the callbacks (e.g., :before_action and friends) aren't declared in the inheritance, and I can't figure out what module I'd need to include in the concern in order to get them.

Helpers are out because they've been deprecated from controller inclusion (you need to take extra steps to get them), and they're primarily for views anyway.

So what is the pattern for writing/placing/including modules specifically for controllers, models, and tests? That have the proper inheritance of methods for those functions? (E.g., :before_action for controllers, :validates for models, and so on.)

RoR is a wealthy hoard of features and hooks, but I'm finding it bloody hard to apply abstraction and DRY patterns to it simply because it is so rich.

Thanks for any help or pointers!

[EDIT] Someone suggested that I include some code. So here in an abbreviated excerpt of my attempt to do this with a controller concern.

module UploadAssistant

  extend ActiveSupport::Concern
  #
  # Code to execute when something does a `include ControllerAssistant`.
  #
  included do
    #
    # Make the application's local storage module more easily
    # accessible, too.
    #
    unless (self.const_defined?('LocalStore'))
      self.const_set('LocalStore', ::PerceptSys::LocalStore)
    end

    def set_title_uploading
      title.base        = 'Uploading records'
      title.add(model_info.friendly)
    end                         # def set_base_title

    #+
    # Supply these routes to the controllers so they needn't define
    # them.
    #

    #
    # GET /<model>/upload(.:format)
    #
    def webupload
    end                         # def webupload

    #
    # POST /<model>/upload(.:format)
    #
    def upload
      title.add('Results')
      @uploads          = {
        :success                => {},
        :failure                => {},
      }
      errors            = 0
      @upload_records.each do |record|
        #
        # Stuff happens here.
        #
      end
      successes         = @uploads[:success].count
      failures          = @uploads[:failure].count
      respond_to do |format|
        format.html {
          render(:upload,
                 :status   => :ok,
                 :template => 'application/upload.html')
        }
        format.json {
          render(:json     => @uploads)
        }
      end
    end

    def upload_file_params
      if (params[:file])
        params.require(:file).require(:upload)
        colname         = model_info.collection
        file_id         = params[:file][:upload]
        #
        # Get the file contents.
        #
      end
      @upload_records   = params.delete(model_info.collection.to_sym)
    end                         # def upload_file_params

    def upload_params
      @upload_records   = params.require(model_info.collection.to_sym)
    end                         # def upload_params

    def set_file_upload
      file_id           = params.require(:file).require(:upload)
      #
      # Read/decompress the file.
      #
      data              = JSON.parse(data)
      params[model_info.collection] = data[model_info.collection]
    end                         # def set_file_upload

  end                           # included do

  #+
  # Insert here any class methods we want added to our including class
  # or module.
  #
  class_methods do

    #
    # Stuff relating specifically to bulk uploading.
    #
    before_action(:set_title_uploading,
                  :only => [
                    :upload,
                    :webupload,
                  ])
    before_action(:set_file_upload,
                  :only => [
                    :upload,
                  ])
    before_action(:upload_params,
                  :only => [
                    :upload,
                  ])

  end                           # class_methods do

end                             # module ControllerAssistant

# Local Variables:
# mode: ruby
# eval: (fci-mode t)
# End:

Solution

  • You have completly misunderstood pretty much everything about ActiveSupport::Concern and how module mixins work.

    Lets start by using composition to seperate concerns. For example:

    module LocalStorage
      extend ActiveSupport::Concern
      class_methods do
        # use a memoized helper instead of a constant
        # as its easier to stub for testing
        def local_storage
          @local_storage ||= ::PerceptSys::LocalStore
        end
      end
    end
    

    This makes sense to extract to a separate concern since its a reusable behaviour.

    We can then compose an Uploadable concern:

    module Uploadable
      # we compose modules by extending
      extend ActiveSupport::Concern
      extend LocalStorage 
    
      # put instance methods in the module body
    
      # GET /<model>/upload(.:format)
      def webupload
          # ...
      end
    
            #
      # POST /<model>/upload(.:format)
      #
      def upload
        # ...
    
      end
    
      # don't abuse use a callback for this - just use a straight 
      # method that returns a value and preferably does not have side effects
      def upload_params
        # ...
      end
    
      # ...
    
      # use "included" to hook in the class definition
      # self here is the singleton class instance
      # so this is where you put callbacks, attr_accessor etc
      # which would normally go in the class defintion
      included do
        before_action(:set_title_uploading,
                  :only => [
                    :upload,
                    :webupload,
                  ])
        before_action(:set_file_upload,
                  :only => [
                    :upload,
                  ])
      end
    
      # just use class_methods for actual class_methods!
      class_methods do
         # for example to derive the name of a model from the controller name
         def resource_class_name
           controller_name.singularize
         end
    
         def resource_class
           @resource_class ||= resource_class_name.classify.constantize
         end
      end
    end