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.
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:
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:
Move the rescue clauses to the repository.
Introduce monads to return a Success or Failure monad based on the outcome of the repository code.
Update our controller code to pattern match on the possible outcomes.
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:
Successful responses.
Invalid params. These tests handle responses from our PostContract class.
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.