Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions lib/rolemodel/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 15 additions & 0 deletions lib/rolemodel/resource_for/controller_extension.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

module Rolemodel
Comment thread
OutlawAndy marked this conversation as resolved.
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
Comment thread
OutlawAndy marked this conversation as resolved.
end
end
end
94 changes: 94 additions & 0 deletions spec/resource_for/controller_extension_spec.rb
Original file line number Diff line number Diff line change
@@ -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