Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add inline serializer #56

Merged
merged 2 commits into from
Mar 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions lib/typed/field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ module Typed
class Field
extend T::Sig

InlineSerializer = T.type_alias { T.proc.params(arg0: T.untyped).returns(T.untyped) }

sig { returns(Symbol) }
attr_reader :name

Expand All @@ -13,11 +15,22 @@ class Field
sig { returns(T::Boolean) }
attr_reader :required

sig { params(name: Symbol, type: T.any(T::Class[T.anything], T::Types::Base), required: T::Boolean).void }
def initialize(name:, type:, required: true)
sig { returns(T.nilable(InlineSerializer)) }
attr_reader :inline_serializer

sig do
params(
name: Symbol,
type: T.any(T::Class[T.anything], T::Types::Base),
required: T::Boolean,
inline_serializer: T.nilable(InlineSerializer)
).void
end
def initialize(name:, type:, required: true, inline_serializer: nil)
@name = name
@type = T.let(T::Utils.coerce(type), T::Types::Base)
@required = required
@inline_serializer = inline_serializer
end

sig { params(other: Field).returns(T.nilable(T::Boolean)) }
Expand All @@ -37,6 +50,15 @@ def optional?
!required
end

sig { params(value: Value).returns(Value) }
def serialize(value)
if inline_serializer && value
T.must(inline_serializer).call(value)
else
value
end
end

sig { params(value: Value).returns(Validations::ValidationResult) }
def validate(value)
Validations::FieldTypeValidator.new.validate(field: self, value: value)
Expand Down
4 changes: 1 addition & 3 deletions lib/typed/hash_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@ def deserialize(source)
def serialize(struct)
return Failure.new(SerializeError.new("'#{struct.class}' cannot be serialized to target type of '#{schema.target}'.")) if struct.class != schema.target

hsh = schema.fields.each_with_object({}) { |field, hsh| hsh[field.name] = struct.send(field.name) }

Success.new(HashTransformer.new(should_serialize_values: should_serialize_values).deep_symbolize_keys(hsh.compact))
Success.new(serialize_from_struct(struct: struct, should_serialize_values: should_serialize_values))
end

private
Expand Down
2 changes: 1 addition & 1 deletion lib/typed/json_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def deserialize(source)
def serialize(struct)
return Failure.new(SerializeError.new("'#{struct.class}' cannot be serialized to target type of '#{schema.target}'.")) if struct.class != schema.target

Success.new(JSON.generate(struct.serialize))
Success.new(JSON.generate(serialize_from_struct(struct: struct, should_serialize_values: true)))
end
end
end
7 changes: 7 additions & 0 deletions lib/typed/serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,12 @@ def deserialize_from_creation_params(creation_params)
Success.new(schema.target.new(**validated_params))
end
end

sig { params(struct: T::Struct, should_serialize_values: T::Boolean).returns(T::Hash[Symbol, T.untyped]) }
def serialize_from_struct(struct:, should_serialize_values: false)
hsh = schema.fields.each_with_object({}) { |field, hsh| hsh[field.name] = field.serialize(struct.send(field.name)) }.compact

HashTransformer.new(should_serialize_values: should_serialize_values).deep_symbolize_keys(hsh)
end
end
end
14 changes: 14 additions & 0 deletions test/support/structs/job.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
# typed: true

require "date"

class Job < T::Struct
include ActsAsComparable

const :title, String
const :salary, Integer
const :start_date, T.nilable(Date)
end

JOB_SCHEMA_WITH_INLINE_SERIALIZER = Typed::Schema.new(
target: Job,
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") })
]
)
DEVELOPER_JOB = Job.new(title: "Software Developer", salary: 90_000_00)
DEVELOPER_JOB_WITH_START_DATE = Job.new(title: "Software Developer", salary: 90_000_00, start_date: Date.new(2024, 3, 1))
2 changes: 1 addition & 1 deletion test/support/structs/person.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ class Person < T::Struct
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: Job.new(title: "Software Developer", salary: 1_000_000_00))
ALEX_PERSON = Person.new(name: "Alex", age: 31, ruby_rank: RubyRank::Brilliant, job: DEVELOPER_JOB)
8 changes: 3 additions & 5 deletions test/typed/coercion/struct_coercer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,10 @@ def test_when_non_struct_type_given_returns_failure
end

