DEV Community

Blacksmoke16
Blacksmoke16

Posted on • Updated on

Object Validation with Assert

UPDATE: Oct 21, 2019 Update examples for Assert v0.2.0.

Assert

Assert is an annotation based object validation library heavily inspired by Symfony Validation Constraint Annotations.

@[Assert::NotBlank]
property name : String

@[Assert::GreaterThanOrEqual(value: 0)]
property age : Int32
Enter fullscreen mode Exit fullscreen mode

Introduction

Validations in Crystal, up until now, have mostly been shard specific. One shard could have their own implementation for validating POST body data, while an ORM in the same application could be completely different. Assert was initially included in CrSerializer but has since gone through a rewrite and re-released as its own shard with the idea of brining some level of standardization that every shard could benefit from. The main benefit of this would be the ability to use the same validations throughout your application.

This article is intended to be an overview of features/example use cases. Most of the example code comes from the API Docs which contains more detailed documentation on each feature.

Features

Assert was created with flexibility and extensibility in mind, by default it includes:

  • Multitude of built-in assertions:
    • Email
    • Ip
    • URL
    • Choice
    • Sub object/array of objects are all valid
    • Etc.
  • Ability to create/use custom assertions
    • Use generics within custom assertions
  • Ability to run a subset of assertions on an object
  • Control how and when the object is validated

Usage

In order to use Assert:

  • Add it to your shard.yml
  • Require it - require "assert"
  • Include it in your object - include Assert

An example usage can be seen in the API docs.

Groups

Each assertion can also be assigned to a group(s) in order to run subsets of assertions.

class Groups
  include Assert

  def initialize(@group_1 : Int32, @group_2 : Int32, @default_group : Int32); end

  @[Assert::EqualTo(value: 100, groups: ["group1"])]
  property group_1 : Int32

  @[Assert::EqualTo(value: 200, groups: ["group2"])]
  property group_2 : Int32

  @[Assert::EqualTo(value: 300)]
  property default_group : Int32
end

Groups.new(100, 200, 300).valid?                      # => true
Groups.new(100, 100, 100).valid?                      # => false
Groups.new(100, 100, 100).valid?(["group1"])          # => true
Groups.new(200, 100, 300).valid?(["default"])         # => true
Groups.new(100, 200, 200).valid?("group1", "default") # => false
Enter fullscreen mode Exit fullscreen mode

This allows you to reuse the same class/struct in various scenarios since you can control which assertions run. For example, running a different set of assertions when a user registers for the first time, versus when they update some of their information.

Custom Assertions & Generics

If your application has some unique validation requirements that the included assertions do not cover; you can create custom assertions.

A custom assertion is simply a class that inherits from Assert::Assertions::Assertion, applies the Assert::Assertions::Register annotation, and implements some methods.

@[Assert::Assertions::Register(annotation: Assert::Exists)]
# A custom assertion that validates if a record exists with the given *id*.
#
# For example, an ORM model where `.exists?` checks if a record exists with the given PK.
# I.e. `SELECT exists(select 1 from "users" WHERE id = 123);`
class Exists(PropertyType, Model) < Assert::Assertions::Assertion
  # This is a helper macro to make defining the initialize method easier
  initializer(
    actual: PropertyType
  )

  # :inherit:
  def default_message_template : String
    "'%{actual}' is not a valid %{property_name}."
  end

  # :inherit:
  def valid? : Bool
    Model.exists? @actual
  end
end
Enter fullscreen mode Exit fullscreen mode

In this example, we're defining a custom assertion called Exists with the following methods:

  • initializer - A helper macro that defines the initializer.
  • default_message_template - The error message template to use if the assertion fails and no custom message was provided.
    • Instance variables on the assertion can be used within the template by surrounding the ivar's name in double curly braces.
  • valid? - Implements the logic that determines if the property is valid or not.
    • The implementation could be whatever you wish as long as it returns true or false.

We also need to apply the Assert::Assertions::Register annotation, which is used to define the name of the annotation that should be applied to properties. In this case Assert::Exists. Now this assertion is ready to be used.

class Post < SomeORM::Model
  include Assert

  def initialize; end

  @[Assert::Exists(User)]
  property author_id : Int64 = 17
end
Enter fullscreen mode Exit fullscreen mode

In this example, the assertion would run a SQL query to determine if a User with an id of 17 exists.

A Dependency Injection shard, such as Athena's DI Module could also be used to inject the current user/request, or some ACL service into the assertion.

Example Use Cases

Assert is not useful unless there are objects that need validating. For some projects/applications it might not be necessary. However web frameworks and ORMs could easily benefit, as validations are an important part of both.

Web Framework

Web applications have the most apparent need for validations. This could either be validating the POST body in an API, or validating user input from a form submission. However, Assert is not for client side validation. It would not, for example, prevent a user from entering a string in a numeric form field. It would be caught when that form was submitted, and the server runs the validations.

Lets go over an example of how Assert could be used within an API using Kemal.

require "kemal"
require "assert"
require "json"

# user.cr
class User
  include JSON::Serializable
  include Assert

  # Asserts that their age is >= 0 AND not nil
  @[Assert::NotNil]
  @[Assert::GreaterThanOrEqual(value: 0)]
  property age : Int32?

  # Assert their name is not blank
  @[Assert::NotBlank]
  property name : String

  # Assert their email is not blank AND is a valid format
  @[Assert::Email(message: "'%{actual}' is not a proper email")]
  @[Assert::NotBlank]
  property email : String

  # Assert their password is between 7 and 25 characters
  @[Assert::Size(Range(Int32, Int32), range: 7..25)]
  property password : String

  # Have the object be validated after the it is deserialized
  def after_initialize
    validate!
  end
