Skip to content

Extendable serialization and deserialization to various formats for Sorbet T::Structs

License

Notifications You must be signed in to change notification settings

maxveldink/sorbet-schema

Repository files navigation

Sorbet Schema

Extendable serialization and deserialization to various formats for Sorbet T::Structs.

Installation

Install the gem and add to the application's Gemfile by executing:

$ bundle add sorbet-schema

If bundler is not being used to manage dependencies, install the gem by executing:

$ gem install sorbet-schema

Usage

Sorbet Schema is designed to be compatible with Sorbet's T::Struct class, and seeks to update many of the common pitfalls developers encountering when deserializing to and serializing from a T::Struct.

Getting Started

While you can directly define a Typed::Schema to be used for your serialization needs, you'll typically use the provided helper class method to generate a Schema from an existing T::Struct.

class Person < T::Struct
  const :name, String
  const :age, Integer
end

schema = Person.schema # => <Typed::Schema
#                              fields=[....]
#                              target=Person>

Once you have a schema, you can use the built-in serializers (or a custom one that inherits from the Typed::Serializer abstract base class) to create new instances of the struct or convert an instance of the struct to the target format.

json_serializer = Typed::JSONSerializer.new(schema: Person.schema)

# Deserialize from target format
result = json_serializer.deserialize('{"name":"Max","age":29}')
max = result.payload # == Person.new(name: "Max", age: 29)

result = json_serializer.serialize(max)
result.payload # == '{"name":"Max","age":29}'

Alternatively, you can use the built-in helper methods added to T::Structs to quickly use the built-in serializers.

result = Person.deserialize_from(:json, '{"name":"Max","age":29}')
max = result.payload # == Person.new(name: "Max", age: 29)

result = max.serialize_to(:json)
result.payload # == '{"name":"Max","age":29}'

Notice that both deserialize and serialize return Typed::Results (from the sorbet-result gem) that need to be checked for success or failure before being used. Check out that gem's README for more information on how to interact with Results.

One benefit of using Results is we can add much more details information about why a format is unsuccessfully deserialized or serialized, to provide call sites with more information for error handling, messaging and formatting.

# Unparsable JSON
result = json_serializer.deserialize('{"name""Max","age":29}')
result.error # == Typed::ParseError: json could not be parsed. Check for typos.

# Missing required field
result = json_serializer.deserialize('{"age": 29}')
result.error # == Typed::Validations::RequiredFieldError: name is required.

result = json_serializer.deserialize('{"age":"29-0"}')
result.error # == Typed::Validations::MultipleValidationError: Multiple validation errors found: name is required. | '29-0' cannot be coerced into Integer.

Finally, there are built-in coercers that do their best effort to convert common types from the source format to the required schema type.

# Deserialize from target format, with integer coercion
result = json_serializer.deserialize('{"name":"Max","age":"29"}')
max = result.payload # == Person.new(name: "Max", age: 29)

Rails Example

Here's an extended example of how Sorbet Schema can be combined with a normal Rails request to easily convert between formats.

def verify
  Typed::HashSerializer
    .new(schema: Address.schema) # Generate schema from the `Address` Struct
    .deserialize(address_params.to_h) # Use Rails' strong parameters to deserialize into the struct
    .and_then { |address| VerifyAddress.new.call(address: T.cast(address, Address)) } # Use sorbet-result's chaining
    .and_then do |address|
        return render json: Typed::JSONSerializer.new(schema: Address.schema).serialize(address).payload # return a JSON response from the Address struct instance
    end
    .on_error do |failure| # Use sorbet-result's error handling
      case failure
      when AddressNotFoundError
        head :not_found
      when GeoNotSupportedError
        head :not_implemented
      else
        render json: failure, status: :bad_request # use `Typed::Failure`s built-in `to_json` behavior
      end
    end
end

Available Serializers

These are the currently available serializers. For more information about implementing a custom one (or contributing one back!), see Custom Coercers.

JSONSerializer

See Getting Started for more information on how to use the JSONSerializer.

HashSerializer

While not strictly serialization, converting T::Structs to and from Ruby Hashes has traditionally had many pitfalls (well-documented in the Sorbet docs). The Typed::HashSerializer aims to address several common issues, while providing the same Result handling for invalid or missing data and coercion behavior.

To use it, simply instantiate and use it like the JSONSerializer:

hash_serializer = Typed::HashSerializer.new(schema: Person.schema)

# Deserialize from target format
result = hash_serializer.deserialize({"name" => "Max", age: 29})
max = result.payload # == Person.new(name: "Max", age: 29)

By default, the HashSerializer will not serialize values when converting to a Hash. For instance, if a field is an T::Enum type, when it is serialized to a Hash the value will be the Enum and not the String representation. The should_serialize_values option can be passed during initialization to serialize the values when converting to the Hash.

Customization

From the get-go, Sorbet Schema is designed to be extensible to model more complex data validation requirements and many serialization formats. We try out best to include built-in, battle-tested coercers and serializers from real world use cases and would love to see/upstream any customizations that the community have found useful!

Custom Coercers

