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:
+
+ <% @form.errors.full_messages.each do |message| %>
+ - <%= message %>
+ <% end %>
+
+
+ <% 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