From 7fa9d806b350a302b6fa79e0165fdfcfbad37651 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Sun, 1 Mar 2026 03:58:56 +0900 Subject: [PATCH 1/6] Add form object classes and specs for user registration and order parameters --- lib/structured_params/params.rb | 80 +++++++++++++- sig/structured_params/params.rbs | 54 +++++++++- spec/form_object_spec.rb | 176 +++++++++++++++++++++++++++++++ spec/spec_helper.rb | 3 + spec/support/form_objects.rb | 47 +++++++++ 5 files changed, 352 insertions(+), 8 deletions(-) create mode 100644 spec/form_object_spec.rb create mode 100644 spec/support/form_objects.rb diff --git a/lib/structured_params/params.rb b/lib/structured_params/params.rb index c8e8b7e..e74421d 100644 --- a/lib/structured_params/params.rb +++ b/lib/structured_params/params.rb @@ -4,13 +4,37 @@ module StructuredParams # Parameter model that supports structured objects and arrays # - # Usage example: - # class UserParameter < StructuredParams::Params + # This class can be used in two ways: + # 1. Strong Parameters validation (params validation in controllers) + # 2. Form objects (using with form_with/form_for in views) + # + # Strong Parameters example: + # class UserParameters < StructuredParams::Params # attribute :name, :string - # attribute :address, :object, value_class: AddressParameter - # attribute :hobbies, :array, value_class: HobbyParameter + # attribute :address, :object, value_class: AddressParameters + # attribute :hobbies, :array, value_class: HobbyParameters # attribute :tags, :array, value_type: :string # end + # + # Form object example: + # class UserRegistrationForm < StructuredParams::Params + # attribute :name, :string + # attribute :email, :string + # validates :name, presence: true + # validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + # end + # + # # In controller: + # @form = UserRegistrationForm.new(params[:user]) + # if @form.valid? + # # Save to database + # end + # + # # In view: + # <%= form_with model: @form, url: users_path do |f| %> + # <%= f.text_field :name %> + # <%= f.text_field :email %> + # <% end %> class Params include ActiveModel::Model include ActiveModel::Attributes @@ -19,6 +43,28 @@ class Params class << self # @rbs @structured_attributes: Hash[Symbol, singleton(::StructuredParams::Params)]? + # @rbs @_model_name: ::ActiveModel::Name? + + # Override model_name for form helpers + # By default, removes "Parameters", "Parameter", or "Form" suffix from class name + # This allows the class to work seamlessly with Rails form helpers + # + # Example: + # UserRegistrationForm.model_name.name # => "UserRegistration" + # UserRegistrationForm.model_name.param_key # => "user_registration" + # UserParameters.model_name.name # => "User" + # Admin::UserForm.model_name.name # => "Admin::User" + # @rbs return: ::ActiveModel::Name + def model_name + # @rbs @_model_name: ::ActiveModel::Name? + + @_model_name ||= begin + namespace = module_parents.detect { |n| n.respond_to?(:use_relative_model_naming?) } + # Remove suffix from the full class name (preserving namespace) + name_without_suffix = name.sub(/(Parameters?|Form)$/, '') + ActiveModel::Name.new(self, namespace, name_without_suffix) + end + end # Generate permitted parameter structure for Strong Parameters #: () -> Array[untyped] @@ -72,6 +118,32 @@ 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 + # @rbs return: bool + def persisted? + false + end + + # Returns the primary key value for the model + # Always returns nil for parameter/form objects + # @rbs return: nil + def to_key + nil + end + + # Returns self for form helpers + # Required by Rails form helpers to get the model object + # @rbs return: self + def to_model + self + end + # Convert structured objects to Hash and get attributes #: (?symbolize: false, ?compact_mode: :none | :nil_only | :all_blank) -> Hash[String, untyped] #: (?symbolize: true, ?compact_mode: :none | :nil_only | :all_blank) -> Hash[Symbol, untyped] diff --git a/sig/structured_params/params.rbs b/sig/structured_params/params.rbs index e22342a..9e56a5d 100644 --- a/sig/structured_params/params.rbs +++ b/sig/structured_params/params.rbs @@ -3,13 +3,25 @@ module StructuredParams # Parameter model that supports structured objects and arrays # - # Usage example: - # class UserParameter < StructuredParams::Params + # This class can be used in two ways: + # 1. Strong Parameters validation (params validation in controllers) + # 2. Form objects (using with form_with/form_for in views) + # + # Strong Parameters example: + # class UserParameters < StructuredParams::Params # attribute :name, :string - # attribute :address, :object, value_class: AddressParameter - # attribute :hobbies, :array, value_class: HobbyParameter + # attribute :address, :object, value_class: AddressParameters + # attribute :hobbies, :array, value_class: HobbyParameters # attribute :tags, :array, value_type: :string # end + # + # Form object example: + # class UserRegistrationForm < StructuredParams::Params + # attribute :name, :string + # attribute :email, :string + # validates :name, presence: true + # validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + # end class Params include ActiveModel::Model @@ -19,6 +31,20 @@ module StructuredParams self.@structured_attributes: Hash[Symbol, singleton(::StructuredParams::Params)]? + self.@_model_name: ::ActiveModel::Name? + + # Override model_name for form helpers + # By default, removes "Parameters", "Parameter", or "Form" suffix from class name + # This allows the class to work seamlessly with Rails form helpers + # + # Example: + # UserRegistrationForm.model_name.name # => "UserRegistration" + # UserRegistrationForm.model_name.param_key # => "user_registration" + # UserParameters.model_name.name # => "User" + # Admin::UserForm.model_name.name # => "Admin::User" + # : () -> ::ActiveModel::Name + def self.model_name: () -> ::ActiveModel::Name + # Generate permitted parameter structure for Strong Parameters # : () -> Array[untyped] def self.permit_attribute_names: () -> Array[untyped] @@ -37,6 +63,26 @@ module StructuredParams # : () -> ::StructuredParams::Errors def errors: () -> ::StructuredParams::Errors + # ======================================== + # 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 + # : () -> 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 + # Convert structured objects to Hash and get attributes # : (?symbolize: false, ?compact_mode: :none | :nil_only | :all_blank) -> Hash[String, untyped] # : (?symbolize: true, ?compact_mode: :none | :nil_only | :all_blank) -> Hash[Symbol, untyped] diff --git a/spec/form_object_spec.rb b/spec/form_object_spec.rb new file mode 100644 index 0000000..d8ddc01 --- /dev/null +++ b/spec/form_object_spec.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'StructuredParams::Params as Form Object' do + describe '.model_name' do + it 'removes "Form" suffix from class name' do + expect(UserRegistrationForm.model_name.name).to eq('UserRegistration') + end + + it 'provides proper param_key' do + expect(UserRegistrationForm.model_name.param_key).to eq('user_registration') + end + + it 'provides proper route_key' do + expect(UserRegistrationForm.model_name.route_key).to eq('user_registrations') + end + + it 'provides proper singular form' do + expect(UserRegistrationForm.model_name.singular).to eq('user_registration') + end + + it 'provides proper plural form' do + expect(UserRegistrationForm.model_name.plural).to eq('user_registrations') + end + end + + describe '#persisted?' do + it 'returns false' do + form = UserRegistrationForm.new({}) + expect(form.persisted?).to be(false) + end + end + + describe '#to_key' do + it 'returns nil' do + form = UserRegistrationForm.new({}) + expect(form.to_key).to be_nil + end + end + + describe '#to_model' do + it 'returns self' do + form = UserRegistrationForm.new({}) + expect(form.to_model).to eq(form) + end + end + + describe 'validation' do + context 'with valid parameters' do + let(:params) do + { + name: 'John Doe', + email: 'john@example.com', + age: 25, + terms_accepted: true + } + end + + it 'is valid' do + form = UserRegistrationForm.new(params) + expect(form).to be_valid + end + end + + context 'with invalid parameters' do + let(:params) do + { + name: '', + email: 'invalid-email', + age: -5, + terms_accepted: false + } + end + + it 'is invalid' do + form = UserRegistrationForm.new(params) + expect(form).not_to be_valid + end + + it 'has errors for invalid fields' do + form = UserRegistrationForm.new(params) + form.valid? + + expect(form.errors[:name]).to be_present + expect(form.errors[:email]).to be_present + expect(form.errors[:age]).to be_present + end + end + end + + describe 'integration with Rails form helpers' do + it 'provides all necessary methods for form_with' do + form = UserRegistrationForm.new({}) + + # form_with requires these methods + expect(form).to respond_to(:model_name) + expect(form).to respond_to(:persisted?) + expect(form).to respond_to(:to_key) + expect(form).to respond_to(:to_model) + expect(form).to respond_to(:errors) + end + end + + describe 'class name with "Parameters" suffix' do + it 'removes "Parameters" suffix from model_name' do + expect(OrderParameters.model_name.name).to eq('Order') + expect(OrderParameters.model_name.param_key).to eq('order') + end + end + + describe 'class name with "Parameter" suffix' do + it 'removes "Parameter" suffix from model_name' do + expect(PaymentParameter.model_name.name).to eq('Payment') + expect(PaymentParameter.model_name.param_key).to eq('payment') + end + end + + describe 'class name without suffix' do + it 'keeps the class name as is' do + expect(Profile.model_name.name).to eq('Profile') + expect(Profile.model_name.param_key).to eq('profile') + end + end + + describe 'nested class within module' do + it 'handles namespace correctly in name' do + expect(Admin::UserForm.model_name.name).to eq('Admin::User') + end + + it 'provides correct param_key with namespace' do + # Rails includes namespace in param_key when full name is provided + expect(Admin::UserForm.model_name.param_key).to eq('admin_user') + end + + it 'provides correct route_key with namespace' do + expect(Admin::UserForm.model_name.route_key).to eq('admin_users') + end + + it 'provides correct i18n_key with namespace' do + expect(Admin::UserForm.model_name.i18n_key).to eq(:'admin/user') + end + end + + describe 'deeply nested class' do + it 'handles multiple namespaces correctly in name' do + expect(Api::V1::RegistrationForm.model_name.name).to eq('Api::V1::Registration') + end + + it 'provides correct param_key for deeply nested class' do + expect(Api::V1::RegistrationForm.model_name.param_key).to eq('api_v1_registration') + end + + it 'provides correct route_key for deeply nested class' do + expect(Api::V1::RegistrationForm.model_name.route_key).to eq('api_v1_registrations') + end + + it 'provides correct i18n_key for deeply nested class' do + expect(Api::V1::RegistrationForm.model_name.i18n_key).to eq(:'api/v1/registration') + end + end + + describe 'nested class with Parameters suffix' do + it 'removes suffix and keeps namespace' do + expect(Internal::OrderParameters.model_name.name).to eq('Internal::Order') + end + + it 'provides correct param_key' do + expect(Internal::OrderParameters.model_name.param_key).to eq('internal_order') + end + + it 'provides correct i18n_key' do + expect(Internal::OrderParameters.model_name.i18n_key).to eq(:'internal/order') + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 4ef9364..e8b91a7 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -7,6 +7,9 @@ StructuredParams.register_types +# Load all support files (including test helper classes) +Dir[File.expand_path('support/**/*.rb', __dir__)].each { |f| require f } + RSpec.configure do |config| config.include FactoryBot::Syntax::Methods # Enable flags like --only-failures and --next-failure diff --git a/spec/support/form_objects.rb b/spec/support/form_objects.rb new file mode 100644 index 0000000..9434cca --- /dev/null +++ b/spec/support/form_objects.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# Test helper classes for form object specs +# These classes are used to test StructuredParams::Params as form objects + +class UserRegistrationForm < StructuredParams::Params + attribute :name, :string + attribute :email, :string + attribute :age, :integer + attribute :terms_accepted, :boolean + + validates :name, presence: true + validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :age, numericality: { greater_than: 0 } +end + +class OrderParameters < StructuredParams::Params + attribute :product_name, :string +end + +class PaymentParameter < StructuredParams::Params + attribute :amount, :decimal +end + +class Profile < StructuredParams::Params + attribute :bio, :string +end + +module Admin + class UserForm < StructuredParams::Params + attribute :name, :string + end +end + +module Api + module V1 + class RegistrationForm < StructuredParams::Params + attribute :email, :string + end + end +end + +module Internal + class OrderParameters < StructuredParams::Params + attribute :item_name, :string + end +end From 725869c96cf8e69e866e96cd717384de5936a0b8 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Sun, 1 Mar 2026 05:13:27 +0900 Subject: [PATCH 2/6] Add permit method to handle optional parameter requirements in Form Objects --- lib/structured_params/params.rb | 26 +++- sig/structured_params/params.rbs | 15 ++ spec/permit_spec.rb | 260 +++++++++++++++++++++++++++++++ 3 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 spec/permit_spec.rb diff --git a/lib/structured_params/params.rb b/lib/structured_params/params.rb index e74421d..c66b020 100644 --- a/lib/structured_params/params.rb +++ b/lib/structured_params/params.rb @@ -80,6 +80,30 @@ def permit_attribute_names end end + # Permit parameters with optional require + # + # For Form Objects (with require): + # UserRegistrationForm.permit(params) + # # equivalent to: + # params.require(:user_registration).permit(UserRegistrationForm.permit_attribute_names) + # + # For API requests (without require): + # UserParams.permit(params, require: false) + # # equivalent to: + # params.permit(UserParams.permit_attribute_names) + # + # @rbs params: ActionController::Parameters + # @rbs require: bool + # @rbs return: ActionController::Parameters + def permit(params, require: true) + if require + key = model_name.param_key.to_sym + params.require(key).permit(permit_attribute_names) + else + params.permit(permit_attribute_names) + end + end + # Get structured attributes and their classes #: () -> Hash[Symbol, singleton(::StructuredParams::Params)] def structured_attributes @@ -174,7 +198,7 @@ def attributes(symbolize: false, compact_mode: :none) def process_input_parameters(params) case params when ActionController::Parameters - params.permit(self.class.permit_attribute_names).to_h + self.class.permit(params, require: false).to_h when Hash # ActiveModel::Attributes can handle both symbol and string keys params diff --git a/sig/structured_params/params.rbs b/sig/structured_params/params.rbs index 9e56a5d..89eb2c5 100644 --- a/sig/structured_params/params.rbs +++ b/sig/structured_params/params.rbs @@ -49,6 +49,21 @@ module StructuredParams # : () -> Array[untyped] def self.permit_attribute_names: () -> Array[untyped] + # Permit parameters with optional require + # + # For Form Objects (with require): + # UserRegistrationForm.permit(params) + # # equivalent to: + # params.require(:user_registration).permit(UserRegistrationForm.permit_attribute_names) + # + # For API requests (without require): + # UserParams.permit(params, require: false) + # # equivalent to: + # params.permit(UserParams.permit_attribute_names) + # + # : (ActionController::Parameters params, ?require: bool) -> ActionController::Parameters + def self.permit: (ActionController::Parameters params, ?require: bool) -> ActionController::Parameters + # Get structured attributes and their classes # : () -> Hash[Symbol, singleton(::StructuredParams::Params)] def self.structured_attributes: () -> Hash[Symbol, singleton(::StructuredParams::Params)] diff --git a/spec/permit_spec.rb b/spec/permit_spec.rb new file mode 100644 index 0000000..675245e --- /dev/null +++ b/spec/permit_spec.rb @@ -0,0 +1,260 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'StructuredParams::Params.permit' do + describe 'Form Object context (with nested params structure)' do + class UserRegistrationForm < StructuredParams::Params + attribute :name, :string + attribute :email, :string + attribute :age, :integer + end + + let(:params) do + ActionController::Parameters.new( + user_registration: { + name: 'John Doe', + email: 'john@example.com', + age: 30, + extra_field: 'should be filtered' + } + ) + end + + it 'automatically requires and permits parameters from nested structure' do + permitted = UserRegistrationForm.permit(params) + + expect(permitted).to be_permitted + expect(permitted[:name]).to eq('John Doe') + expect(permitted[:email]).to eq('john@example.com') + expect(permitted[:age]).to eq(30) + expect(permitted[:extra_field]).to be_nil + end + + it 'raises ParameterMissing when required key is missing' do + params = ActionController::Parameters.new(other_key: {}) + + expect { + UserRegistrationForm.permit(params) + }.to raise_error(ActionController::ParameterMissing) + end + + context 'with nested objects' do + class AddressForm < StructuredParams::Params + attribute :street, :string + attribute :city, :string + end + + class UserWithAddressForm < StructuredParams::Params + attribute :name, :string + attribute :address, :object, value_class: AddressForm + end + + let(:params) do + ActionController::Parameters.new( + user_with_address: { + name: 'John', + address: { + street: '123 Main St', + city: 'New York', + extra: 'filtered' + } + } + ) + end + + it 'permits nested object parameters' do + permitted = UserWithAddressForm.permit(params) + + expect(permitted).to be_permitted + expect(permitted[:name]).to eq('John') + expect(permitted[:address][:street]).to eq('123 Main St') + expect(permitted[:address][:city]).to eq('New York') + expect(permitted[:address][:extra]).to be_nil + end + end + + context 'with arrays' do + class ItemForm < StructuredParams::Params + attribute :title, :string + attribute :description, :string + end + + class OrderForm < StructuredParams::Params + attribute :name, :string + attribute :items, :array, value_class: ItemForm + attribute :tags, :array, value_type: :string + end + + let(:params) do + ActionController::Parameters.new( + order: { + name: 'My Order', + items: [ + { title: 'Item 1', description: 'Desc 1', extra: 'filtered' }, + { title: 'Item 2', description: 'Desc 2' } + ], + tags: ['tag1', 'tag2', 'tag3'] + } + ) + end + + it 'permits array parameters' do + permitted = OrderForm.permit(params) + + expect(permitted).to be_permitted + expect(permitted[:name]).to eq('My Order') + expect(permitted[:items].length).to eq(2) + expect(permitted[:items][0][:title]).to eq('Item 1') + expect(permitted[:items][0][:extra]).to be_nil + expect(permitted[:tags]).to eq(['tag1', 'tag2', 'tag3']) + end + end + + context 'with namespaced form' do + module Admin + class UserForm < StructuredParams::Params + attribute :title, :string + end + end + + let(:params) do + ActionController::Parameters.new( + admin_user: { + title: 'Admin Title', + extra: 'filtered' + } + ) + end + + it 'uses correct param_key from model_name' do + permitted = Admin::UserForm.permit(params) + + expect(permitted).to be_permitted + expect(permitted[:title]).to eq('Admin Title') + expect(permitted[:extra]).to be_nil + end + end + end + + describe 'API context (with flat params structure)' do + class UserParams < StructuredParams::Params + attribute :name, :string + attribute :email, :string + attribute :age, :integer + end + + let(:params) do + ActionController::Parameters.new( + name: 'Jane Doe', + email: 'jane@example.com', + age: 25, + extra_field: 'should be filtered' + ) + end + + it 'permits parameters without requiring a key' do + permitted = UserParams.permit(params, require: false) + + expect(permitted).to be_permitted + expect(permitted[:name]).to eq('Jane Doe') + expect(permitted[:email]).to eq('jane@example.com') + expect(permitted[:age]).to eq(25) + expect(permitted[:extra_field]).to be_nil + end + + context 'with nested objects' do + class ApiAddressParams < StructuredParams::Params + attribute :street, :string + attribute :city, :string + end + + class ApiUserParams < StructuredParams::Params + attribute :name, :string + attribute :address, :object, value_class: ApiAddressParams + end + + let(:params) do + ActionController::Parameters.new( + name: 'Alice', + address: { + street: '456 Oak Ave', + city: 'Tokyo', + extra: 'filtered' + }, + extra_field: 'should be filtered' + ) + end + + it 'permits nested object parameters without require' do + permitted = ApiUserParams.permit(params, require: false) + + expect(permitted).to be_permitted + expect(permitted[:name]).to eq('Alice') + expect(permitted[:address][:street]).to eq('456 Oak Ave') + expect(permitted[:address][:city]).to eq('Tokyo') + expect(permitted[:address][:extra]).to be_nil + expect(permitted[:extra_field]).to be_nil + end + end + + context 'with arrays' do + class ApiItemParams < StructuredParams::Params + attribute :title, :string + attribute :description, :string + end + + class ApiOrderParams < StructuredParams::Params + attribute :name, :string + attribute :items, :array, value_class: ApiItemParams + attribute :tags, :array, value_type: :string + end + + let(:params) do + ActionController::Parameters.new( + name: 'My Order', + items: [ + { title: 'Item 1', description: 'Desc 1', extra: 'filtered' }, + { title: 'Item 2', description: 'Desc 2' } + ], + tags: ['tag1', 'tag2', 'tag3'], + extra_field: 'filtered' + ) + end + + it 'permits array parameters without require' do + permitted = ApiOrderParams.permit(params, require: false) + + expect(permitted).to be_permitted + expect(permitted[:name]).to eq('My Order') + expect(permitted[:items].length).to eq(2) + expect(permitted[:items][0][:title]).to eq('Item 1') + expect(permitted[:items][0][:extra]).to be_nil + expect(permitted[:tags]).to eq(['tag1', 'tag2', 'tag3']) + expect(permitted[:extra_field]).to be_nil + end + end + + context 'when user manually extracts nested params' do + let(:nested_params) do + ActionController::Parameters.new( + user: { + name: 'Bob', + email: 'bob@example.com', + age: 35 + } + ) + end + + it 'works with manually extracted params' do + # User manually extracts the nested params + permitted = UserParams.permit(nested_params[:user], require: false) + + expect(permitted).to be_permitted + expect(permitted[:name]).to eq('Bob') + expect(permitted[:email]).to eq('bob@example.com') + expect(permitted[:age]).to eq(35) + end + end + end +end From 48be322c40fbcc4dda03d5543c6b8b02bf24aaa7 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Sun, 1 Mar 2026 05:31:55 +0900 Subject: [PATCH 3/6] Add copilot instructions for structured parameters and coding style guidelines --- .github/copilot-instructions.md | 111 ++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..95394f3 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,111 @@ +# Copilot Instructions for structured_params + +## Coding Style + +### RBS Inline + +This project uses `rbs-inline` for type annotations. Use the **method_type_signature** style: + +```ruby +# Good: method_type_signature style +class Example + #: () -> String + def method_name + "result" + end + + #: (String) -> Integer + def method_with_param(value) + value.length + end + + #: (String value, ?Integer default) -> String + def method_with_optional(value, default: 0) + "#{value}: #{default}" + end +end +``` + +**Exception: Instance variables** must use doc style (`# @rbs`): + +```ruby +# Good: instance variable type definition +class Example + # @rbs @name: String? + + class << self + # @rbs @cache: Hash[Symbol, String]? + end + + #: (String) -> void + def initialize(name) + @name = name + end +end +``` + +**DO NOT** use the doc style for method signatures: + +```ruby +# Bad: doc style for methods (do not use) +# @rbs return: String +def method_name + "result" +end + +# @rbs param: String +# @rbs return: Integer +def method_with_param(value) + value.length +end +``` + +### Configuration + +The RuboCop configuration enforces this style: + +```yaml +Style/RbsInline/MissingTypeAnnotation: + EnforcedStyle: method_type_signature +``` + +### RBS Signature Generation + +**DO NOT** manually edit files in `sig/` directory. These files are auto-generated from inline annotations. + +To generate RBS signature files: + +```bash +# Run this command to generate sig files from inline annotations +lefthook run prepare-commit-msg +``` + +This command will: +1. Extract type annotations from Ruby files using `rbs-inline` +2. Generate corresponding `.rbs` files in `sig/` directory +3. Ensure type signatures are in sync with the code + +**Note:** The `sig/` directory is automatically updated by the git hook, but you can manually run it when needed. + +## Project-Specific Guidelines + +### Strong Parameters + +- For API usage: Use simple `UserParams.new(params)` +- For Form Objects: Use `UserForm.new(UserForm.permit(params))` +- `permit` method is available but not required for API usage + +### Form Objects + +This gem supports both Strong Parameters validation and Form Object pattern: + +- Form Objects should use `permit(params)` to handle `require` + `permit` +- `model_name` automatically removes "Parameters", "Parameter", or "Form" suffix +- Provides `persisted?`, `to_key`, `to_model` for Rails form helpers integration + +### Testing + +- Use RSpec for testing +- Group tests by context (e.g., "API context", "Form Object context") +- Test files are in `spec/` directory +- Support files (test helper classes) are in `spec/support/` From 093f194b4f26e83de9531e3426c6c049f9ad3b50 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Sun, 1 Mar 2026 05:32:17 +0900 Subject: [PATCH 4/6] Refactor RBS annotations in params.rb for clarity and consistency --- lib/structured_params/params.rb | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/lib/structured_params/params.rb b/lib/structured_params/params.rb index c66b020..7e881e3 100644 --- a/lib/structured_params/params.rb +++ b/lib/structured_params/params.rb @@ -43,7 +43,7 @@ class Params class << self # @rbs @structured_attributes: Hash[Symbol, singleton(::StructuredParams::Params)]? - # @rbs @_model_name: ::ActiveModel::Name? + # @rbs @model_name: ::ActiveModel::Name? # Override model_name for form helpers # By default, removes "Parameters", "Parameter", or "Form" suffix from class name @@ -54,11 +54,9 @@ class << self # UserRegistrationForm.model_name.param_key # => "user_registration" # UserParameters.model_name.name # => "User" # Admin::UserForm.model_name.name # => "Admin::User" - # @rbs return: ::ActiveModel::Name + #: () -> ::ActiveModel::Name def model_name - # @rbs @_model_name: ::ActiveModel::Name? - - @_model_name ||= begin + @model_name ||= begin namespace = module_parents.detect { |n| n.respond_to?(:use_relative_model_naming?) } # Remove suffix from the full class name (preserving namespace) name_without_suffix = name.sub(/(Parameters?|Form)$/, '') @@ -92,9 +90,7 @@ def permit_attribute_names # # equivalent to: # params.permit(UserParams.permit_attribute_names) # - # @rbs params: ActionController::Parameters - # @rbs require: bool - # @rbs return: ActionController::Parameters + #: (ActionController::Parameters params, ?require: bool) -> ActionController::Parameters def permit(params, require: true) if require key = model_name.param_key.to_sym @@ -149,21 +145,21 @@ def errors # Indicates whether the form object has been persisted to database # Always returns false for parameter/form objects - # @rbs return: bool + #: () -> bool def persisted? false end # Returns the primary key value for the model # Always returns nil for parameter/form objects - # @rbs return: nil + #: () -> nil def to_key nil end # Returns self for form helpers # Required by Rails form helpers to get the model object - # @rbs return: self + #: () -> self def to_model self end From 9374ddb769187ba9597564fe5291280cce7126e8 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Sun, 1 Mar 2026 05:38:55 +0900 Subject: [PATCH 5/6] Add form object classes and specs for user registration, address, and order parameters --- spec/form_object_spec.rb | 3 ++ spec/permit_spec.rb | 85 +++++++--------------------------- spec/support/form_objects.rb | 5 ++ spec/support/permit_helpers.rb | 68 +++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 68 deletions(-) create mode 100644 spec/support/permit_helpers.rb diff --git a/spec/form_object_spec.rb b/spec/form_object_spec.rb index d8ddc01..2e6975a 100644 --- a/spec/form_object_spec.rb +++ b/spec/form_object_spec.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true +# rbs_inline: enabled require 'spec_helper' +# rubocop:disable RSpec/DescribeClass RSpec.describe 'StructuredParams::Params as Form Object' do describe '.model_name' do it 'removes "Form" suffix from class name' do @@ -174,3 +176,4 @@ end end end +# rubocop:enable RSpec/DescribeClass diff --git a/spec/permit_spec.rb b/spec/permit_spec.rb index 675245e..e764970 100644 --- a/spec/permit_spec.rb +++ b/spec/permit_spec.rb @@ -1,15 +1,11 @@ # frozen_string_literal: true +# rbs_inline: enabled require 'spec_helper' +# rubocop:disable RSpec/DescribeClass RSpec.describe 'StructuredParams::Params.permit' do describe 'Form Object context (with nested params structure)' do - class UserRegistrationForm < StructuredParams::Params - attribute :name, :string - attribute :email, :string - attribute :age, :integer - end - let(:params) do ActionController::Parameters.new( user_registration: { @@ -34,22 +30,12 @@ class UserRegistrationForm < StructuredParams::Params it 'raises ParameterMissing when required key is missing' do params = ActionController::Parameters.new(other_key: {}) - expect { + expect do UserRegistrationForm.permit(params) - }.to raise_error(ActionController::ParameterMissing) + end.to raise_error(ActionController::ParameterMissing) end context 'with nested objects' do - class AddressForm < StructuredParams::Params - attribute :street, :string - attribute :city, :string - end - - class UserWithAddressForm < StructuredParams::Params - attribute :name, :string - attribute :address, :object, value_class: AddressForm - end - let(:params) do ActionController::Parameters.new( user_with_address: { @@ -75,17 +61,6 @@ class UserWithAddressForm < StructuredParams::Params end context 'with arrays' do - class ItemForm < StructuredParams::Params - attribute :title, :string - attribute :description, :string - end - - class OrderForm < StructuredParams::Params - attribute :name, :string - attribute :items, :array, value_class: ItemForm - attribute :tags, :array, value_type: :string - end - let(:params) do ActionController::Parameters.new( order: { @@ -94,11 +69,12 @@ class OrderForm < StructuredParams::Params { title: 'Item 1', description: 'Desc 1', extra: 'filtered' }, { title: 'Item 2', description: 'Desc 2' } ], - tags: ['tag1', 'tag2', 'tag3'] + tags: %w[tag1 tag2 tag3] } ) end + # rubocop:disable RSpec/MultipleExpectations it 'permits array parameters' do permitted = OrderForm.permit(params) @@ -107,20 +83,15 @@ class OrderForm < StructuredParams::Params expect(permitted[:items].length).to eq(2) expect(permitted[:items][0][:title]).to eq('Item 1') expect(permitted[:items][0][:extra]).to be_nil - expect(permitted[:tags]).to eq(['tag1', 'tag2', 'tag3']) + expect(permitted[:tags]).to eq(%w[tag1 tag2 tag3]) end + # rubocop:enable RSpec/MultipleExpectations end context 'with namespaced form' do - module Admin - class UserForm < StructuredParams::Params - attribute :title, :string - end - end - let(:params) do ActionController::Parameters.new( - admin_user: { + admin_namespaced: { title: 'Admin Title', extra: 'filtered' } @@ -128,7 +99,7 @@ class UserForm < StructuredParams::Params end it 'uses correct param_key from model_name' do - permitted = Admin::UserForm.permit(params) + permitted = Admin::NamespacedForm.permit(params) expect(permitted).to be_permitted expect(permitted[:title]).to eq('Admin Title') @@ -138,12 +109,6 @@ class UserForm < StructuredParams::Params end describe 'API context (with flat params structure)' do - class UserParams < StructuredParams::Params - attribute :name, :string - attribute :email, :string - attribute :age, :integer - end - let(:params) do ActionController::Parameters.new( name: 'Jane Doe', @@ -164,16 +129,6 @@ class UserParams < StructuredParams::Params end context 'with nested objects' do - class ApiAddressParams < StructuredParams::Params - attribute :street, :string - attribute :city, :string - end - - class ApiUserParams < StructuredParams::Params - attribute :name, :string - attribute :address, :object, value_class: ApiAddressParams - end - let(:params) do ActionController::Parameters.new( name: 'Alice', @@ -186,6 +141,7 @@ class ApiUserParams < StructuredParams::Params ) end + # rubocop:disable RSpec/MultipleExpectations it 'permits nested object parameters without require' do permitted = ApiUserParams.permit(params, require: false) @@ -196,20 +152,10 @@ class ApiUserParams < StructuredParams::Params expect(permitted[:address][:extra]).to be_nil expect(permitted[:extra_field]).to be_nil end + # rubocop:enable RSpec/MultipleExpectations end context 'with arrays' do - class ApiItemParams < StructuredParams::Params - attribute :title, :string - attribute :description, :string - end - - class ApiOrderParams < StructuredParams::Params - attribute :name, :string - attribute :items, :array, value_class: ApiItemParams - attribute :tags, :array, value_type: :string - end - let(:params) do ActionController::Parameters.new( name: 'My Order', @@ -217,11 +163,12 @@ class ApiOrderParams < StructuredParams::Params { title: 'Item 1', description: 'Desc 1', extra: 'filtered' }, { title: 'Item 2', description: 'Desc 2' } ], - tags: ['tag1', 'tag2', 'tag3'], + tags: %w[tag1 tag2 tag3], extra_field: 'filtered' ) end + # rubocop:disable RSpec/MultipleExpectations it 'permits array parameters without require' do permitted = ApiOrderParams.permit(params, require: false) @@ -230,9 +177,10 @@ class ApiOrderParams < StructuredParams::Params expect(permitted[:items].length).to eq(2) expect(permitted[:items][0][:title]).to eq('Item 1') expect(permitted[:items][0][:extra]).to be_nil - expect(permitted[:tags]).to eq(['tag1', 'tag2', 'tag3']) + expect(permitted[:tags]).to eq(%w[tag1 tag2 tag3]) expect(permitted[:extra_field]).to be_nil end + # rubocop:enable RSpec/MultipleExpectations end context 'when user manually extracts nested params' do @@ -258,3 +206,4 @@ class ApiOrderParams < StructuredParams::Params end end end +# rubocop:enable RSpec/DescribeClass diff --git a/spec/support/form_objects.rb b/spec/support/form_objects.rb index 9434cca..4f5f0e3 100644 --- a/spec/support/form_objects.rb +++ b/spec/support/form_objects.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +# rubocop:disable Style/OneClassPerFile +# rbs_inline: enabled + # Test helper classes for form object specs # These classes are used to test StructuredParams::Params as form objects @@ -45,3 +48,5 @@ class OrderParameters < StructuredParams::Params attribute :item_name, :string end end + +# rubocop:enable Style/OneClassPerFile diff --git a/spec/support/permit_helpers.rb b/spec/support/permit_helpers.rb new file mode 100644 index 0000000..6cd60dc --- /dev/null +++ b/spec/support/permit_helpers.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +# rubocop:disable Style/OneClassPerFile +# rbs_inline: enabled + +# Test helper classes for permit specs + +class UserRegistrationForm < StructuredParams::Params + attribute :name, :string + attribute :email, :string + attribute :age, :integer +end + +class AddressForm < StructuredParams::Params + attribute :street, :string + attribute :city, :string +end + +class UserWithAddressForm < StructuredParams::Params + attribute :name, :string + attribute :address, :object, value_class: AddressForm +end + +class ItemForm < StructuredParams::Params + attribute :title, :string + attribute :description, :string +end + +class OrderForm < StructuredParams::Params + attribute :name, :string + attribute :items, :array, value_class: ItemForm + attribute :tags, :array, value_type: :string +end + +module Admin + class NamespacedForm < StructuredParams::Params + attribute :title, :string + end +end + +class UserParams < StructuredParams::Params + attribute :name, :string + attribute :email, :string + attribute :age, :integer +end + +class ApiAddressParams < StructuredParams::Params + attribute :street, :string + attribute :city, :string +end + +class ApiUserParams < StructuredParams::Params + attribute :name, :string + attribute :address, :object, value_class: ApiAddressParams +end + +class ApiItemParams < StructuredParams::Params + attribute :title, :string + attribute :description, :string +end + +class ApiOrderParams < StructuredParams::Params + attribute :name, :string + attribute :items, :array, value_class: ApiItemParams + attribute :tags, :array, value_type: :string +end + +# rubocop:enable Style/OneClassPerFile From 5dd7c1fed81e477e8f5ecc36a336686d9ee5c742 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Sun, 1 Mar 2026 05:54:00 +0900 Subject: [PATCH 6/6] Refactor permit specs to use UserParameter and enhance nested parameter handling --- spec/permit_spec.rb | 60 +++++++++------- spec/spec_helper.rb | 2 +- spec/support/permit_helpers.rb | 68 ------------------- .../{form_objects.rb => test_classes.rb} | 12 +++- 4 files changed, 46 insertions(+), 96 deletions(-) delete mode 100644 spec/support/permit_helpers.rb rename spec/support/{form_objects.rb => test_classes.rb} (80%) diff --git a/spec/permit_spec.rb b/spec/permit_spec.rb index e764970..ada7d6a 100644 --- a/spec/permit_spec.rb +++ b/spec/permit_spec.rb @@ -38,11 +38,15 @@ context 'with nested objects' do let(:params) do ActionController::Parameters.new( - user_with_address: { + user: { name: 'John', + email: 'john@example.com', + age: 30, address: { street: '123 Main St', city: 'New York', + postal_code: '100-0001', + prefecture: 'Tokyo', extra: 'filtered' } } @@ -50,7 +54,7 @@ end it 'permits nested object parameters' do - permitted = UserWithAddressForm.permit(params) + permitted = UserParameter.permit(params) expect(permitted).to be_permitted expect(permitted[:name]).to eq('John') @@ -63,11 +67,13 @@ context 'with arrays' do let(:params) do ActionController::Parameters.new( - order: { - name: 'My Order', - items: [ - { title: 'Item 1', description: 'Desc 1', extra: 'filtered' }, - { title: 'Item 2', description: 'Desc 2' } + user: { + name: 'John', + email: 'john@example.com', + age: 30, + hobbies: [ + { name: 'Reading', level: 'beginner', extra: 'filtered' }, + { name: 'Gaming', level: 'advanced' } ], tags: %w[tag1 tag2 tag3] } @@ -76,13 +82,13 @@ # rubocop:disable RSpec/MultipleExpectations it 'permits array parameters' do - permitted = OrderForm.permit(params) + permitted = UserParameter.permit(params) expect(permitted).to be_permitted - expect(permitted[:name]).to eq('My Order') - expect(permitted[:items].length).to eq(2) - expect(permitted[:items][0][:title]).to eq('Item 1') - expect(permitted[:items][0][:extra]).to be_nil + expect(permitted[:name]).to eq('John') + expect(permitted[:hobbies].length).to eq(2) + expect(permitted[:hobbies][0][:name]).to eq('Reading') + expect(permitted[:hobbies][0][:extra]).to be_nil expect(permitted[:tags]).to eq(%w[tag1 tag2 tag3]) end # rubocop:enable RSpec/MultipleExpectations @@ -119,7 +125,7 @@ end it 'permits parameters without requiring a key' do - permitted = UserParams.permit(params, require: false) + permitted = UserParameter.permit(params, require: false) expect(permitted).to be_permitted expect(permitted[:name]).to eq('Jane Doe') @@ -132,9 +138,13 @@ let(:params) do ActionController::Parameters.new( name: 'Alice', + email: 'alice@example.com', + age: 28, address: { street: '456 Oak Ave', city: 'Tokyo', + postal_code: '123-4567', + prefecture: 'Tokyo', extra: 'filtered' }, extra_field: 'should be filtered' @@ -143,7 +153,7 @@ # rubocop:disable RSpec/MultipleExpectations it 'permits nested object parameters without require' do - permitted = ApiUserParams.permit(params, require: false) + permitted = UserParameter.permit(params, require: false) expect(permitted).to be_permitted expect(permitted[:name]).to eq('Alice') @@ -158,10 +168,12 @@ context 'with arrays' do let(:params) do ActionController::Parameters.new( - name: 'My Order', - items: [ - { title: 'Item 1', description: 'Desc 1', extra: 'filtered' }, - { title: 'Item 2', description: 'Desc 2' } + name: 'Bob', + email: 'bob@example.com', + age: 35, + hobbies: [ + { name: 'Cooking', level: 'intermediate', extra: 'filtered' }, + { name: 'Sports', level: 'beginner' } ], tags: %w[tag1 tag2 tag3], extra_field: 'filtered' @@ -170,13 +182,13 @@ # rubocop:disable RSpec/MultipleExpectations it 'permits array parameters without require' do - permitted = ApiOrderParams.permit(params, require: false) + permitted = UserParameter.permit(params, require: false) expect(permitted).to be_permitted - expect(permitted[:name]).to eq('My Order') - expect(permitted[:items].length).to eq(2) - expect(permitted[:items][0][:title]).to eq('Item 1') - expect(permitted[:items][0][:extra]).to be_nil + expect(permitted[:name]).to eq('Bob') + expect(permitted[:hobbies].length).to eq(2) + expect(permitted[:hobbies][0][:name]).to eq('Cooking') + expect(permitted[:hobbies][0][:extra]).to be_nil expect(permitted[:tags]).to eq(%w[tag1 tag2 tag3]) expect(permitted[:extra_field]).to be_nil end @@ -196,7 +208,7 @@ it 'works with manually extracted params' do # User manually extracts the nested params - permitted = UserParams.permit(nested_params[:user], require: false) + permitted = UserParameter.permit(nested_params[:user], require: false) expect(permitted).to be_permitted expect(permitted[:name]).to eq('Bob') diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e8b91a7..8c0222b 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -7,7 +7,7 @@ StructuredParams.register_types -# Load all support files (including test helper classes) +# Load support files (test helper classes) Dir[File.expand_path('support/**/*.rb', __dir__)].each { |f| require f } RSpec.configure do |config| diff --git a/spec/support/permit_helpers.rb b/spec/support/permit_helpers.rb deleted file mode 100644 index 6cd60dc..0000000 --- a/spec/support/permit_helpers.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -# rubocop:disable Style/OneClassPerFile -# rbs_inline: enabled - -# Test helper classes for permit specs - -class UserRegistrationForm < StructuredParams::Params - attribute :name, :string - attribute :email, :string - attribute :age, :integer -end - -class AddressForm < StructuredParams::Params - attribute :street, :string - attribute :city, :string -end - -class UserWithAddressForm < StructuredParams::Params - attribute :name, :string - attribute :address, :object, value_class: AddressForm -end - -class ItemForm < StructuredParams::Params - attribute :title, :string - attribute :description, :string -end - -class OrderForm < StructuredParams::Params - attribute :name, :string - attribute :items, :array, value_class: ItemForm - attribute :tags, :array, value_type: :string -end - -module Admin - class NamespacedForm < StructuredParams::Params - attribute :title, :string - end -end - -class UserParams < StructuredParams::Params - attribute :name, :string - attribute :email, :string - attribute :age, :integer -end - -class ApiAddressParams < StructuredParams::Params - attribute :street, :string - attribute :city, :string -end - -class ApiUserParams < StructuredParams::Params - attribute :name, :string - attribute :address, :object, value_class: ApiAddressParams -end - -class ApiItemParams < StructuredParams::Params - attribute :title, :string - attribute :description, :string -end - -class ApiOrderParams < StructuredParams::Params - attribute :name, :string - attribute :items, :array, value_class: ApiItemParams - attribute :tags, :array, value_type: :string -end - -# rubocop:enable Style/OneClassPerFile diff --git a/spec/support/form_objects.rb b/spec/support/test_classes.rb similarity index 80% rename from spec/support/form_objects.rb rename to spec/support/test_classes.rb index 4f5f0e3..c7b1745 100644 --- a/spec/support/form_objects.rb +++ b/spec/support/test_classes.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true +# rbs_inline: enabled # rubocop:disable Style/OneClassPerFile -# rbs_inline: enabled -# Test helper classes for form object specs -# These classes are used to test StructuredParams::Params as form objects +# Additional test helper classes for form object and permit specs +# Form object with validations class UserRegistrationForm < StructuredParams::Params attribute :name, :string attribute :email, :string @@ -17,6 +17,7 @@ class UserRegistrationForm < StructuredParams::Params validates :age, numericality: { greater_than: 0 } end +# Classes for testing suffix removal class OrderParameters < StructuredParams::Params attribute :product_name, :string end @@ -29,10 +30,15 @@ class Profile < StructuredParams::Params attribute :bio, :string end +# Namespaced classes for testing model_name module Admin class UserForm < StructuredParams::Params attribute :name, :string end + + class NamespacedForm < StructuredParams::Params + attribute :title, :string + end end module Api