diff --git a/lib/typed/hash_serializer.rb b/lib/typed/hash_serializer.rb new file mode 100644 index 0000000..042f2d1 --- /dev/null +++ b/lib/typed/hash_serializer.rb @@ -0,0 +1,25 @@ +# typed: strict + +module Typed + class HashSerializer < Serializer + Input = type_member { {fixed: T::Hash[T.any(Symbol, String), T.untyped]} } + Output = type_member { {fixed: T::Hash[Symbol, T.untyped]} } + + sig { override.params(source: Input).returns(Result[T::Struct, DeserializeError]) } + def deserialize(source) + deserialize_from_creation_params(symbolize_keys(source)) + end + + sig { override.params(struct: T::Struct).returns(Output) } + def serialize(struct) + symbolize_keys(struct.serialize) + end + + private + + sig { params(hash: T::Hash[T.any(String, Symbol), T.untyped]).returns(T::Hash[Symbol, T.untyped]) } + def symbolize_keys(hash) + hash.each_with_object({}) { |(k, v), h| h[k.intern] = v } + end + end +end diff --git a/lib/typed/json_serializer.rb b/lib/typed/json_serializer.rb index 6a9a2a7..f893450 100644 --- a/lib/typed/json_serializer.rb +++ b/lib/typed/json_serializer.rb @@ -4,9 +4,10 @@ module Typed class JSONSerializer < Serializer - extend T::Sig + Input = type_member { {fixed: String} } + Output = type_member { {fixed: String} } - sig { override.params(source: String).returns(Result[T::Struct, DeserializeError]) } + sig { override.params(source: Input).returns(Result[T::Struct, DeserializeError]) } def deserialize(source) parsed_json = JSON.parse(source) @@ -14,21 +15,12 @@ def deserialize(source) hsh[field.name] = parsed_json[field.name.to_s] end - results = creation_params.map do |name, value| - schema.field(name:)&.validate(value) - end.compact - - Validations::ValidationResults - .new(results:) - .combine - .and_then do - Success.new(schema.target.new(**creation_params)) - end + deserialize_from_creation_params(creation_params) rescue JSON::ParserError Failure.new(ParseError.new(format: :json)) end - sig { override.params(struct: T::Struct).returns(String) } + sig { override.params(struct: T::Struct).returns(Output) } def serialize(struct) JSON.generate(struct.serialize) end diff --git a/lib/typed/schema.rb b/lib/typed/schema.rb index 53bdb17..4a38fc6 100644 --- a/lib/typed/schema.rb +++ b/lib/typed/schema.rb @@ -2,16 +2,9 @@ module Typed class Schema < T::Struct - extend T::Sig - include T::Struct::ActsAsComparable const :fields, T::Array[Field], default: [] const :target, T.class_of(T::Struct) - - sig { params(name: Symbol).returns(T.nilable(Field)) } - def field(name:) - fields.find { |field| field.name == name } - end end end diff --git a/lib/typed/serializer.rb b/lib/typed/serializer.rb index c1d314c..f4db1c1 100644 --- a/lib/typed/serializer.rb +++ b/lib/typed/serializer.rb @@ -4,9 +4,13 @@ module Typed class Serializer extend T::Sig extend T::Helpers + extend T::Generic abstract! + Input = type_member + Output = type_member Params = T.type_alias { T::Hash[Symbol, T.untyped] } + DeserializeResult = T.type_alias { Typed::Result[T::Struct, DeserializeError] } sig { returns(Schema) } attr_reader :schema @@ -16,12 +20,28 @@ def initialize(schema:) @schema = schema end - sig { abstract.params(source: String).returns(Typed::Result[T::Struct, DeserializeError]) } + sig { abstract.params(source: Output).returns(DeserializeResult) } def deserialize(source) end - sig { abstract.params(struct: T::Struct).returns(String) } + sig { abstract.params(struct: T::Struct).returns(Output) } def serialize(struct) end + + private + + sig { params(creation_params: Params).returns(DeserializeResult) } + def deserialize_from_creation_params(creation_params) + results = schema.fields.map do |field| + field.validate(creation_params[field.name]) + end + + Validations::ValidationResults + .new(results:) + .combine + .and_then do + Success.new(schema.target.new(**creation_params)) + end + end end end diff --git a/test/struct_ext_test.rb b/test/struct_ext_test.rb index f83b30d..75cf75b 100644 --- a/test/struct_ext_test.rb +++ b/test/struct_ext_test.rb @@ -1,7 +1,5 @@ # typed: true -require "sorbet-schema/struct_ext" - class StructExtTest < Minitest::Test def test_create_schema_is_available assert_equal(PersonSchema, Person.create_schema) diff --git a/test/test_helper.rb b/test/test_helper.rb index 61c5648..0bd6c77 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -10,5 +10,6 @@ require "debug" require "sorbet-schema" +require "sorbet-schema/struct_ext" require_relative "support/schemas/person_schema" diff --git a/test/typed/hash_serializer_test.rb b/test/typed/hash_serializer_test.rb new file mode 100644 index 0000000..110202e --- /dev/null +++ b/test/typed/hash_serializer_test.rb @@ -0,0 +1,59 @@ +# typed: true + +class HashSerializerTest < Minitest::Test + def setup + @serializer = Typed::HashSerializer.new(schema: PersonSchema) + end + + # Serialize Tests + + def test_it_can_simple_serialize + max = PersonSchema.target.new(name: "Max", age: 29) + + assert_equal({name: "Max", age: 29}, @serializer.serialize(max)) + end + + # Deserialize Tests + + def test_it_can_simple_deserialize + max_hash = {name: "Max", age: 29} + + result = @serializer.deserialize(max_hash) + + assert_success(result) + assert_payload(PersonSchema.target.new(name: "Max", age: 29), result) + end + + def test_it_can_simple_deserialize_from_string_keys + max_hash = {"name" => "Max", "age" => 29} + + result = @serializer.deserialize(max_hash) + + assert_success(result) + assert_payload(PersonSchema.target.new(name: "Max", age: 29), result) + end + + def test_it_reports_validation_errors_on_deserialize + max_hash = {name: "Max"} + + result = @serializer.deserialize(max_hash) + + assert_failure(result) + assert_error(Typed::Validations::RequiredFieldError.new(field_name: :age), result) + end + + def test_it_reports_multiple_validation_errors_on_deserialize + result = @serializer.deserialize({}) + + assert_failure(result) + assert_error( + Typed::Validations::MultipleValidationError.new( + errors: [ + Typed::Validations::RequiredFieldError.new(field_name: :name), + Typed::Validations::RequiredFieldError.new(field_name: :age) + ] + ), + result + ) + end +end diff --git a/test/typed/schema_test.rb b/test/typed/schema_test.rb deleted file mode 100644 index 951a28f..0000000 --- a/test/typed/schema_test.rb +++ /dev/null @@ -1,11 +0,0 @@ -# typed: true - -class SchemaTest < Minitest::Test - def test_field_accessor_returns_matched_field - assert_equal(Typed::Field.new(name: :name, type: String), PersonSchema.field(name: :name)) - end - - def test_field_accessor_returns_nil_when_no_matched_fields - assert_nil(PersonSchema.field(name: :missing)) - end -end