At their simplest forms, coercers are any class that inherit from the Typed::Coercion::Coercer abstract base class. The list of default coercers that are applied can be found in the CoercerRegistry. Let's look at the DateCoercer's implementation:

require "date"

class DateCoercer < Coercer
  extend T::Generic

  Target = type_member { {fixed: Date} }

  sig { override.params(type: T::Types::Base).returns(T::Boolean) }
  def used_for_type?(type)
    T::Utils.coerce(type) == T::Utils.coerce(Date)
  end

  sig { override.params(type: T::Types::Base, value: Value).returns(Result[Target, CoercionError]) }
  def coerce(type:, value:)
    return Failure.new(CoercionError.new("Type must be a Date.")) unless used_for_type?(type)

    return Success.new(value) if value.is_a?(Date)

    Success.new(Date.parse(value))
  rescue Date::Error, TypeError
    Failure.new(CoercionError.new("'#{value}' cannot be coerced into Date."))
  end
end

Notice that this utilizes sorbet generic, so the target type must be defined using type_member. For dates, this is the built-in std lib type Date.

From there, implement the used_for_type? method which receives a type and returns true if the coercer can be used to coerce to that type or false if it should not be used. Notice that we use the T::Types module directly from Sorbet, which allows us to model the built-in Sorbet types, such as T::Boolean and T::Array. Typically, T::Utils.coerce(TargetType) is used to match the target type. For dates, this is a very simple type check for a Date.

Finally, implement the coerce method. If a coercion is successful, return a Success.new(coerced_value). If not, return a Failure with a coercion error Failure.new(CoercionError.new("I can't coerce to the type")). Take care to handle any exceptions that could arise from the attempted coercion. For dates, first it checks and make sure the type given matches the target type. This is a common check and is largely an edge case check for completeness. Next, if the value is already a Date we simply return a Success with it. Finally, we use the built-in Date.parse method to actually attempt a coercion. Since this can throw a Date::Error and a TypeError, rescue from those with a Failure.

Once a custom coercer is defined, the last step is to register it with Sorbet Schema during initialization. Typically, this is after sorbet-schema has been required or during the bootstrapping step of a framework, such as Rails' initializers. Call register_coercer like so:

Typed::Coercion.register_coercer(MyCoercer) # make sure `MyCoercer` is loaded by this point

Note Custom coercers are prepended to the list of available coercers so that they are checked during deserialization before the built-in coercers. This allows consuming projects to override default behavior by creating a coercer that re-implements the coerce method for that type.

Inline Serializers

Sometimes, there is custom behavior that needs to be added to how a field is serialized (represented as a String), such as when you need to use a different strftime format for Dates and Times. This can be accomplished with an InlineSerializer (defined in Typed::Field), which is a Proc that takes the value and returns a different representation. At present, these are both very loose T.untyped types to allow for flexibility. Typically, a String is returned.

The serializer can be used when creating a Schema and defining its fields, or with the add_serializer helper on Schemas.

my_date_serializer = ->(date) { date.strftime("%Y/%m") }

# use directly on a Schema
Typed::Schema.new(
  target: SchemaWithDateField,
  fields: [
    Typed::Field.new(name: :date, type: Date, serializer: my_date_serializer)
  ]
)

# use `add_serializer` helper
SchemaWithDateField.schema.add_serializer(:date, my_date_serializer)

Implementing Custom Serializers

While Sorbet Schema ships with popular serializers, you can define your own by inheriting from Typed::Serializer. Let's look at the JSONSerializer:

require "json"

class JSONSerializer < Serializer
  Input = type_member { {fixed: String} }
  Output = type_member { {fixed: String} }

  sig { override.params(source: Input).returns(Result[T::Struct, DeserializeError]) }
  def deserialize(source)
    parsed_json = JSON.parse(source)

    creation_params = schema.fields.each_with_object(T.let({}, Params)) do |field, hsh|
      hsh[field.name] = parsed_json[field.name.to_s]
    end

    deserialize_from_creation_params(creation_params)
  rescue JSON::ParserError
    Failure.new(ParseError.new(format: :json))
  end

  sig { override.params(struct: T::Struct).returns(Result[Output, SerializeError]) }
  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(serialize_from_struct(struct: struct, should_serialize_values: true)))
  end
end

Since Serializer is a generic class, we need to define our Input and Output types. For JSON, deserialization and serialization both use JSON strings, so these are both strings.

Next, the deserialize and serialize methods must be implemented. Notice that both of these return Results.

For deserialization, the JSON is parsed (and a parse error is handled). Then we build up a creation params hash from the parsed json to pass to the deserialize_from_creation_params helper, defined on Serializer.

For serialization, the passed struct is checked to make sure it matches the Schema. Then it uses the serialize_from_struct helper and passes the resulting Hash to generate JSON.

Inspirations

This project is heavily inspired by serde from the Rust community and the dry-rb family of gems.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake to run Standard and the tests. bin/console for an interactive prompt that aids with experimentation.

To install this gem onto a local machine, run bundle exec rake install.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/maxveldink/sorbet-schema. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in this project's codebase, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.

Sponsorships

I love creating in the open. If you find this or any other maxveld.ink content useful, please consider sponsoring me on GitHub.