Back to home

Dry Validation With Rails main image

Dry Validation With Rails

This post will continue on from the previous post on the repository pattern in Rails.

In this post we will be adding in dry-validation and dry-monads to clean up both our controller and tests, as well as more explicitly handle scenarios in our repository code.

Source code can be found here. If you need to continue on from the previous code, then you can checkout the repo-pattern branch and work from there.

Prerequisites

  1. Optionally read through the previous post.

Getting started

We will be working on from the previous repo. Alter the following code as required to suit whether or not you already have the repo:

# Clone our project $ git clone https://github.com/okeeffed/demo-repo-pattern-dry-validation $ cd demo-repo-pattern-dry-validation $ git checkout repo-pattern # Add in the gems that we will require for this post $ bundler add dry-validation dry-monads # Create the files that we will need for this post $ mkdir -p app/contracts $ touch app/contracts/post_contract.rb # Start the server $ bin/rails s

At this stage, our project is now ready and has the extra gems that will use to clean up the code.

Adding our post validator

The first step will be adding in our validator for the Post model.

Inside the app/contracts/post_contract.rb file, add the following:

require 'dry/validation' class PostContract < Dry::Validation::Contract params do required(:title).filled(:string) required(:rating).filled(:string) end end

dry-validation has some handy docs, so you can use them to reference what is capable.

In our particular case, we just want to ensure that there is a title and a rating that are both strings and are available.

Whenever there are errors, our resulting Dry::Validation::Result object will have a :errors key that will contain a hash of the errors.

In our case, we are about to do some clean up of our code, so we will be using the dry-monads gem to handle the errors. Before that though, I wanted to show some input values and demonstrate the errors that we are wanting to return with invalid input:

// Example 1: { "title": "Another valid post" } // ... response { "message": "Unprocessable entity", "errors": { "rating": [ "must be filled" ] } } // Example 2: { "rating": "Good" } // ... response { "message": "Unprocessable entity", "errors": { "title": [ "must be filled" ] } } // Example 3: { "title": "Another valid post", "rating": 123 } // ... response { "message": "Unprocessable entity", "errors": { "rating": [ "must be a string" ] } }

At this stage, we want to begin refactoring our controller and our repository code.

Refactoring our controller

In the previous code, we were essentially letting errors bubble up to the controller. An example of our create workflow is the following:

# app/repositories/posts_repository.rb class PostsRepository class << self def create(title:, rating:) post = Post.new(title: title, rating: rating) post.save! # `save!`` will raise if there is an error, so we assume here # that we were successful and return the post to follow RESTful conventions. # @see https://restfulapi.net/http-status-200-ok/ post end # ... rest omitted end end # app/controllers/posts_controller.rb require 'posts_repository' class PostsController < ApplicationController def create post = PostsRepository.create(title: params[:title], rating: params[:rating]) render json: post, status: :ok rescue ActiveRecord::ActiveRecordError render json: { message: 'Internal server error' }, status: :internal_server_error rescue StandardError render json: { message: 'Internal server error' }, status: :internal_server_error end # ... rest omitted end

Letting that error bubble up so loosely isn't an ideal situation. We want to actively handle the error in the file and then make a decision on how we want to manage it from the repository file.

Our refactor is going to do the following:

  1. Move the rescue clauses to the repository.
  2. Introduce monads to return a Success or Failure monad based on the outcome of the repository code.
  3. Update our controller code to pattern match on the possible outcomes.
  4. Ensure that we introduce our freshly validation into the controller. We also want this to be handled by the pattern matching.

Let's update our PostsRepository file to the following:

require 'dry/monads' class PostsRepository class << self include Dry::Monads[:result] def create(title:, rating:) post = Post.new(title: title, rating: rating) post.save! # `save!`` will raise if there is an error, so we assume here # that we were successful and return the post to follow RESTful conventions. # @see https://restfulapi.net/http-status-200-ok/ Success(post) rescue ActiveRecord::ActiveRecordError => e Failure(e) rescue StandardError => e Failure(e) end # ... rest omitted end end

As you can see, we are now handling the errors in the repository. From reading our code now, we know exactly what sort of errors we may expect to run into while creating our new post.

In the error handling for both cases, we are currently returning the error wrapped in the Failure monad. This enables us to keep this method functional, and we can return the error up to the controller to action any side-effects such as error logging.

Back in our controller, we can now pattern match on the possible outcomes. Update the controller code to the following:

