Skip to content

Commit

Permalink
feat: support T.any for deserialization (#107)
Browse files Browse the repository at this point in the history
  • Loading branch information
antoinesaliba authored Jul 5, 2024
1 parent 6829bbf commit c0c2ca3
Show file tree
Hide file tree
Showing 7 changed files with 79 additions and 27 deletions.
20 changes: 20 additions & 0 deletions lib/typed/serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,26 @@ def deserialize_from_creation_params(creation_params)
Success.new(Validations::ValidatedValue.new(name: field.name, value: field.default))
elsif value.nil? || field.works_with?(value)
field.validate(value)
elsif field.type.class <= T::Types::Union
errors = []
validated_value = T.let(nil, T.nilable(Typed::Result[Typed::Validations::ValidatedValue, Typed::Validations::ValidationError]))

T.cast(field.type, T::Types::Union).types.each do |sub_type|
# the if clause took care of cases where value is nil so we can skip NilClass
next if sub_type.raw_type.equal?(NilClass)

coercion_result = Coercion.coerce(type: sub_type, value: value)

if coercion_result.success?
validated_value = field.validate(coercion_result.payload)

break
else
errors << Validations::ValidationError.new(coercion_result.error.message)
end
end

validated_value.nil? ? Failure.new(Validations::ValidationError.new(errors.map(&:message).join(", "))) : validated_value
else
coercion_result = Coercion.coerce(type: field.type, value:)

Expand Down
10 changes: 10 additions & 0 deletions test/support/enums/diamond_rank.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# typed: true

class DiamondRank < T::Enum
enums do
Excellent = new
Good = new
Fair = new
Poor = new
end
end
7 changes: 4 additions & 3 deletions test/support/structs/person.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@

require_relative "job"
require_relative "../enums/ruby_rank"
require_relative "../enums/diamond_rank"

class Person < T::Struct
include ActsAsComparable

const :name, String
const :age, Integer
const :ruby_rank, RubyRank
const :stone_rank, T.any(RubyRank, DiamondRank)
const :job, T.nilable(Job)
end

MAX_PERSON = Person.new(name: "Max", age: 29, ruby_rank: RubyRank::Luminary)
ALEX_PERSON = Person.new(name: "Alex", age: 31, ruby_rank: RubyRank::Brilliant, job: DEVELOPER_JOB)
MAX_PERSON = Person.new(name: "Max", age: 29, stone_rank: RubyRank::Luminary)
ALEX_PERSON = Person.new(name: "Alex", age: 31, stone_rank: RubyRank::Brilliant, job: DEVELOPER_JOB)
12 changes: 6 additions & 6 deletions test/typed/coercion/struct_coercer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,17 @@ def test_when_struct_cannot_be_coerced_returns_failure
def test_when_struct_has_nested_struct_and_all_values_passed_for_nested_struct
name = "Alex"
age = 31
ruby_rank = "pretty"
stone_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:}})
result = @coercer.coerce(type: @type_with_nested_struct, value: {name:, age:, stone_rank:, job: {title:, salary:, start_date:}})

person = Person.new(
name:,
age:,
ruby_rank: RubyRank.deserialize(ruby_rank),
stone_rank: RubyRank.deserialize(stone_rank),
job: Job.new(title:, salary:, start_date:)
)

Expand All @@ -79,16 +79,16 @@ def test_when_struct_has_nested_struct_and_all_values_passed_for_nested_struct
def test_when_struct_has_nested_struct_and_optional_start_date_not_passed_for_nested_struct
name = "Alex"
age = 31
ruby_rank = "pretty"
stone_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:}})
result = @coercer.coerce(type: @type_with_nested_struct, value: {name:, age:, stone_rank:, job: {title:, salary:}})

person = Person.new(
name:,
age:,
ruby_rank: RubyRank.deserialize(ruby_rank),
stone_rank: RubyRank.deserialize(stone_rank),
job: Job.new(title:, salary:)
)

Expand Down
37 changes: 29 additions & 8 deletions test/typed/hash_serializer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@ def test_it_can_simple_serialize
result = @serializer.serialize(MAX_PERSON)