end

# users_controller.cr
post "/users" do |env|
  env.response.content_type = "application/json"
  user = User.from_json env.request.body.not_nil!
  # Do stuff with a valid user

  # Return the user as JSON in the response
  user.to_json
rescue ex : Assert::Exceptions::ValidationError
  env.response.status_code = 400
  env.response.print ex.to_json
end

Kemal.run  
Enter fullscreen mode Exit fullscreen mode

This setup makes the validations on User run after the object has been deserialized from the JSON POST body. If the object is not valid #validate! will raise a ValidationError exception. We are catching that exception and returning the JSON error message back to the user.

Assert also defines #valid? and #validate methods which return a Bool if it the object is valid, or an array of failed assertions respectively. The former being most useful if you just want to know if an object is valid, with the latter being most useful if you wanted to do something with the assertion data, such as formatting an error message.

curl -X POST \
  http://localhost:3000/users \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Jim",
    "age": -1,
    "email": "foobar",
    "password": "monkey123"
}'
Enter fullscreen mode Exit fullscreen mode

Would return the following error

{
    "code": 400,
    "message": "Validation tests failed",
    "errors": [
        "'age' should be greater than or equal to '0'",
        "'foobar' is not a proper email"
    ]
}
Enter fullscreen mode Exit fullscreen mode

The JSON error response format can be changed by overriding the #to_json(builder : JSON::Builder) method within the exception class; if you wanted to group the errors by the property name for example.

class Assert::Exceptions::ValidationError
  def to_json(builder : JSON::Builder)
    builder.object do
      builder.field "code", 400
      builder.field "message", @message
      builder.field "errors" do
        builder.object do
          @failed_assertions.group_by(&.property_name).each do |prop, errors|
            builder.field prop, errors.map &.message
          end
        end
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Would output:

{
    "code": 400,
    "message": "Validation tests failed",
    "errors": {
        "age": [
            "'age' should be greater than or equal to '0'"
        ],
        "email": [
            "'foobar' is not a proper email"
        ]
    }
}
Enter fullscreen mode Exit fullscreen mode

This, of course, is just a demonstration. A framework could easily integrate Assert to make the process more streamlined. Such as how its used within Athena.

@[Athena::Routing::Post(path: "users")]
@[Athena::Routing::ParamConverter(param: "body", type: User, converter: Athena::Routing::Converters::RequestBody)]
def new_user(body : User) : User
  body
end
Enter fullscreen mode Exit fullscreen mode

The ParamConverter handles converting the request body into a User object, any validations on User will also be executed after it has been deserialized. The JSON error will be returned automatically if it is not valid. The controller action will also never execute if the object is not valid.

While using Assert may make your objects larger, it removes the need for validation within the controller code as you know can be assured that invalid objects will not make it far. It also allows you to share the same validation logic from other parts of your application. Such as an ORM.

ORM Models

ORMs models inherently require validation to make sure what is being persisted to the database is correct. Continuing along with our Kemal example, we could easily convert our User object into a Granite model.

NOTE: This is just an example and does not include setting up the ORM

require "granite"
require "assert"

class User < Granite::Base
  include Assert

  table "users"

  column id : Int64?, primary: true

  # Asserts that their age is >= 0 AND not nil
  @[Assert::NotNil]
  @[Assert::GreaterThanOrEqual(value: 0)]
  column age : Int32?

  # Assert their name is not blank
  @[Assert::NotBlank]
  column name : String

  # Assert their email is not blank AND is a valid format
  @[Assert::Email(message: "'%{actual}' is not a proper email")]
  @[Assert::NotBlank]
  column email : String

  # Assert their password is between 7 and 25 characters
  @[Assert::Size(Range(Int32, Int32), range: 7..25)]
  column password : String

  # Granite includes `JSON::Serializble` by default
  def after_initialize
    validate!
  end
end
Enter fullscreen mode Exit fullscreen mode

Our controller action would also slightly change.

post "/users" do |env|
  env.response.content_type = "application/json"
  user = User.from_json env.request.body.not_nil!
  # We can now just save the user since we know its valid
  user.save

  # Return the user as JSON in the response
  user.to_json
rescue ex : Assert::Exceptions::ValidationError
  env.response.status_code = 400
  env.response.print ex.to_json
end
Enter fullscreen mode Exit fullscreen mode

POSTing a valid user would return:

{
    "id": 1,
    "age": 17,
    "name": "Jim",
    "email": "test@gmail.com",
    "password": "monkey123"
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Assert's benefits:

  1. Having a framework agnostic validation library that can be used across shards.
    • Sharing validation logic between your API controllers and ORM models for example.
  2. Easily extensible by the user.
    • Custom assertions can be easily defined without needing to edit any shard's source code.
  3. Easy to integrate into existing frameworks/shards.
    • Current projects/frameworks could easily add Assert to their objects and control when/how they get validated.
      • Such as the Kemal example. Moving validations into the objects and out of the controller actions
      • Or an ORM that would make sure the model is valid before saving

As usual, if you have any questions, feedback, or ideas for new assertions; feel free to message me on the Crystal Gitter, or create an issue on the Github Repo.

Top comments (0)