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/` diff --git a/lib/structured_params/params.rb b/lib/structured_params/params.rb index c8e8b7e..7e881e3 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,26 @@ 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" + #: () -> ::ActiveModel::Name + def model_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] @@ -34,6 +78,28 @@ 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) + # + #: (ActionController::Parameters params, ?require: bool) -> 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 @@ -72,6 +138,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 + #: () -> 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 + 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] @@ -102,7 +194,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 e22342a..89eb2c5 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,10 +31,39 @@ 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] + # 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)] @@ -37,6 +78,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..2e6975a --- /dev/null +++ b/spec/form_object_spec.rb @@ -0,0 +1,179 @@ +# 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 + 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 +# rubocop:enable RSpec/DescribeClass diff --git a/spec/permit_spec.rb b/spec/permit_spec.rb new file mode 100644 index 0000000..ada7d6a --- /dev/null +++ b/spec/permit_spec.rb @@ -0,0 +1,221 @@ +# 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 + 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 do + UserRegistrationForm.permit(params) + end.to raise_error(ActionController::ParameterMissing) + end + + context 'with nested objects' do + let(:params) do + ActionController::Parameters.new( + 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' + } + } + ) + end + + it 'permits nested object parameters' do + permitted = UserParameter.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 + let(:params) do + ActionController::Parameters.new( + 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] + } + ) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'permits array parameters' do + permitted = UserParameter.permit(params) + + expect(permitted).to be_permitted + 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 + end + + context 'with namespaced form' do + let(:params) do + ActionController::Parameters.new( + admin_namespaced: { + title: 'Admin Title', + extra: 'filtered' + } + ) + end + + it 'uses correct param_key from model_name' do + permitted = Admin::NamespacedForm.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 + 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 = UserParameter.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 + 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' + ) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'permits nested object parameters without require' do + permitted = UserParameter.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 + # rubocop:enable RSpec/MultipleExpectations + end + + context 'with arrays' do + let(:params) do + ActionController::Parameters.new( + 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' + ) + end + + # rubocop:disable RSpec/MultipleExpectations + it 'permits array parameters without require' do + permitted = UserParameter.permit(params, require: false) + + expect(permitted).to be_permitted + 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 + # rubocop:enable RSpec/MultipleExpectations + 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 = UserParameter.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 +# rubocop:enable RSpec/DescribeClass diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 4ef9364..8c0222b 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -7,6 +7,9 @@ StructuredParams.register_types +# Load support files (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/test_classes.rb b/spec/support/test_classes.rb new file mode 100644 index 0000000..c7b1745 --- /dev/null +++ b/spec/support/test_classes.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true +# rbs_inline: enabled + +# rubocop:disable Style/OneClassPerFile + +# Additional test helper classes for form object and permit specs + +# Form object with validations +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 + +# Classes for testing suffix removal +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 + +# 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 + module V1 + class RegistrationForm < StructuredParams::Params + attribute :email, :string + end + end +end + +module Internal + class OrderParameters < StructuredParams::Params + attribute :item_name, :string + end +end + +# rubocop:enable Style/OneClassPerFile