From 2a20b17c7cc6f56d5e823e4aeffffdd56a89f3d9 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Thu, 4 Sep 2025 15:12:23 +0900 Subject: [PATCH 01/11] Add error formatting module with JSON Pointer support --- lib/structured_params.rb | 3 ++ lib/structured_params/error_formatter.rb | 57 +++++++++++++++++++++ lib/structured_params/params.rb | 1 + spec/error_formatter_spec.rb | 65 ++++++++++++++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 lib/structured_params/error_formatter.rb create mode 100644 spec/error_formatter_spec.rb diff --git a/lib/structured_params.rb b/lib/structured_params.rb index 0c6931f..ba4cba3 100644 --- a/lib/structured_params.rb +++ b/lib/structured_params.rb @@ -7,6 +7,9 @@ # version require_relative 'structured_params/version' +# error formatter +require_relative 'structured_params/error_formatter' + # types (load first for module definition) require_relative 'structured_params/type/object' require_relative 'structured_params/type/array' diff --git a/lib/structured_params/error_formatter.rb b/lib/structured_params/error_formatter.rb new file mode 100644 index 0000000..5e62c42 --- /dev/null +++ b/lib/structured_params/error_formatter.rb @@ -0,0 +1,57 @@ +# rbs_inline: enabled +# frozen_string_literal: true + +module StructuredParams + # Error formatting functionality for StructuredParams + # Provides methods to format error messages in different formats + module ErrorFormatter + extend ActiveSupport::Concern + + # Get error messages with JSON Pointer keys + #: () -> Hash[String, Array[String]] + def messages_with_json_pointer_keys + errors.to_hash.transform_keys { |key| to_json_pointer(key.to_s) } + end + + # Get full error messages with JSON Pointer keys + #: () -> Hash[String, String] + def full_messages_with_json_pointer_keys + messages_with_json_pointer_keys.transform_values do |messages| + messages.map { |message| humanize_error_key(message) }.join(', ') + end + end + + private + + # Convert any attribute key to JSON Pointer format + # This is a general utility method that can be used for any key conversion + #: (String | Symbol) -> String + def to_json_pointer(key) + "/#{key.to_s.gsub('.', '/')}" + end + + # Convert JSON Pointer back to dot notation + #: (String) -> String + def from_json_pointer(pointer) + pointer.sub(%r{^/}, '').gsub('/', '.') + end + + # Check if a string is a valid JSON Pointer + #: (String) -> bool + def json_pointer?(string) + string.start_with?('/') + end + + # Convert attribute key to JSON Pointer format (kept for backward compatibility) + #: (String) -> String + def attribute_key_to_json_pointer(attribute_key) + to_json_pointer(attribute_key) + end + + # Humanize error key for better display + #: (String) -> String + def humanize_error_key(message) + message.humanize + end + end +end diff --git a/lib/structured_params/params.rb b/lib/structured_params/params.rb index 77c5009..464ed85 100644 --- a/lib/structured_params/params.rb +++ b/lib/structured_params/params.rb @@ -14,6 +14,7 @@ module StructuredParams class Params include ActiveModel::Model include ActiveModel::Attributes + include ErrorFormatter class << self # Generate permitted parameter structure for Strong Parameters diff --git a/spec/error_formatter_spec.rb b/spec/error_formatter_spec.rb new file mode 100644 index 0000000..3b83472 --- /dev/null +++ b/spec/error_formatter_spec.rb @@ -0,0 +1,65 @@ +# rbs_inline: enabled +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe StructuredParams::ErrorFormatter do + let(:invalid_params) do + { + name: '', + email: '', + address: { + postal_code: '', + prefecture: '', + city: 'Tokyo', + street: '' + } + } + end + + let(:user_params) { UserParameter.new(invalid_params) } + + before do + user_params.valid? + end + + describe '#messages_with_json_pointer_keys' do + it 'converts error keys to JSON Pointer format' do + result = user_params.messages_with_json_pointer_keys + + expect(result.keys).to include('/name', '/email', '/address/postal_code', '/address/prefecture') + expect(result['/name']).to eq(["can't be blank"]) + expect(result['/email']).to include("can't be blank") # emailには複数のエラーがあるのでincludeを使用 + end + end + + describe '#full_messages_with_json_pointer_keys' do + it 'returns full messages with JSON Pointer keys' do + result = user_params.full_messages_with_json_pointer_keys + + expect(result).to be_a(Hash) + expect(result.keys).to include('/name', '/email', '/address/postal_code', '/address/prefecture') + expect(result['/name']).to include("Can't be blank") # ActiveModelは自動的に大文字で始める + end + end + + # Private methods are tested indirectly through public methods + describe 'private utility methods' do + it 'converts dot notation to JSON Pointer format through public methods' do + result = user_params.messages_with_json_pointer_keys + + # Check that dot notation keys are properly converted to JSON Pointer format + expect(result.keys).to all(start_with('/')) + expect(result.keys).to include('/address/postal_code', '/address/prefecture') + end + + it 'handles nested structures correctly' do + result = user_params.messages_with_json_pointer_keys + + # Verify that nested address errors use JSON Pointer format + nested_keys = result.keys.select { |key| key.include?('/address/') } + expect(nested_keys).not_to be_empty + expect(nested_keys).to all(match(%r{^/address/\w+$})) + end + end +end From 36d3206b587a752f6c56199462fb8c970ba9697e Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Thu, 4 Sep 2025 19:33:29 +0900 Subject: [PATCH 02/11] Refactor structured parameter handling to use updated type references --- lib/structured_params/params.rb | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/lib/structured_params/params.rb b/lib/structured_params/params.rb index 464ed85..e784ec9 100644 --- a/lib/structured_params/params.rb +++ b/lib/structured_params/params.rb @@ -23,7 +23,7 @@ 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 @@ -31,11 +31,17 @@ 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 + #: return [Hash[Symbol, StructuredParams::Type]] + 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 @@ -44,8 +50,8 @@ def each_structured_attribute_name # Determine if the specified type is a StructuredParams type #: (untyped) -> 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 @@ -63,7 +69,7 @@ def initialize(params) 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 @@ -90,15 +96,15 @@ 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 From d7a9c4117ce7755c7e31c1c21b8a6ccf6ddcff7a Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 5 Sep 2025 12:20:49 +0900 Subject: [PATCH 03/11] Add custom error handling for nested attributes in StructuredParams --- lib/structured_params.rb | 9 +- lib/structured_params/errors.rb | 36 +++++ lib/structured_params/params.rb | 47 +++--- sig/structured_params.rbs | 12 ++ sig/structured_params/errors.rbs | 19 +++ sig/structured_params/params.rbs | 71 ++++++---- spec/errors_spec.rb | 236 +++++++++++++++++++++++++++++++ 7 files changed, 382 insertions(+), 48 deletions(-) create mode 100644 lib/structured_params/errors.rb create mode 100644 sig/structured_params.rbs create mode 100644 sig/structured_params/errors.rbs create mode 100644 spec/errors_spec.rb diff --git a/lib/structured_params.rb b/lib/structured_params.rb index ba4cba3..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,8 +8,8 @@ # version require_relative 'structured_params/version' -# error formatter -require_relative 'structured_params/error_formatter' +# errors +require_relative 'structured_params/errors' # types (load first for module definition) require_relative 'structured_params/type/object' @@ -20,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..0db7b02 --- /dev/null +++ b/lib/structured_params/errors.rb @@ -0,0 +1,36 @@ +# rbs_inline: enabled +# frozen_string_literal: true + +module StructuredParams + # Custom errors collection that handles nested attribute names + class Errors < ActiveModel::Errors + # Override to_hash to provide nested structure for dot-notation attributes + # This maintains compatibility with ActiveModel::Errors while adding nested functionality + # rubocop:disable Style/OptionalBooleanParameter + #: (?bool) -> Hash[String, String] + def to_hash(full_messages = false) + message_method = full_messages ? :full_message : :message + + # Group errors by attribute and convert to messages + group_by_attribute.each_with_object({}) do |(attribute, error_list), result| + build_nested_hash(result, [[attribute, error_list.map(&message_method)]].to_h) + end + end + # rubocop:enable Style/OptionalBooleanParameter + + private + + # Build a nested hash structure from flat dot-notation keys + # Converts "address.postal_code" to {address: {postal_code: value}} + #: (Hash[untyped, untyped], Hash[Symbol, untyped], ?String) -> Hash[String, String] + 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 + prefix.reduce(result) do |hash, k| + hash[k] ||= {} + end[last] = value + end + end + end +end diff --git a/lib/structured_params/params.rb b/lib/structured_params/params.rb index e784ec9..4cd46da 100644 --- a/lib/structured_params/params.rb +++ b/lib/structured_params/params.rb @@ -14,9 +14,12 @@ module StructuredParams class Params include ActiveModel::Model include ActiveModel::Attributes - include ErrorFormatter + + # @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 @@ -32,7 +35,7 @@ def permit_attribute_names end # Get structured attributes and their classes - #: return [Hash[Symbol, StructuredParams::Type]] + #: () -> 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) @@ -48,7 +51,7 @@ def structured_attributes private # Determine if the specified type is a StructuredParams type - #: (untyped) -> bool + #: (ActiveModel::Type::Value) -> bool def structured_params_type?(type) type.is_a?(Type::Object) || (type.is_a?(Type::Array) && type.item_type_is_structured_params_object?) @@ -58,14 +61,20 @@ def structured_params_type?(type) # 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() @@ -110,7 +119,9 @@ def validate_structured_parameters 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) @@ -121,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) @@ -130,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) @@ -157,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..e45ce4e --- /dev/null +++ b/sig/structured_params/errors.rbs @@ -0,0 +1,19 @@ +# Generated from lib/structured_params/errors.rb with RBS::Inline + +module StructuredParams + # Custom errors collection that handles nested attribute names + class Errors < ActiveModel::Errors + # Override to_hash to provide nested structure for dot-notation attributes + # This maintains compatibility with ActiveModel::Errors while adding nested functionality + # rubocop:disable Style/OptionalBooleanParameter + # : (?bool) -> Hash[String, String] + def to_hash: (?bool) -> Hash[String, String] + + private + + # Build a nested hash structure from flat dot-notation keys + # Converts "address.postal_code" to {address: {postal_code: value}} + # : (Hash[untyped, untyped], Hash[Symbol, untyped], ?String) -> Hash[String, String] + def build_nested_hash: (Hash[untyped, untyped], Hash[Symbol, untyped], ?String) -> Hash[String, String] + 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..501b805 --- /dev/null +++ b/spec/errors_spec.rb @@ -0,0 +1,236 @@ +# 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) } + + let(:option_full_messages) { false } + + 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).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).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).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).to eq({ + 'address' => { + 'postal_code' => ["can't be blank", 'is invalid format'] + }, + 'hobbies' => { + '0' => { + 'name' => ["can't be blank", 'is too short'] + } + } + }) + 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 string 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 + + context 'with flat key (no separator)' do + it 'adds key directly without nesting' do + errors.send(:build_nested_hash, target_hash, { 'name' => ['error'] }) + + expect(target_hash).to eq({ + 'name' => ['error'] + }) + end + end + end +end From 87149b5df1abc713eee517845c561929103d2725 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 5 Sep 2025 13:11:34 +0900 Subject: [PATCH 04/11] Add custom key transformation for error messages --- lib/structured_params/error_formatter.rb | 57 ------------------- lib/structured_params/errors.rb | 18 ++++++ sig/structured_params/errors.rbs | 11 ++++ spec/error_formatter_spec.rb | 65 ---------------------- spec/errors_spec.rb | 71 ++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 122 deletions(-) delete mode 100644 lib/structured_params/error_formatter.rb delete mode 100644 spec/error_formatter_spec.rb diff --git a/lib/structured_params/error_formatter.rb b/lib/structured_params/error_formatter.rb deleted file mode 100644 index 5e62c42..0000000 --- a/lib/structured_params/error_formatter.rb +++ /dev/null @@ -1,57 +0,0 @@ -# rbs_inline: enabled -# frozen_string_literal: true - -module StructuredParams - # Error formatting functionality for StructuredParams - # Provides methods to format error messages in different formats - module ErrorFormatter - extend ActiveSupport::Concern - - # Get error messages with JSON Pointer keys - #: () -> Hash[String, Array[String]] - def messages_with_json_pointer_keys - errors.to_hash.transform_keys { |key| to_json_pointer(key.to_s) } - end - - # Get full error messages with JSON Pointer keys - #: () -> Hash[String, String] - def full_messages_with_json_pointer_keys - messages_with_json_pointer_keys.transform_values do |messages| - messages.map { |message| humanize_error_key(message) }.join(', ') - end - end - - private - - # Convert any attribute key to JSON Pointer format - # This is a general utility method that can be used for any key conversion - #: (String | Symbol) -> String - def to_json_pointer(key) - "/#{key.to_s.gsub('.', '/')}" - end - - # Convert JSON Pointer back to dot notation - #: (String) -> String - def from_json_pointer(pointer) - pointer.sub(%r{^/}, '').gsub('/', '.') - end - - # Check if a string is a valid JSON Pointer - #: (String) -> bool - def json_pointer?(string) - string.start_with?('/') - end - - # Convert attribute key to JSON Pointer format (kept for backward compatibility) - #: (String) -> String - def attribute_key_to_json_pointer(attribute_key) - to_json_pointer(attribute_key) - end - - # Humanize error key for better display - #: (String) -> String - def humanize_error_key(message) - message.humanize - end - end -end diff --git a/lib/structured_params/errors.rb b/lib/structured_params/errors.rb index 0db7b02..128aad2 100644 --- a/lib/structured_params/errors.rb +++ b/lib/structured_params/errors.rb @@ -18,6 +18,24 @@ def to_hash(full_messages = false) end # rubocop:enable Style/OptionalBooleanParameter + # Get error messages with custom key transformation + # Users can provide a block to transform attribute keys + # + # Examples: + # errors.messages_with { |attr| "/#{attr.gsub('.', '/')}" } # JSON Pointer + # errors.messages_with { |attr| attr.upcase } # Uppercase keys + # errors.messages_with(true) { |attr| "custom_#{attr}" } # With full messages + # + #: (?bool) { (String) -> String } -> Hash[String, Array[String]] + def messages_with(full_messages = false) + message_method = full_messages ? :full_message : :message + + group_by_attribute.each_with_object({}) do |(attribute, error_list), result| + key = yield attribute.to_s + result[key] = error_list.map(&message_method) + end + end + private # Build a nested hash structure from flat dot-notation keys diff --git a/sig/structured_params/errors.rbs b/sig/structured_params/errors.rbs index e45ce4e..d0b80ca 100644 --- a/sig/structured_params/errors.rbs +++ b/sig/structured_params/errors.rbs @@ -9,6 +9,17 @@ module StructuredParams # : (?bool) -> Hash[String, String] def to_hash: (?bool) -> Hash[String, String] + # Get error messages with custom key transformation + # Users can provide a block to transform attribute keys + # + # Examples: + # errors.messages_with { |attr| "/#{attr.gsub('.', '/')}" } # JSON Pointer + # errors.messages_with { |attr| attr.upcase } # Uppercase keys + # errors.messages_with(true) { |attr| "custom_#{attr}" } # With full messages + # + # : (?bool) { (String) -> String } -> Hash[String, Array[String]] + def messages_with: (?bool) { (String) -> String } -> Hash[String, Array[String]] + private # Build a nested hash structure from flat dot-notation keys diff --git a/spec/error_formatter_spec.rb b/spec/error_formatter_spec.rb deleted file mode 100644 index 3b83472..0000000 --- a/spec/error_formatter_spec.rb +++ /dev/null @@ -1,65 +0,0 @@ -# rbs_inline: enabled -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe StructuredParams::ErrorFormatter do - let(:invalid_params) do - { - name: '', - email: '', - address: { - postal_code: '', - prefecture: '', - city: 'Tokyo', - street: '' - } - } - end - - let(:user_params) { UserParameter.new(invalid_params) } - - before do - user_params.valid? - end - - describe '#messages_with_json_pointer_keys' do - it 'converts error keys to JSON Pointer format' do - result = user_params.messages_with_json_pointer_keys - - expect(result.keys).to include('/name', '/email', '/address/postal_code', '/address/prefecture') - expect(result['/name']).to eq(["can't be blank"]) - expect(result['/email']).to include("can't be blank") # emailには複数のエラーがあるのでincludeを使用 - end - end - - describe '#full_messages_with_json_pointer_keys' do - it 'returns full messages with JSON Pointer keys' do - result = user_params.full_messages_with_json_pointer_keys - - expect(result).to be_a(Hash) - expect(result.keys).to include('/name', '/email', '/address/postal_code', '/address/prefecture') - expect(result['/name']).to include("Can't be blank") # ActiveModelは自動的に大文字で始める - end - end - - # Private methods are tested indirectly through public methods - describe 'private utility methods' do - it 'converts dot notation to JSON Pointer format through public methods' do - result = user_params.messages_with_json_pointer_keys - - # Check that dot notation keys are properly converted to JSON Pointer format - expect(result.keys).to all(start_with('/')) - expect(result.keys).to include('/address/postal_code', '/address/prefecture') - end - - it 'handles nested structures correctly' do - result = user_params.messages_with_json_pointer_keys - - # Verify that nested address errors use JSON Pointer format - nested_keys = result.keys.select { |key| key.include?('/address/') } - expect(nested_keys).not_to be_empty - expect(nested_keys).to all(match(%r{^/address/\w+$})) - end - end -end diff --git a/spec/errors_spec.rb b/spec/errors_spec.rb index 501b805..6169a98 100644 --- a/spec/errors_spec.rb +++ b/spec/errors_spec.rb @@ -233,4 +233,75 @@ end end end + + describe '#messages_with' 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') + errors.add('hobbies.1.level', 'is invalid') + end + + context 'with JSON Pointer transformation' do + it 'converts attribute keys to JSON Pointer format' do + result = errors.messages_with { |attr| "/#{attr.gsub('.', '/')}" } + + expect(result).to eq({ + '/name' => ["can't be blank"], + '/address/postal_code' => ["can't be blank"], + '/hobbies/0/name' => ['is required'], + '/hobbies/1/level' => ['is invalid'] + }) + end + end + + context 'with uppercase transformation' do + it 'converts attribute keys to uppercase' do + result = errors.messages_with { |attr| attr.upcase } + + expect(result).to eq({ + 'NAME' => ["can't be blank"], + 'ADDRESS.POSTAL_CODE' => ["can't be blank"], + 'HOBBIES.0.NAME' => ['is required'], + 'HOBBIES.1.LEVEL' => ['is invalid'] + }) + end + end + + context 'with custom prefix transformation' do + it 'adds custom prefix to attribute keys' do + result = errors.messages_with { |attr| "error_#{attr}" } + + expect(result).to eq({ + 'error_name' => ["can't be blank"], + 'error_address.postal_code' => ["can't be blank"], + 'error_hobbies.0.name' => ['is required'], + 'error_hobbies.1.level' => ['is invalid'] + }) + end + end + + context 'with full_messages = true' do + it 'returns full error messages with transformed keys' do + result = errors.messages_with(true) { |attr| "/#{attr.gsub('.', '/')}" } + + expect(result).to be_a(Hash) + expect(result.keys).to include('/name', '/address/postal_code', '/hobbies/0/name') + + # Check that full messages are used (they include attribute names) + expect(result['/name']).to contain_exactly("Name can't be blank") + expect(result['/address/postal_code']).to contain_exactly("Address postal code can't be blank") + expect(result['/hobbies/0/name']).to contain_exactly("Hobbies 0 name is required") + end + end + + context 'with empty errors' do + before { errors.clear } + + it 'returns empty hash' do + result = errors.messages_with { |attr| attr.upcase } + expect(result).to eq({}) + end + end + end end From 77455f4ff4ba0dfd2b9ad025ccc23a7c6ea0023f Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 5 Sep 2025 13:12:26 +0900 Subject: [PATCH 05/11] Refactor error handling to enable rubocop compliance for optional boolean parameters --- lib/structured_params/errors.rb | 4 ++-- sig/structured_params/errors.rbs | 2 +- spec/errors_spec.rb | 36 ++++++++++++++++---------------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/lib/structured_params/errors.rb b/lib/structured_params/errors.rb index 128aad2..c5170f0 100644 --- a/lib/structured_params/errors.rb +++ b/lib/structured_params/errors.rb @@ -3,10 +3,10 @@ module StructuredParams # Custom errors collection that handles nested attribute names + # rubocop:disable Style/OptionalBooleanParameter class Errors < ActiveModel::Errors # Override to_hash to provide nested structure for dot-notation attributes # This maintains compatibility with ActiveModel::Errors while adding nested functionality - # rubocop:disable Style/OptionalBooleanParameter #: (?bool) -> Hash[String, String] def to_hash(full_messages = false) message_method = full_messages ? :full_message : :message @@ -16,7 +16,6 @@ def to_hash(full_messages = false) build_nested_hash(result, [[attribute, error_list.map(&message_method)]].to_h) end end - # rubocop:enable Style/OptionalBooleanParameter # Get error messages with custom key transformation # Users can provide a block to transform attribute keys @@ -51,4 +50,5 @@ def build_nested_hash(target_hash, flat_hash, separator = '.') end end end + # rubocop:enable Style/OptionalBooleanParameter end diff --git a/sig/structured_params/errors.rbs b/sig/structured_params/errors.rbs index d0b80ca..b5a1832 100644 --- a/sig/structured_params/errors.rbs +++ b/sig/structured_params/errors.rbs @@ -2,10 +2,10 @@ module StructuredParams # Custom errors collection that handles nested attribute names + # rubocop:disable Style/OptionalBooleanParameter class Errors < ActiveModel::Errors # Override to_hash to provide nested structure for dot-notation attributes # This maintains compatibility with ActiveModel::Errors while adding nested functionality - # rubocop:disable Style/OptionalBooleanParameter # : (?bool) -> Hash[String, String] def to_hash: (?bool) -> Hash[String, String] diff --git a/spec/errors_spec.rb b/spec/errors_spec.rb index 6169a98..3f3fcab 100644 --- a/spec/errors_spec.rb +++ b/spec/errors_spec.rb @@ -247,24 +247,24 @@ result = errors.messages_with { |attr| "/#{attr.gsub('.', '/')}" } expect(result).to eq({ - '/name' => ["can't be blank"], - '/address/postal_code' => ["can't be blank"], - '/hobbies/0/name' => ['is required'], - '/hobbies/1/level' => ['is invalid'] - }) + '/name' => ["can't be blank"], + '/address/postal_code' => ["can't be blank"], + '/hobbies/0/name' => ['is required'], + '/hobbies/1/level' => ['is invalid'] + }) end end context 'with uppercase transformation' do it 'converts attribute keys to uppercase' do - result = errors.messages_with { |attr| attr.upcase } + result = errors.messages_with(&:upcase) expect(result).to eq({ - 'NAME' => ["can't be blank"], - 'ADDRESS.POSTAL_CODE' => ["can't be blank"], - 'HOBBIES.0.NAME' => ['is required'], - 'HOBBIES.1.LEVEL' => ['is invalid'] - }) + 'NAME' => ["can't be blank"], + 'ADDRESS.POSTAL_CODE' => ["can't be blank"], + 'HOBBIES.0.NAME' => ['is required'], + 'HOBBIES.1.LEVEL' => ['is invalid'] + }) end end @@ -273,11 +273,11 @@ result = errors.messages_with { |attr| "error_#{attr}" } expect(result).to eq({ - 'error_name' => ["can't be blank"], - 'error_address.postal_code' => ["can't be blank"], - 'error_hobbies.0.name' => ['is required'], - 'error_hobbies.1.level' => ['is invalid'] - }) + 'error_name' => ["can't be blank"], + 'error_address.postal_code' => ["can't be blank"], + 'error_hobbies.0.name' => ['is required'], + 'error_hobbies.1.level' => ['is invalid'] + }) end end @@ -291,7 +291,7 @@ # Check that full messages are used (they include attribute names) expect(result['/name']).to contain_exactly("Name can't be blank") expect(result['/address/postal_code']).to contain_exactly("Address postal code can't be blank") - expect(result['/hobbies/0/name']).to contain_exactly("Hobbies 0 name is required") + expect(result['/hobbies/0/name']).to contain_exactly('Hobbies 0 name is required') end end @@ -299,7 +299,7 @@ before { errors.clear } it 'returns empty hash' do - result = errors.messages_with { |attr| attr.upcase } + result = errors.messages_with(&:upcase) expect(result).to eq({}) end end From 1bea89c991d53eb99a7febdb07443e05ab61a0df Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 5 Sep 2025 13:18:27 +0900 Subject: [PATCH 06/11] Enhance to_hash method to support nested error structures --- lib/structured_params/errors.rb | 21 ++- sig/structured_params/errors.rbs | 8 +- spec/errors_spec.rb | 249 ++++++++++++++++++------------- 3 files changed, 160 insertions(+), 118 deletions(-) diff --git a/lib/structured_params/errors.rb b/lib/structured_params/errors.rb index c5170f0..9d8fe79 100644 --- a/lib/structured_params/errors.rb +++ b/lib/structured_params/errors.rb @@ -5,15 +5,20 @@ module StructuredParams # Custom errors collection that handles nested attribute names # rubocop:disable Style/OptionalBooleanParameter class Errors < ActiveModel::Errors - # Override to_hash to provide nested structure for dot-notation attributes - # This maintains compatibility with ActiveModel::Errors while adding nested functionality - #: (?bool) -> Hash[String, String] - def to_hash(full_messages = false) - message_method = full_messages ? :full_message : :message + # Override to_hash to maintain compatibility with ActiveModel::Errors by default + # Add nested option to get nested structure for dot-notation attributes + #: (?bool, ?nested: bool) -> Hash[String, Array[String]] + def to_hash(full_messages = false, nested: false) + if nested + message_method = full_messages ? :full_message : :message - # Group errors by attribute and convert to messages - group_by_attribute.each_with_object({}) do |(attribute, error_list), result| - build_nested_hash(result, [[attribute, error_list.map(&message_method)]].to_h) + # Group errors by attribute and convert to messages + group_by_attribute.each_with_object({}) do |(attribute, error_list), result| + build_nested_hash(result, [[attribute, error_list.map(&message_method)]].to_h) + end + else + # Use default ActiveModel::Errors behavior + super(full_messages) end end diff --git a/sig/structured_params/errors.rbs b/sig/structured_params/errors.rbs index b5a1832..a794329 100644 --- a/sig/structured_params/errors.rbs +++ b/sig/structured_params/errors.rbs @@ -4,10 +4,10 @@ module StructuredParams # Custom errors collection that handles nested attribute names # rubocop:disable Style/OptionalBooleanParameter class Errors < ActiveModel::Errors - # Override to_hash to provide nested structure for dot-notation attributes - # This maintains compatibility with ActiveModel::Errors while adding nested functionality - # : (?bool) -> Hash[String, String] - def to_hash: (?bool) -> Hash[String, String] + # Override to_hash to maintain compatibility with ActiveModel::Errors by default + # Add nested option to get nested structure for dot-notation attributes + # : (?bool, ?nested: bool) -> Hash[String, Array[String]] + def to_hash: (?bool, ?nested: bool) -> Hash[String, Array[String]] # Get error messages with custom key transformation # Users can provide a block to transform attribute keys diff --git a/spec/errors_spec.rb b/spec/errors_spec.rb index 3f3fcab..1368d38 100644 --- a/spec/errors_spec.rb +++ b/spec/errors_spec.rb @@ -8,141 +8,178 @@ before { errors.clear } describe '#to_hash' do - subject(:errors_to_hash) { errors.to_hash(option_full_messages) } + subject(:errors_to_hash) { errors.to_hash(option_full_messages, nested: option_nested) } let(:option_full_messages) { false } + let(:option_nested) { false } - context 'with some nested errors' do + context 'with default behavior (nested: false)' 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') + errors.add('hobbies.0.name', 'is required') 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 + 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 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"]) - ) + 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 only flat attributes' do - before do - errors.add('name', "can't be blank") - errors.add('email', 'is invalid') - end + context 'with nested option (nested: true)' do + let(:option_nested) { 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 - it 'returns flat structure for non-nested attributes' do - expect(errors.to_hash).to eq({ - 'name' => ["can't be blank"], - 'email' => ['is invalid'] - }) - end - 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 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') + 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 - # rubocop:disable RSpec/ExampleLength - it 'creates deep nested structure' do - expect(errors_to_hash).to eq({ - 'items' => { - '0' => { - 'subitems' => { - '1' => { - 'name' => ["can't be blank"] - } - } - }, - '1' => { - 'subitems' => { - '0' => { - 'description' => ['is too long'] - } - } - } - } - }) + 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, nested: true)).to eq({ + 'name' => ["can't be blank"], + 'email' => ['is invalid'] + }) + end 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') + 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, nested: 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 - it 'handles both flat and nested attributes correctly' do - expect(errors_to_hash).to eq({ - 'name' => ["can't be blank"], - 'email' => ['is invalid'], - 'address' => { - 'postal_code' => ["can't be blank"] - }, - 'hobbies' => { - '0' => { - 'name' => ['is required'] - } - } - }) + 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, nested: 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 - 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') + 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, nested: 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 - it 'groups multiple errors for the same nested attribute' do - expect(errors_to_hash).to eq({ - 'address' => { - 'postal_code' => ["can't be blank", 'is invalid format'] - }, - 'hobbies' => { - '0' => { - 'name' => ["can't be blank", 'is too short'] - } - } - }) + context 'with empty errors' do + it 'returns empty hash' do + expect(errors.to_hash(false, nested: true)).to eq({}) + end end end end From f5a679789807c0f2b73967a42c07e867c8a23f0a Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 5 Sep 2025 13:24:44 +0900 Subject: [PATCH 07/11] Refactor to_hash method to use structured option for nested error handling --- lib/structured_params/errors.rb | 8 +-- sig/structured_params/errors.rbs | 6 +- spec/errors_spec.rb | 95 ++++++++++++++++---------------- 3 files changed, 55 insertions(+), 54 deletions(-) diff --git a/lib/structured_params/errors.rb b/lib/structured_params/errors.rb index 9d8fe79..8c5f8be 100644 --- a/lib/structured_params/errors.rb +++ b/lib/structured_params/errors.rb @@ -6,10 +6,10 @@ module StructuredParams # rubocop:disable Style/OptionalBooleanParameter class Errors < ActiveModel::Errors # Override to_hash to maintain compatibility with ActiveModel::Errors by default - # Add nested option to get nested structure for dot-notation attributes - #: (?bool, ?nested: bool) -> Hash[String, Array[String]] - def to_hash(full_messages = false, nested: false) - if nested + # Add structured option to get nested structure for dot-notation attributes + #: (?bool, ?structured: bool) -> Hash[String, Array[String]] + def to_hash(full_messages = false, structured: false) + if structured message_method = full_messages ? :full_message : :message # Group errors by attribute and convert to messages diff --git a/sig/structured_params/errors.rbs b/sig/structured_params/errors.rbs index a794329..aaeb4b5 100644 --- a/sig/structured_params/errors.rbs +++ b/sig/structured_params/errors.rbs @@ -5,9 +5,9 @@ module StructuredParams # rubocop:disable Style/OptionalBooleanParameter class Errors < ActiveModel::Errors # Override to_hash to maintain compatibility with ActiveModel::Errors by default - # Add nested option to get nested structure for dot-notation attributes - # : (?bool, ?nested: bool) -> Hash[String, Array[String]] - def to_hash: (?bool, ?nested: bool) -> Hash[String, Array[String]] + # Add structured option to get nested structure for dot-notation attributes + # : (?bool, ?structured: bool) -> Hash[String, Array[String]] + def to_hash: (?bool, ?structured: bool) -> Hash[String, Array[String]] # Get error messages with custom key transformation # Users can provide a block to transform attribute keys diff --git a/spec/errors_spec.rb b/spec/errors_spec.rb index 1368d38..4d6f6e6 100644 --- a/spec/errors_spec.rb +++ b/spec/errors_spec.rb @@ -8,12 +8,12 @@ before { errors.clear } describe '#to_hash' do - subject(:errors_to_hash) { errors.to_hash(option_full_messages, nested: option_nested) } + subject(:errors_to_hash) { errors.to_hash(option_full_messages, structured: option_structured) } let(:option_full_messages) { false } - let(:option_nested) { false } + let(:option_structured) { false } - context 'with default behavior (nested: false)' do + 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") @@ -39,8 +39,8 @@ end end - context 'with nested option (nested: true)' do - let(:option_nested) { true } + context 'with structured option (structured: true)' do + let(:option_structured) { true } context 'with some nested errors' do before do @@ -93,10 +93,10 @@ end it 'returns flat structure for non-nested attributes' do - expect(errors.to_hash(false, nested: true)).to eq({ - 'name' => ["can't be blank"], - 'email' => ['is invalid'] - }) + expect(errors.to_hash(false, structured: true)).to eq({ + 'name' => ["can't be blank"], + 'email' => ['is invalid'] + }) end end @@ -108,24 +108,24 @@ # rubocop:disable RSpec/ExampleLength it 'creates deep nested structure' do - expect(errors.to_hash(false, nested: true)).to eq({ - 'items' => { - '0' => { - 'subitems' => { - '1' => { - 'name' => ["can't be blank"] - } - } - }, - '1' => { - 'subitems' => { + expect(errors.to_hash(false, structured: true)).to eq({ + 'items' => { '0' => { - 'description' => ['is too long'] + 'subitems' => { + '1' => { + 'name' => ["can't be blank"] + } + } + }, + '1' => { + 'subitems' => { + '0' => { + 'description' => ['is too long'] + } + } } } - } - } - }) + }) end # rubocop:enable RSpec/ExampleLength end @@ -139,18 +139,18 @@ end it 'handles both flat and nested attributes correctly' do - expect(errors.to_hash(false, nested: true)).to eq({ - 'name' => ["can't be blank"], - 'email' => ['is invalid'], - 'address' => { - 'postal_code' => ["can't be blank"] - }, - 'hobbies' => { - '0' => { - 'name' => ['is required'] - } - } - }) + 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 @@ -163,22 +163,23 @@ end it 'groups multiple errors for the same nested attribute' do - expect(errors.to_hash(false, nested: true)).to eq({ - 'address' => { - 'postal_code' => ["can't be blank", 'is invalid format'] - }, - 'hobbies' => { - '0' => { - 'name' => ["can't be blank", 'is too short'] - } - } - }) + 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, nested: true)).to eq({}) + expect(errors.to_hash(false, structured: true)).to eq({}) end end end From 118ed6a6db2b68b3822a472fad36847789d0bb16 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 5 Sep 2025 13:59:29 +0900 Subject: [PATCH 08/11] Refactor to_hash method to use symbols for keys in nested error structures --- lib/structured_params/errors.rb | 33 ++---- sig/structured_params/errors.rbs | 23 ++-- spec/errors_spec.rb | 189 +++++++++---------------------- 3 files changed, 69 insertions(+), 176 deletions(-) diff --git a/lib/structured_params/errors.rb b/lib/structured_params/errors.rb index 8c5f8be..76ee2e6 100644 --- a/lib/structured_params/errors.rb +++ b/lib/structured_params/errors.rb @@ -3,11 +3,12 @@ module StructuredParams # Custom errors collection that handles nested attribute names - # rubocop:disable Style/OptionalBooleanParameter 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: bool) -> Hash[String, Array[String]] + # rubocop:disable Style/OptionalBooleanParameter + #: (?bool, ?structured: false) -> Hash[Symbol, String] + #: (?bool, structured: true) -> Hash[Symbol, untyped] def to_hash(full_messages = false, structured: false) if structured message_method = full_messages ? :full_message : :message @@ -21,39 +22,21 @@ def to_hash(full_messages = false, structured: false) super(full_messages) end end - - # Get error messages with custom key transformation - # Users can provide a block to transform attribute keys - # - # Examples: - # errors.messages_with { |attr| "/#{attr.gsub('.', '/')}" } # JSON Pointer - # errors.messages_with { |attr| attr.upcase } # Uppercase keys - # errors.messages_with(true) { |attr| "custom_#{attr}" } # With full messages - # - #: (?bool) { (String) -> String } -> Hash[String, Array[String]] - def messages_with(full_messages = false) - message_method = full_messages ? :full_message : :message - - group_by_attribute.each_with_object({}) do |(attribute, error_list), result| - key = yield attribute.to_s - result[key] = error_list.map(&message_method) - end - end + # rubocop:enable Style/OptionalBooleanParameter private # Build a nested hash structure from flat dot-notation keys # Converts "address.postal_code" to {address: {postal_code: value}} - #: (Hash[untyped, untyped], Hash[Symbol, untyped], ?String) -> Hash[String, String] + #: (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 + # Navigate/create nested structure and use symbols for keys prefix.reduce(result) do |hash, k| - hash[k] ||= {} - end[last] = value + hash[k.to_sym] ||= {} + end[last.to_sym] = value end end end - # rubocop:enable Style/OptionalBooleanParameter end diff --git a/sig/structured_params/errors.rbs b/sig/structured_params/errors.rbs index aaeb4b5..6a552a7 100644 --- a/sig/structured_params/errors.rbs +++ b/sig/structured_params/errors.rbs @@ -2,29 +2,20 @@ module StructuredParams # Custom errors collection that handles nested attribute names - # rubocop:disable Style/OptionalBooleanParameter 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: bool) -> Hash[String, Array[String]] - def to_hash: (?bool, ?structured: bool) -> Hash[String, Array[String]] - - # Get error messages with custom key transformation - # Users can provide a block to transform attribute keys - # - # Examples: - # errors.messages_with { |attr| "/#{attr.gsub('.', '/')}" } # JSON Pointer - # errors.messages_with { |attr| attr.upcase } # Uppercase keys - # errors.messages_with(true) { |attr| "custom_#{attr}" } # With full messages - # - # : (?bool) { (String) -> String } -> Hash[String, Array[String]] - def messages_with: (?bool) { (String) -> String } -> Hash[String, Array[String]] + # rubocop:disable Style/OptionalBooleanParameter + # : (?bool, ?structured: false) -> Hash[Symbol, String] + # : (?bool, structured: true) -> Hash[Symbol, untyped] + def to_hash: (?bool, ?structured: false) -> Hash[Symbol, String] + | (?bool, structured: true) -> Hash[Symbol, untyped] private # Build a nested hash structure from flat dot-notation keys # Converts "address.postal_code" to {address: {postal_code: value}} - # : (Hash[untyped, untyped], Hash[Symbol, untyped], ?String) -> Hash[String, String] - def build_nested_hash: (Hash[untyped, untyped], Hash[Symbol, untyped], ?String) -> Hash[String, String] + # : (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/spec/errors_spec.rb b/spec/errors_spec.rb index 4d6f6e6..761b4ca 100644 --- a/spec/errors_spec.rb +++ b/spec/errors_spec.rb @@ -55,18 +55,18 @@ 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'] + 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'] + hobbies: { + '0': { + name: ["can't be blank"], + level: ['is not included in the list'] }, - '1' => { - 'name' => ['is too short'] + '1': { + name: ['is too short'] } } }) end @@ -77,10 +77,10 @@ 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"]) + 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 @@ -94,8 +94,8 @@ 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'] + name: ["can't be blank"], + email: ['is invalid'] }) end end @@ -109,18 +109,18 @@ # 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"] + items: { + '0': { + subitems: { + '1': { + name: ["can't be blank"] } } }, - '1' => { - 'subitems' => { - '0' => { - 'description' => ['is too long'] + '1': { + subitems: { + '0': { + description: ['is too long'] } } } @@ -140,14 +140,14 @@ 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"] + name: ["can't be blank"], + email: ['is invalid'], + address: { + postal_code: ["can't be blank"] }, - 'hobbies' => { - '0' => { - 'name' => ['is required'] + hobbies: { + '0': { + name: ['is required'] } } }) @@ -164,13 +164,13 @@ 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", + address: { + postal_code: ["can't be blank", 'is invalid format'] }, - 'hobbies' => { - '0' => { - 'name' => ["can't be blank", 'is too short'] + hobbies: { + '0': { + name: ["can't be blank", 'is too short'] } } }) @@ -193,21 +193,21 @@ errors.send(:build_nested_hash, target_hash, { 'address.postal_code' => ['error'] }) expect(target_hash).to eq({ - 'address' => { - 'postal_code' => ['error'] + address: { + postal_code: ['error'] } }) end end context 'with array index in key' do - it 'creates structure with array index as string 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'] + hobbies: { + '0': { + name: ['error'] } } }) @@ -219,11 +219,11 @@ 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'] + a: { + b: { + c: { + d: { + e: ['deep error'] } } } @@ -234,16 +234,16 @@ context 'with existing structure' do before do - target_hash['address'] = { 'city' => ['existing error'] } + 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'] + address: { + city: ['existing error'], + postal_code: ['new error'] } }) end @@ -254,92 +254,11 @@ errors.send(:build_nested_hash, target_hash, { 'address/postal_code' => ['error'] }, '/') expect(target_hash).to eq({ - 'address' => { - 'postal_code' => ['error'] + address: { + postal_code: ['error'] } }) end end - - context 'with flat key (no separator)' do - it 'adds key directly without nesting' do - errors.send(:build_nested_hash, target_hash, { 'name' => ['error'] }) - - expect(target_hash).to eq({ - 'name' => ['error'] - }) - end - end - end - - describe '#messages_with' 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') - errors.add('hobbies.1.level', 'is invalid') - end - - context 'with JSON Pointer transformation' do - it 'converts attribute keys to JSON Pointer format' do - result = errors.messages_with { |attr| "/#{attr.gsub('.', '/')}" } - - expect(result).to eq({ - '/name' => ["can't be blank"], - '/address/postal_code' => ["can't be blank"], - '/hobbies/0/name' => ['is required'], - '/hobbies/1/level' => ['is invalid'] - }) - end - end - - context 'with uppercase transformation' do - it 'converts attribute keys to uppercase' do - result = errors.messages_with(&:upcase) - - expect(result).to eq({ - 'NAME' => ["can't be blank"], - 'ADDRESS.POSTAL_CODE' => ["can't be blank"], - 'HOBBIES.0.NAME' => ['is required'], - 'HOBBIES.1.LEVEL' => ['is invalid'] - }) - end - end - - context 'with custom prefix transformation' do - it 'adds custom prefix to attribute keys' do - result = errors.messages_with { |attr| "error_#{attr}" } - - expect(result).to eq({ - 'error_name' => ["can't be blank"], - 'error_address.postal_code' => ["can't be blank"], - 'error_hobbies.0.name' => ['is required'], - 'error_hobbies.1.level' => ['is invalid'] - }) - end - end - - context 'with full_messages = true' do - it 'returns full error messages with transformed keys' do - result = errors.messages_with(true) { |attr| "/#{attr.gsub('.', '/')}" } - - expect(result).to be_a(Hash) - expect(result.keys).to include('/name', '/address/postal_code', '/hobbies/0/name') - - # Check that full messages are used (they include attribute names) - expect(result['/name']).to contain_exactly("Name can't be blank") - expect(result['/address/postal_code']).to contain_exactly("Address postal code can't be blank") - expect(result['/hobbies/0/name']).to contain_exactly('Hobbies 0 name is required') - end - end - - context 'with empty errors' do - before { errors.clear } - - it 'returns empty hash' do - result = errors.messages_with(&:upcase) - expect(result).to eq({}) - end - end end end From ce8117ea0902309ee49d187785ad4ed357d51d97 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 5 Sep 2025 14:09:37 +0900 Subject: [PATCH 09/11] Add documentation for advanced usage, error handling, serialization, and strong parameters integration --- README.md | 217 ++++++-------------------------------- README_ja.md | 217 ++++++-------------------------------- docs/advanced-usage.md | 131 +++++++++++++++++++++++ docs/basic-usage.md | 101 ++++++++++++++++++ docs/error-handling.md | 104 ++++++++++++++++++ docs/installation.md | 50 +++++++++ docs/serialization.md | 83 +++++++++++++++ docs/strong-parameters.md | 66 ++++++++++++ docs/validation.md | 60 +++++++++++ 9 files changed, 657 insertions(+), 372 deletions(-) create mode 100644 docs/advanced-usage.md create mode 100644 docs/basic-usage.md create mode 100644 docs/error-handling.md create mode 100644 docs/installation.md create mode 100644 docs/serialization.md create mode 100644 docs/strong-parameters.md create mode 100644 docs/validation.md 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`). From 7bc087f3871af606599ab1a43af5cef512b8a8f6 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 5 Sep 2025 14:58:09 +0900 Subject: [PATCH 10/11] Enhance error handling by adding structured option to to_hash, as_json, and messages methods --- lib/structured_params/errors.rb | 49 +++++++++++++---- sig/structured_params/errors.rbs | 21 ++++++-- spec/errors_spec.rb | 92 +++++++++++++++++++++++++++++++- 3 files changed, 149 insertions(+), 13 deletions(-) diff --git a/lib/structured_params/errors.rb b/lib/structured_params/errors.rb index 76ee2e6..b0f1efb 100644 --- a/lib/structured_params/errors.rb +++ b/lib/structured_params/errors.rb @@ -1,31 +1,61 @@ # 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 - # rubocop:disable Style/OptionalBooleanParameter #: (?bool, ?structured: false) -> Hash[Symbol, String] - #: (?bool, structured: true) -> Hash[Symbol, untyped] + #: (?bool, structured: bool) -> Hash[Symbol, untyped] def to_hash(full_messages = false, structured: false) if structured - message_method = full_messages ? :full_message : :message - - # Group errors by attribute and convert to messages - group_by_attribute.each_with_object({}) do |(attribute, error_list), result| - build_nested_hash(result, [[attribute, error_list.map(&message_method)]].to_h) - end + 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 - # rubocop:enable Style/OptionalBooleanParameter + + # 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) + if options&.key?(:structured) + full_messages = options[:full_messages] || false + structured = options[:structured] || false + to_hash(full_messages, structured: structured) + else + # Use default ActiveModel::Errors behavior + super + end + 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] @@ -40,3 +70,4 @@ def build_nested_hash(target_hash, flat_hash, separator = '.') end end end +# rubocop:enable Style/OptionalBooleanParameter diff --git a/sig/structured_params/errors.rbs b/sig/structured_params/errors.rbs index 6a552a7..b415827 100644 --- a/sig/structured_params/errors.rbs +++ b/sig/structured_params/errors.rbs @@ -1,18 +1,33 @@ # 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 - # rubocop:disable Style/OptionalBooleanParameter # : (?bool, ?structured: false) -> Hash[Symbol, String] - # : (?bool, structured: true) -> Hash[Symbol, untyped] + # : (?bool, structured: bool) -> Hash[Symbol, untyped] def to_hash: (?bool, ?structured: false) -> Hash[Symbol, String] - | (?bool, structured: true) -> Hash[Symbol, untyped] + | (?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] diff --git a/spec/errors_spec.rb b/spec/errors_spec.rb index 761b4ca..b5617e1 100644 --- a/spec/errors_spec.rb +++ b/spec/errors_spec.rb @@ -166,7 +166,7 @@ expect(errors.to_hash(false, structured: true)).to eq({ address: { postal_code: ["can't be blank", - 'is invalid format'] + 'is invalid format'] }, hobbies: { '0': { @@ -185,6 +185,96 @@ 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) + 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) { {} } From 2ddc265eac01301142b1cac6df40feade382c72d Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 5 Sep 2025 15:19:37 +0900 Subject: [PATCH 11/11] Refactor as_json method to simplify option handling for structured output --- lib/structured_params/errors.rb | 11 +++-------- spec/errors_spec.rb | 2 +- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/lib/structured_params/errors.rb b/lib/structured_params/errors.rb index b0f1efb..cac5360 100644 --- a/lib/structured_params/errors.rb +++ b/lib/structured_params/errors.rb @@ -23,14 +23,9 @@ def to_hash(full_messages = false, structured: false) # This maintains compatibility with ActiveModel::Errors while adding structured functionality #: (?{ full_messages?: bool, structured?: bool }?) -> Hash[Symbol, untyped] def as_json(options = nil) - if options&.key?(:structured) - full_messages = options[:full_messages] || false - structured = options[:structured] || false - to_hash(full_messages, structured: structured) - else - # Use default ActiveModel::Errors behavior - super - end + options ||= {} + to_hash(options.fetch(:full_messages, false), + structured: options.fetch(:structured, false)) end # Override messages to support structured option diff --git a/spec/errors_spec.rb b/spec/errors_spec.rb index b5617e1..a23d234 100644 --- a/spec/errors_spec.rb +++ b/spec/errors_spec.rb @@ -202,7 +202,7 @@ 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) + expect(errors).to have_received(:to_hash).with(true, structured: false) end end