The dry-schema
gem is part of a set of gems in the dry-rb
family. Its main focus is data-structure and value-type validation. It is a general-purpose data validation library and multiple times faster than ActiveRecord/ActiveModel::Validations
and strong parameters. It can be used for, but is not restricted to, validating the following:
- Form params
- "GET" params
- JSON documents
- YAML documents
- Application configuration (i.e., stored in ENV)
- Replacement for strong-parameters
If you’re asking, "Do I need it?", a quick example might convince you. Consider a "User" model with name and age attributes in a Rails app with validation for the name attribute as shown below.
Let's add a validation to the user model:
class User < ApplicationRecord
validates :name, presence: true
end
In our Rails console, we can see the result of certain commands run:
We observe that the error shown when the name key is missing is the same as when it's present, but its value is nil
. This leads to some confusion about what exactly made the params unacceptable. However, with dry-schema
, not only can the key presence be verified separately from values, but validations are also separated from the model. Let's dive into dry-schema
and how to use it.
Understanding dry-schema macros
Let's create a new folder named dry-schema
and, within it, create a file called user_schema.rb
. Within this folder, install the gem using the command gem install dry-schema
.
Beginning with tackling the validation error we had above, let's start with the macro called filled. Within the user_schema.rb
file, we'll create a user schema and add a validation for the name field.
module User
require 'dry-schema'
Schema = Dry::Schema.Params do
required(:name).filled(:string)
end
end
Above, we have required the dry-shcema
gem and created a schema for the user params. We have indicated that we require the name field to be present and be filled with a string. Starting an irb session, we can load the file via the command load './user_schema.rb'
.
From the above, we can see that there's a clear distinction between the error shown when a key is absent and when a value is absent. With dry-schema
, we're not in doubt about what exactly went wrong with the params provided. Within Rails, these validations can be carried out on a model instance before it is saved, as opposed to ActiveRecord validations.
The filled
macro should be employed when a value is expected to be filled. This means that the value is non-nil and, in the case of a string, hash, or array value, that the value is not .empty?
.
The opposite of the "filled" macro is the "maybe" macro. It is used when a value can be nil. Macros are specific to the values and not the keys, which means that "maybe" does not in any way signify that a key can be missing or that the value type is not enforced.
module User
require 'dry-schema'
Schema = Dry::Schema.Params do
required(:name).maybe(:string)
end
end
As seen above, there are no errors when the value of the name key is empty or nil, because the "maybe" macro is in play. However, we get an error when the name key is not present because the key is "required". There are several other macros, such as hash, each, schema, and array. You can find out more about them here.
Optional keys
As stated earlier, we can declare a value optional by using the "maybe" macro. However, to make a key optional, the optional
method is invoked.
module User
require 'dry-schema'
Schema = Dry::Schema.Params do
required(:name).filled(:string)
optional(:age).filled(:integer)
end
end
Here, we're requiring the "name" key to have a string value but making it optional to supply the "age" key. However, if the "age" key is present, we expect that it must be filled with a value of integer type.
Notice something cool? One of the features of dry-schema is type coercion. As seen above, despite the age value being supplied as a string type, it is coerced into an integer type. Also, we have no errors when the "age" key is not supplied, however, when supplied, there is an insistence on it being filled with a value of integer type.
Carrying out type and value checks
As seen above, we have already been introduced to two type checks for values: "string" and "integer". However, values can be type-checked directly using the "value" method, and further checks can be carried out on these types, such as size checks, using built-in predicates.
Examples:
module User
require 'dry-schema'
Schema = Dry::Schema.Params do
required(:age).value(:integer, gt?: 18)
end
end
The first error shows us that the type is wrong, but when corrected, the second check for value is carried out using the built-in predicates. This can also be carried further down into arrays:
module User
require 'dry-schema'
Schema = Dry::Schema.Params do
required(:words).value(array[:string], size?: 3)
end
end
As seen above, the errors are thrown accordingly, based on the values provided and how well they conform to our requirements. The dry-schema built-in predicates can also be used to check values directly without initial type checks.
For example:
module User
require 'dry-schema'
Schema = Dry::Schema.Params do
required(:age).value(eql?: 12)
end
end
You’ll find a list of more predicates here and how to use them.
Working with schemas
To accurately work with schemas, one would need to know how to access the result of schema calls and determine whether the calls were successful. Let's assume that a school was hosting a party, and all students would have to enroll for the party; however, there are conditions to be met before being successfully enrolled. We can draw up a schema of the types and values we would accept, validate the params provide using this schema, and if successful, go ahead and enroll a student.
class PartyEnrollment
attr_accessor :enrollment_list
def initialize
@enrollment_list = []
end
def enroll(enrollment_params)
validation = Student::PartySchema.call(enrollment_params)
if validation.success?
enroll_student(validation)
else
reject_student(validation)
end
end
private
def enroll_student(validation)
student_details = validation.to_h
enrollment_list.push(student_details[:name])
"You have been successfully enrolled"
end
def reject_student(validation)
errors = validation.errors.to_h
errors.each do |key, value|
puts "Your #{key} #{value.first}"
end
end
end
As seen above, we have a PartyEnrollment
class, which, when initialized, possesses an enrollment list. When the enroll
method is called, we ask the StudentSchema
to validate the parameters supplied. To determine whether the validation was successful, we have the .success?
method, and to check whether it wasn't, we have the .failure?
method. If successful, we proceed to the enroll_student
method, where we see that we can access the result of this validation as a hash by calling the to_h
method on it; otherwise, we go ahead and reject that student using the reject_student
method, where we access the errors by calling the errors
method on the validation and then, proceed to convert it to a hash for easy accessibility.
Next in line would be to write the student schema to determine the rules we want to be applied. Let's assume that in our case, we would want the name and age filled, and we would be checking that the student is above 15 years of age.
module Student
require 'dry-schema'
PartySchema = Dry::Schema.Params do
required(:name).filled(:string)
required(:age).filled(:integer, gt?: 15)
end
end
Let's see if this works 🤞.
Re-using schemas
Schemas can be re-used. This ensures that we can keep our code dry. In addition to the example above, let's include an address to the required parameters to enable the school bus to drop students off at home after the supposed party. However, we're certain that this is not the only occasion where we would be required to save a student's address. Let's create an address schema to take care of this need:
AddressSchema = Dry::Schema.Params do
required(:street).filled(:string)
required(:city).filled(:string)
required(:zipcode).filled(:string)
end
We can use the AddressSchema
within the party schema this way:
PartySchema = Dry::Schema.Params do
required(:name).filled(:string)
required(:age).filled(:integer, gt?: 15)
required(:address).filled(AddressSchema)
end
Attempting to enroll a student for the party without providing the proper address results in the following errors:
As seen above, the address field is being validated by the AddressSchema
; furthermore, since the address is a hash, the error for the address is in that same format {:address=>{:city=>["is missing"],:zipcode=>["is missing"]}}
. As a result, the error we're returning to the user will need to be rewritten to take this into consideration.
Conclusion
In the dry-schema documentation, we find the following schema-definition best practices:
- Be specific about the exact shape of the data, and define all the keys that you expect to be present.
- Specify optional keys, too, even if you don't need additional rules to be applied to their values.
- Specify type specs for all values.
- Assign schema objects to constants for convenient access.
- Define a base schema for your application with a common configuration.
If adhered to, parameter validation can be carried out seamlessly, thereby reducing the number of errors encountered within an application. You can find more information about dry-schema in the documentation.