require 'dry/validation' require 'dry/monads' require 'posts_repository' require 'post_contract' Dry::Validation.load_extensions(:monads) class PostsController < ApplicationController include Dry::Monads[:result, :do] def create case create_post in Success(post) then render json: post, status: :ok in Failure(ActiveRecord::ActiveRecordError) then internal_server_error(code: '002') in Failure(StandardError) then internal_server_error(code: '001') in Failure(Dry::Validation::Result => result) then unprocessable_entity(result: result) else internal_server_error(code: '003') end end # ... index and show omitted private def create_post # Validate the params contract = PostContract.new res = yield contract.call(title: params[:title], rating: params[:rating]).to_monad # Create the post PostsRepository.create(title: params[:title], rating: params[:rating]) end def internal_server_error(code:) render json: { message: 'Internal server error', code: code }, status: :internal_server_error end def unprocessable_entity(result:) render json: { message: 'Unprocessable entity', errors: result.errors.to_h }, status: :unprocessable_entity end def not_found render json: { message: 'Not found' }, status: :not_found end end

In our code now, we have some private helper functions to help with both creating the post as well as common responses that will be used once we refactor the index and show methods.

Our create_post method is where we now bring in our contract and run the validation with our yield contract.call invocation. Using the to_monad extension, we can have that validation return Success or Failure monads.