assert_success(result)
assert_payload({name: "Max", age: 29, ruby_rank: RubyRank::Luminary}, result)
assert_payload({name: "Max", age: 29, stone_rank: RubyRank::Luminary}, result)
end

def test_it_can_serialize_with_nested_struct
result = @serializer.serialize(ALEX_PERSON)

assert_success(result)
assert_payload({name: "Alex", age: 31, ruby_rank: RubyRank::Brilliant, job: DEVELOPER_JOB}, result)
assert_payload({name: "Alex", age: 31, stone_rank: RubyRank::Brilliant, job: DEVELOPER_JOB}, result)
end

def test_it_can_deep_serialize
Expand All @@ -39,7 +39,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, needs_credential: false}}, result)
assert_payload({name: "Alex", age: 31, stone_rank: "pretty", job: {title: "Software Developer", salary: 90_000_00, needs_credential: false}}, result)
end

def test_with_boolean_it_can_serialize
Expand Down Expand Up @@ -80,14 +80,14 @@ def test_will_use_inline_serializers
# Deserialize Tests

def test_it_can_simple_deserialize
result = @serializer.deserialize({name: "Max", age: 29, ruby_rank: RubyRank::Luminary})
result = @serializer.deserialize({name: "Max", age: 29, stone_rank: RubyRank::Luminary})

assert_success(result)
assert_payload(MAX_PERSON, result)
end

def test_it_can_simple_deserialize_from_string_keys
result = @serializer.deserialize({"name" => "Max", "age" => 29, "ruby_rank" => RubyRank::Luminary})
result = @serializer.deserialize({"name" => "Max", "age" => 29, "stone_rank" => RubyRank::Luminary})

assert_success(result)
assert_payload(MAX_PERSON, result)
Expand Down Expand Up @@ -115,12 +115,33 @@ def test_with_array_it_can_deep_deserialize
end

def test_it_can_deserialize_with_nested_object
result = @serializer.deserialize({name: "Alex", age: 31, ruby_rank: "pretty", job: {title: "Software Developer", salary: 90_000_00}})
result = @serializer.deserialize({name: "Alex", age: 31, stone_rank: "pretty", job: {title: "Software Developer", salary: 90_000_00}})

assert_success(result)
assert_payload(ALEX_PERSON, result)
end

def test_it_can_deserialize_something_that_is_the_first_of_multiple_types
result = Typed::HashSerializer.new(schema: Typed::Schema.from_struct(Person)).deserialize({name: "Max", age: 29, stone_rank: "shiny"})

assert_success(result)
assert_payload(MAX_PERSON, result)
end

def test_it_can_deserialize_something_that_is_the_second_of_multiple_types
result = Typed::HashSerializer.new(schema: Typed::Schema.from_struct(Person)).deserialize({name: "Max", age: 29, stone_rank: "good"})

assert_success(result)
assert_payload(Person.new(name: "Max", age: 29, stone_rank: DiamondRank::Good), result)
end

def test_if_it_cannot_be_deserialized_against_something_with_multiple_types_it_will_fail
result = Typed::HashSerializer.new(schema: Typed::Schema.from_struct(Person)).deserialize({name: "Max", age: 29, stone_rank: "not valid"})

assert_failure(result)
assert_error(Typed::Validations::ValidationError.new('Enum RubyRank key not found: "not valid", Enum DiamondRank key not found: "not valid"'), result)
end

def test_it_can_deserialize_with_default_value_boolean_true
serializer = Typed::HashSerializer.new(schema: Typed::Schema.from_struct(StructWithBooleanDefaultSetToTrue))
result = serializer.deserialize({})
Expand All @@ -138,7 +159,7 @@ def test_it_can_deserialize_with_default_value_boolean_false
end

def test_it_reports_validation_errors_on_deserialize
result = @serializer.deserialize({name: "Max", ruby_rank: RubyRank::Luminary})
result = @serializer.deserialize({name: "Max", stone_rank: RubyRank::Luminary})

