diff --git a/README.md b/README.md index 5962fcc6..8adf1ffd 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,98 @@ DRY_RUN=true rake users:dev[_,bob@example.com] #=> -> 0.0000s ``` +### `Rolemodel::ResourceFor::ControllerExtension` + +Provides the private `resource_for` utility method to all controller classes throughout your app. Use it in conjunction with Routing concerns. + +#### The Problem It Solves + +Apps accumulate resources that hang off many different parents. Good examples include `comments`, `reports`, `duplications`. + +The naive approach involves many namespaced controllers that all do the same thing but with a different parent resource, or adding several non-restful actions to each parent controller. + +The following example illustrates a better pattern. + +##### Routing + +Declare the child resource inside a route concern and pass the parent's class name as a route default using `parent_resource`: + +```ruby +concern :commentable do + resources :comments, commentable_type: parent_resource.name.classify +end + +concern :reportable do + resources :generated_reports, only: %i[show create], reportable_type: parent_resource.name.classify +end + +shallow do + resources :accounts do + resources :estimates, concerns: %i[commentable reportable] do + resources :widgets, concerns: %i[commentable reportable] + end + end +end +``` + +##### Controller + +One controller serves every parent: + +```ruby +class CommentsController < ApplicationController + before_action :set_commentable, only: %i[index new create] + before_action :set_comment, except: %i[index new create] + + def create + # ... + end + + private + + def set_commentable + @commentable = resource_for(:commentable_type) # pass in the symbol specified in your routing concern. + end +end +``` + +Under `shallow: true`, only the collection actions (`index`, `new`, `create`) carry the parent id — hence the `only:`/`except:` split above. Member actions find the child directly by `params[:id]` and reach the parent through its own association. + +`resource_for` returns the record itself, so it composes with authorization and presentation: + +```ruby +def set_resource + @resource = authorize resource_for(:resource_type) +end +``` + +#### Guarding User-Supplied Types + +Route defaults are merged into `params` last, so a route-supplied type cannot be overridden by a query string or request body. If a type ever arrives from user input instead, allowlist it before calling `resource_for` — `safe_constantize` will happily resolve any constant in the app: + +```ruby +REPORT_CONTEXTS = %w[Accessory Estimate PartProxy Tank].freeze + +before_action :verify_context_type, :set_context, only: %i[create] + +private + +def verify_context_type + return if REPORT_CONTEXTS.include?(params[:context_type]) + + redirect_back_or_to root_url, alert: 'Invalid Request' +end +``` + +Doing this even for route-supplied types is cheap insurance: it documents which parents the controller actually supports and fails loudly when a new route wires up a parent the controller cannot handle. + +#### Notes + +* Raises `ActiveRecord::RecordNotFound` when the id does not resolve, which Rails renders as a 404 — the same behavior as any other `find`. +* `safe_constantize` returns `nil` for an unknown constant, producing a `NoMethodError`; allowlisting avoids that. +* The class name is demodulized when deriving the id param, so `Reporting::Tank` looks for `params[:tank_id]`. +* Included via `ActiveSupport.on_load(:action_controller_base)`, so `ActionController::API` controllers do not get it. + ## Development Install the versions of Node and Ruby specified in `.node-version` and `.ruby-version` on your machine. https://asdf-vm.com/ is a great tool for managing language versions. Then run `corepack enable` to activate the Yarn 4+ version pinned by each project's `packageManager` field. diff --git a/lib/rolemodel/engine.rb b/lib/rolemodel/engine.rb index ba77692c..fac79ab5 100644 --- a/lib/rolemodel/engine.rb +++ b/lib/rolemodel/engine.rb @@ -3,9 +3,16 @@ module Rolemodel class Engine < ::Rails::Engine require_relative 'generator_base' + require_relative 'resource_for/controller_extension' generators do require 'generators/rolemodel/all_generator' end + + initializer 'rolemodel.action_controller' do + ActiveSupport.on_load(:action_controller_base) do + include Rolemodel::ResourceFor::ControllerExtension + end + end end end diff --git a/lib/rolemodel/resource_for/controller_extension.rb b/lib/rolemodel/resource_for/controller_extension.rb new file mode 100644 index 00000000..89a27fde --- /dev/null +++ b/lib/rolemodel/resource_for/controller_extension.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Rolemodel + module ResourceFor + module ControllerExtension + extend ActiveSupport::Concern + + private + + def resource_for(type_symbol) + params[type_symbol].safe_constantize.find(params[params[type_symbol].foreign_key]) + end + end + end +end diff --git a/spec/resource_for/controller_extension_spec.rb b/spec/resource_for/controller_extension_spec.rb new file mode 100644 index 00000000..117c9774 --- /dev/null +++ b/spec/resource_for/controller_extension_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'action_controller' +require 'action_dispatch/testing/integration' + +# The engine hands `resource_for` to controllers through this initializer during app boot. +# Running it here gives us the same wiring without booting an app inside the generator specs' process. +Rolemodel::Engine.initializers.detect { it.name == 'rolemodel.action_controller' }.run + +# Stand-ins for ActiveRecord models. `resource_for` only needs a constant that responds to `find`, +# so these keep the spec database-free while still exercising the real router & params stack. +class SpecResource + attr_reader :id + + def initialize(id) + @id = id + end + + def self.find(id) = new(id) +end + +class Estimate < SpecResource; end +class Widget < SpecResource; end + +module Reporting + class Widget < SpecResource; end +end + +# Note the absence of an `include` — the engine adds `resource_for` to every controller. +class CommentsController < ActionController::Base + def index + commentable = resource_for(:commentable_type) + + render plain: "#{commentable.class.name}##{commentable.id}" + end +end + +class ReportsController < ActionController::Base + def index + reportable = resource_for(:reportable_type) + + render plain: "#{reportable.class.name}##{reportable.id}" + end +end + +RSpec.describe Rolemodel::ResourceFor::ControllerExtension, type: :request do + let(:routes) do + ActionDispatch::Routing::RouteSet.new.tap do |route_set| + route_set.draw do + concern :commentable do + resources :comments, only: %i[index], commentable_type: parent_resource.name.classify + end + + shallow do + resources :accounts do + resources :estimates, concerns: :commentable do + resources :widgets, concerns: :commentable do + # An explicit, namespaced type — its id param comes from the demodulized name + resources :reports, only: %i[index], reportable_type: 'Reporting::Widget' + end + end + end + end + end + end + end + + let(:session) { ActionDispatch::Integration::Session.new(routes) } + + def get_body(path, **params) + session.get(path, **params) + session.response.body + end + + it 'loads the parent named by the route default' do + expect(get_body('/estimates/1/comments')).to eq('Estimate#1') + end + + it 'loads a different parent for the same controller' do + expect(get_body('/widgets/9/comments')).to eq('Widget#9') + end + + it 'prefers the route default over a request param of the same name' do + expect(get_body('/estimates/1/comments', params: { commentable_type: 'Widget' })).to eq('Estimate#1') + end + + it 'derives the id param from the demodulized class name' do + expect(get_body('/widgets/9/reports')).to eq('Reporting::Widget#9') + end + + it 'is available to every controller as a private method' do + expect(ActionController::Base.private_method_defined?(:resource_for)).to be(true) + end +end