If validation fails, a Failure(Dry::Validation::Result will be returned early from the create_post call and handled in our create method in the pattern matching block.

If validation succeeds, then we make a call to PostsRepository.create. Based on result monads that we are now returning from that invocation, they too will end up being part of the pattern matching in the create method.

I have also included a code argument for our private internal_server_error method. Given that we may return different errors in the future, we can use this to differentiate between them.

With all of that said, we can now see that our create method is down to a measly 7 lines of code, and for anyone now coming into our codebase, they should see from there exactly how we expect our endpoint to respond based on all expected scenarios.

Note: As mentioned previously, the side-effects can be handled in the controller. If we wanted to send logs to a logging service, we could do that here. For example, Failure(StandardError) could be changed to Failure(StandardError => e) and we could pass that error to be logged from our private handler method.

Awesome, our first refactor is looking good, so let's get to the rest before we get into our testing.

Refactoring the rest

I won't spend too much time explaining the changes here. They are essentially the same, with the one different being that our find_by_id repository method is handling a ActiveRecord::RecordNotFound error differently (which will also return a 404 error).

The completed repo code looks like this:

require 'dry/monads' class PostsRepository class << self include Dry::Monads[:result] def create(title:, rating:) post = Post.new(title: title, rating: rating) post.save! # `save!`` will raise if there is an error, so we assume here # that we were successful and return the post to follow RESTful conventions. # @see https://restfulapi.net/http-status-200-ok/ Success(post) rescue ActiveRecord::ActiveRecordError => e Failure(e) rescue StandardError => e Failure(e) end def find_all posts = Post.all Success(posts) rescue ActiveRecord::ActiveRecordError => e Failure(e) rescue StandardError => e Failure(e) end def find_by_id(id:) post = Post.find_by!(id: id) Success(post) rescue ActiveRecord::RecordNotFound => e Failure(e) rescue ActiveRecord::ActiveRecordError => e Failure(e) rescue StandardError => e Failure(e) end end end

As for the controller, the completed code looks like so:

require 'dry/validation' require 'dry/monads' require 'posts_repository' require 'post_contract' Dry::Validation.load_extensions(:monads) class PostsController < ApplicationController include Dry::Monads[:result, :do] def create case create_post in Success(post) then render json: post, status: :ok in Failure(ActiveRecord::ActiveRecordError) then internal_server_error(code: '002') in Failure(StandardError) then internal_server_error(code: '001') in Failure(Dry::Validation::Result => result) then unprocessable_entity(result: result) else internal_server_error(code: '003') end end def index case PostsRepository.find_all in Success(posts) then render json: posts, status: :ok in Failure(ActiveRecord::ActiveRecordError) then internal_server_error(code: '002') in Failure(StandardError) then internal_server_error(code: '001') else internal_server_error(code: '003') end end def show case PostsRepository.find_by_id(id: params[:id]) in Success(post) then render json: post, status: :ok in Failure(ActiveRecord::RecordNotFound) then not_found in Failure(ActiveRecord::ActiveRecordError) then internal_server_error(code: '002') in Failure(StandardError) then internal_server_error(code: '001') else internal_server_error(code: '003') end end private def create_post # Validate the params contract = PostContract.new res = yield contract.call(title: params[:title], rating: params[:rating]).to_monad # Create the post PostsRepository.create(title: params[:title], rating: params[:rating]) end def internal_server_error(code:) render json: { message: 'Internal server error', code: code }, status: :internal_server_error end def unprocessable_entity(result:) render json: { message: 'Unprocessable entity', errors: result.errors.to_h }, status: :unprocessable_entity end def not_found render json: { message: 'Not found' }, status: :not_found end end

At this point, it is required that we refactor our test code as we are now returning monads instead of raising errors around the place.

Refactoring our tests

I am going to be kind to myself here and only work through the PostsController#create tests here. Effectively the tests all ended up very similar (and I didn't bother refactoring for DRYness).

The tests for the PostsController#create method look like the following:

require 'rails_helper' require 'dry/monads' require 'post_contract' require 'posts_repository' Dry::Validation.load_extensions(:monads) RSpec.describe PostsController, type: :controller do include Dry::Monads[:result] # ... GET #index and GET #show testing omitted describe 'POST #create' do context 'successful responses' do it 'returns 200 and created post when the post is valid' do stubbed_response = { id: 1, title: 'Title', rating: 'Good' } posts_respository_klass = class_double(PostsRepository, create: Success(stubbed_response)).as_stubbed_const expect(posts_respository_klass).to receive(:create).with(title: 'Title', rating: 'Good') post :create, params: { title: 'Title', rating: 'Good' }, as: :json expect(response).to have_http_status(:ok) expect(response.parsed_body).to eq(JSON.parse(stubbed_response.to_json)) end end context 'invalid params' do it 'returns 422 when there are invalid params for title' do res = { message: 'Unprocessable entity', errors: { title: ['must be a string'] } } posts_respository_klass = class_double(PostsRepository).as_stubbed_const expect(posts_respository_klass).to_not receive(:create) post :create, params: { title: 100, rating: 'Good' }, as: :json expect(response).to have_http_status(:unprocessable_entity) expect(response.parsed_body).to eq(JSON.parse(res.to_json)) end it 'returns 422 when there are invalid params for rating' do res = { message: 'Unprocessable entity', errors: { rating: ['must be a string'] } } posts_respository_klass = class_double(PostsRepository).as_stubbed_const expect(posts_respository_klass).to_not receive(:create) post :create, params: { title: 'Title', rating: 100 }, as: :json expect(response).to have_http_status(:unprocessable_entity) expect(response.parsed_body).to eq(JSON.parse(res.to_json)) end end context 'unsuccessful responses' do it 'returns 500 when there is an ActiveRecord::ActiveRecordError' do res = { message: 'Internal server error', code: '002' } posts_respository_klass = class_double(PostsRepository, create: Failure(ActiveRecord::ActiveRecordError.new)).as_stubbed_const expect(posts_respository_klass).to receive(:create).with(title: 'Title', rating: 'Good') post :create, params: { title: 'Title', rating: 'Good' }, as: :json expect(response).to have_http_status(:internal_server_error) expect(response.parsed_body).to eq(JSON.parse(res.to_json)) end it 'returns 500 when there is a StandardError' do res = { message: 'Internal server error', code: '001' } posts_respository_klass = class_double(PostsRepository, create: Failure(StandardError.new)).as_stubbed_const expect(posts_respository_klass).to receive(:create).with(title: 'Title', rating: 'Good') post :create, params: { title: 'Title', rating: 'Good' }, as: :json expect(response).to have_http_status(:internal_server_error) expect(response.parsed_body).to eq(JSON.parse(res.to_json)) end it 'returns 500 when there is an unexpected state' do res = { message: 'Internal server error', code: '003' } posts_respository_klass = class_double(PostsRepository, create: Failure(Exception.new)).as_stubbed_const expect(posts_respository_klass).to receive(:create).with(title: 'Title', rating: 'Good') post :create, params: { title: 'Title', rating: 'Good' }, as: :json expect(response).to have_http_status(:internal_server_error) expect(response.parsed_body).to eq(JSON.parse(res.to_json)) end end end end

I have split the testing for the #create method into three separate contexts:

  1. Successful responses.
  2. Invalid params. These tests handle responses from our PostContract class.
  3. Unsuccessful responses.

Throughout the tests, I am using RSpec class doubles to mock out the PostsRepository class as our unit tests do not require a persistence layer.

The class doubles allow us to stub out specific methods responses and verify that the response for the controller operates as expected based on our pattern matching.

While I won't go too deep into the tests, there are some things worth noting:

  • My 500 response tests ensure we are checking the correct error code to ensure that we are not hitting our fallback pattern matching branch.
  • Our 422 response tests rely on the PostContract class to ensure that we are returning the correct error messages for invalid input. This is not an exhaustive set of tests, but it is a good start.
  • The success response tests are pretty simple. We are just ensuring that the response is correct for an expected entity value.

My final tests look like this:

require 'rails_helper' require 'dry/monads' require 'post_contract' require 'posts_repository' Dry::Validation.load_extensions(:monads) RSpec.describe PostsController, type: :controller do include Dry::Monads[:result] describe 'GET #index' do context 'successful responses' do it 'returns empty successful array when there are no posts' do stubbed_response = [] posts_respository_klass = class_double(PostsRepository, find_all: Success([stubbed_response])).as_stubbed_const expect(posts_respository_klass).to receive(:find_all) get :index expect(response).to have_http_status(:success) expect(response.parsed_body).to eq(JSON.parse(stubbed_response.to_json)) end it 'returns correct array when there are posts' do posts = [{ id: 1, title: 'Cool post', rating: 'Good' }] posts_respository_klass = class_double(PostsRepository, find_all: Success([posts])).as_stubbed_const expect(posts_respository_klass).to receive(:find_all) get :index expect(response).to have_http_status(:success) expect(response.parsed_body).to eq(JSON.parse(posts.to_json)) end end context 'unsuccessful responses' do it 'returns 500 when there is an ActiveRecord::ActiveRecordError' do res = { message: 'Internal server error', code: '002' } posts_respository_klass = class_double(PostsRepository, find_all: Failure(ActiveRecord::ActiveRecordError.new)).as_stubbed_const expect(posts_respository_klass).to receive(:find_all) get :index expect(response).to have_http_status(:internal_server_error) expect(response.parsed_body).to eq(JSON.parse(res.to_json)) end it 'returns 500 when there is a StandardError' do res = { message: 'Internal server error', code: '001' } posts_respository_klass = class_double(PostsRepository, find_all: Failure(StandardError.new)).as_stubbed_const expect(posts_respository_klass).to receive(:find_all) get :index expect(response).to have_http_status(:internal_server_error) expect(response.parsed_body).to eq(JSON.parse(res.to_json)) end end end describe 'GET #show' do context 'successful responses' do it 'returns correct array when there is a post with the expected ID' do stubbed_response = { id: 1, title: 'Cool post', rating: 'Good' } posts_respository_klass = class_double(PostsRepository, find_by_id: Success(stubbed_response)).as_stubbed_const expect(posts_respository_klass).to receive(:find_by_id).with(id: '1') get :show, params: { id: '1' } expect(response).to have_http_status(:success) expect(response.parsed_body).to eq(JSON.parse(stubbed_response.to_json)) end end context 'unsuccessful responses' do it 'returns 500 when there is an ActiveRecord::ActiveRecordError' do res = { message: 'Internal server error', code: '002' } posts_respository_klass = class_double(PostsRepository, find_by_id: Failure(ActiveRecord::ActiveRecordError.new)).as_stubbed_const expect(posts_respository_klass).to receive(:find_by_id).with(id: '1') get :show, params: { id: '1' } expect(response).to have_http_status(:internal_server_error) expect(response.parsed_body).to eq(JSON.parse(res.to_json)) end it 'returns 500 when there is a StandardError' do res = { message: 'Internal server error', code: '001' } posts_respository_klass = class_double(PostsRepository, find_by_id: Failure(StandardError.new)).as_stubbed_const expect(posts_respository_klass).to receive(:find_by_id).with(id: '1') get :show, params: { id: '1' } expect(response).to have_http_status(:internal_server_error) expect(response.parsed_body).to eq(JSON.parse(res.to_json)) end it 'returns 404 when there is no matching post' do res = { message: 'Not found' } posts_respository_klass = class_double(PostsRepository).as_stubbed_const allow(posts_respository_klass).to receive(:find_by_id).and_return(Failure(ActiveRecord::RecordNotFound.new)) expect(posts_respository_klass).to receive(:find_by_id).with(id: '1') get :show, params: { id: '1' } expect(response).to have_http_status(:not_found) expect(response.parsed_body).to eq(JSON.parse(res.to_json)) end end end describe 'POST #create' do context 'successful responses' do it 'returns 200 and created post when the post is valid' do stubbed_response = { id: 1, title: 'Title', rating: 'Good' } posts_respository_klass = class_double(PostsRepository, create: Success(stubbed_response)).as_stubbed_const expect(posts_respository_klass).to receive(:create).with(title: 'Title', rating: 'Good') post :create, params: { title: 'Title', rating: 'Good' }, as: :json expect(response).to have_http_status(:ok) expect(response.parsed_body).to eq(JSON.parse(stubbed_response.to_json)) end end context 'invalid params' do it 'returns 422 when there are invalid params for title' do res = { message: 'Unprocessable entity', errors: { title: ['must be a string'] } } posts_respository_klass = class_double(PostsRepository).as_stubbed_const expect(posts_respository_klass).to_not receive(:create) post :create, params: { title: 100, rating: 'Good' }, as: :json expect(response).to have_http_status(:unprocessable_entity) expect(response.parsed_body).to eq(JSON.parse(res.to_json)) end it 'returns 422 when there are invalid params for rating' do res = { message: 'Unprocessable entity', errors: { rating: ['must be a string'] } } posts_respository_klass = class_double(PostsRepository).as_stubbed_const expect(posts_respository_klass).to_not receive(:create) post :create, params: { title: 'Title', rating: 100 }, as: :json expect(response).to have_http_status(:unprocessable_entity) expect(response.parsed_body).to eq(JSON.parse(res.to_json)) end end context 'unsuccessful responses' do it 'returns 500 when there is an ActiveRecord::ActiveRecordError' do res = { message: 'Internal server error', code: '002' } posts_respository_klass = class_double(PostsRepository, create: Failure(ActiveRecord::ActiveRecordError.new)).as_stubbed_const expect(posts_respository_klass).to receive(:create).with(title: 'Title', rating: 'Good') post :create, params: { title: 'Title', rating: 'Good' }, as: :json expect(response).to have_http_status(:internal_server_error) expect(response.parsed_body).to eq(JSON.parse(res.to_json)) end it 'returns 500 when there is a StandardError' do res = { message: 'Internal server error', code: '001' } posts_respository_klass = class_double(PostsRepository, create: Failure(StandardError.new)).as_stubbed_const expect(posts_respository_klass).to receive(:create).with(title: 'Title', rating: 'Good') post :create, params: { title: 'Title', rating: 'Good' }, as: :json expect(response).to have_http_status(:internal_server_error) expect(response.parsed_body).to eq(JSON.parse(res.to_json)) end it 'returns 500 when there is an unexpected state' do res = { message: 'Internal server error', code: '003' } posts_respository_klass = class_double(PostsRepository, create: Failure(Exception.new)).as_stubbed_const expect(posts_respository_klass).to receive(:create).with(title: 'Title', rating: 'Good') post :create, params: { title: 'Title', rating: 'Good' }, as: :json expect(response).to have_http_status(:internal_server_error) expect(response.parsed_body).to eq(JSON.parse(res.to_json)) end end end end

Again - there is room to improve here on the testing. The next post will be going into using Factory Bot to test our repository, so I will consider refactoring the controller tests there based on what I have learned.

Summary

Today's post demonstrated how to implement dry-validation as an alternative to ActiveModel.

Decoupling our contracts keeps our model files cleaner (in addition to our repository pattern).

Afterwards, we refactored our code so that our functional core (the repository code in this case) handled the errors but without side-effects as expect a range that only consists of Success and Failure values.

Any failures would have the error message returned to controller to be handled at the top based on pattern matching, allowing a centralized place for us to handle side-effects while also enabling a helpful developer experience where anyone new to the controller knows exactly how we expect an endpoint to operate in a few lines of code.

Some thoughts on this: I am not sold on whether or not this is the penultimate approach to working. Admittedly, I am working through a few different patterns and seeing what fits. I do think there is improvement to be made in how we handle errors, but as far as I understand there is no real "catch-all" and that would impede on the developer experience to not illustrate which errors we expect where they happen.

I am hoping to improve on the test code when I dive into using Factory Bot to test our repository code in the next post.

Resources and further reading

Photo credit: joannakosinska

Personal image

Dennis O'Keeffe

@dennisokeeffe92
  • Melbourne, Australia

Hi, I am a professional Software Engineer. Formerly of Culture Amp, UsabilityHub, Present Company and NightGuru.
I am currently working on workingoutloud.dev, Den Dribbles and LandPad .

1,200+ PEOPLE ALREADY JOINED ❤️️

Get fresh posts + news direct to your inbox.

No spam. We only send you relevant content.