assert_failure(result)
assert_error(Typed::Validations::RequiredFieldError.new(field_name: :age), result)
Expand All @@ -153,7 +174,7 @@ def test_it_reports_multiple_validation_errors_on_deserialize
errors: [
Typed::Validations::RequiredFieldError.new(field_name: :name),
Typed::Validations::RequiredFieldError.new(field_name: :age),
Typed::Validations::RequiredFieldError.new(field_name: :ruby_rank)
Typed::Validations::RequiredFieldError.new(field_name: :stone_rank)
]
),
result
Expand Down
14 changes: 7 additions & 7 deletions test/typed/json_serializer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ def test_it_can_simple_serialize
result = @serializer.serialize(MAX_PERSON)

assert_success(result)
assert_payload('{"name":"Max","age":29,"ruby_rank":"shiny"}', result)
assert_payload('{"name":"Max","age":29,"stone_rank":"shiny"}', result)
end

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,"needs_credential":false}}', result)
assert_payload('{"name":"Alex","age":31,"stone_rank":"pretty","job":{"title":"Software Developer","salary":9000000,"needs_credential":false}}', result)
end

def test_with_boolean_it_can_serialize
Expand All @@ -47,7 +47,7 @@ def test_will_use_inline_serializers
# Deserialize Tests

def test_it_can_simple_deserialize
result = @serializer.deserialize('{"name":"Max","age":29,"ruby_rank":"shiny"}')
result = @serializer.deserialize('{"name":"Max","age":29,"stone_rank":"shiny"}')

assert_success(result)
assert_payload(MAX_PERSON, result)
Expand All @@ -68,21 +68,21 @@ def test_with_array_it_can_deep_deserialize
end

def test_it_can_deserialize_with_nested_object
result = @serializer.deserialize('{"name":"Alex","age":31,"ruby_rank":"pretty","job":{"title":"Software Developer","salary":9000000}}')
result = @serializer.deserialize('{"name":"Alex","age":31,"stone_rank":"pretty","job":{"title":"Software Developer","salary":9000000}}')

assert_success(result)
assert_payload(ALEX_PERSON, result)
end

def test_it_reports_on_parse_errors_on_deserialize
result = @serializer.deserialize('{"name": "Max", age": 29, "ruby_rank": "shiny"}') # Missing quotation
result = @serializer.deserialize('{"name": "Max", age": 29, "stone_rank": "shiny"}') # Missing quotation

assert_failure(result)
assert_error(Typed::ParseError.new(format: :json), result)
end

def test_it_reports_validation_errors_on_deserialize
result = @serializer.deserialize('{"name": "Max", "ruby_rank": "shiny"}')
result = @serializer.deserialize('{"name": "Max", "stone_rank": "shiny"}')

assert_failure(result)
assert_error(Typed::Validations::RequiredFieldError.new(field_name: :age), result)
Expand All @@ -99,7 +99,7 @@ def test_it_reports_multiple_validation_errors_on_deserialize
errors: [
Typed::Validations::RequiredFieldError.new(field_name: :name),
Typed::Validations::RequiredFieldError.new(field_name: :age),
Typed::Validations::RequiredFieldError.new(field_name: :ruby_rank)
Typed::Validations::RequiredFieldError.new(field_name: :stone_rank)
]
),
result
Expand Down
6 changes: 3 additions & 3 deletions test/typed/schema_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ def setup
fields: [
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: :stone_rank, type: T::Utils.coerce(T.any(RubyRank, DiamondRank))),
Typed::Field.new(name: :job, type: Job, optional: true)
],
target: Person
Expand All @@ -18,14 +18,14 @@ def test_from_struct_returns_schema
end

def test_from_hash_create_struct
result = @schema.from_hash({name: "Max", age: 29, ruby_rank: RubyRank::Luminary})
result = @schema.from_hash({name: "Max", age: 29, stone_rank: RubyRank::Luminary})

assert_success(result)
assert_payload(MAX_PERSON, result)
end

def test_from_json_creates_struct
result = @schema.from_json('{"name": "Max", "age": 29, "ruby_rank": "shiny"}')
result = @schema.from_json('{"name": "Max", "age": 29, "stone_rank": "shiny"}')

assert_success(result)
assert_payload(MAX_PERSON, result)
Expand Down

0 comments on commit c0c2ca3

Please sign in to comment.