diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..ecec1f9 --- /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 self.@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/.rubocop_rbs.yml b/.rubocop_rbs.yml index a81a9f5..d2a194d 100644 --- a/.rubocop_rbs.yml +++ b/.rubocop_rbs.yml @@ -7,3 +7,7 @@ plugins: # ========= RBS =========== Style/RbsInline/MissingTypeAnnotation: EnforcedStyle: method_type_signature + +Style/RbsInline/UntypedInstanceVariable: + Exclude: + - 'lib/structured_params/params.rb' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2c589d0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,238 @@ +# Contributing to StructuredParams + +Thank you for your interest in contributing to StructuredParams! This document provides guidelines and information for developers. + +## Development Setup + +### Prerequisites + +- Ruby 3.2 or higher (Ruby 3.3+ required for RBS inline features) +- Bundler + +### Installation + +```bash +# Clone the repository +git clone https://github.com/Syati/structured_params.git +cd structured_params + +# Setup everything (installs dependencies, RBS collection, and git hooks) +bin/setup +``` + +## Development Workflow + +### Running Tests + +```bash +# Run all tests +bundle exec rspec + +# Run specific test file +bundle exec rspec spec/params_spec.rb + +# Run with coverage +bundle exec rspec --format documentation +``` + +### Code Quality Checks + +```bash +# Run RuboCop +bundle exec rubocop + +# Auto-correct RuboCop offenses +bundle exec rubocop -a + +# Run Steep type checker (Ruby 3.3+ only) +bundle exec steep check +``` + +## RBS Type Signature Generation + +This project uses `rbs-inline` for type annotations. RBS signature files in the `sig/` directory are **auto-generated** from inline type annotations in Ruby files. + +**DO NOT manually edit files in the `sig/` directory.** + +### During Development (Watch Mode) + +Automatically regenerate signatures on file changes: + +```bash +./bin/dev +``` + +This command uses `fswatch` to monitor the `lib/` directory and automatically runs `rbs-inline` when files change. + +### Manual Generation + +Generate all signatures manually: + +```bash +# Generate for all lib files +bundle exec rbs-inline --output=sig lib/**/*.rb +``` + +### Automatic Generation (Git Hooks) + +RBS signatures are automatically generated before each commit via Lefthook: + +```bash +# Manually trigger the pre-commit hook +lefthook run prepare-commit-msg +``` + +The git hook configuration is in `lefthook.yml`: + +```yaml +prepare-commit-msg: + commands: + rbs-inline: + run: bundle exec rbs-inline --output=sig lib/**/*.rb +``` + +## Coding Style + +### RBS Inline Annotations + +This project uses `rbs-inline` with 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 +# Instance variable +class Example + # @rbs @name: String? + + #: (String) -> void + def initialize(name) + @name = name + end +end + +# Class instance variable (use self.@) +class Example + class << self + # @rbs self.@cache: Hash[Symbol, String]? + + def cache + @cache ||= {} + end + end +end +``` + +**DO NOT use doc style for method signatures:** + +```ruby +# Bad: doc style for methods (do not use) +# @rbs return: String +def method_name + "result" +end +``` + +### RuboCop Configuration + +The project enforces this style via RuboCop: + +```yaml +Style/RbsInline/MissingTypeAnnotation: + EnforcedStyle: method_type_signature +``` + +## Testing Different Rails Versions + +You can test against different Rails versions using the `RAILS_VERSION` environment variable: + +```bash +# Test with Rails 7.2 +RAILS_VERSION="~> 7.2.0" bundle update && bundle exec rspec + +# Test with Rails 8.0 +RAILS_VERSION="~> 8.0.0" bundle update && bundle exec rspec +``` + +## Project Structure + +``` +structured_params/ +├── lib/ +│ ├── structured_params.rb # Main entry point +│ ├── structured_params/ +│ │ ├── params.rb # Core Params class +│ │ ├── errors.rb # Enhanced error handling +│ │ ├── version.rb # Version constant +│ │ └── type/ +│ │ ├── array.rb # Array type handler +│ │ └── object.rb # Object type handler +├── sig/ # Auto-generated RBS files +│ └── generated/ # DO NOT EDIT +├── spec/ # RSpec tests +│ ├── factories/ # Test parameter classes +│ ├── support/ # Test helpers +│ └── *_spec.rb # Test files +└── docs/ # Documentation +``` + +## Submitting Changes + +### Pull Request Process + +1. **Fork the repository** and create your branch from `main` +2. **Write tests** for your changes +3. **Update documentation** if needed (README, docs/, inline comments) +4. **Ensure all tests pass**: `bundle exec rspec` +5. **Ensure code quality**: `bundle exec rubocop` +6. **Ensure type safety** (Ruby 3.3+): `bundle exec steep check` +7. **RBS signatures will be auto-generated** by git hooks +8. **Write a clear commit message** describing your changes +9. **Submit a pull request** with a description of your changes + +## Reporting Issues + +When reporting issues, please include: + +- Ruby version (`ruby -v`) +- Rails version (if applicable) +- Gem version +- Steps to reproduce +- Expected behavior +- Actual behavior +- Code samples or error messages + +## Questions? + +If you have questions about contributing, feel free to: + +- Open an issue with the `question` label +- Start a discussion in GitHub Discussions +- Contact the maintainers + +## Code of Conduct + +This project follows the [Contributor Covenant Code of Conduct](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). By participating, you are expected to uphold this code. + +## License + +By contributing to StructuredParams, you agree that your contributions will be licensed under the [MIT License](LICENSE.txt). diff --git a/README.md b/README.md index 7999ee5..219b356 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ English | [日本語](README_ja.md) - **Array handling** for both primitive types and nested objects - **Strong Parameters integration** with automatic permit lists - **ActiveModel compatibility** with validations and serialization +- **Form object pattern support** with Rails form helpers integration - **Enhanced error handling** with flat and structured formats - **RBS type definitions** for better development experience @@ -34,15 +35,47 @@ class UserParams < StructuredParams::Params validates :age, numericality: { greater_than: 0 } end -# 4. Use in controllers +# 4. Use in controllers (API) def create - user_params = UserParams.new(params[:user]) + user_params = UserParams.new(params) + if user_params.valid? User.create!(user_params.attributes) else render json: { errors: user_params.errors.to_hash(false, structured: true) } end end + +# 5. Use as Form Objects (View Integration) + +# Define a Form Object class +class UserRegistrationForm < StructuredParams::Params + attribute :name, :string + attribute :email, :string + attribute :password, :string + + validates :name, presence: true + validates :email, format: { with: URI::MailTo::EMAIL_REGEXP } +end + +# Use in controllers +def create + @form = UserRegistrationForm.new(UserRegistrationForm.permit(params)) + + if @form.valid? + User.create!(@form.attributes) + redirect_to user_path + else + render :new + end +end + +# Use in views +<%= form_with model: @form, url: users_path do |f| %> + <%= f.text_field :name %> + <%= f.email_field :email %> + <%= f.password_field :password %> +<% end %> ``` ## Documentation @@ -51,42 +84,15 @@ end - **[Basic Usage](docs/basic-usage.md)** - Parameter classes, nested objects, and arrays - **[Validation](docs/validation.md)** - Using ActiveModel validations with nested structures - **[Strong Parameters](docs/strong-parameters.md)** - Automatic permit list generation +- **[Form Objects](docs/form-objects.md)** - Using as form objects with Rails form helpers - **[Error Handling](docs/error-handling.md)** - Flat and structured error formats - **[Serialization](docs/serialization.md)** - Converting parameters to hashes and JSON - **[Gem Comparison](docs/comparison.md)** - Comparison with typed_params, dry-validation, and reform +- **[Contributing Guide](CONTRIBUTING.md)** - Developer setup and guidelines -## Example - -```ruby -class AddressParams < StructuredParams::Params - attribute :street, :string - attribute :city, :string - attribute :postal_code, :string - - validates :street, :city, :postal_code, presence: true -end +## For Developers -class UserParams < StructuredParams::Params - attribute :name, :string - attribute :email, :string - attribute :address, :object, value_class: AddressParams - - validates :name, presence: true - validates :email, format: { with: URI::MailTo::EMAIL_REGEXP } -end - -# Usage -params = { - name: "John Doe", - email: "john@example.com", - address: { street: "123 Main St", city: "New York", postal_code: "10001" } -} - -user_params = UserParams.new(params) -user_params.valid? # => true -user_params.address.city # => "New York" -user_params.attributes # => Hash ready for ActiveRecord -``` +If you're interested in contributing to this project, please see [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, RBS generation, testing guidelines, and more. ## Contributing diff --git a/README_ja.md b/README_ja.md index d104e24..395dadb 100644 --- a/README_ja.md +++ b/README_ja.md @@ -11,6 +11,7 @@ StructuredParams は、Rails アプリケーションでタイプセーフなパ - **プリミティブ型とネストオブジェクトの両方に対応した配列処理** - **自動 permit リスト生成による Strong Parameters 統合** - **バリデーションとシリアライゼーションを含む ActiveModel 互換性** +- **Rails フォームヘルパーと統合された Form Object パターンのサポート** - **フラットと構造化フォーマットによる拡張エラーハンドリング** - **より良い開発体験のための RBS 型定義** @@ -34,15 +35,47 @@ class UserParams < StructuredParams::Params validates :age, numericality: { greater_than: 0 } end -# 4. コントローラーで使用 +# 4. コントローラーで使用(API向け) def create - user_params = UserParams.new(params[:user]) + user_params = UserParams.new(params) + if user_params.valid? User.create!(user_params.attributes) else render json: { errors: user_params.errors.to_hash(false, structured: true) } end end + +# 5. Form Object として使用(View統合) + +# Form Object クラスを定義 +class UserRegistrationForm < StructuredParams::Params + attribute :name, :string + attribute :email, :string + attribute :password, :string + + validates :name, presence: true + validates :email, format: { with: URI::MailTo::EMAIL_REGEXP } +end + +# コントローラーで使用 +def create + @form = UserRegistrationForm.new(UserRegistrationForm.permit(params)) + + if @form.valid? + User.create!(@form.attributes) + redirect_to user_path + else + render :new + end +end + +# View で使用 +<%= form_with model: @form, url: users_path do |f| %> + <%= f.text_field :name %> + <%= f.email_field :email %> + <%= f.password_field :password %> +<% end %> ``` ## ドキュメント @@ -51,42 +84,15 @@ end - **[基本的な使用方法](docs/basic-usage.md)** - パラメータクラス、ネストオブジェクト、配列 - **[バリデーション](docs/validation.md)** - ネスト構造でのActiveModelバリデーション使用 - **[Strong Parameters](docs/strong-parameters.md)** - 自動permit リスト生成 +- **[Form Object](docs/form-objects.md)** - Rails フォームヘルパーとの Form Object としての使用 - **[エラーハンドリング](docs/error-handling.md)** - フラットと構造化エラーフォーマット - **[シリアライゼーション](docs/serialization.md)** - パラメータのハッシュとJSON変換 - **[Gem比較](docs/comparison.md)** - typed_params、dry-validation、reformとの比較 +- **[コントリビューションガイド](CONTRIBUTING.md)** - 開発者向けセットアップとガイドライン -## 例 - -```ruby -class AddressParams < StructuredParams::Params - attribute :street, :string - attribute :city, :string - attribute :postal_code, :string - - validates :street, :city, :postal_code, presence: true -end +## 開発者向け -class UserParams < StructuredParams::Params - attribute :name, :string - attribute :email, :string - attribute :address, :object, value_class: AddressParams - - validates :name, presence: true - validates :email, format: { with: URI::MailTo::EMAIL_REGEXP } -end - -# 使用例 -params = { - name: "山田太郎", - email: "yamada@example.com", - address: { street: "新宿区新宿1-1-1", city: "東京都", postal_code: "160-0022" } -} - -user_params = UserParams.new(params) -user_params.valid? # => true -user_params.address.city # => "東京都" -user_params.attributes # => ActiveRecord で使用可能なハッシュ -``` +プロジェクトへの貢献に興味がある方は、[CONTRIBUTING.md](CONTRIBUTING.md) をご覧ください。開発環境のセットアップ、RBS型定義の生成方法、テストの実行方法などが記載されています。 ## コントリビューション diff --git a/docs/comparison.md b/docs/comparison.md index 53766e0..4263972 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -11,6 +11,7 @@ This document compares StructuredParams with other parameter handling gems in th | Array Handling | ✅ Typed arrays | ❌ Basic arrays | ✅ Array validation | ✅ Collection forms | | Strong Parameters | ✅ Auto-generation | ❌ Manual | ❌ Manual | ❌ Manual | | ActiveModel Integration | ✅ Full compatibility | ❌ Limited | ❌ None | ✅ Full compatibility | +| Form Helpers | ✅ form_with/form_for | ❌ None | ❌ None | ✅ Simple Form | | Error Handling | ✅ Flat & structured | ✅ Basic | ✅ Detailed | ✅ ActiveModel errors | | RBS Support | ✅ Built-in | ❌ None | ❌ None | ❌ None | @@ -111,6 +112,23 @@ class UserParams < StructuredParams::Params validates :name, presence: true end + +# StructuredParams can also be used as form objects +class UserRegistrationForm < StructuredParams::Params + attribute :name, :string + attribute :email, :string + attribute :password, :string + + validates :name, presence: true + validates :email, format: { with: URI::MailTo::EMAIL_REGEXP } +end + +# In views +<%= form_with model: @form, url: users_path do |f| %> + <%= f.text_field :name %> + <%= f.email_field :email %> + <%= f.password_field :password %> +<% end %> ``` **Advantages of StructuredParams:** @@ -119,6 +137,8 @@ end - Built-in Strong Parameters integration - Cleaner syntax for API-first applications - Better TypeScript/RBS integration +- **Can be used as form objects** with Rails form helpers (form_with/form_for) +- **Dual purpose**: Strong Parameters validation AND form object pattern ## When to Choose StructuredParams @@ -128,8 +148,10 @@ Choose **StructuredParams** when you need: 2. **Complex nested structures** with automatic casting 3. **Strong Parameters integration** without manual permit lists 4. **ActiveModel compatibility** for validations and serialization -5. **Enhanced error handling** with structured formats -6. **RBS type definitions** for better development experience +5. **Form object pattern** with Rails form helpers integration +6. **Enhanced error handling** with structured formats +7. **RBS type definitions** for better development experience +8. **Dual-purpose classes** that work as both parameter validators and form objects ## Migration Examples diff --git a/docs/form-objects.md b/docs/form-objects.md new file mode 100644 index 0000000..edb7c09 --- /dev/null +++ b/docs/form-objects.md @@ -0,0 +1,441 @@ +# Using as Form Objects + +`StructuredParams::Params` can be used as a Rails form object pattern. Integration with form helpers (`form_with`, `form_for`) makes it easy to use in views. + +## Basic Usage + +### Defining a Form Object + +```ruby +class UserRegistrationForm < StructuredParams::Params + attribute :name, :string + attribute :email, :string + attribute :password, :string + attribute :password_confirmation, :string + attribute :terms_accepted, :boolean + + validates :name, presence: true, length: { minimum: 2 } + validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :password, presence: true, length: { minimum: 8 } + validates :password_confirmation, presence: true + validates :terms_accepted, acceptance: true + + validate :passwords_match + + private + + def passwords_match + return if password == password_confirmation + + errors.add(:password_confirmation, "doesn't match password") + end +end +``` + +### Using in Controllers + +```ruby +class UsersController < ApplicationController + def new + @form = UserRegistrationForm.new({}) + end + + def create + @form = UserRegistrationForm.new(UserRegistrationForm.permit(params)) + + if @form.valid? + user = User.create!(@form.attributes.except('password_confirmation')) + redirect_to user, notice: 'User was successfully created.' + else + render :new, status: :unprocessable_entity + end + end +end + +# UserRegistrationForm.permit(params) is equivalent to: +# params.require(:user_registration).permit(UserRegistrationForm.permit_attribute_names) +``` + +### Using in Views + +```erb +<%= form_with model: @form, url: users_path do |f| %> + <% if @form.errors.any? %> +
+

