From b714c9f0263957be3e716d81b68283819ae2a6c2 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Sat, 11 Jul 2026 03:06:56 +0000 Subject: [PATCH 1/5] Simplify form object params initialization --- README.md | 6 ++-- docs/form-objects.md | 50 +++++++-------------------------- docs/strong-parameters.md | 18 ++++++------ lib/structured_params/params.rb | 11 ++++++-- spec/form_object_spec.rb | 32 +++++++++++++++++++++ 5 files changed, 62 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 8d6c0b7..ec927d2 100644 --- a/README.md +++ b/README.md @@ -92,10 +92,10 @@ class UserRegistrationForm < StructuredParams::Params end # Use in controller -# permit calls params.require(:user_registration).permit(...) internally. -# Unlike API usage, form objects need require to scope to the correct key. +# Passing ActionController::Parameters to a Form class automatically calls +# params.require(:user_registration).permit(...) internally. def create - form = UserRegistrationForm.new(UserRegistrationForm.permit(params)) + form = UserRegistrationForm.new(params) if form.valid? User.create!(form.attributes) diff --git a/docs/form-objects.md b/docs/form-objects.md index 08b66b8..ccade58 100644 --- a/docs/form-objects.md +++ b/docs/form-objects.md @@ -20,7 +20,7 @@ - [Strong Parameters Integration](#strong-parameters-integration) - [Testing](#testing) - [Best Practices](#best-practices) - - [Base Form Class with Auto-permit](#base-form-class-with-auto-permit) + - [ActionController::Parameters Support](#actioncontrollerparameters-support) - [Implementing a save Method](#implementing-a-save-method) - [Using Transactions](#using-transactions) - [Conditional Validations](#conditional-validations) @@ -63,7 +63,7 @@ class UsersController < ApplicationController end def create - @form = UserRegistrationForm.new(UserRegistrationForm.permit(params)) + @form = UserRegistrationForm.new(params) if @form.valid? user = User.create!(@form.attributes.except('password_confirmation')) @@ -74,7 +74,7 @@ class UsersController < ApplicationController end end -# UserRegistrationForm.permit(params) is equivalent to: +# UserRegistrationForm.new(params) internally resolves: # params.require(:user_registration).permit(UserRegistrationForm.permit_attribute_names) ``` @@ -315,7 +315,7 @@ Form objects can also be used for API request validation. ```ruby class Api::V1::UsersController < Api::V1::BaseController def create - @form = UserRegistrationForm.new(UserRegistrationForm.permit(params)) + @form = UserRegistrationForm.new(params) if @form.valid? user = User.create!(@form.attributes) @@ -334,8 +334,8 @@ Form objects integrate automatically with Strong Parameters. ```ruby class UsersController < ApplicationController def create - # permit automatically calls require and permit internally - @form = UserRegistrationForm.new(UserRegistrationForm.permit(params)) + # Form classes automatically call require and permit internally + @form = UserRegistrationForm.new(params) if @form.valid? user = User.create!(@form.attributes) @@ -411,41 +411,11 @@ end ## Best Practices -### Base Form Class with Auto-permit +### ActionController::Parameters Support -When using form objects with Rails views, wrapping `permit` inside `initialize` via a shared base class eliminates the repetitive `FormClass.permit(params)` pattern in every controller action. +`Form` classes call `require(model_name.param_key).permit(...)` automatically when initialized with `ActionController::Parameters`. -```ruby -# app/forms/application_form.rb -class ApplicationForm < StructuredParams::Params - def initialize(params) - permitted = params.is_a?(ActionController::Parameters) ? self.class.permit(params) : params - super(permitted) - end -end -``` - -All form objects inherit from `ApplicationForm`: - -```ruby -class UserRegistrationForm < ApplicationForm - attribute :name, :string - attribute :email, :string - attribute :password, :string -end -``` - -Controllers become simpler — `permit` is called transparently: - -```ruby -# Before -@form = UserRegistrationForm.new(UserRegistrationForm.permit(params)) - -# After -@form = UserRegistrationForm.new(params) -``` - -The `ActionController::Parameters` guard ensures that plain hashes (e.g. in tests or `def new`) are passed through unchanged: +Plain hashes (e.g. in tests or `def new`) are still passed through unchanged: ```ruby # Works fine in the new action @@ -455,7 +425,7 @@ The `ActionController::Parameters` guard ensures that plain hashes (e.g. in test form = UserRegistrationForm.new(name: "Alice", email: "alice@example.com") ``` -> **Note:** This pattern is most useful when you consistently use form objects with Rails views. For API-only parameter classes, the plain `UserParams.new(params)` approach is sufficient and requires no base class. +> **Note:** Automatic nested-key extraction is only enabled for classes whose names end with `Form`. API-oriented parameter classes can continue to use `UserParams.new(params)`. ### Implementing a save Method diff --git a/docs/strong-parameters.md b/docs/strong-parameters.md index dc5eb87..9fa206b 100644 --- a/docs/strong-parameters.md +++ b/docs/strong-parameters.md @@ -14,7 +14,7 @@ StructuredParams provides three ways to integrate with Strong Parameters dependi - [How `permit` Determines the Parameter Key](#how-permit-determines-the-parameter-key) - [Choosing the Right Approach](#choosing-the-right-approach) - [`UserParams.new(params)` — Recommended for APIs](#userparamsnewparams--recommended-for-apis) - - [`permit` method — Required for Form Objects](#permit-method--required-for-form-objects) + - [`UserRegistrationForm.new(params)` — Recommended for Form Objects](#userregistrationformnewparams--recommended-for-form-objects) - [`permit_attribute_names` — Manual Control](#permit_attribute_names--manual-control) ## API Requests @@ -49,12 +49,12 @@ user_params = UserParams.new(UserParams.permit(params, require: false)) ## Form Objects -For web forms, use `permit` (default `require: true`). It automatically resolves nested parameter keys such as `params[:user_registration]`. +For web forms, pass `params` directly to the form object. `Form` classes automatically resolve nested parameter keys such as `params[:user_registration]`. ```ruby class UsersController < ApplicationController def create - @form = UserRegistrationForm.new(UserRegistrationForm.permit(params)) + @form = UserRegistrationForm.new(params) if @form.valid? user = User.create!(@form.attributes) @@ -145,7 +145,7 @@ end ```ruby class UsersController < ApplicationController def create - @form = UserRegistrationForm.new(UserRegistrationForm.permit(params)) + @form = UserRegistrationForm.new(params) if @form.valid? user = User.create!(@form.attributes) @@ -186,15 +186,15 @@ See [Form Objects](form-objects.md) for details on `model_name` customization. user_params = UserParams.new(params) ``` -### `permit` method — Required for Form Objects +### `UserRegistrationForm.new(params)` — Recommended for Form Objects -- ✅ **Required for form helpers** — when using `form_with`/`form_for` in views +- ✅ **Consistent interface** — instantiate form objects the same way as API parameter classes - ✅ **Nested key resolution** — automatically extracts from `params[:user_registration]` etc. -- ✅ **Explicit intent** — makes Strong Parameters usage clear +- ✅ **Works with form helpers** — fits `form_with`/`form_for` usage naturally ```ruby -# Form object - Required -@form = UserRegistrationForm.new(UserRegistrationForm.permit(params)) +# Form object - Recommended +@form = UserRegistrationForm.new(params) # API with explicit permit - Optional but acceptable user_params = UserParams.new(UserParams.permit(params, require: false)) diff --git a/lib/structured_params/params.rb b/lib/structured_params/params.rb index e0baec5..a8e7b0f 100644 --- a/lib/structured_params/params.rb +++ b/lib/structured_params/params.rb @@ -37,7 +37,7 @@ module StructuredParams # end # # # In controller: - # @form = UserRegistrationForm.new(UserRegistrationForm.permit(params)) + # @form = UserRegistrationForm.new(params) # if @form.valid? # User.create!(@form.attributes) # redirect_to user_path @@ -98,7 +98,7 @@ def permit_attribute_names # Permit parameters with optional require # - # For Form Objects (with require): + # For Form Objects (explicit manual permit): # UserRegistrationForm.permit(params) # # equivalent to: # params.require(:user_registration).permit(*UserRegistrationForm.permit_attribute_names) @@ -132,6 +132,11 @@ def structured_attributes end end + #: () -> bool + def require_nested_parameters_by_default? + name.end_with?('Form') + end + private # Determine if the specified type is a StructuredParams type @@ -212,7 +217,7 @@ def attributes(symbolize: false, compact_mode: :none) def process_input_parameters(params) case params when ActionController::Parameters - self.class.permit(params, require: false).to_h + self.class.permit(params, require: self.class.require_nested_parameters_by_default?).to_h when Hash # ActiveModel::Attributes can handle both symbol and string keys params diff --git a/spec/form_object_spec.rb b/spec/form_object_spec.rb index 542e6ce..a8cc846 100644 --- a/spec/form_object_spec.rb +++ b/spec/form_object_spec.rb @@ -40,6 +40,38 @@ end describe 'validation' do + context 'with ActionController::Parameters' do + let(:params) do + ActionController::Parameters.new( + user_registration: { + name: 'John Doe', + email: 'john@example.com', + age: 25, + terms_accepted: true, + extra_field: 'filtered' + } + ) + end + + it 'requires the nested form key and filters unpermitted parameters' do + form = UserRegistrationForm.new(params) + + expect(form).to have_attributes( + name: 'John Doe', + email: 'john@example.com', + age: 25, + terms_accepted: true + ) + expect { form.extra_field }.to raise_error(NoMethodError) + end + + it 'raises ParameterMissing when the nested form key is missing' do + missing_params = ActionController::Parameters.new(other_key: {}) + + expect { UserRegistrationForm.new(missing_params) }.to raise_error(ActionController::ParameterMissing) + end + end + context 'with valid parameters' do let(:params) do { From 88b874abbc3e3dccd27475a1b78ee0705ade465f Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Sat, 11 Jul 2026 23:09:12 +0000 Subject: [PATCH 2/5] Handle form params without double require --- README.md | 1 + README_ja.md | 8 ++-- docs/form-objects.md | 2 + docs/installation.md | 3 +- lib/structured_params/params.rb | 45 +++++++++++++------ sig/structured_params/params.rbs | 27 ++++++++---- spec/form_object_spec.rb | 74 ++++++++++++++++++++++++++++++++ 7 files changed, 133 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index c8a6677..c0b525a 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ end # Use in controller # Passing ActionController::Parameters to a Form class automatically calls # params.require(:user_registration).permit(...) internally. +# Use a class name ending with `Form` to enable this behavior. def create form = UserRegistrationForm.new(params) diff --git a/README_ja.md b/README_ja.md index fca7447..e3be3ad 100644 --- a/README_ja.md +++ b/README_ja.md @@ -92,11 +92,11 @@ class UserRegistrationForm < StructuredParams::Params end # コントローラーで使用 -# permit は内部で params.require(:user_registration).permit(...) を呼びます。 -# API と異なり、フォームオブジェクトでは require によるキー絞り込みが必要なため permit を使う - +# ActionController::Parameters を Form クラスに渡すと、内部で +# params.require(:user_registration).permit(...) が自動的に呼ばれます。 +# この挙動を使うには、クラス名を `Form` で終わらせてください。 def create - form = UserRegistrationForm.new(UserRegistrationForm.permit(params)) + form = UserRegistrationForm.new(params) if form.valid? User.create!(form.attributes) diff --git a/docs/form-objects.md b/docs/form-objects.md index ccade58..1a8c86b 100644 --- a/docs/form-objects.md +++ b/docs/form-objects.md @@ -2,6 +2,8 @@ `StructuredParams::Params` can be used as a Rails form object. It integrates with `form_with` / `form_for` and works seamlessly in views. +Classes whose names end with `Form` automatically require and permit the nested root key when initialized with `ActionController::Parameters`. + ## Table of Contents - [Defining a Form Object](#defining-a-form-object) diff --git a/docs/installation.md b/docs/installation.md index c5e0bf3..ff30730 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -44,7 +44,8 @@ The registration is explicit on purpose: - They are also generic names, so auto-registering them at gem load time could silently collide with other code - Keeping registration in an initializer makes the opt-in explicit and keeps custom aliases available when needed -If you skip this step, `attribute :name, :object` and `attribute :name, :array` will not resolve in ActiveModel type lookup. +If you skip this step, `attribute :name, :object` and `attribute :name, :array` will not resolve to +StructuredParams' types in ActiveModel type lookup. ## Configuration diff --git a/lib/structured_params/params.rb b/lib/structured_params/params.rb index a8e7b0f..91d2283 100644 --- a/lib/structured_params/params.rb +++ b/lib/structured_params/params.rb @@ -50,6 +50,7 @@ module StructuredParams # <%= f.text_field :name %> # <%= f.text_field :email %> # <% end %> + # rubocop:disable Metrics/ClassLength class Params include ActiveModel::Model include ActiveModel::Attributes @@ -133,8 +134,8 @@ def structured_attributes end #: () -> bool - def require_nested_parameters_by_default? - name.end_with?('Form') + def form_class? + name&.end_with?('Form') || false end private @@ -161,27 +162,17 @@ def errors @errors ||= Errors.new(self) end - # ======================================== - # Form object support methods - # These methods enable integration with Rails form helpers (form_with, form_for) - # ======================================== - - # Indicates whether the form object has been persisted to database - # Always returns false for parameter/form objects + # Form object support for Rails helpers. #: () -> bool def persisted? false end - # Returns the primary key value for the model - # Always returns nil for parameter/form objects #: () -> nil def to_key nil end - # Returns self for form helpers - # Required by Rails form helpers to get the model object #: () -> self def to_model self @@ -217,7 +208,7 @@ def attributes(symbolize: false, compact_mode: :none) def process_input_parameters(params) case params when ActionController::Parameters - self.class.permit(params, require: self.class.require_nested_parameters_by_default?).to_h + process_action_controller_parameters(params) when Hash # ActiveModel::Attributes can handle both symbol and string keys params @@ -226,6 +217,31 @@ def process_input_parameters(params) end end + #: (ActionController::Parameters) -> Hash[untyped, untyped] + def process_action_controller_parameters(params) + self.class.permit(params, require: require_nested_parameters?(params)).to_h + end + + #: (ActionController::Parameters) -> bool + def require_nested_parameters?(params) + return false unless self.class.form_class? + return false if params.permitted? + return true if matches_model_name?(params) + return false if attribute_keys_present?(params) + + true + end + + #: (ActionController::Parameters) -> bool + def matches_model_name?(params) + params.key?(self.class.model_name.param_key) + end + + #: (ActionController::Parameters) -> bool + def attribute_keys_present?(params) + params.keys.any? { |key| self.class.attribute_types.key?(key.to_s) } + end + # Execute structured parameter validation #: () -> void def validate_structured_parameters @@ -304,4 +320,5 @@ def import_structured_errors(structured_errors, prefix) end end end + # rubocop:enable Metrics/ClassLength end diff --git a/sig/structured_params/params.rbs b/sig/structured_params/params.rbs index 7586b2e..9fce422 100644 --- a/sig/structured_params/params.rbs +++ b/sig/structured_params/params.rbs @@ -36,7 +36,7 @@ module StructuredParams # end # # # In controller: - # @form = UserRegistrationForm.new(UserRegistrationForm.permit(params)) + # @form = UserRegistrationForm.new(params) # if @form.valid? # User.create!(@form.attributes) # redirect_to user_path @@ -49,6 +49,7 @@ module StructuredParams # <%= f.text_field :name %> # <%= f.text_field :email %> # <% end %> + # rubocop:disable Metrics/ClassLength class Params include ActiveModel::Model @@ -84,7 +85,7 @@ module StructuredParams # Permit parameters with optional require # - # For Form Objects (with require): + # For Form Objects (explicit manual permit): # UserRegistrationForm.permit(params) # # equivalent to: # params.require(:user_registration).permit(*UserRegistrationForm.permit_attribute_names) @@ -101,6 +102,9 @@ module StructuredParams # : () -> Hash[Symbol, singleton(::StructuredParams::Params)] def self.structured_attributes: () -> Hash[Symbol, singleton(::StructuredParams::Params)] + # : () -> bool + def self.form_class?: () -> bool + # Determine if the specified type is a StructuredParams type # : (ActiveModel::Type::Value) -> bool private def self.structured_params_type?: (ActiveModel::Type::Value) -> bool @@ -111,18 +115,13 @@ module StructuredParams # : () -> ::StructuredParams::Errors def errors: () -> ::StructuredParams::Errors - # Indicates whether the form object has been persisted to database - # Always returns false for parameter/form objects + # Form object support for Rails helpers. # : () -> bool def persisted?: () -> bool - # Returns the primary key value for the model - # Always returns nil for parameter/form objects # : () -> nil def to_key: () -> nil - # Returns self for form helpers - # Required by Rails form helpers to get the model object # : () -> self def to_model: () -> self @@ -138,6 +137,18 @@ module StructuredParams # : (untyped) -> Hash[untyped, untyped] def process_input_parameters: (untyped) -> Hash[untyped, untyped] + # : (ActionController::Parameters) -> Hash[untyped, untyped] + def process_action_controller_parameters: (ActionController::Parameters) -> Hash[untyped, untyped] + + # : (ActionController::Parameters) -> bool + def require_nested_parameters?: (ActionController::Parameters) -> bool + + # : (ActionController::Parameters) -> bool + def matches_model_name?: (ActionController::Parameters) -> bool + + # : (ActionController::Parameters) -> bool + def attribute_keys_present?: (ActionController::Parameters) -> bool + # Execute structured parameter validation # : () -> void def validate_structured_parameters: () -> void diff --git a/spec/form_object_spec.rb b/spec/form_object_spec.rb index a8cc846..8036814 100644 --- a/spec/form_object_spec.rb +++ b/spec/form_object_spec.rb @@ -4,6 +4,22 @@ # rubocop:disable RSpec/DescribeClass RSpec.describe 'StructuredParams::Params as Form Object' do + describe '.form_class?' do + it 'returns true for classes with a Form suffix' do + expect(UserRegistrationForm.form_class?).to be(true) + end + + it 'returns false for classes without a Form suffix' do + expect(UserParameter.form_class?).to be(false) + end + + it 'returns false for anonymous classes' do + klass = Class.new(StructuredParams::Params) + + expect(klass.form_class?).to be(false) + end + end + describe '.model_name' do it 'removes "Form" suffix from class name' do expect(UserRegistrationForm.model_name.name).to eq('UserRegistration') @@ -70,6 +86,64 @@ expect { UserRegistrationForm.new(missing_params) }.to raise_error(ActionController::ParameterMissing) end + + it 'accepts already permitted form parameters without requiring again' do + permitted_params = UserRegistrationForm.permit(params) + form = UserRegistrationForm.new(permitted_params) + + expect(form).to have_attributes( + name: 'John Doe', + email: 'john@example.com', + age: 25, + terms_accepted: true + ) + end + + it 'accepts scoped parameters without requiring again' do + form = UserRegistrationForm.new(params[:user_registration]) + + expect(form).to have_attributes( + name: 'John Doe', + email: 'john@example.com', + age: 25, + terms_accepted: true + ) + end + end + + context 'with flat ActionController::Parameters' do + let(:params) do + ActionController::Parameters.new( + name: 'Jane Doe', + email: 'jane@example.com', + age: 20, + terms_accepted: true + ) + end + + it 'permits flat parameters without requiring a nested key' do + form = UserRegistrationForm.new(params) + + expect(form).to have_attributes( + name: 'Jane Doe', + email: 'jane@example.com', + age: 20, + terms_accepted: true + ) + end + end + + context 'with anonymous params class' do + it 'does not call form-only require logic' do + klass = Class.new(StructuredParams::Params) do + attribute :name, :string + end + + params = ActionController::Parameters.new(name: 'Anonymous') + form = klass.new(params) + + expect(form.name).to eq('Anonymous') + end end context 'with valid parameters' do From 51ba74c5c06f0d7dae9e46b33be3bbd5b1cf6331 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Sun, 12 Jul 2026 08:10:07 +0900 Subject: [PATCH 3/5] Add 'irb' gem to development and test group --- Gemfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Gemfile b/Gemfile index f27bc04..838cf75 100644 --- a/Gemfile +++ b/Gemfile @@ -21,6 +21,7 @@ end group :development, :test do gem 'factory_bot' + gem 'irb' gem 'lefthook', require: false gem 'rake', '~> 13.0' gem 'rspec', '~> 3.0' From 5db0e66c33a15292fb562cd33856dde524d9e6dd Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Sun, 12 Jul 2026 04:37:43 +0000 Subject: [PATCH 4/5] Require nested form params before permitted check --- lib/structured_params/params.rb | 2 +- spec/form_object_spec.rb | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/structured_params/params.rb b/lib/structured_params/params.rb index 91d2283..4378784 100644 --- a/lib/structured_params/params.rb +++ b/lib/structured_params/params.rb @@ -225,8 +225,8 @@ def process_action_controller_parameters(params) #: (ActionController::Parameters) -> bool def require_nested_parameters?(params) return false unless self.class.form_class? - return false if params.permitted? return true if matches_model_name?(params) + return false if params.permitted? return false if attribute_keys_present?(params) true diff --git a/spec/form_object_spec.rb b/spec/form_object_spec.rb index 8036814..ff65cd3 100644 --- a/spec/form_object_spec.rb +++ b/spec/form_object_spec.rb @@ -99,6 +99,18 @@ ) end + it 'requires the nested form key even when top-level params are already permitted' do + params.permit! + form = UserRegistrationForm.new(params) + + expect(form).to have_attributes( + name: 'John Doe', + email: 'john@example.com', + age: 25, + terms_accepted: true + ) + end + it 'accepts scoped parameters without requiring again' do form = UserRegistrationForm.new(params[:user_registration]) From 44da48bcd75686c1873f80c1bd8805f14ddae29a Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Sun, 12 Jul 2026 14:14:34 +0900 Subject: [PATCH 5/5] Guard require_nested_parameters? against ambiguous flat/nested shapes matches_model_name? treated any top-level key matching the param_key as a nested wrapper without checking the value's shape, so a Form-suffixed class whose attribute name equals its own param_key (e.g. comment/CommentForm) crashed with NoMethodError when given flat params. Separately, attribute_keys_present? treated a single stray top-level key matching an attribute name as proof the whole payload was flat, silently discarding legitimately nested data under an unexpected key instead of raising. Both are now decided by whether values are actually ActionController::Parameters (nested) rather than by key presence alone, so ambiguous shapes fall through to require() and raise ParameterMissing instead of guessing wrong. Co-Authored-By: Claude Sonnet 5 --- lib/structured_params/params.rb | 31 +++++++++++++++++-- sig/structured_params/params.rbs | 20 ++++++++++++ spec/form_object_spec.rb | 24 ++++++++++++++ .../test_classes/form_object_classes.rb | 6 ++++ 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/lib/structured_params/params.rb b/lib/structured_params/params.rb index 4378784..02ab255 100644 --- a/lib/structured_params/params.rb +++ b/lib/structured_params/params.rb @@ -222,19 +222,46 @@ def process_action_controller_parameters(params) self.class.permit(params, require: require_nested_parameters?(params)).to_h end + # Whether to call params.require(param_key) before permitting. + # + # Only ever true for Form-suffixed classes (form_class?); non-Form + # Params/Parameters subclasses always permit the top level directly. + # + # - A value nested under the model's param_key (e.g. params[:user_registration]) + # always wins, even if params were already permitted higher up, since that + # inner key still needs to be require()'d out. + # - Otherwise, params that are already permitted, or whose shape looks flat + # (see flat_parameters?), are used as-is without requiring. + # - Anything else falls through to true, so params.require raises + # ActionController::ParameterMissing instead of guessing which keys + # belong to this form. #: (ActionController::Parameters) -> bool def require_nested_parameters?(params) return false unless self.class.form_class? return true if matches_model_name?(params) return false if params.permitted? - return false if attribute_keys_present?(params) + return false if flat_parameters?(params) true end #: (ActionController::Parameters) -> bool def matches_model_name?(params) - params.key?(self.class.model_name.param_key) + key = self.class.model_name.param_key + return false unless params.key?(key) + + params[key].is_a?(ActionController::Parameters) + end + + # Only treat params as already-flat attributes when none of the top-level + # values are themselves nested. A nested value under some other key means + # the request shape is ambiguous, so we fall through to raising + # ParameterMissing instead of silently guessing which keys belong here. + #: (ActionController::Parameters) -> bool + def flat_parameters?(params) + return false if params.values.any?(ActionController::Parameters) + + attribute_keys_present?(params) end #: (ActionController::Parameters) -> bool diff --git a/sig/structured_params/params.rbs b/sig/structured_params/params.rbs index 9fce422..55aa1e7 100644 --- a/sig/structured_params/params.rbs +++ b/sig/structured_params/params.rbs @@ -140,12 +140,32 @@ module StructuredParams # : (ActionController::Parameters) -> Hash[untyped, untyped] def process_action_controller_parameters: (ActionController::Parameters) -> Hash[untyped, untyped] + # Whether to call params.require(param_key) before permitting. + # + # Only ever true for Form-suffixed classes (form_class?); non-Form + # Params/Parameters subclasses always permit the top level directly. + # + # - A value nested under the model's param_key (e.g. params[:user_registration]) + # always wins, even if params were already permitted higher up, since that + # inner key still needs to be require()'d out. + # - Otherwise, params that are already permitted, or whose shape looks flat + # (see flat_parameters?), are used as-is without requiring. + # - Anything else falls through to true, so params.require raises + # ActionController::ParameterMissing instead of guessing which keys + # belong to this form. # : (ActionController::Parameters) -> bool def require_nested_parameters?: (ActionController::Parameters) -> bool # : (ActionController::Parameters) -> bool def matches_model_name?: (ActionController::Parameters) -> bool + # Only treat params as already-flat attributes when none of the top-level + # values are themselves nested. A nested value under some other key means + # the request shape is ambiguous, so we fall through to raising + # ParameterMissing instead of silently guessing which keys belong here. + # : (ActionController::Parameters) -> bool + def flat_parameters?: (ActionController::Parameters) -> bool + # : (ActionController::Parameters) -> bool def attribute_keys_present?: (ActionController::Parameters) -> bool diff --git a/spec/form_object_spec.rb b/spec/form_object_spec.rb index ff65cd3..4d318c7 100644 --- a/spec/form_object_spec.rb +++ b/spec/form_object_spec.rb @@ -158,6 +158,30 @@ end end + context 'when a flat attribute name matches the model_name param_key' do + it 'permits the flat parameters instead of requiring the colliding key as a nested wrapper' do + params = ActionController::Parameters.new(comment: 'nice post', author: 'Bob') + form = CommentForm.new(params) + + expect(form).to have_attributes(comment: 'nice post', author: 'Bob') + end + end + + context 'when a stray top-level key matches an attribute name but the real payload is nested elsewhere' do + let(:params) do + ActionController::Parameters.new( + email: 'newsletter@example.com', + registration_data: { + name: 'Jane', email: 'jane@example.com', age: 22, terms_accepted: true + } + ) + end + + it 'raises ParameterMissing instead of silently building a form from the unrelated flat data' do + expect { UserRegistrationForm.new(params) }.to raise_error(ActionController::ParameterMissing) + end + end + context 'with valid parameters' do let(:params) do { diff --git a/spec/support/test_classes/form_object_classes.rb b/spec/support/test_classes/form_object_classes.rb index 9a8d5d2..758dcae 100644 --- a/spec/support/test_classes/form_object_classes.rb +++ b/spec/support/test_classes/form_object_classes.rb @@ -31,6 +31,12 @@ class Profile < StructuredParams::Params attribute :bio, :string end +# Class whose model_name.param_key coincides with one of its own attribute names +class CommentForm < StructuredParams::Params + attribute :comment, :string + attribute :author, :string +end + # Namespaced classes for testing model_name / permit module Admin class UserForm < StructuredParams::Params