diff --git a/README.md b/README.md index d895bac..2b62dc7 100644 --- a/README.md +++ b/README.md @@ -11,238 +11,83 @@ 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 +- **Enhanced error handling** with flat and structured formats - **RBS type definitions** for better development experience -## Installation - -Add this line to your application's Gemfile: +## Quick Start ```ruby +# 1. Install the gem gem 'structured_params' -``` - -And then execute: - -```bash -$ bundle install -``` - -Or install it yourself as: - -```bash -$ gem install structured_params -``` - -## Setup -Register the custom types in your Rails application: - -```ruby -# config/initializers/structured_params.rb +# 2. Register types in initializer StructuredParams.register_types -``` - -This registers `:object` and `:array` types with ActiveModel::Type. - -## Usage -### Basic Parameter Class - -```ruby +# 3. Define parameter classes class UserParams < StructuredParams::Params attribute :name, :string attribute :age, :integer - attribute :email, :string + attribute :address, :object, value_class: AddressParams + attribute :hobbies, :array, value_class: HobbyParams validates :name, presence: true - validates :email, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :age, numericality: { greater_than: 0 } end -# Usage in controller +# 4. Use in controllers def create user_params = UserParams.new(params[:user]) if user_params.valid? User.create!(user_params.attributes) else - render json: { errors: user_params.errors } + render json: { errors: user_params.errors.to_hash(false, structured: true) } end end ``` -### Nested Objects +## Documentation + +- **[Installation and Setup](docs/installation.md)** - Getting started with StructuredParams +- **[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 +- **[Error Handling](docs/error-handling.md)** - Flat and structured error formats +- **[Serialization](docs/serialization.md)** - Converting parameters to hashes and JSON +- **[Advanced Usage](docs/advanced-usage.md)** - Type introspection, performance tips, and more + +## Example ```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 # Usage params = { name: "John Doe", - address: { - street: "123 Main St", - city: "New York", - postal_code: "10001" - } + email: "john@example.com", + address: { street: "123 Main St", city: "New York", postal_code: "10001" } } user_params = UserParams.new(params) -user_params.address # => AddressParams instance +user_params.valid? # => true user_params.address.city # => "New York" +user_params.attributes # => Hash ready for ActiveRecord ``` -### Arrays - -#### Array of Primitive Types - -```ruby -class UserParams < StructuredParams::Params - attribute :tags, :array, value_type: :string - attribute :scores, :array, value_type: :integer -end - -# Usage -params = { - tags: ["ruby", "rails", "programming"], - scores: [85, 92, 78] -} - -user_params = UserParams.new(params) -user_params.tags # => ["ruby", "rails", "programming"] -user_params.scores # => [85, 92, 78] -``` - -#### Array of Nested Objects - -```ruby -class HobbyParams < StructuredParams::Params - attribute :name, :string - attribute :level, :string -end - -class UserParams < StructuredParams::Params - attribute :name, :string - attribute :hobbies, :array, value_class: HobbyParams -end - -# Usage -params = { - name: "Alice", - hobbies: [ - { name: "Photography", level: "beginner" }, - { name: "Cooking", level: "intermediate" } - ] -} - -user_params = UserParams.new(params) -user_params.hobbies # => [HobbyParams, HobbyParams] -user_params.hobbies.first.name # => "Photography" -``` - -### Strong Parameters Integration - -StructuredParams automatically generates permit lists for Strong Parameters: - -```ruby -class UsersController < ApplicationController - def create - permitted_params = params.require(:user).permit(*UserParams.permit_attribute_names) - user_params = UserParams.new(permitted_params) - - if user_params.valid? - User.create!(user_params.attributes) - else - render json: { errors: user_params.errors } - end - end -end - -# UserParams.permit_attribute_names returns: -# [:name, :age, :email, { address: [:street, :city, :postal_code] }, { hobbies: [:name, :level] }] -``` - -### Validation - -Since StructuredParams inherits from ActiveModel, you can use all ActiveModel validations: - -```ruby -class UserParams < StructuredParams::Params - attribute :name, :string - attribute :age, :integer - attribute :email, :string - attribute :address, :object, value_class: AddressParams - - validates :name, presence: true, length: { minimum: 2 } - validates :age, presence: true, numericality: { greater_than: 0 } - validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } - validates :address, presence: true - - validate :custom_validation - - private - - def custom_validation - errors.add(:age, "must be adult") if age && age < 18 - end -end -``` - -### Serialization - -```ruby -user_params = UserParams.new(params) -user_params.attributes # => Hash with all attributes -user_params.to_json # => JSON string -``` - -## Advanced Usage - -### Custom Type Registration - -If you want to avoid potential naming conflicts, you can register types with custom names: - -```ruby -# Register with custom names -StructuredParams.register_types_as( - object_name: :structured_object, - array_name: :structured_array -) - -# Then use in your parameter classes -class UserParams < StructuredParams::Params - attribute :address, :structured_object, value_class: AddressParams - attribute :hobbies, :structured_array, value_class: HobbyParams -end -``` - -### Type Introspection - -```ruby -user_params = UserParams.new(params) - -# Check attribute types -UserParams.attribute_types[:name].type # => :string -UserParams.attribute_types[:address].type # => :object -UserParams.attribute_types[:hobbies].type # => :array - -# Access nested value classes -UserParams.attribute_types[:address].value_class # => AddressParams -UserParams.attribute_types[:hobbies].value_class # => HobbyParams -``` - -## Development - -After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. - -To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). - ## Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/Syati/structured_params. diff --git a/README_ja.md b/README_ja.md index 91a2c9e..d12e63e 100644 --- a/README_ja.md +++ b/README_ja.md @@ -11,238 +11,83 @@ StructuredParams は、Rails アプリケーションでタイプセーフなパ - **プリミティブ型とネストオブジェクトの両方に対応した配列処理** - **自動 permit リスト生成による Strong Parameters 統合** - **バリデーションとシリアライゼーションを含む ActiveModel 互換性** +- **フラットと構造化フォーマットによる拡張エラーハンドリング** - **より良い開発体験のための RBS 型定義** -## インストール - -Gemfile に以下の行を追加してください: +## クイックスタート ```ruby +# 1. gem をインストール gem 'structured_params' -``` - -そして実行: - -```bash -$ bundle install -``` - -または手動でインストール: - -```bash -$ gem install structured_params -``` - -## セットアップ -Rails アプリケーションでカスタム型を登録します: - -```ruby -# config/initializers/structured_params.rb +# 2. イニ��ャライザで型を登録 StructuredParams.register_types -``` - -これにより `:object` と `:array` 型が ActiveModel::Type に登録されます。 - -## 使用方法 -### 基本的なパラメータクラス - -```ruby +# 3. パラメータクラスを定義 class UserParams < StructuredParams::Params attribute :name, :string attribute :age, :integer - attribute :email, :string + attribute :address, :object, value_class: AddressParams + attribute :hobbies, :array, value_class: HobbyParams validates :name, presence: true - validates :email, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :age, numericality: { greater_than: 0 } end -# コントローラーでの使用 +# 4. コントローラーで使用 def create user_params = UserParams.new(params[:user]) if user_params.valid? User.create!(user_params.attributes) else - render json: { errors: user_params.errors } + render json: { errors: user_params.errors.to_hash(false, structured: true) } end end ``` -### ネストしたオブジェクト +## ドキュメント + +- **[インストールとセットアップ](docs/installation.md)** - StructuredParams の始め方 +- **[基本的な使用方法](docs/basic-usage.md)** - パラメータクラス、ネストオブジェクト、配列 +- **[バリデーション](docs/validation.md)** - ネスト構造での ActiveModel バリデーション +- **[Strong Parameters](docs/strong-parameters.md)** - 自動 permit リスト生成 +- **[エラーハンドリング](docs/error-handling.md)** - フラットと構造化エラーフォーマット +- **[シリアライゼーション](docs/serialization.md)** - パラメータのハッシュ・JSON変換 +- **[高度な使用方法](docs/advanced-usage.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: "山田太郎", - address: { - street: "新宿区新宿1-1-1", - city: "東京都", - postal_code: "160-0022" - } + email: "yamada@example.com", + address: { street: "新宿区新宿1-1-1", city: "東京都", postal_code: "160-0022" } } user_params = UserParams.new(params) -user_params.address # => AddressParams インスタンス +user_params.valid? # => true user_params.address.city # => "東京都" +user_params.attributes # => ActiveRecord で使用可能なハッシュ ``` -### 配列 - -#### プリミティブ型の配列 - -```ruby -class UserParams < StructuredParams::Params - attribute :tags, :array, value_type: :string - attribute :scores, :array, value_type: :integer -end - -# 使用例 -params = { - tags: ["ruby", "rails", "programming"], - scores: [85, 92, 78] -} - -user_params = UserParams.new(params) -user_params.tags # => ["ruby", "rails", "programming"] -user_params.scores # => [85, 92, 78] -``` - -#### ネストオブジェクトの配列 - -```ruby -class HobbyParams < StructuredParams::Params - attribute :name, :string - attribute :level, :string -end - -class UserParams < StructuredParams::Params - attribute :name, :string - attribute :hobbies, :array, value_class: HobbyParams -end - -# 使用例 -params = { - name: "佐藤花子", - hobbies: [ - { name: "写真", level: "初心者" }, - { name: "料理", level: "中級者" } - ] -} - -user_params = UserParams.new(params) -user_params.hobbies # => [HobbyParams, HobbyParams] -user_params.hobbies.first.name # => "写真" -``` - -### Strong Parameters 統合 - -StructuredParams は Strong Parameters 用の permit リストを自動生成します: - -```ruby -class UsersController < ApplicationController - def create - permitted_params = params.require(:user).permit(*UserParams.permit_attribute_names) - user_params = UserParams.new(permitted_params) - - if user_params.valid? - User.create!(user_params.attributes) - else - render json: { errors: user_params.errors } - end - end -end - -# UserParams.permit_attribute_names は以下を返します: -# [:name, :age, :email, { address: [:street, :city, :postal_code] }, { hobbies: [:name, :level] }] -``` - -### バリデーション - -StructuredParams は ActiveModel を継承しているため、すべての ActiveModel バリデーションを使用できます: - -```ruby -class UserParams < StructuredParams::Params - attribute :name, :string - attribute :age, :integer - attribute :email, :string - attribute :address, :object, value_class: AddressParams - - validates :name, presence: true, length: { minimum: 2 } - validates :age, presence: true, numericality: { greater_than: 0 } - validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } - validates :address, presence: true - - validate :custom_validation - - private - - def custom_validation - errors.add(:age, "成人である必要があります") if age && age < 18 - end -end -``` - -### シリアライゼーション - -```ruby -user_params = UserParams.new(params) -user_params.attributes # => すべての属性を含むハッシュ -user_params.to_json # => JSON 文字列 -``` - -## 高度な使用方法 - -### カスタム型登録 - -潜在的な命名衝突を避けたい場合、カスタム名で型を登録できます: - -```ruby -# カスタム名で登録 -StructuredParams.register_types_as( - object_name: :structured_object, - array_name: :structured_array -) - -# パラメータクラスで使用 -class UserParams < StructuredParams::Params - attribute :address, :structured_object, value_class: AddressParams - attribute :hobbies, :structured_array, value_class: HobbyParams -end -``` - -### 型の内省 - -```ruby -user_params = UserParams.new(params) - -# 属性の型を確認 -UserParams.attribute_types[:name].type # => :string -UserParams.attribute_types[:address].type # => :object -UserParams.attribute_types[:hobbies].type # => :array - -# ネストした value_class にアクセス -UserParams.attribute_types[:address].value_class # => AddressParams -UserParams.attribute_types[:hobbies].value_class # => HobbyParams -``` - -## 開発 - -リポジトリをチェックアウト後、`bin/setup` を実行して依存関係をインストールしてください。その後、`rake spec` でテストを実行できます。また、`bin/console` で対話的なプロンプトを使用して実験することもできます。 - -ローカルマシンにこの gem をインストールするには、`bundle exec rake install` を実行してください。新しいバージョンをリリースするには、`version.rb` でバージョン番号を更新し、`bundle exec rake release` を実行してください。これにより、バージョンの git タグが作成され、git コミットとタグがプッシュされ、`.gem` ファイルが [rubygems.org](https://rubygems.org) にプッシュされます。 - ## コントリビューション バグレポートやプルリクエストは GitHub の https://github.com/Syati/structured_params で歓迎しています。 diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md new file mode 100644 index 0000000..142cd53 --- /dev/null +++ b/docs/advanced-usage.md @@ -0,0 +1,131 @@ +# Advanced Usage + +## Type Introspection + +You can inspect the types and structure of your parameter classes: + +```ruby +user_params = UserParams.new(params) + +# Check attribute types +UserParams.attribute_types[:name].type # => :string +UserParams.attribute_types[:address].type # => :object +UserParams.attribute_types[:hobbies].type # => :array + +# Access nested value classes +UserParams.attribute_types[:address].value_class # => AddressParams +UserParams.attribute_types[:hobbies].value_class # => HobbyParams +``` + +## Custom Type Registration + +For advanced scenarios, you can register custom types: + +```ruby +class CustomParams < StructuredParams::Params + # Register with custom names to avoid conflicts + attribute :config, :structured_object, value_class: ConfigParams + attribute :items, :structured_array, value_class: ItemParams +end +``` + +## Conditional Validation + +You can implement complex validation logic: + +```ruby +class UserParams < StructuredParams::Params + attribute :user_type, :string + attribute :company_name, :string + attribute :personal_info, :object, value_class: PersonalInfoParams + + validates :user_type, inclusion: { in: %w[individual business] } + validates :company_name, presence: true, if: :business_user? + validates :personal_info, presence: true, if: :individual_user? + + private + + def business_user? + user_type == 'business' + end + + def individual_user? + user_type == 'individual' + end +end +``` + +## Dynamic Attribute Definition + +For cases where you need dynamic attributes: + +```ruby +class ConfigParams < StructuredParams::Params + # Define attributes dynamically based on configuration + def self.define_config_attributes(config_schema) + config_schema.each do |field_name, field_type| + attribute field_name.to_sym, field_type + end + end +end + +# Usage +ConfigParams.define_config_attributes({ + 'api_key' => :string, + 'timeout' => :integer, + 'enabled' => :boolean +}) +``` + +## Performance Considerations + +### Permit List Caching + +For better performance, cache permit lists: + +```ruby +class UserParams < StructuredParams::Params + # ... attribute definitions + + def self.cached_permit_names + @cached_permit_names ||= permit_attribute_names.freeze + end +end + +# In controller +def user_params + @user_params ||= begin + permitted = params.require(:user).permit(*UserParams.cached_permit_names) + UserParams.new(permitted) + end +end +``` + +### Memory Optimization + +For large nested structures, consider lazy loading: + +```ruby +class LargeDataParams < StructuredParams::Params + attribute :metadata, :object, value_class: MetadataParams + attribute :large_dataset, :array, value_class: DataPointParams + + # Only validate what's necessary + validates :metadata, presence: true + + private + + def validate_large_dataset + return unless large_dataset&.any? + + # Validate only first few items for performance + large_dataset.first(10).each_with_index do |item, index| + next if item.valid? + + item.errors.each do |error| + errors.add("large_dataset.#{index}.#{error.attribute}", error.message) + end + end + end +end +``` diff --git a/docs/basic-usage.md b/docs/basic-usage.md new file mode 100644 index 0000000..45c13d3 --- /dev/null +++ b/docs/basic-usage.md @@ -0,0 +1,101 @@ +# Basic Usage + +## Basic Parameter Class + +```ruby +class UserParams < StructuredParams::Params + attribute :name, :string + attribute :age, :integer + attribute :email, :string + + validates :name, presence: true + validates :email, format: { with: URI::MailTo::EMAIL_REGEXP } +end + +# Usage in controller +def create + user_params = UserParams.new(params[:user]) + if user_params.valid? + User.create!(user_params.attributes) + else + render json: { errors: user_params.errors } + end +end +``` + +## Nested Objects + +```ruby +class AddressParams < StructuredParams::Params + attribute :street, :string + attribute :city, :string + attribute :postal_code, :string +end + +class UserParams < StructuredParams::Params + attribute :name, :string + attribute :address, :object, value_class: AddressParams +end + +# Usage +params = { + name: "John Doe", + address: { + street: "123 Main St", + city: "New York", + postal_code: "10001" + } +} + +user_params = UserParams.new(params) +user_params.address # => AddressParams instance +user_params.address.city # => "New York" +``` + +## Arrays + +### Array of Primitive Types + +```ruby +class UserParams < StructuredParams::Params + attribute :tags, :array, value_type: :string + attribute :scores, :array, value_type: :integer +end + +# Usage +params = { + tags: ["ruby", "rails", "programming"], + scores: [85, 92, 78] +} + +user_params = UserParams.new(params) +user_params.tags # => ["ruby", "rails", "programming"] +user_params.scores # => [85, 92, 78] +``` + +### Array of Nested Objects + +```ruby +class HobbyParams < StructuredParams::Params + attribute :name, :string + attribute :level, :string +end + +class UserParams < StructuredParams::Params + attribute :name, :string + attribute :hobbies, :array, value_class: HobbyParams +end + +# Usage +params = { + name: "Alice", + hobbies: [ + { name: "Photography", level: "beginner" }, + { name: "Cooking", level: "intermediate" } + ] +} + +user_params = UserParams.new(params) +user_params.hobbies # => [HobbyParams, HobbyParams] +user_params.hobbies.first.name # => "Photography" +``` diff --git a/docs/error-handling.md b/docs/error-handling.md new file mode 100644 index 0000000..e83e8eb --- /dev/null +++ b/docs/error-handling.md @@ -0,0 +1,104 @@ +# Error Handling + +StructuredParams provides enhanced error handling for nested structures with a custom `Errors` class that supports both flat and structured error formats: + +## Basic Error Access + +```ruby +user_params = UserParams.new(invalid_params) +user_params.valid? # => false + +# Standard error access (flat structure with dot notation) +user_params.errors.to_hash +# => { :name => ["can't be blank"], :'address.postal_code' => ["can't be blank"] } + +# Full error messages +user_params.errors.full_messages +# => ["Name can't be blank", "Address postal code can't be blank"] +``` + +## Structured Error Format + +For better integration with frontend applications, you can get errors in a nested structure: + +```ruby +# Get errors in structured format (symbol keys) +user_params.errors.to_hash(false, structured: true) +# => { +# :name => ["can't be blank"], +# :address => { :postal_code => ["can't be blank"] }, +# :hobbies => { :'0' => { :name => ["can't be blank"] } } +# } + +# With full error messages +user_params.errors.to_hash(true, structured: true) +# => { +# :name => ["Name can't be blank"], +# :address => { :postal_code => ["Address postal code can't be blank"] } +# } +``` + +## Custom Error Key Formatting + +You can transform error keys using standard Ruby methods for different output formats: + +```ruby +# JSON Pointer format +user_params.errors.to_hash.transform_keys { |key| "/#{key.to_s.gsub('.', '/')}" } +# => { "/name" => ["can't be blank"], "/address/postal_code" => ["can't be blank"] } + +# Uppercase format +user_params.errors.to_hash.transform_keys(&:upcase) +# => { "NAME" => ["can't be blank"], "ADDRESS.POSTAL_CODE" => ["can't be blank"] } + +# Custom prefix +user_params.errors.to_hash.transform_keys { |key| "field_#{key}" } +# => { "field_name" => ["can't be blank"], "field_address.postal_code" => ["can't be blank"] } +``` + +## API Response Examples + +### JSON API Format + +```ruby +class UsersController < ApplicationController + def create + user_params = UserParams.new(params[:user]) + + if user_params.valid? + User.create!(user_params.attributes) + render json: { success: true } + else + # Choose the error format that best fits your frontend needs + render json: { + errors: user_params.errors.to_hash(false, structured: true), + success: false + }, status: :unprocessable_entity + end + end +end +``` + +### JSON:API Compliant Format + +```ruby +def create + user_params = UserParams.new(params[:user]) + + if user_params.valid? + # ... success handling + else + # Transform to JSON:API errors format + json_api_errors = user_params.errors.to_hash.map do |field, messages| + messages.map do |message| + { + source: { pointer: "/data/attributes/#{field.to_s.gsub('.', '/')}" }, + detail: message + } + end + end.flatten + + render json: { errors: json_api_errors }, status: :unprocessable_entity + end +end +``` diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..f15dbf9 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,50 @@ +# Installation and Setup + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'structured_params' +``` + +And then execute: + +```bash +$ bundle install +``` + +Or install it yourself as: + +```bash +$ gem install structured_params +``` + +## Setup + +Register the custom types in your Rails application: + +```ruby +# config/initializers/structured_params.rb +StructuredParams.register_types +``` + +This registers `:object` and `:array` types with ActiveModel::Type. + +### Custom Type Registration + +If you want to avoid potential naming conflicts, you can register types with custom names: + +```ruby +# Register with custom names +StructuredParams.register_types_as( + object_name: :structured_object, + array_name: :structured_array +) + +# Then use in your parameter classes +class UserParams < StructuredParams::Params + attribute :address, :structured_object, value_class: AddressParams + attribute :hobbies, :structured_array, value_class: HobbyParams +end +``` diff --git a/docs/serialization.md b/docs/serialization.md new file mode 100644 index 0000000..5f413f1 --- /dev/null +++ b/docs/serialization.md @@ -0,0 +1,83 @@ +# Serialization + +StructuredParams provides multiple ways to serialize your parameter objects: + +## Basic Serialization + +```ruby +user_params = UserParams.new(params) +user_params.attributes # => Hash with all attributes +user_params.to_json # => JSON string +``` + +## Attributes Method + +The `attributes` method returns a hash representation of all attributes, with nested objects properly serialized: + +```ruby +user_params = UserParams.new({ + name: "John Doe", + address: { street: "123 Main St", city: "New York" }, + hobbies: [ + { name: "Photography", level: "beginner" }, + { name: "Cooking", level: "intermediate" } + ] +}) + +user_params.attributes +# => { +# "name" => "John Doe", +# "address" => { "street" => "123 Main St", "city" => "New York" }, +# "hobbies" => [ +# { "name" => "Photography", "level" => "beginner" }, +# { "name" => "Cooking", "level" => "intermediate" } +# ] +# } +``` + +## Symbol vs String Keys + +By default, `attributes` returns string keys. You can get symbol keys instead: + +```ruby +user_params.attributes(symbolize: false) # Default: string keys +user_params.attributes(symbolize: true) # Symbol keys +``` + +## JSON Serialization + +StructuredParams integrates with Rails' JSON serialization: + +```ruby +user_params.to_json +# => JSON string representation + +user_params.as_json +# => Hash ready for JSON serialization +``` + +## Integration with ActiveRecord + +You can easily pass StructuredParams attributes to ActiveRecord models: + +```ruby +class UsersController < ApplicationController + def create + user_params = UserParams.new(params[:user]) + + if user_params.valid? + # Direct attribute passing + user = User.create!(user_params.attributes) + + # Or with specific attributes + user = User.new + user.assign_attributes(user_params.attributes.except('internal_field')) + user.save! + + render json: user + else + render json: { errors: user_params.errors }, status: :unprocessable_entity + end + end +end +``` diff --git a/docs/strong-parameters.md b/docs/strong-parameters.md new file mode 100644 index 0000000..dd9e318 --- /dev/null +++ b/docs/strong-parameters.md @@ -0,0 +1,66 @@ +# Strong Parameters Integration + +StructuredParams automatically generates permit lists for Strong Parameters: + +```ruby +class UsersController < ApplicationController + def create + permitted_params = params.require(:user).permit(*UserParams.permit_attribute_names) + user_params = UserParams.new(permitted_params) + + if user_params.valid? + User.create!(user_params.attributes) + else + render json: { errors: user_params.errors } + end + end +end + +# UserParams.permit_attribute_names returns: +# [:name, :age, :email, { address: [:street, :city, :postal_code] }, { hobbies: [:name, :level] }] +``` + +## Automatic Permit List Generation + +The `permit_attribute_names` method automatically generates the correct structure for nested objects and arrays: + +```ruby +class UserParams < StructuredParams::Params + attribute :name, :string + attribute :age, :integer + attribute :address, :object, value_class: AddressParams + attribute :hobbies, :array, value_class: HobbyParams + attribute :tags, :array, value_type: :string +end + +UserParams.permit_attribute_names +# => [:name, :age, { address: [:street, :city, :postal_code] }, { hobbies: [:name, :level] }, { tags: [] }] +``` + +## Controller Pattern + +Here's a typical controller pattern using StructuredParams: + +```ruby +class UsersController < ApplicationController + def create + user_params = build_user_params + + if user_params.valid? + user = User.create!(user_params.attributes) + render json: UserSerializer.new(user), status: :created + else + render json: { + errors: user_params.errors.to_hash(false, structured: true) + }, status: :unprocessable_entity + end + end + + private + + def build_user_params + permitted_params = params.require(:user).permit(*UserParams.permit_attribute_names) + UserParams.new(permitted_params) + end +end +``` diff --git a/docs/validation.md b/docs/validation.md new file mode 100644 index 0000000..694e413 --- /dev/null +++ b/docs/validation.md @@ -0,0 +1,60 @@ +# Validation + +Since StructuredParams inherits from ActiveModel, you can use all ActiveModel validations: + +```ruby +class UserParams < StructuredParams::Params + attribute :name, :string + attribute :age, :integer + attribute :email, :string + attribute :address, :object, value_class: AddressParams + + validates :name, presence: true, length: { minimum: 2 } + validates :age, presence: true, numericality: { greater_than: 0 } + validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :address, presence: true + + validate :custom_validation + + private + + def custom_validation + errors.add(:age, "must be adult") if age && age < 18 + end +end +``` + +## Nested Validation + +Validation automatically cascades to nested objects and arrays: + +```ruby +class AddressParams < StructuredParams::Params + attribute :street, :string + attribute :city, :string + attribute :postal_code, :string + + validates :street, presence: true + validates :city, presence: true + validates :postal_code, presence: true, format: { with: /\A\d{5}\z/ } +end + +class HobbyParams < StructuredParams::Params + attribute :name, :string + attribute :level, :string + + validates :name, presence: true + validates :level, inclusion: { in: %w[beginner intermediate advanced] } +end + +class UserParams < StructuredParams::Params + attribute :name, :string + attribute :address, :object, value_class: AddressParams + attribute :hobbies, :array, value_class: HobbyParams + + validates :name, presence: true + validates :address, presence: true +end +``` + +When you call `valid?` on the parent object, it automatically validates all nested objects and arrays. Errors from nested objects are aggregated with dot notation (e.g., `address.postal_code`, `hobbies.0.name`). diff --git a/lib/structured_params.rb b/lib/structured_params.rb index 0c6931f..8311ea2 100644 --- a/lib/structured_params.rb +++ b/lib/structured_params.rb @@ -1,3 +1,4 @@ +# rbs_inline: enabled # frozen_string_literal: true require 'active_model' @@ -7,6 +8,9 @@ # version require_relative 'structured_params/version' +# errors +require_relative 'structured_params/errors' + # types (load first for module definition) require_relative 'structured_params/type/object' require_relative 'structured_params/type/array' @@ -17,13 +21,15 @@ # Main module module StructuredParams # Helper method to register types + #: () -> void def self.register_types ActiveModel::Type.register(:object, StructuredParams::Type::Object) ActiveModel::Type.register(:array, StructuredParams::Type::Array) end # Helper method to register types with custom names - def self.register_types_as(object_name: :object, array_name: :array) + #: (object_name: Symbol, array_name: Symbol) -> void + def self.register_types_as(object_name:, array_name:) ActiveModel::Type.register(object_name, StructuredParams::Type::Object) ActiveModel::Type.register(array_name, StructuredParams::Type::Array) end diff --git a/lib/structured_params/errors.rb b/lib/structured_params/errors.rb new file mode 100644 index 0000000..cac5360 --- /dev/null +++ b/lib/structured_params/errors.rb @@ -0,0 +1,68 @@ +# rbs_inline: enabled +# frozen_string_literal: true + +# rubocop:disable Style/OptionalBooleanParameter +module StructuredParams + # Custom errors collection that handles nested attribute names + class Errors < ActiveModel::Errors + # Override to_hash to maintain compatibility with ActiveModel::Errors by default + # Add structured option to get nested structure for dot-notation attributes + #: (?bool, ?structured: false) -> Hash[Symbol, String] + #: (?bool, structured: bool) -> Hash[Symbol, untyped] + def to_hash(full_messages = false, structured: false) + if structured + attribute_messages_hash = build_attribute_messages_hash(full_messages) + build_nested_hash({}, attribute_messages_hash) + else + # Use default ActiveModel::Errors behavior + super(full_messages) + end + end + + # Override as_json to support structured option + # This maintains compatibility with ActiveModel::Errors while adding structured functionality + #: (?{ full_messages?: bool, structured?: bool }?) -> Hash[Symbol, untyped] + def as_json(options = nil) + options ||= {} + to_hash(options.fetch(:full_messages, false), + structured: options.fetch(:structured, false)) + end + + # Override messages to support structured option + # This maintains compatibility with ActiveModel::Errors while adding structured functionality + #: (?structured: bool) -> Hash[Symbol, untyped] + def messages(structured: false) + hash = to_hash(false, structured: structured) + hash.default = [].freeze + hash.freeze + hash + end + + private + + # Build a hash with attribute names as keys and their error messages as values + # This is used for to_hash(structured: true) + #: (bool) -> Hash[Symbol, Array[String]] + def build_attribute_messages_hash(full_messages = false) + message_method = full_messages ? :full_message : :message + + group_by_attribute.transform_values do |error_list| + error_list.map(&message_method) + end + end + + # Build a nested hash structure from flat dot-notation keys + # Converts "address.postal_code" to {address: {postal_code: value}} + #: (Hash[untyped, untyped], Hash[Symbol, Array[String]], ?String) -> Hash[Symbol, untyped] + def build_nested_hash(target_hash, flat_hash, separator = '.') + flat_hash.each_with_object(target_hash) do |(key, value), result| + *prefix, last = key.to_s.split(separator) + # Navigate/create nested structure and use symbols for keys + prefix.reduce(result) do |hash, k| + hash[k.to_sym] ||= {} + end[last.to_sym] = value + end + end + end +end +# rubocop:enable Style/OptionalBooleanParameter diff --git a/lib/structured_params/params.rb b/lib/structured_params/params.rb index 77c5009..4cd46da 100644 --- a/lib/structured_params/params.rb +++ b/lib/structured_params/params.rb @@ -15,14 +15,18 @@ class Params include ActiveModel::Model include ActiveModel::Attributes + # @rbs @errors: ::StructuredParams::Errors? + class << self + # @rbs self.@structured_attributes: Hash[Symbol, singleton(::StructuredParams::Params)]? + # Generate permitted parameter structure for Strong Parameters #: () -> Array[untyped] def permit_attribute_names attribute_types.map do |name, type| name = name.to_sym - if type.is_a?(StructuredParams::Type::Object) || type.is_a?(StructuredParams::Type::Array) + if type.is_a?(Type::Object) || type.is_a?(Type::Array) { name => type.permit_attribute_names } else name @@ -30,39 +34,51 @@ def permit_attribute_names end end - # Get names of StructuredParams attributes (object and array types) - #: () { (String) -> void } -> void - def each_structured_attribute_name - attribute_types.each do |name, type| - yield name if structured_params_type?(type) + # Get structured attributes and their classes + #: () -> Hash[Symbol, singleton(::StructuredParams::Params)] + def structured_attributes + @structured_attributes ||= attribute_types.each_with_object({}) do |(name, type), hash| + next unless structured_params_type?(type) + + hash[name] = if type.is_a?(Type::Array) + type.item_type.value_class + else + type.value_class + end end end private # Determine if the specified type is a StructuredParams type - #: (untyped) -> bool + #: (ActiveModel::Type::Value) -> bool def structured_params_type?(type) - type.is_a?(StructuredParams::Type::Object) || - (type.is_a?(StructuredParams::Type::Array) && type.item_type_is_structured_params_object?) + type.is_a?(Type::Object) || + (type.is_a?(Type::Array) && type.item_type_is_structured_params_object?) end end # Integrate validation of structured objects validate :validate_structured_parameters - #: (untyped) -> void + #: (Hash[untyped, untyped]|::ActionController::Parameters) -> void def initialize(params) processed_params = process_input_parameters(params) super(**processed_params) end + #: () -> ::StructuredParams::Errors + def errors + @errors ||= Errors.new(self) + end + # Convert structured objects to Hash and get attributes - #: (symbolize: bool) -> Hash[untyped, untyped] + #: (symbolize: true) -> Hash[Symbol, untyped] + #: (symbolize: false) -> Hash[String, untyped] def attributes(symbolize: false) attrs = super() - self.class.each_structured_attribute_name do |name| + self.class.structured_attributes.each_key do |name| value = attrs[name.to_s] attrs[name.to_s] = serialize_structured_value(value) end @@ -89,21 +105,23 @@ def process_input_parameters(params) # Execute structured parameter validation #: () -> void def validate_structured_parameters - self.class.each_structured_attribute_name do |attr_name| - value = attribute(attr_name) + self.class.structured_attributes.each_key do |name| + value = attribute(name) next if value.blank? case value when Array - validate_structured_array(attr_name, value) + validate_structured_array(name, value) else - validate_structured_object(attr_name, value) + validate_structured_object(name, value) end end end # Validate structured arrays - #: (String, Array[untyped]) -> void + # @rbs attr_name: Symbol + # @rbs array_value: Array[untyped] + # @rbs return: void def validate_structured_array(attr_name, array_value) array_value.each_with_index do |item, index| next if item.valid?(validation_context) @@ -114,7 +132,9 @@ def validate_structured_array(attr_name, array_value) end # Validate structured objects - #: (String, StructuredParams::Params) -> void + # @rbs attr_name: Symbol + # @rbs object_value: ::StructuredParams::Params + # @rbs return: void def validate_structured_object(attr_name, object_value) return if object_value.valid?(validation_context) @@ -123,21 +143,13 @@ def validate_structured_object(attr_name, object_value) end # Format error path using dot notation (always consistent) - #: (String, Integer?) -> String + #: (Symbol, Integer?) -> String def format_error_path(attr_name, index = nil) path_parts = [attr_name] path_parts << index.to_s if index path_parts.join('.') end - # Integrate structured parameter errors into parent errors - #: (untyped, String) -> void - def import_structured_errors(structured_errors, prefix) - structured_errors.each do |error| - errors.import(error, attribute: :"#{prefix}.#{error.attribute}") - end - end - # Serialize structured values #: (untyped) -> untyped def serialize_structured_value(value) @@ -150,5 +162,15 @@ def serialize_structured_value(value) value end end + + # Integrate structured parameter errors into parent errors + #: (untyped, String) -> void + def import_structured_errors(structured_errors, prefix) + structured_errors.each do |error| + # Create dotted attribute path and import normally + error_attribute = "#{prefix}.#{error.attribute}" + errors.import(error, attribute: error_attribute.to_sym) + end + end end end diff --git a/sig/structured_params.rbs b/sig/structured_params.rbs new file mode 100644 index 0000000..ac67ded --- /dev/null +++ b/sig/structured_params.rbs @@ -0,0 +1,12 @@ +# Generated from lib/structured_params.rb with RBS::Inline + +# Main module +module StructuredParams + # Helper method to register types + # : () -> void + def self.register_types: () -> void + + # Helper method to register types with custom names + # : (object_name: Symbol, array_name: Symbol) -> void + def self.register_types_as: (object_name: Symbol, array_name: Symbol) -> void +end diff --git a/sig/structured_params/errors.rbs b/sig/structured_params/errors.rbs new file mode 100644 index 0000000..b415827 --- /dev/null +++ b/sig/structured_params/errors.rbs @@ -0,0 +1,36 @@ +# Generated from lib/structured_params/errors.rb with RBS::Inline + +# rubocop:disable Style/OptionalBooleanParameter +module StructuredParams + # Custom errors collection that handles nested attribute names + class Errors < ActiveModel::Errors + # Override to_hash to maintain compatibility with ActiveModel::Errors by default + # Add structured option to get nested structure for dot-notation attributes + # : (?bool, ?structured: false) -> Hash[Symbol, String] + # : (?bool, structured: bool) -> Hash[Symbol, untyped] + def to_hash: (?bool, ?structured: false) -> Hash[Symbol, String] + | (?bool, structured: bool) -> Hash[Symbol, untyped] + + # Override as_json to support structured option + # This maintains compatibility with ActiveModel::Errors while adding structured functionality + # : (?{ full_messages?: bool, structured?: bool }?) -> Hash[Symbol, untyped] + def as_json: (?{ :full_messages? => bool, :structured? => bool }?) -> Hash[Symbol, untyped] + + # Override messages to support structured option + # This maintains compatibility with ActiveModel::Errors while adding structured functionality + # : (?structured: bool) -> Hash[Symbol, untyped] + def messages: (?structured: bool) -> Hash[Symbol, untyped] + + private + + # Build a hash with attribute names as keys and their error messages as values + # This is used for to_hash(structured: true) + # : (bool) -> Hash[Symbol, Array[String]] + def build_attribute_messages_hash: (bool) -> Hash[Symbol, Array[String]] + + # Build a nested hash structure from flat dot-notation keys + # Converts "address.postal_code" to {address: {postal_code: value}} + # : (Hash[untyped, untyped], Hash[Symbol, Array[String]], ?String) -> Hash[Symbol, untyped] + def build_nested_hash: (Hash[untyped, untyped], Hash[Symbol, Array[String]], ?String) -> Hash[Symbol, untyped] + end +end diff --git a/sig/structured_params/params.rbs b/sig/structured_params/params.rbs index 1e066dc..cfabe34 100644 --- a/sig/structured_params/params.rbs +++ b/sig/structured_params/params.rbs @@ -1,12 +1,12 @@ # Generated from lib/structured_params/params.rb with RBS::Inline module StructuredParams - # Parameter model that supports nested structures + # Parameter model that supports structured objects and arrays # # Usage example: # class UserParameter < StructuredParams::Params # attribute :name, :string - # attribute :address, :nested, value_class: AddressParameter + # attribute :address, :object, value_class: AddressParameter # attribute :hobbies, :array, value_class: HobbyParameter # attribute :tags, :array, value_type: :string # end @@ -15,24 +15,33 @@ module StructuredParams include ActiveModel::Attributes + @errors: ::StructuredParams::Errors? + + self.@structured_attributes: Hash[Symbol, singleton(::StructuredParams::Params)]? + # Generate permitted parameter structure for Strong Parameters # : () -> Array[untyped] def self.permit_attribute_names: () -> Array[untyped] - # Get names of nested StructuredParams attributes - # : () { (String) -> void } -> void - def self.each_nested_attribute_name: () { (String) -> void } -> void + # Get structured attributes and their classes + # : () -> Hash[Symbol, singleton(::StructuredParams::Params)] + def self.structured_attributes: () -> Hash[Symbol, singleton(::StructuredParams::Params)] - # Determine if the specified type is a nested parameter type - # : (untyped) -> bool - private def self.structured_params_type?: (untyped) -> bool + # Determine if the specified type is a StructuredParams type + # : (ActiveModel::Type::Value) -> bool + private def self.structured_params_type?: (ActiveModel::Type::Value) -> bool - # : (untyped) -> void - def initialize: (untyped) -> void + # : (Hash[untyped, untyped]|::ActionController::Parameters) -> void + def initialize: (Hash[untyped, untyped] | ::ActionController::Parameters) -> void - # Convert nested objects to Hash and get attributes - # : (symbolize: bool) -> Hash[untyped, untyped] - def attributes: (symbolize: bool) -> Hash[untyped, untyped] + # : () -> ::StructuredParams::Errors + def errors: () -> ::StructuredParams::Errors + + # Convert structured objects to Hash and get attributes + # : (symbolize: true) -> Hash[Symbol, untyped] + # : (symbolize: false) -> Hash[String, untyped] + def attributes: (symbolize: true) -> Hash[Symbol, untyped] + | (symbolize: false) -> Hash[String, untyped] private @@ -40,28 +49,32 @@ module StructuredParams # : (untyped) -> Hash[untyped, untyped] def process_input_parameters: (untyped) -> Hash[untyped, untyped] - # Execute nested parameter validation + # Execute structured parameter validation # : () -> void - def validate_nested_parameters: () -> void + def validate_structured_parameters: () -> void - # Validate nested arrays - # : (String, Array[untyped]) -> void - def validate_nested_array: (String, Array[untyped]) -> 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 - # Validate nested objects - # : (String, StructuredParams::Params) -> void - def validate_nested_object: (String, StructuredParams::Params) -> 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 # Format error path using dot notation (always consistent) - # : (String, Integer?) -> String - def format_error_path: (String, Integer?) -> String + # : (Symbol, Integer?) -> String + def format_error_path: (Symbol, Integer?) -> String - # Integrate nested errors into parent errors - # : (untyped, String) -> void - def import_nested_errors: (untyped, String) -> void - - # Serialize nested values + # Serialize structured values # : (untyped) -> untyped - def serialize_nested_value: (untyped) -> untyped + def serialize_structured_value: (untyped) -> untyped + + # Integrate structured parameter errors into parent errors + # : (untyped, String) -> void + def import_structured_errors: (untyped, String) -> void end end diff --git a/spec/errors_spec.rb b/spec/errors_spec.rb new file mode 100644 index 0000000..a23d234 --- /dev/null +++ b/spec/errors_spec.rb @@ -0,0 +1,354 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe StructuredParams::Errors do + let(:errors) { build(:user_parameter).errors } + + before { errors.clear } + + describe '#to_hash' do + subject(:errors_to_hash) { errors.to_hash(option_full_messages, structured: option_structured) } + + let(:option_full_messages) { false } + let(:option_structured) { false } + + context 'with default behavior (structured: false)' do + before do + errors.add('name', "can't be blank") + errors.add('address.postal_code', "can't be blank") + errors.add('hobbies.0.name', 'is required') + end + + it 'returns flat structure like standard ActiveModel::Errors' do + expect(errors_to_hash).to eq({ + name: ["can't be blank"], + 'address.postal_code': ["can't be blank"], + 'hobbies.0.name': ['is required'] + }) + end + + context 'with full_messages = true' do + let(:option_full_messages) { true } + + it 'returns flat structure with full messages' do + expect(errors_to_hash[:name]).to contain_exactly("Name can't be blank") + expect(errors_to_hash[:'address.postal_code']).to contain_exactly("Address postal code can't be blank") + expect(errors_to_hash[:'hobbies.0.name']).to contain_exactly('Hobbies 0 name is required') + end + end + end + + context 'with structured option (structured: true)' do + let(:option_structured) { true } + + context 'with some nested errors' do + before do + # Add some nested errors + errors.add('name', "can't be blank") + errors.add('address.postal_code', "can't be blank") + errors.add('address.prefecture', 'is invalid') + errors.add('hobbies.0.name', "can't be blank") + errors.add('hobbies.0.level', 'is not included in the list') + errors.add('hobbies.1.name', 'is too short') + end + + context 'with full_messages = false (default)' do + it 'returns nested structure for dot-notation attributes' do + expect(errors_to_hash).to eq({ name: ["can't be blank"], + address: { + postal_code: ["can't be blank"], + prefecture: ['is invalid'] + }, + hobbies: { + '0': { + name: ["can't be blank"], + level: ['is not included in the list'] + }, + '1': { + name: ['is too short'] + } + } }) + end + end + + context 'with full_messages = true' do + let(:option_full_messages) { true } + + it 'returns nested structure with full error messages' do + # Check that full messages are used (they include attribute names) + expect(errors_to_hash[:name]).to contain_exactly("Name can't be blank") + expect(errors_to_hash[:address]).to include(postal_code: ["Address postal code can't be blank"]) + expect(errors_to_hash[:hobbies]).to include( + '0': hash_including(name: ["Hobbies 0 name can't be blank"]) + ) + end + end + end + + context 'with only flat attributes' do + before do + errors.add('name', "can't be blank") + errors.add('email', 'is invalid') + end + + it 'returns flat structure for non-nested attributes' do + expect(errors.to_hash(false, structured: true)).to eq({ + name: ["can't be blank"], + email: ['is invalid'] + }) + end + end + + context 'with deeply nested attributes' do + before do + errors.add('items.0.subitems.1.name', "can't be blank") + errors.add('items.1.subitems.0.description', 'is too long') + end + + # rubocop:disable RSpec/ExampleLength + it 'creates deep nested structure' do + expect(errors.to_hash(false, structured: true)).to eq({ + items: { + '0': { + subitems: { + '1': { + name: ["can't be blank"] + } + } + }, + '1': { + subitems: { + '0': { + description: ['is too long'] + } + } + } + } + }) + end + # rubocop:enable RSpec/ExampleLength + end + + context 'with mixed flat and nested attributes' do + before do + errors.add('name', "can't be blank") + errors.add('address.postal_code', "can't be blank") + errors.add('email', 'is invalid') + errors.add('hobbies.0.name', 'is required') + end + + it 'handles both flat and nested attributes correctly' do + expect(errors.to_hash(false, structured: true)).to eq({ + name: ["can't be blank"], + email: ['is invalid'], + address: { + postal_code: ["can't be blank"] + }, + hobbies: { + '0': { + name: ['is required'] + } + } + }) + end + end + + context 'with multiple errors on same attribute' do + before do + errors.add('address.postal_code', "can't be blank") + errors.add('address.postal_code', 'is invalid format') + errors.add('hobbies.0.name', "can't be blank") + errors.add('hobbies.0.name', 'is too short') + end + + it 'groups multiple errors for the same nested attribute' do + expect(errors.to_hash(false, structured: true)).to eq({ + address: { + postal_code: ["can't be blank", + 'is invalid format'] + }, + hobbies: { + '0': { + name: ["can't be blank", 'is too short'] + } + } + }) + end + end + + context 'with empty errors' do + it 'returns empty hash' do + expect(errors.to_hash(false, structured: true)).to eq({}) + end + end + end + end + + describe '#as_json' do + before do + errors.add('name', "can't be blank") + errors.add('address.postal_code', "can't be blank") + allow(errors).to receive(:to_hash).and_call_original + end + + context 'with default behavior (no structured option)' do + it 'uses standard ActiveModel::Errors behavior when no options provided' do + # ActiveModel::Errors.as_json calls to_hash(options && options[:full_messages]) + # When options is nil, it calls to_hash(nil), but our override calls super which handles this + result = errors.as_json + expect(result).to eq(errors.to_hash) + end + + it 'delegates to standard behavior with full_messages option' do + errors.as_json(full_messages: true) + expect(errors).to have_received(:to_hash).with(true, structured: false) + end + end + + context 'with structured option' do + it 'delegates to to_hash with structured: true' do + errors.as_json(structured: true) + expect(errors).to have_received(:to_hash).with(false, structured: true) + end + + it 'delegates to to_hash with both full_messages and structured options' do + errors.as_json(full_messages: true, structured: true) + expect(errors).to have_received(:to_hash).with(true, structured: true) + end + end + end + + describe '#messages' do + before do + errors.add('name', "can't be blank") + errors.add('address.postal_code', "can't be blank") + errors.add('hobbies.0.name', 'is required') + end + + context 'with default behavior (structured: false)' do + it 'uses standard ActiveModel::Errors behavior' do + result = errors.messages + + expect(result).to eq({ + name: ["can't be blank"], + 'address.postal_code': ["can't be blank"], + 'hobbies.0.name': ['is required'] + }) + + # Check that it has default value and is frozen + expect(result.default).to eq([].freeze) + expect(result).to be_frozen + end + end + + context 'with structured: true' do + it 'returns structured format' do + result = errors.messages(structured: true) + + expect(result).to eq({ + name: ["can't be blank"], + address: { postal_code: ["can't be blank"] }, + hobbies: { '0': { name: ['is required'] } } + }) + + # Check that it has default value and is frozen + expect(result.default).to eq([].freeze) + expect(result).to be_frozen + end + end + + context 'with empty errors' do + before { errors.clear } + + it 'returns empty frozen hash for default behavior' do + result = errors.messages + expect(result).to eq({}) + expect(result).to be_frozen + end + + it 'returns empty frozen hash for structured behavior' do + result = errors.messages(structured: true) + expect(result).to eq({}) + expect(result).to be_frozen + end + end + end + + describe '#build_nested_hash' do + let(:target_hash) { {} } + + context 'with simple nested key' do + it 'creates nested structure' do + errors.send(:build_nested_hash, target_hash, { 'address.postal_code' => ['error'] }) + + expect(target_hash).to eq({ + address: { + postal_code: ['error'] + } + }) + end + end + + context 'with array index in key' do + it 'creates structure with array index as symbol key' do + errors.send(:build_nested_hash, target_hash, { 'hobbies.0.name' => ['error'] }) + + expect(target_hash).to eq({ + hobbies: { + '0': { + name: ['error'] + } + } + }) + end + end + + context 'with deeply nested key' do + it 'creates deep nested structure' do + errors.send(:build_nested_hash, target_hash, { 'a.b.c.d.e' => ['deep error'] }) + + expect(target_hash).to eq({ + a: { + b: { + c: { + d: { + e: ['deep error'] + } + } + } + } + }) + end + end + + context 'with existing structure' do + before do + target_hash[:address] = { city: ['existing error'] } + end + + it 'preserves existing nested structure' do + errors.send(:build_nested_hash, target_hash, { 'address.postal_code' => ['new error'] }) + + expect(target_hash).to eq({ + address: { + city: ['existing error'], + postal_code: ['new error'] + } + }) + end + end + + context 'with custom separator' do + it 'uses custom separator for splitting keys' do + errors.send(:build_nested_hash, target_hash, { 'address/postal_code' => ['error'] }, '/') + + expect(target_hash).to eq({ + address: { + postal_code: ['error'] + } + }) + end + end + end +end