<%= pluralize(@form.errors.count, "error") %> prohibited this registration:

+ +
+ <% end %> + +
+ <%= f.label :name %> + <%= f.text_field :name %> +
+ +
+ <%= f.label :email %> + <%= f.email_field :email %> +
+ +
+ <%= f.label :password %> + <%= f.password_field :password %> +
+ +
+ <%= f.label :password_confirmation %> + <%= f.password_field :password_confirmation %> +
+ +
+ <%= f.check_box :terms_accepted %> + <%= f.label :terms_accepted, "I accept the terms and conditions" %> +
+ +
+ <%= f.submit "Sign up" %> +
+<% end %> +``` + +## Benefits of Form Objects + +### 1. Separation from Models + +Using form objects allows you to separate validation logic from models. + +```ruby +# Model focuses on persistence +class User < ApplicationRecord + has_secure_password +end + +# Form object handles form-specific validations +class UserRegistrationForm < StructuredParams::Params + attribute :name, :string + attribute :email, :string + attribute :password, :string + attribute :password_confirmation, :string + + validates :password_confirmation, presence: true + validate :passwords_match +end +``` + +### 2. Combining Multiple Models + +You can easily create forms that handle multiple models together. + +```ruby +class UserProfileForm < StructuredParams::Params + attribute :user_name, :string + attribute :user_email, :string + attribute :profile, :object, value_class: ProfileAttributes + + validates :user_name, presence: true + validates :user_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + + def save + return false unless valid? + + ActiveRecord::Base.transaction do + user = User.create!(name: user_name, email: user_email) + Profile.create!(profile.attributes.merge(user: user)) + end + + true + end +end + +class ProfileAttributes < StructuredParams::Params + attribute :bio, :string + attribute :website, :string + attribute :location, :string + + validates :website, format: { with: URI::DEFAULT_PARSER.make_regexp }, allow_blank: true +end +``` + +### 3. Nested Forms + +Forms with nested attributes are also easy to handle. + +```ruby +class OrderForm < StructuredParams::Params + attribute :product_name, :string + attribute :quantity, :integer + attribute :shipping_address, :object, value_class: AddressForm + attribute :billing_address, :object, value_class: AddressForm + + validates :product_name, presence: true + validates :quantity, numericality: { greater_than: 0 } +end + +class AddressForm < StructuredParams::Params + attribute :street, :string + attribute :city, :string + attribute :postal_code, :string + attribute :country, :string + + validates :street, :city, :postal_code, :country, presence: true +end +``` + +## Class Name Conventions + +`StructuredParams::Params` automatically removes the following suffixes from class names: + +- `Parameters` (plural) +- `Parameter` (singular) +- `Form` + +```ruby +UserRegistrationForm.model_name.name # => "UserRegistration" +UserRegistrationForm.model_name.param_key # => "user_registration" +UserParameters.model_name.name # => "User" +``` + +### Nested Modules + +When defined within a module, the namespace is preserved: + +```ruby +module Admin + class UserForm < StructuredParams::Params + attribute :name, :string + end +end + +Admin::UserForm.model_name.name # => "Admin::User" +Admin::UserForm.model_name.param_key # => "admin_user" +Admin::UserForm.model_name.route_key # => "admin_users" +``` + +## i18n Support + +Form objects are integrated with Rails' i18n system. + +### Setting Up Translation Files + +```yaml +# config/locales/ja.yml +ja: + activemodel: + models: + user_registration: "ユーザー登録" + attributes: + user_registration: + name: "名前" + email: "メールアドレス" + password: "パスワード" + password_confirmation: "パスワード(確認)" + terms_accepted: "利用規約への同意" + errors: + models: + user_registration: + attributes: + password_confirmation: + confirmation: "パスワードが一致しません" +``` + +### Using in Views + +```erb +<%= form_with model: @form, url: users_path do |f| %> +

