A small shard for dealing with input/output schema & validation

Hello,

I’ve made Tarot’s Schema, a small shard used to validate input and output from/to json.

https://github.com/tarot-cr/schema

One of the frustrations I got during my previous developments in Crystal is how to handle dynamic input/output.
I found it very nice in Typescript, where interfaces and unions definition is very easy. Except that in Typescript we never check the validity of the data, we just cast it and assume it’s correct.

Anyway, I was playing with JSON::Serializable and testing some shards but nothing was really giving me this “vibe” that I can in a few seconds define what I want, even if the structure is complex with a lot of branching/unions.

Using JSON::Any is far from perfect too, so I’ve built this. I had good fun writing it, and handling Generic/Schema Inheritance wasn’t easy.

Features are pretty dense, you can check a complex usage here: https://github.com/tarot-cr/schema/blob/main/sample/complex_example.cr to get a grasp of what this is capable of.

I’m still polishing the documentation, writing CI/CD, and I’m waiting for some remarks if someone is interested to test it :slight_smile:.

1 Like

Tried to check it out but looks like it isn’t set for public access.

2 Likes

Yeah, I didn’t realize that, I just changed it to public now !

The combination of data modeling and validation is kinda neat, same with the nested schemas to reduce boilerplate. But I’m not so sure there is enough to make it worth using over JSON::Serializable which also supports pretty much all of the features you point out, but using normal constructors/getters and is built into the stdlib. Especially if you use structs with only getters to transfer the data. You also then have the JSON::Serializable::Strict and JSON::Serializable::Unmapped modules that can also be included, and use_json_descriminator for constructing the right class based on a key.

E.g.

require "json"

struct MySchema
  include JSON::Serializable

  def initialize(@content : String); end

  getter content : String
end

pp MySchema.new 0                           # => compiler error
pp MySchema.new "foo"                       # => MySchema(@content="foo")
pp MySchema.from_json %({"content": "bar"}) # => MySchema(@content="bar")
pp MySchema.from_json %({"age": 12})        # => Unhandled exception: Missing JSON attribute: content parsing MySchema at line 1, column 1 (JSON::SerializableError)
pp MySchema.from_json %({"content": 12})    # => Unhandled exception: Expected String but was Int at line 1, column 15 parsing MySchema#content at line 1, column 2 (JSON::SerializableError)

There’s also a little known serialization feature, see hook post_deserialize method in Serializable · Issue #12048 · crystal-lang/crystal · GitHub, that is perfect for simple validations. In that you could do something like

struct MyObj
  include JSON::Serializable

  getter age : Int32
  getter email : String

  def after_initialize
    errors = [] of String
    errors << "too_old" if @age > 100
    errors << "invalid_email" unless @email.matches? /[^@]+@[^@]+/

    raise ValidationError.new errors unless errors.empty?
  end
end

IMO it keeps things more readable as you don’t need to use any extra special DSL/macro logic. Not sure if you played with it at all, but i also think Athena’s Validator component plays super well with JSON::Serializable. E.g.

struct MyObj
  include JSON::Serializable
  include AVD::Validatable

  @[Assert::Range(1..100)]
  getter age : Int32

  @[Assert::Email(:html5)]
  getter email : String
end

obj = MyObj.from_json %({"age": 105, "email": "blah"})
pp obj # => MyObj(@age=105, @email="blah")

puts AVD.validator.validate obj # =>
# Object(MyObj).age:
#   This value should be between 1 and 100. (code: 7e62386d-30ae-4e7c-918f-1b7e571c6d69)
# Object(MyObj).email:
#   This value is not a valid email address. (code: ad9d877d-9ad1-4dd7-b77b-e419934e5910)
6 Likes