From 587d50cf10a3c5e06e374a206df7fdfad1d277df Mon Sep 17 00:00:00 2001 From: Andy Cohen Date: Mon, 3 Aug 2026 17:52:39 -0500 Subject: [PATCH 1/3] Rolemodel::ResourceFor::ControllerExtension --- README.md | 92 ++++++++++++++++ lib/rolemodel/engine.rb | 7 ++ .../resource_for/controller_extension.rb | 13 +++ .../resource_for/controller_extension_spec.rb | 100 ++++++++++++++++++ 4 files changed, 212 insertions(+) create mode 100644 lib/rolemodel/resource_for/controller_extension.rb create mode 100644 spec/resource_for/controller_extension_spec.rb 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..8b2669f6 --- /dev/null +++ b/lib/rolemodel/resource_for/controller_extension.rb @@ -0,0 +1,13 @@ +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..db00cf67 --- /dev/null +++ b/spec/resource_for/controller_extension_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'logger' +require 'action_controller/railtie' +require 'action_dispatch/testing/integration' + +# Stand-ins for ActiveRecord models. `resource_for` only needs a constant that responds to `find`, +# so these avoid a database while still exercising the real routing & 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` — Rolemodel::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 + +class ResourceForApp < Rails::Application + config.root = __dir__ + config.eager_load = false + config.enable_reloading = false + config.secret_key_base = 'resource_for_spec' + config.logger = Logger.new(File::NULL) + config.hosts.clear + # Surface controller exceptions as raised errors rather than error pages + config.action_dispatch.show_exceptions = :none + + routes.append 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 is derived from the demodulized name + resources :reports, only: %i[index], reportable_type: 'Reporting::Widget' + end + end + end + end + end +end + +ResourceForApp.initialize! + +RSpec.describe Rolemodel::ResourceFor::ControllerExtension, type: :request do + let(:session) { ActionDispatch::Integration::Session.new(ResourceForApp.instance) } + + 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 From 3ca878356ca7428e8e008141d3610f31112b3a73 Mon Sep 17 00:00:00 2001 From: Andy Cohen Date: Mon, 3 Aug 2026 18:00:46 -0500 Subject: [PATCH 2/3] add frozen string literal comment --- lib/rolemodel/resource_for/controller_extension.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/rolemodel/resource_for/controller_extension.rb b/lib/rolemodel/resource_for/controller_extension.rb index 8b2669f6..89a27fde 100644 --- a/lib/rolemodel/resource_for/controller_extension.rb +++ b/lib/rolemodel/resource_for/controller_extension.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Rolemodel module ResourceFor module ControllerExtension From a6b8a14dd6a583acaaf2568817b3249abf44b399 Mon Sep 17 00:00:00 2001 From: Andy Cohen Date: Mon, 3 Aug 2026 18:07:30 -0500 Subject: [PATCH 3/3] update spec --- .../resource_for/controller_extension_spec.rb | 52 ++++++++----------- 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/spec/resource_for/controller_extension_spec.rb b/spec/resource_for/controller_extension_spec.rb index db00cf67..117c9774 100644 --- a/spec/resource_for/controller_extension_spec.rb +++ b/spec/resource_for/controller_extension_spec.rb @@ -1,11 +1,14 @@ # frozen_string_literal: true -require 'logger' -require 'action_controller/railtie' +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 avoid a database while still exercising the real routing & params stack. +# so these keep the spec database-free while still exercising the real router & params stack. class SpecResource attr_reader :id @@ -23,7 +26,7 @@ module Reporting class Widget < SpecResource; end end -# Note the absence of an `include` — Rolemodel::Engine adds `resource_for` to every controller. +# 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) @@ -40,38 +43,29 @@ def index end end -class ResourceForApp < Rails::Application - config.root = __dir__ - config.eager_load = false - config.enable_reloading = false - config.secret_key_base = 'resource_for_spec' - config.logger = Logger.new(File::NULL) - config.hosts.clear - # Surface controller exceptions as raised errors rather than error pages - config.action_dispatch.show_exceptions = :none - - routes.append do - concern :commentable do - resources :comments, only: %i[index], commentable_type: parent_resource.name.classify - 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 is derived from the demodulized name - resources :reports, only: %i[index], reportable_type: 'Reporting::Widget' + 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 -end - -ResourceForApp.initialize! -RSpec.describe Rolemodel::ResourceFor::ControllerExtension, type: :request do - let(:session) { ActionDispatch::Integration::Session.new(ResourceForApp.instance) } + let(:session) { ActionDispatch::Integration::Session.new(routes) } def get_body(path, **params) session.get(path, **params)