<%= @form.model_name.human %>

+ + <%= f.label :name %> + <%= f.text_field :name %> + + <%= f.label :email %> + <%= f.email_field :email %> +<% end %> +``` + +## API Integration + +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)) + + if @form.valid? + user = User.create!(@form.attributes) + render json: user, status: :created + else + render json: { errors: @form.errors }, status: :unprocessable_entity + end + end +end +``` + +## Strong Parameters Integration + +Form objects are automatically integrated with Strong Parameters. + +```ruby +class UsersController < ApplicationController + def create + # permit method automatically executes require and permit + @form = UserRegistrationForm.new(UserRegistrationForm.permit(params)) + + if @form.valid? + # Save to database + user = User.create!(@form.attributes) + redirect_to user + else + render :new + end + end +end + +# If you want manual control +class UsersController < ApplicationController + def create + permitted_params = params.require(:user_registration).permit( + UserRegistrationForm.permit_attribute_names + ) + + @form = UserRegistrationForm.new(permitted_params) + + if @form.valid? + user = User.create!(@form.attributes) + redirect_to user + else + render :new + end + end +end +``` + +## Testing + +Form object tests can be easily written with standard RSpec. + +```ruby +RSpec.describe UserRegistrationForm do + describe 'validations' do + it 'is valid with valid attributes' do + form = UserRegistrationForm.new( + name: 'John Doe', + email: 'john@example.com', + password: 'password123', + password_confirmation: 'password123', + terms_accepted: true + ) + + expect(form).to be_valid + end + + it 'is invalid without a name' do + form = UserRegistrationForm.new(name: '') + expect(form).not_to be_valid + expect(form.errors[:name]).to be_present + end + + it 'is invalid with a short password' do + form = UserRegistrationForm.new(password: 'short') + expect(form).not_to be_valid + expect(form.errors[:password]).to be_present + end + end + + describe '#save' do + it 'creates a user when valid' do + form = UserRegistrationForm.new(valid_attributes) + + expect { + form.save + }.to change(User, :count).by(1) + end + end +end +``` + +## Best Practices + +### 1. Implementing the save Method + +Implementing a `save` method in the form object helps keep controllers simple. + +```ruby +class UserRegistrationForm < StructuredParams::Params + attribute :name, :string + attribute :email, :string + attribute :password, :string + + validates :name, :email, :password, presence: true + + def save + return false unless valid? + + User.create!(attributes.except('password_confirmation')) + end +end + +# Controller +def create + @form = UserRegistrationForm.new(user_params) + + if @form.save + redirect_to root_path, notice: 'Successfully registered!' + else + render :new + end +end +``` + +### 2. Using Transactions + +Use transactions when creating multiple models. + +```ruby +def save + return false unless valid? + + ActiveRecord::Base.transaction do + user = User.create!(name: name, email: email) + Profile.create!(bio: bio, user: user) + end + + true +rescue ActiveRecord::RecordInvalid + false +end +``` + +### 3. Conditional Validations + +You can implement validations based on state. + +```ruby +class UserUpdateForm < StructuredParams::Params + attribute :name, :string + attribute :email, :string + attribute :password, :string + attribute :current_password, :string + + validates :name, :email, presence: true + validates :current_password, presence: true, if: :password_change? + validates :password, length: { minimum: 8 }, if: :password_change? + + private + + def password_change? + password.present? + end +end +``` + +## Related Documentation + +- [Basic Usage](basic-usage.md) +- [Validation](validation.md) +- [Error Handling](error-handling.md) +- [Strong Parameters](strong-parameters.md) diff --git a/docs/strong-parameters.md b/docs/strong-parameters.md index dd9e318..d7f19e9 100644 --- a/docs/strong-parameters.md +++ b/docs/strong-parameters.md @@ -1,6 +1,65 @@ # Strong Parameters Integration -StructuredParams automatically generates permit lists for Strong Parameters: +StructuredParams provides flexible ways to handle Strong Parameters for different use cases: + +## 1. API Requests (simple) + +For API endpoints, simply pass `params` directly: + +```ruby +class Api::V1::UsersController < ApplicationController + def create + # Simply pass params - unpermitted params are automatically filtered + user_params = UserParams.new(params) + + if user_params.valid? + user = User.create!(user_params.attributes) + render json: user, status: :created + else + render json: { errors: user_params.errors.to_hash }, status: :unprocessable_entity + end + end +end + +# Example request body: +# { "name": "John", "email": "john@example.com", "age": 30 } +``` + +**Note:** `StructuredParams::Params` automatically extracts only defined attributes from unpermitted `ActionController::Parameters`, providing the same protection as Strong Parameters without explicit `permit` calls. + +**Alternative (explicit):** If you prefer to be explicit, you can use `permit` with `require: false`: + +```ruby +# Explicit permit (optional) +user_params = UserParams.new(UserParams.permit(params, require: false)) +``` + +## 2. Form Objects (with require) + +For web forms, use `permit` with default `require: true`: + +```ruby +class UsersController < ApplicationController + def create + # permit with require - expects params[:user_registration] + @form = UserRegistrationForm.new(UserRegistrationForm.permit(params)) + + if @form.valid? + user = User.create!(@form.attributes) + redirect_to user, notice: 'User was successfully created.' + else + render :new, status: :unprocessable_entity + end + end +end + +# Example form submission: +# params = { user_registration: { name: "John", email: "john@example.com" } } +``` + +## 3. Manual Control (Traditional - Backward Compatible) + +If you need more control, you can use `permit_attribute_names` directly: ```ruby class UsersController < ApplicationController @@ -20,6 +79,8 @@ end # [:name, :age, :email, { address: [:street, :city, :postal_code] }, { hobbies: [:name, :level] }] ``` +**Note:** Both approaches are fully supported and maintain backward compatibility. Existing code using `permit_attribute_names` will continue to work without any changes. + ## Automatic Permit List Generation The `permit_attribute_names` method automatically generates the correct structure for nested objects and arrays: @@ -41,26 +102,100 @@ UserParams.permit_attribute_names Here's a typical controller pattern using StructuredParams: +### API Controller (Simple) + ```ruby -class UsersController < ApplicationController +class Api::V1::UsersController < ApplicationController def create - user_params = build_user_params + user_params = UserParams.new(params) if user_params.valid? user = User.create!(user_params.attributes) - render json: UserSerializer.new(user), status: :created + render json: user, status: :created else - render json: { - errors: user_params.errors.to_hash(false, structured: true) - }, status: :unprocessable_entity + render json: { errors: user_params.errors.to_hash }, status: :unprocessable_entity end end - - private - - def build_user_params - permitted_params = params.require(:user).permit(*UserParams.permit_attribute_names) - UserParams.new(permitted_params) + + def update + user = User.find(params[:id]) + user_params = UserParams.new(params) + + if user_params.valid? && user.update(user_params.attributes) + render json: user + else + render json: { errors: user_params.errors.to_hash }, status: :unprocessable_entity + end end end ``` + +### Form Object Controller (With require) + +```ruby +class UsersController < ApplicationController + def create + @form = UserRegistrationForm.new(UserRegistrationForm.permit(params)) + + if @form.valid? + user = User.create!(@form.attributes) + redirect_to user, notice: 'User was successfully created.' + else + render :new, status: :unprocessable_entity + end + end +end +``` + +## How `permit` determines the parameter key + +The `permit` method uses `model_name.param_key` to determine which key to require: + +```ruby +UserParams.permit(params) +# Internally calls: params.require(:user).permit(...) + +UserRegistrationForm.permit(params) +# Internally calls: params.require(:user_registration).permit(...) + +Admin::UserForm.permit(params) +# Internally calls: params.require(:admin_user).permit(...) +``` + +See [Form Objects](form-objects.md) for more details about `model_name` customization. + +## When to use `permit` method? + +### Use `UserParams.new(params)` (Recommended for API) +- ✅ **Simple and clean** - No boilerplate code +- ✅ **Automatic filtering** - Unpermitted attributes are automatically filtered +- ✅ **Same protection** - Provides the same security as Strong Parameters + +```ruby +# API endpoint - Recommended +user_params = UserParams.new(params) +``` + +### Use `permit` method (Required for Form Objects) +- ✅ **Required for form helpers** - When using `form_with`/`form_for` in views +- ✅ **Nested param extraction** - Automatically extracts from nested structure like `params[:user_registration]` +- ✅ **Explicit about intent** - Makes it clear you're using Strong Parameters + +```ruby +# Form object - Required +@form = UserRegistrationForm.new(UserRegistrationForm.permit(params)) + +# API with explicit permit - Optional but acceptable +user_params = UserParams.new(UserParams.permit(params, require: false)) +``` + +### Use `permit_attribute_names` (Manual control) +- ✅ **Custom permit logic** - When you need to add extra fields +- ✅ **Backward compatibility** - For existing codebases +- ✅ **Fine-grained control** - When integrating with complex Strong Parameters code + +```ruby +# Custom permit logic +permitted = params.require(:user).permit(*UserParams.permit_attribute_names, :custom_field) +user_params = UserParams.new(permitted) +``` diff --git a/lefthook.yml b/lefthook.yml index 6b0611d..0f5272b 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,7 +1,7 @@ prepare-commit-msg: commands: rbs-inline: - run: bundle exec rbs-inline --output=sig + run: bundle exec rbs-inline --output=sig lib/**/*.rb pre-commit: commands: diff --git a/lib/structured_params/params.rb b/lib/structured_params/params.rb index c8e8b7e..689fe95 100644 --- a/lib/structured_params/params.rb +++ b/lib/structured_params/params.rb @@ -4,13 +4,48 @@ 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 (API requests) + # 2. Form objects (View integration with form_with/form_for) + # + # Strong Parameters example (API): + # class UserParams < StructuredParams::Params # attribute :name, :string - # attribute :address, :object, value_class: AddressParameter - # attribute :hobbies, :array, value_class: HobbyParameter + # attribute :address, :object, value_class: AddressParams + # attribute :hobbies, :array, value_class: HobbyParams # attribute :tags, :array, value_type: :string # end + # + # # In controller: + # user_params = UserParams.new(params) + # if user_params.valid? + # User.create!(user_params.attributes) + # else + # render json: { errors: user_params.errors } + # end + # + # Form object example (View integration): + # 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(UserRegistrationForm.permit(params)) + # if @form.valid? + # User.create!(@form.attributes) + # redirect_to user_path + # else + # render :new + # 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 @@ -18,7 +53,27 @@ class Params # @rbs @errors: ::StructuredParams::Errors? class << self - # @rbs @structured_attributes: Hash[Symbol, singleton(::StructuredParams::Params)]? + # @rbs self.@structured_attributes: Hash[Symbol, singleton(::StructuredParams::Params)]? + # @rbs 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 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 +89,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 +149,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 +205,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/rbs_collection.lock.yaml b/rbs_collection.lock.yaml index e0c04a9..906d309 100644 --- a/rbs_collection.lock.yaml +++ b/rbs_collection.lock.yaml @@ -33,6 +33,14 @@ gems: revision: 14112a82013860a7d2e5246b4c4f36fbb8c349dc remote: https://github.com/ruby/gem_rbs_collection.git repo_dir: gems +- name: ast + version: '2.4' + source: + type: git + name: ruby/gem_rbs_collection + revision: 9bf2eebb1c54b5d6f23f2acb65d4c36f195b4783 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems - name: base64 version: 0.3.0 source: @@ -49,6 +57,14 @@ gems: revision: 14112a82013860a7d2e5246b4c4f36fbb8c349dc remote: https://github.com/ruby/gem_rbs_collection.git repo_dir: gems +- name: binding_of_caller + version: '1.0' + source: + type: git + name: ruby/gem_rbs_collection + revision: 9bf2eebb1c54b5d6f23f2acb65d4c36f195b4783 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems - name: date version: '0' source: @@ -81,6 +97,26 @@ gems: version: '0' source: type: stdlib +- name: parser + version: '3.2' + source: + type: git + name: ruby/gem_rbs_collection + revision: 9bf2eebb1c54b5d6f23f2acb65d4c36f195b4783 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: prism + version: 1.9.0 + source: + type: rubygems +- name: rspec-parameterized-core + version: 2.0.1 + source: + type: rubygems +- name: rspec-parameterized-table_syntax + version: 2.1.1 + source: + type: rubygems - name: securerandom version: '0' source: diff --git a/sig/structured_params/params.rbs b/sig/structured_params/params.rbs index e22342a..1af4b63 100644 --- a/sig/structured_params/params.rbs +++ b/sig/structured_params/params.rbs @@ -3,13 +3,48 @@ 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 (API requests) + # 2. Form objects (View integration with form_with/form_for) + # + # Strong Parameters example (API): + # class UserParams < StructuredParams::Params # attribute :name, :string - # attribute :address, :object, value_class: AddressParameter - # attribute :hobbies, :array, value_class: HobbyParameter + # attribute :address, :object, value_class: AddressParams + # attribute :hobbies, :array, value_class: HobbyParams # attribute :tags, :array, value_type: :string # end + # + # # In controller: + # user_params = UserParams.new(params) + # if user_params.valid? + # User.create!(user_params.attributes) + # else + # render json: { errors: user_params.errors } + # end + # + # Form object example (View integration): + # 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(UserRegistrationForm.permit(params)) + # if @form.valid? + # User.create!(@form.attributes) + # redirect_to user_path + # else + # render :new + # 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 @@ -19,10 +54,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 +101,21 @@ 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 + # : () -> 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] @@ -54,16 +133,12 @@ module StructuredParams def validate_structured_parameters: () -> void # Validate structured arrays - # @rbs attr_name: Symbol - # @rbs array_value: Array[untyped] - # @rbs return: void - def validate_structured_array: (Symbol attr_name, Array[untyped] array_value) -> void + # : (Symbol, Array[untyped]) -> void + def validate_structured_array: (Symbol, Array[untyped]) -> void # Validate structured objects - # @rbs attr_name: Symbol - # @rbs object_value: ::StructuredParams::Params - # @rbs return: void - def validate_structured_object: (Symbol attr_name, ::StructuredParams::Params object_value) -> void + # : (Symbol, StructuredParams::Params) -> void + def validate_structured_object: (Symbol, StructuredParams::Params) -> void # Format error path using dot notation (always consistent) # : (Symbol, Integer?) -> String 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..83432ae --- /dev/null +++ b/spec/support/test_classes.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true +# rbs_inline: enabled + +require 'uri' + +# 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