diff --git a/lib/typed/coercion/struct_coercer.rb b/lib/typed/coercion/struct_coercer.rb index 608b921..c79ab11 100644 --- a/lib/typed/coercion/struct_coercer.rb +++ b/lib/typed/coercion/struct_coercer.rb @@ -21,41 +21,15 @@ 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) - 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.respond_to?(:valid?) && attribute_type.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.respond_to?(:unwrap_nilable) ? attribute_type.unwrap_nilable : attribute_type - - # if the prop is a struct, we need to recursively coerce it - if simple_attribute_type.respond_to?(:raw_type) && simple_attribute_type.raw_type <= T::Struct - Typed::HashSerializer - .new(schema: simple_attribute_type.raw_type.schema) - .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 + deserialization_result = T.cast(type, T::Types::Simple) + .raw_type + .deserialize_from(:hash, value) + + if deserialization_result.success? + deserialization_result + else + Failure.new(CoercionError.new(deserialization_result.error.message)) 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/lib/typed/field.rb b/lib/typed/field.rb index a77a019..e195fb7 100644 --- a/lib/typed/field.rb +++ b/lib/typed/field.rb @@ -12,6 +12,9 @@ class Field sig { returns(T::Types::Base) } attr_reader :type + sig { returns(T.untyped) } + attr_reader :default + sig { returns(T::Boolean) } attr_reader :required @@ -22,15 +25,36 @@ class Field params( name: Symbol, type: T.any(T::Class[T.anything], T::Types::Base), - required: T::Boolean, + optional: T::Boolean, + default: T.untyped, inline_serializer: T.nilable(InlineSerializer) ).void end - def initialize(name:, type:, required: true, inline_serializer: nil) + def initialize(name:, type:, optional: false, default: nil, inline_serializer: nil) @name = name - @type = T.let(T::Utils.coerce(type), T::Types::Base) - @required = required + # TODO: Guarentee type signature of the serializer will be valid @inline_serializer = inline_serializer + + coerced_type = T::Utils.coerce(type) + + if coerced_type.valid?(nil) + @required = T.let(false, T::Boolean) + @type = T.let(T.unsafe(coerced_type).unwrap_nilable, T::Types::Base) + else + @required = true + @type = coerced_type + end + + if optional + @required = false + end + + if !default.nil? && @type.valid?(default) + @default = T.let(default, T.untyped) + @required = false + elsif !default.nil? && @required + raise ArgumentError, "Given #{default} with class of #{default.class} for default, invalid with type #{@type}" + end end sig { params(other: Field).returns(T.nilable(T::Boolean)) } @@ -38,6 +62,7 @@ def ==(other) name == other.name && type == other.type && required == other.required && + default == other.default && inline_serializer == other.inline_serializer end diff --git a/lib/typed/schema.rb b/lib/typed/schema.rb index bb244bb..a2a564a 100644 --- a/lib/typed/schema.rb +++ b/lib/typed/schema.rb @@ -13,7 +13,7 @@ def self.from_struct(struct) Typed::Schema.new( target: struct, fields: struct.props.map do |name, properties| - Typed::Field.new(name: name, type: properties[:type], required: !properties[:fully_optional]) + Typed::Field.new(name:, type: properties[:type_object], default: properties.fetch(:default, nil)) end ) end @@ -34,7 +34,7 @@ def add_serializer(field_name, serializer) target: target, fields: fields.map do |field| if field.name == field_name - Field.new(name: field.name, type: field.type, required: field.required, inline_serializer: serializer) + Field.new(name: field.name, type: field.type, default: field.default, inline_serializer: serializer) else field end diff --git a/lib/typed/serializer.rb b/lib/typed/serializer.rb index 0e97520..b07263e 100644 --- a/lib/typed/serializer.rb +++ b/lib/typed/serializer.rb @@ -33,12 +33,14 @@ def serialize(struct) sig { params(creation_params: Params).returns(DeserializeResult) } def deserialize_from_creation_params(creation_params) results = schema.fields.map do |field| - value = creation_params[field.name] + value = creation_params.fetch(field.name, nil) - if value.nil? || field.works_with?(value) + if value.nil? && field.default + Success.new(Validations::ValidatedValue.new(field.default)) + elsif value.nil? || field.works_with?(value) field.validate(value) else - coercion_result = Coercion.coerce(type: field.type, value: value) + coercion_result = Coercion.coerce(type: field.type, value:) if coercion_result.success? field.validate(coercion_result.payload) @@ -49,7 +51,7 @@ def deserialize_from_creation_params(creation_params) end Validations::ValidationResults - .new(results: results) + .new(results:) .combine .and_then do |validated_params| Success.new(schema.target.new(**validated_params)) diff --git a/lib/typed/validations/field_type_validator.rb b/lib/typed/validations/field_type_validator.rb index 3148761..bfbfd73 100644 --- a/lib/typed/validations/field_type_validator.rb +++ b/lib/typed/validations/field_type_validator.rb @@ -10,11 +10,15 @@ class FieldTypeValidator sig { override.params(field: Field, value: Value).returns(ValidationResult) } def validate(field:, value:) if field.works_with?(value) - Success.new(ValidatedValue.new(name: field.name, value: value)) + Success.new(ValidatedValue.new(name: field.name, value:)) elsif field.required? && value.nil? Failure.new(RequiredFieldError.new(field_name: field.name)) elsif field.optional? && value.nil? - Success.new(ValidatedValue.new(name: field.name, value: value)) + if field.default.nil? + Success.new(ValidatedValue.new(name: field.name, value:)) + else + Success.new(ValidatedValue.new(name: field.name, value: field.default)) + end else Failure.new(TypeMismatchError.new(field_name: field.name, field_type: field.type, given_type: value.class)) end diff --git a/test/support/structs/job.rb b/test/support/structs/job.rb index ce81b72..37e53f5 100644 --- a/test/support/structs/job.rb +++ b/test/support/structs/job.rb @@ -8,6 +8,7 @@ class Job < T::Struct const :title, String const :salary, Integer const :start_date, T.nilable(Date) + const :needs_credential, T::Boolean, default: false end JOB_SCHEMA_WITH_INLINE_SERIALIZER = Typed::Schema.new( @@ -15,7 +16,7 @@ class Job < T::Struct fields: [ Typed::Field.new(name: :title, type: String), Typed::Field.new(name: :salary, type: Integer), - Typed::Field.new(name: :start_date, type: Date, required: false, inline_serializer: ->(start_date) { start_date.strftime("%j %B") }) + Typed::Field.new(name: :start_date, type: T::Utils.coerce(T.nilable(Date)), inline_serializer: ->(start_date) { start_date.strftime("%j %B") }) ] ) DEVELOPER_JOB = Job.new(title: "Software Developer", salary: 90_000_00) diff --git a/test/t/struct_test.rb b/test/t/struct_test.rb index 6bd342b..df63731 100644 --- a/test/t/struct_test.rb +++ b/test/t/struct_test.rb @@ -13,6 +13,20 @@ def test_schema_can_be_derived_from_struct assert_equal(expected_schema, City.schema) end + def test_schema_can_be_derived_from_struct_with_default + expected_schema = Typed::Schema.new( + fields: [ + Typed::Field.new(name: :title, type: String), + Typed::Field.new(name: :salary, type: Integer), + Typed::Field.new(name: :start_date, type: Date, optional: true), + Typed::Field.new(name: :needs_credential, type: T::Utils.coerce(T::Boolean), default: false, optional: true) + ], + target: Job + ) + + assert_equal(expected_schema, Job.schema) + end + def test_serializer_returns_hash_serializer assert_kind_of(Typed::HashSerializer, City.serializer(:hash)) end diff --git a/test/typed/coercion/struct_coercer_test.rb b/test/typed/coercion/struct_coercer_test.rb index a6b12b6..e81ddea 100644 --- a/test/typed/coercion/struct_coercer_test.rb +++ b/test/typed/coercion/struct_coercer_test.rb @@ -52,7 +52,7 @@ 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("title is required but nil given"), result) + assert_error(Typed::Coercion::CoercionError.new("Multiple validation errors found: title is required. | salary is required."), result) end def test_when_struct_has_nested_struct_and_all_values_passed_for_nested_struct diff --git a/test/typed/field_test.rb b/test/typed/field_test.rb index 31145b2..033b8b1 100644 --- a/test/typed/field_test.rb +++ b/test/typed/field_test.rb @@ -3,28 +3,102 @@ require "test_helper" class FieldTest < Minitest::Test - def setup - @required_field = Typed::Field.new(name: :im_required, type: String) - @optional_field = Typed::Field.new(name: :im_optional, type: String, required: false) + def test_sets_values_correctly_for_required + field = Typed::Field.new(name: :required, type: String) + + assert_equal(:required, field.name) + assert_equal(T::Utils.coerce(String), field.type) + assert(field.required) + assert_nil(field.default) + assert_nil(field.inline_serializer) end - def test_initialize_takes_sorbet_types_and_built_in_types - assert_equal(@required_field, Typed::Field.new(name: :im_required, type: T::Utils.coerce(String))) + def test_sets_values_correctly_for_default + field = Typed::Field.new(name: :with_default, type: T::Utils.coerce(T::Boolean), default: false) + + assert_equal(:with_default, field.name) + assert_equal(T::Utils.coerce(T::Boolean), field.type) + refute(field.required) + assert_equal(false, field.default) + assert_nil(field.inline_serializer) + end + + def test_sets_values_correctly_for_nilable_type + field = Typed::Field.new(name: :nilable_type, type: T::Utils.coerce(T.nilable(String))) + + assert_equal(:nilable_type, field.name) + assert_equal(T::Utils.coerce(String), field.type) + refute(field.required) + assert_nil(field.default) + assert_nil(field.inline_serializer) + end + + def test_sets_values_correctly_for_nilable_type_with_default + field = Typed::Field.new(name: :nilable_type, type: T::Utils.coerce(T.nilable(String)), default: "fallback") + + assert_equal(:nilable_type, field.name) + assert_equal(T::Utils.coerce(String), field.type) + refute(field.required) + assert_equal("fallback", field.default) + assert_nil(field.inline_serializer) + end + + def test_sets_values_correctly_for_optional + field = Typed::Field.new(name: :optional, type: String, optional: true) + + assert_equal(:optional, field.name) + assert_equal(T::Utils.coerce(String), field.type) + refute(field.required) + assert_nil(field.default) + assert_nil(field.inline_serializer) + end + + def test_sets_values_correctly_for_optional_with_default + field = Typed::Field.new(name: :optional, type: String, default: "fallback") + + assert_equal(:optional, field.name) + assert_equal(T::Utils.coerce(String), field.type) + refute(field.required) + assert_equal("fallback", field.default) + assert_nil(field.inline_serializer) + end + + def test_sets_values_with_inline_serializer + field = Typed::Field.new(name: :inline, type: String, inline_serializer: ->(_value) { "banana" }) + + assert_equal(:inline, field.name) + assert_equal(T::Utils.coerce(String), field.type) + assert(field.required) + assert_nil(field.default) + refute_nil(field.inline_serializer) + end + + def test_initialize_raises_argument_error_with_invalid_default_value + error = assert_raises(ArgumentError) { Typed::Field.new(name: :fallback, type: String, default: 1) } + + assert_equal("Given 1 with class of Integer for default, invalid with type String", error.message) end def test_equality - assert_equal(@required_field, Typed::Field.new(name: :im_required, type: String)) - refute_equal(@required_field, @optional_field) + inline_serializer = ->(_value) { "banana" } + assert_equal( + Typed::Field.new(name: :inline, type: String, optional: true, default: "testing", inline_serializer:), + Typed::Field.new(name: :inline, type: String, optional: true, default: "testing", inline_serializer:) + ) end def test_required_and_optional_helpers_work_when_required - assert_predicate(@required_field, :required?) - refute_predicate(@required_field, :optional?) + field = Typed::Field.new(name: :required, type: String) + + assert_predicate(field, :required?) + refute_predicate(field, :optional?) end def test_required_and_optional_helpers_work_when_optional - assert_predicate(@optional_field, :optional?) - refute_predicate(@optional_field, :required?) + field = Typed::Field.new(name: :optional, type: String, optional: true) + + assert_predicate(field, :optional?) + refute_predicate(field, :required?) end def test_when_inline_serializer_serialize_uses_it @@ -35,13 +109,17 @@ def test_when_inline_serializer_serialize_uses_it end def test_when_no_inline_serializer_serialize_returns_given_value - assert_equal("testing", @required_field.serialize("testing")) - assert_nil(@required_field.serialize(nil)) + field = Typed::Field.new(name: :testing, type: String) + + assert_equal("testing", field.serialize("testing")) + assert_nil(field.serialize(nil)) end def test_when_standard_type_work_with_works - assert(@required_field.works_with?("Max")) - refute(@required_field.works_with?(1)) + field = Typed::Field.new(name: :testing, type: String) + + assert(field.works_with?("Max")) + refute(field.works_with?(1)) end def test_when_simple_base_type_works_with_works diff --git a/test/typed/hash_serializer_test.rb b/test/typed/hash_serializer_test.rb index 0ee7f38..4813ef7 100644 --- a/test/typed/hash_serializer_test.rb +++ b/test/typed/hash_serializer_test.rb @@ -27,7 +27,7 @@ def test_it_can_deep_serialize result = serializer.serialize(ALEX_PERSON) assert_success(result) - assert_payload({name: "Alex", age: 31, ruby_rank: "pretty", job: {title: "Software Developer", salary: 90_000_00}}, result) + assert_payload({name: "Alex", age: 31, ruby_rank: "pretty", job: {title: "Software Developer", salary: 90_000_00, needs_credential: false}}, result) end def test_with_boolean_it_can_serialize diff --git a/test/typed/json_serializer_test.rb b/test/typed/json_serializer_test.rb index 501710e..174b8e1 100644 --- a/test/typed/json_serializer_test.rb +++ b/test/typed/json_serializer_test.rb @@ -20,7 +20,7 @@ def test_it_can_serialize_with_nested_struct result = @serializer.serialize(ALEX_PERSON) assert_success(result) - assert_payload('{"name":"Alex","age":31,"ruby_rank":"pretty","job":{"title":"Software Developer","salary":9000000}}', result) + assert_payload('{"name":"Alex","age":31,"ruby_rank":"pretty","job":{"title":"Software Developer","salary":9000000,"needs_credential":false}}', result) end def test_with_boolean_it_can_serialize diff --git a/test/typed/schema_test.rb b/test/typed/schema_test.rb index c512956..ce237c1 100644 --- a/test/typed/schema_test.rb +++ b/test/typed/schema_test.rb @@ -7,7 +7,7 @@ def setup Typed::Field.new(name: :name, type: String), Typed::Field.new(name: :age, type: Integer), Typed::Field.new(name: :ruby_rank, type: RubyRank), - Typed::Field.new(name: :job, type: Job, required: false) + Typed::Field.new(name: :job, type: Job, optional: true) ], target: Person ) diff --git a/test/typed/validations/field_type_validator_test.rb b/test/typed/validations/field_type_validator_test.rb index 3eebf4d..131b127 100644 --- a/test/typed/validations/field_type_validator_test.rb +++ b/test/typed/validations/field_type_validator_test.rb @@ -6,7 +6,7 @@ class FieldTypeValidatorTest < Minitest::Test def setup @validator = Typed::Validations::FieldTypeValidator.new @required_field = Typed::Field.new(name: :im_required, type: String) - @optional_field = Typed::Field.new(name: :im_optional, type: String, required: false) + @optional_field = Typed::Field.new(name: :im_optional, type: String, default: "Fallback") end def test_validate_correct_type_on_required_field @@ -44,9 +44,18 @@ def test_validate_nil_on_required_field assert_error(Typed::Validations::RequiredFieldError.new(field_name: :im_required), result) end - def test_validate_nil_on_optional_field + def test_validate_nil_on_optional_field_with_default result = @validator.validate(field: @optional_field, value: nil) + assert_success(result) + assert_payload(Typed::Validations::ValidatedValue.new(name: :im_optional, value: "Fallback"), result) + end + + def test_validate_nil_on_optional_field_without_default + field = Typed::Field.new(name: :im_optional, type: String, optional: true) + + result = @validator.validate(field:, value: nil) + assert_success(result) assert_payload(Typed::Validations::ValidatedValue.new(name: :im_optional, value: nil), result) end