Introduce Input Data Validation Layer Using Dry-validations

Photo of Kamil Walkowiak

Kamil Walkowiak

Updated Jan 10, 2023 • 12 min read
markus-spiske-8OyKWQgBsKQ-unsplash (1)

The validation of the input data is a very common problem that we face almost every day.

The correct and expected input is very important in order to achieve proper and expected execution flow.

When it comes to the implementation of the data validators, we very often keep the logic of the input validation in the controllers. This is not something wrong but it may lead to the situations when the controller takes too much responsibilities and as a result becomes too large and mode difficult to maintain. In a perfect world, our controllers should only focus on supervising the process of handling the request. The controller should only know who it should call in order to achieve certain goals (not how to achieve those goals). In that case, it would be perfect if we could separate our data validation logic from the actual controller logic.

In my previous blog post, I presented you how to use the Dry stack to make our services even better. In fact, we can use the Dry stack to improve the process of the input data validation too. We can achieve it using the Dry-validation gem and in this blog post I’m going to show you how to use this gem in order to introduce a separate data validation layer.

Example case recap

For the purpose of this blog post, we will continue to use the example case from my previous blog post. So, let’s say that you need to implement a single endpoint that books a conference room. The endpoint accepts parameters listed below:

{
  room_id: Integer, # database id of the room to book
  start_date: Time, # date and time of the reservation start
  end_date: Time,   # date and time of the reservation end
  notes: String,    # some optional notes
}

In such case, there is an obvious need for data validation, e.g. we need to ensure that the room was booked once in given time period or need to check if a room with given room_id even exists in our database. It would also be nice to be able to validate the type of value provided for each parameter (so we are sure that start_date contains an actual date, not some random string for example).

Validate controller params using Dry-validations and Dry-schema

Dry-validation is a gem included in the Dry stack. It allows the programmer to define schema of expected input parameters as well as some additional validation rules. Expectations towards input data are expressed using contract objects.

The contract consists of two separate parts:

  • the schema - in this part, you can define the expected schema of your input data (including the expectation regarding the data type). The schema is checked before the validation rules. This part is very useful when you want to check the format of an input JSON in your controller. It is possible to define a schema that will do a simple types check or will perform an automatic coercion and then check types. Under the hood for defining schemas, Dry-validation uses another gem from Dry stack which is Dry-schema. To get more detailed information on how to define more complicated schemas, refer to Dry-schema gem documentation.
  • the rules - this part is connected to the actual validation logic. Here, you can define and check if a record with id (given in the input params) exists, if the data is in a proper range, etc. Rules are more related to the actual validation logic rather than the data format or type check. For each rule, you can define custom error message and decide for which input key it should be added. The rules are executed after validating the data schema. Additionally, if a rule depends on a certain key from data schema, it is checked only when there were no issues connected to that key.

This is only an overview of the core features offered by the Dry-validation gem. To get the description of all the things that it offers, go to the gem's documentation page.

Right now, let's look at an example of a validator powered by the magic of the Dry-validation that will validate the input data of our create reservation endpoint:

class Reservation::CreateValidator < Dry::Validation::Contract
  params do
    required(:room_id).value(:integer)
    required(:start_date).value(:time)
    required(:end_date).value(:time)
    optional(:notes).value(:string)
  end

  rule(:room_id) do
    room = Room.find_by(id: value)

    if room.present?
      values[:room] = room
    else
      key.failure('could not be found')
    end
  end

  rule(:end_date, :start_date) do
    key.failure('must be after start date') if values[:end_date] < values[:start_date]
  end
end

In the validator above, we have defined a schema with automatic type coercion (using the params keyword). We did that because our endpoint can receive the params in the form of plain strings and this way we don't have to worry about casting them properly before passing to the validator object. We are requiring room_id, start_date and end_date params (notes param is optional). We check the type of the input params using the value macro (it is the simplest one provided by the Dry-schema gem).

After defining the expected data schema, we are defining the rules. The first one checks if the room with required id exists. If it exists, we include it in the validator values hash. We are doing it because after validation process is completed the values hash (including all passed parameters after coercion) is returned. This way, we don't have to fetch the room object again after the validation. If there is no room with passed id, we add an error message under the room_id key. Note that while searching for the room, we are using the ActiveRecord find_by function even when we could use the find (as we have the id value). The reason is that find rises an exception if the record is not present and we don't want to do that in our validator. The rule described above is checked only when the room_id param pass the schema validation (so it is present and it has a proper format).

The second rule is simpler, it checks if end_date is after start_date and if not it adds a proper error message under the end_date key. Similarly as the previous rule, it is executed only when the start_date and end_date params pass the schema validation.

Let's test our validator. First, we pass valid data but all params are strings:

irb(main):001:0> params = { room_id: '2', start_date: Time.current.to_s, end_date: (Time.current + 1.hour).to_s }
=> {:room_id=>"2", :start_date=>"2019-09-21 18:49:43 UTC", :end_date=>"2019-09-21 19:49:43 UTC"}
irb(main):002:0> response = Reservation::CreateValidator.new.call(params)
=> #<Dry::Validation::Result{
  :room_id => 2,
  :start_date => 2019-09-21 18:49:43 UTC,
  :end_date => 2019-09-21 19:49:43 UTC,
  :room => #<Room id: 2, name: "Example room", created_at: "2019-09-04 18:44:21", updated_at: "2019-09-04 18:44:21">}
  errors={}>
irb(main):003:0> response.success?
=> true

There are few things worth our attention:

  • After the validation, we get a Dry::Validation::Result object that includes a hash of coerced input params plus the room added in the validator.
  • We can see that the Dry::Validation::Result does not include any errors because the validation ended successfully.
  • Similarly as in the Dry-monads gem, we can call success? method on the Dry::Validation::Result object to get the information whether the validation process ended successfully or not.

Now, let's pass some incorrect data e.g. when the end_date is before the start_date:

irb(main):001:0> params = { room_id: '2', start_date: Time.current.to_s, end_date: (Time.current - 1.hour).to_s }
=> {:room_id=>"2", :start_date=>"2019-09-21 20:27:46 UTC", :end_date=>"2019-09-21 19:27:46 UTC"}
irb(main):002:0> response = Reservation::CreateValidator.new.call(params)
=> #<Dry::Validation::Result{
  :room_id=>2,
  :start_date=>2019-09-21 20:27:46 UTC,
  :end_date=>2019-09-21 19:27:46 UTC,
  :room=>#<Room id: 2, name: "Example room", created_at: "2019-09-04 18:44:21", updated_at: "2019-09-04 18:44:21"&gr;}
  errors={:end_date=>["must be after start date"]}>
irb(main):003:0> response.failure?
=> true
irb(main):004:0> response.errors
=> #<Dry::Validation::MessageSet messages=[#<Dry::Validation::Message text="must be after start date" path=[:end_date] meta={}&gr;] options={}&gr;

Again, few interesting notices:

  • Because the validation was unsuccessful this time, we get some errors in the Dry::Validation::Result.
  • We can call failure? method on the result object to get information if the validation process ended unsuccessfully.
  • We can call errors method to get the Dry::Validation::MessageSet object including all validation error messages.

"Oh, yeah. It's all coming together": integrate the Dry-validation based validator with Dry-monads based service

As Dry-validation is a part of Dry “family”, it can be easily integrated with different gems from this stack. Here, I wanted to show you how to pass the Dry-validation validator response as the input arguments of Dry-monads based service.

First, you need to write a simple Dry-validator initializer that will load the extension for monads:

Dry::Validation.load_extensions(:monads)

After that, you can “convert” the result of Dry-validation based validator response to a Success/Failure monad used by the Dry-monads gem. Now, we can combine the service from my previous blog post and our new validator into one railway-like call:

irb(main):001:0> user = User.first
=> #<User id: 1, active: false, created_at: "2019-12-09 15:45:55", updated_at: "2019-12-09 15:45:55", full_name: ‘Jan Kowalski’>
irb(main):002:0> params = { room_id: '2', start_date: Time.current.to_s, end_date: (Time.current + 1.hour).to_s }
=> {:room_id=>"2", :start_date=>"2019-12-09 15:37:43 UTC", :end_date=>"2019-12-09 16:37:43 UTC"}
irb(main):003:0> Reservation::CreateValidator
  .new
  .call(params)
  .to_monad
  .bind { |r| Reservation::Create.new(r.to_h.merge(user: user)).call }
  .or { |r| # some fancy handler for errors available under r.errors.to_h }
=> Success(
     #<Reservation
       id: 1,
       user_id: 1,
       room_id: 1,
       start_date: "2019-12-09 15:37:43",
       end_date: "2019-12-09 16:37:43",
       notes: nil,
       created_at: "2019-12-09 15:46:26",
       updated_at: "2019-12-09 15:46:26">
   )

We have achieved a single clear call that will validate the input parameters and then (if the validation ends with success) will call the service or report validation errors. In this simple and very readable form we have covered most of the things that are happening in our controllers.

Conclusions

We looked at the functions offered by Dry-validation. We used this gem to introduce a separate layer for data validation. We learned how to adjust the response of the Dry-validation based validator so that it can be directly passed to the Dry-monads based service. But as it always is, this is just the beginning. The Dry stack offers a lot opportunities to better separate the responsibilities between the system components thus making your code cleaner and easier to maintain. Dive into the docs yourself, maybe you will find your own way to include the Dry ecosystem in your projects.

Further reading:

Photo by Markus Spiske on Unsplash

Tags

Photo of Kamil Walkowiak

More posts by this author

Kamil Walkowiak

Kamil has obtained a Master’s degree in Computer Science at Poznań University of Technology. He is...
Lost with AI?  Get the most important news weekly, straight to your inbox, curated by our CEO  Subscribe to AI'm Informed

Read more on our Blog

Check out the knowledge base collected and distilled by experienced professionals.

We're Netguru

At Netguru we specialize in designing, building, shipping and scaling beautiful, usable products with blazing-fast efficiency.

Let's talk business