Introduce literal serializers (#355)
This PR introduces flexible literal serialization contexts that allow
you to:
1. Build a graph of objects and types that are *serializable* in this
context
2. Serialize data against a type schema
3. Deserialize JSON data against the same type schema
4. Type check data to ensure it is serializable
5. Type check types to ensure the objects they describe are serializable
A `Literal::SerializationContext` is a set of rules about which objects
are serializable and how they should be serialized. It’s essentially a
collection of inter-connected serializers.
You can create a serialization context like this
```ruby
MySerializationContext = Literal::SerializationContext.new(
Literal::IntegerSerializer,
Literal::StringSerializer,
Literal::ArraySerializer
)
```
This context has a `type` which is the type of any object that can be
serialized in this context.
```ruby
MySerializationContext.type === [1] # true
MySerializationContext.type === ["hello"] # true
MySerializationContext.type === [1, -> { true }] # false
```
It also has a `kind` which is the type of any type that describes
serializable objects.
```ruby
MySerializationContext.kind === Integer # true, integers are serializable
MySerializationContext.kind === 1 # true, anything that matches the type 1 also matches the type Integer so 1 is serializable
MySerializationContext.kind === _Integer(1..) # true, anything that matches this type must be an integer too
MySerializationContext.kind === _Union(_Integer(100..), _Integer(..10)) # true, anything that matches this union must be an integer
MySerializationContext.kind === Array # false, arrays can contain non-serializable objects
MySerializationContext.kind === _Array(Integer) # true, arrays of integers *are* serializable
```
This kind is useful for validating types of types. For example, you
could define a class with literal properties but override the `prop`
method to only allow serializable types.
```ruby
def self.prop(name, type, *, **, &)
unless MySerializationContext.kind === type
raise ArgumentError, "The type #{type.inspect} is not a serializable type"
end
super
end
```
This would give you boot-time validation for these types.
You can serialize to JSON data using `serialize`, which takes a value
and a type.
```ruby
json_data = MySerializationContext.serialize([1], type: _Array(Integer))
```
The value must be described by the type and the type must be described
by the serialization context *kind*.
We will also validate that this serialization step returns valid
`_JSONData?`.
To deserialize the data, you can use the `deserialize` method, which
takes JSON data and a type.
```ruby
data = MySerializationContext.deserialize(json_data, type: _Array(Integer))
```
Because the serialization is based on the schema, we don’t typically
need to store any metadata in the serialized output.
Quick aside: what is `_JSONData?`? It’s a type that represents the union
of all JSON data types mapped to native Ruby primitives. Essentially all
the types you could get from parsing JSON. So it includes `String`,
`Integer`, `Float`, `Boolean`, `Array`, `Hash`, and `nil` but does not
include `Symbol`.
When we created the serialization context, we passed in a set of
serializers. These are special classes that are initialized by the
serialization context. Instances must respond to:
- `tag` returns a unique symbol that can be used to tag the type when
necessary (unions)
- `type` returns a type that describes the objects it can serialize
- `kind` returns a type that describes the types of objects it can
serialize (higher-order type)
- `serialize(value, type:)` returns the value serialized to JSON data
- `deserialize(raw, type:)` returns the raw JSON data deserialized back
to the type
Serializers are passed the serialization context at initialization. This
allows them to reference the serializable object and serializable type
kind from the context in their own types.
The `Literal::ArraySerializer` for example defines its type as an array
of any object that is serializable by the serialization context.
```ruby
def initialize(context)
@context = context
@type = _Array(@context.type)
@kind = _Kind(@type)
end
```
When it comes to serializating the arrays, the array serializer can
delegate serialization of nested objects to the context.
```ruby
def serialize(value, type:)
member_type = type.type
value.map do |item|
@context.serialize(item, type: member_type)
end
end
def deserialize(raw, type:)
member_type = type.type
raw.map do |item|
@context.deserialize(item, type: member_type)
end
end
```
Literal will offer a wide set of built in serializers, but you can also
make your own.
authored by