Skip to content

Commit

Permalink
feat!: support defaults in serializers
Browse files Browse the repository at this point in the history
  • Loading branch information
maxveldink committed Jul 3, 2024
1 parent 58c56f0 commit 1205a41
Show file tree
Hide file tree
Showing 11 changed files with 53 additions and 49 deletions.
42 changes: 8 additions & 34 deletions lib/typed/coercion/struct_coercer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lib/typed/field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,10 @@ def initialize(name:, type:, optional: false, default: nil, inline_serializer: n
@required = false
end

if default && @type.valid?(default)
if !default.nil? && @type.valid?(default)
@default = T.let(default, T.untyped)
@required = false
elsif default && @required
elsif !default.nil? && @required
raise ArgumentError, "Given #{default} with class of #{default.class} for default, invalid with type #{@type}"
end
end
Expand Down
10 changes: 6 additions & 4 deletions lib/typed/serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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))
Expand Down
8 changes: 6 additions & 2 deletions lib/typed/validations/field_type_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions test/support/structs/job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
14 changes: 14 additions & 0 deletions test/t/struct_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion test/typed/coercion/struct_coercer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions test/typed/field_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ def test_sets_values_correctly_for_required
end

def test_sets_values_correctly_for_default
field = Typed::Field.new(name: :with_default, type: String, default: "fallback")
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(String), field.type)
assert_equal(T::Utils.coerce(T::Boolean), field.type)
refute(field.required)
assert_equal("fallback", field.default)
assert_equal(false, field.default)
assert_nil(field.inline_serializer)
end

Expand Down
2 changes: 1 addition & 1 deletion test/typed/hash_serializer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion test/typed/json_serializer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion test/typed/validations/field_type_validator_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 1205a41

Please sign in to comment.