Object Validation with Assert
(Source/Credits: https://dev.to/blacksmoke16/object-validation-with-assert-25p6)
UPDATE: Oct 21, 2019 Update examples for Assert v0.2.0. Assert Assert is an annotation ba...
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.
```crystal @[Assert::NotBlank] property name : String
@[Assert::GreaterThanOrEqual(value: 0)] property age : Int32 ```
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:
- 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.
```crystal 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 ```
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.
```crystal @[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 ```
In this example, we're defining a custom assertion called Exists
with the following methods:
- initializer - A helper macro that defines the initializer.
- See the API Docs for more information on what the initializer is used for.
- See Assertion.initializer for more information on the macro itself
- 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
orfalse
.
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.
```crystal class Post < SomeORM::Model include Assert
def initialize; end
@[Assert::Exists(User)] property author_id : Int64 = 17 end ```
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.
```crystal 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
```
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.
bash
curl -X POST \
http://localhost:3000/users \
-H 'Content-Type: application/json' \
-d '{
"name": "Jim",
"age": -1,
"email": "foobar",
"password": "monkey123"
}'
Would return the following error
json
{
"code": 400,
"message": "Validation tests failed",
"errors": [
"'age' should be greater than or equal to '0'",
"'foobar' is not a proper email"
]
}
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.
crystal
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
Would output:
json
{
"code": 400,
"message": "Validation tests failed",
"errors": {
"age": [
"'age' should be greater than or equal to '0'"
],
"email": [
"'foobar' is not a proper email"
]
}
}
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.
crystal
@[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
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
```crystal 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
```
Our controller action would also slightly change.
```crystal 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 ```
POSTing a valid user would return:
json
{
"id": 1,
"age": 17,
"name": "Jim",
"email": "test@gmail.com",
"password": "monkey123"
}
Conclusion
Assert
's benefits:
- Having a framework agnostic validation library that can be used across shards.
- Sharing validation logic between your API controllers and ORM models for example.
- Easily extensible by the user.
- Custom assertions can be easily defined without needing to edit any shard's source code.
- 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
- Such as the
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.
Comments section