def test_when_struct_of_correct_type_given_returns_success
job = Job.new(title: "Software Developer", salary: 90_000_00)

result = @coercer.coerce(type: @type, value: job)
result = @coercer.coerce(type: @type, value: DEVELOPER_JOB)

assert_success(result)
assert_payload(job, result)
assert_payload(DEVELOPER_JOB, result)
end

def test_when_struct_of_incorrect_type_given_returns_failure
Expand All @@ -39,7 +37,7 @@ def test_when_struct_can_be_coerced_returns_success
result = @coercer.coerce(type: @type, value: {"title" => "Software Developer", :salary => 90_000_00})

assert_success(result)
assert_payload(Job.new(title: "Software Developer", salary: 90_000_00), result)
assert_payload(DEVELOPER_JOB, result)
end

def test_when_value_is_not_a_hash_returns_failure
Expand Down
12 changes: 12 additions & 0 deletions test/typed/field_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ def test_required_and_optional_helpers_work_when_optional
refute_predicate(@optional_field, :required?)
end

def test_when_inline_serializer_serialize_uses_it
field = Typed::Field.new(name: :testing, type: String, inline_serializer: ->(_value) { "banana" })

assert_equal("banana", field.serialize("testing"))
assert_nil(field.serialize(nil))
end

def test_when_no_inline_serializer_serialize_returns_given_value
assert_equal("testing", @required_field.serialize("testing"))
assert_nil(@required_field.serialize(nil))
end

def test_when_standard_type_work_with_works
assert(@required_field.works_with?("Max"))
refute(@required_field.works_with?(1))
Expand Down
15 changes: 11 additions & 4 deletions test/typed/hash_serializer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,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: RubyRank::Brilliant, job: Job.new(title: "Software Developer", salary: 1_000_000_00)}, result)
assert_payload({name: "Alex", age: 31, ruby_rank: RubyRank::Brilliant, job: DEVELOPER_JOB}, result)
end

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

def test_with_boolean_it_can_serialize
Expand All @@ -52,12 +52,19 @@ def test_with_array_it_can_deep_serialize
end

def test_when_struct_given_is_not_of_target_type_returns_failure
result = @serializer.serialize(Job.new(title: "Testing", salary: 90_00))
result = @serializer.serialize(DEVELOPER_JOB)

assert_failure(result)
assert_error(Typed::SerializeError.new("'Job' cannot be serialized to target type of 'Person'."), result)
end

def test_will_use_inline_serializers
result = Typed::HashSerializer.new(schema: JOB_SCHEMA_WITH_INLINE_SERIALIZER, should_serialize_values: true).serialize(DEVELOPER_JOB_WITH_START_DATE)

assert_success(result)
assert_payload({title: "Software Developer", salary: 90_000_00, start_date: "061 March"}, result)
end

# Deserialize Tests

def test_it_can_simple_deserialize
Expand Down Expand Up @@ -96,7 +103,7 @@ 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: 1_000_000_00}})
result = @serializer.deserialize({name: "Alex", age: 31, ruby_rank: "pretty", job: {title: "Software Developer", salary: 90_000_00}})

assert_success(result)
assert_payload(ALEX_PERSON, result)
Expand Down
11 changes: 9 additions & 2 deletions 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":100000000}}', result)
assert_payload('{"name":"Alex","age":31,"ruby_rank":"pretty","job":{"title":"Software Developer","salary":9000000}}', result)
end

def test_with_boolean_it_can_serialize
Expand All @@ -37,6 +37,13 @@ def test_with_array_it_can_serialize
assert_payload('{"name":"US","cities":[{"name":"New York","capital":false},{"name":"DC","capital":true}]}', result)
end

def test_will_use_inline_serializers
result = Typed::JSONSerializer.new(schema: JOB_SCHEMA_WITH_INLINE_SERIALIZER).serialize(DEVELOPER_JOB_WITH_START_DATE)

assert_success(result)
assert_payload('{"title":"Software Developer","salary":9000000,"start_date":"061 March"}', result)
end

# Deserialize Tests

def test_it_can_simple_deserialize
Expand All @@ -61,7 +68,7 @@ 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":100000000}}')
result = @serializer.deserialize('{"name":"Alex","age":31,"ruby_rank":"pretty","job":{"title":"Software Developer","salary":9000000}}')

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