diff --git a/lib/typed/coercion/struct_coercer.rb b/lib/typed/coercion/struct_coercer.rb index 7665869..ae53ed7 100644 --- a/lib/typed/coercion/struct_coercer.rb +++ b/lib/typed/coercion/struct_coercer.rb @@ -21,7 +21,41 @@ def coerce(type:, value:) return Failure.new(CoercionError.new("Value of type '#{value.class}' cannot be coerced to #{type} Struct.")) unless value.is_a?(Hash) - Success.new(T.cast(type, T::Types::Simple).raw_type.from_hash!(HashTransformer.new.deep_stringify_keys(value))) + values = {} + + type = T.cast(type, T::Types::Simple) + + type.raw_type.props.each do |name, prop| + attribute_type = prop[:type_object] + value = HashTransformer.new.deep_symbolize_keys(value) + + if value[name].nil? + # if the value is nil but the type is nilable, no need to coerce + next if attribute_type.try(:valid?, value[name]) + + return Typed::Failure.new(CoercionError.new("#{name} is required but nil given")) + end + + # now that we've done the nil check, we can unwrap the nilable type to get the raw type + simple_attribute_type = attribute_type.unwrap_nilable if attribute_type.respond_to?(:unwrap_nilable) + + # if the prop is a struct, we need to recursively coerce it + if simple_attribute_type.try(:raw_type).respond_to?(:props) + Typed::HashSerializer + .new(schema: Typed::Schema.from_struct(simple_attribute_type.raw_type)) + .deserialize(value[name]) + .and_then { |struct| Typed::Success.new(values[name] = struct) } + .on_error { |error| return Typed::Failure.new(CoercionError.new("Nested hash for #{type} could not be coerced to #{name}, error: #{error}")) } + else + value = HashTransformer.new.deep_symbolize_keys(value) + + Coercion + .coerce(type: attribute_type, value: value[name]) + .and_then { |coerced_value| Typed::Success.new(values[name] = coerced_value) } + end + end + + Success.new(type.raw_type.new(values)) rescue ArgumentError, RuntimeError Failure.new(CoercionError.new("Given hash could not be coerced to #{type}.")) end diff --git a/test/typed/coercion/struct_coercer_test.rb b/test/typed/coercion/struct_coercer_test.rb index 5db3eaa..a6b12b6 100644 --- a/test/typed/coercion/struct_coercer_test.rb +++ b/test/typed/coercion/struct_coercer_test.rb @@ -4,6 +4,7 @@ class StructCoercerTest < Minitest::Test def setup @coercer = Typed::Coercion::StructCoercer.new @type = T::Utils.coerce(Job) + @type_with_nested_struct = T::Utils.coerce(Person) end def test_used_for_type_works @@ -51,6 +52,47 @@ def test_when_struct_cannot_be_coerced_returns_failure result = @coercer.coerce(type: @type, value: {"not" => "valid"}) assert_failure(result) - assert_error(Typed::Coercion::CoercionError.new("Given hash could not be coerced to Job."), result) + assert_error(Typed::Coercion::CoercionError.new("title is required but nil given"), result) + end + + def test_when_struct_has_nested_struct_and_all_values_passed_for_nested_struct + name = "Alex" + age = 31 + ruby_rank = "pretty" + salary = 90_000_00 + title = "Software Developer" + start_date = Date.new(2024, 3, 1) + + result = @coercer.coerce(type: @type_with_nested_struct, value: {name:, age:, ruby_rank:, job: {title:, salary:, start_date:}}) + + person = Person.new( + name:, + age:, + ruby_rank: RubyRank.deserialize(ruby_rank), + job: Job.new(title:, salary:, start_date:) + ) + + assert_success(result) + assert_payload(person, result) + end + + def test_when_struct_has_nested_struct_and_optional_start_date_not_passed_for_nested_struct + name = "Alex" + age = 31 + ruby_rank = "pretty" + salary = 90_000_00 + title = "Software Developer" + + result = @coercer.coerce(type: @type_with_nested_struct, value: {name:, age:, ruby_rank:, job: {title:, salary:}}) + + person = Person.new( + name:, + age:, + ruby_rank: RubyRank.deserialize(ruby_rank), + job: Job.new(title:, salary:) + ) + + assert_success(result) + assert_payload(person, result) end end