Dry Validation With Rails

Continuing on with our project, the next step is to add in dry-validation and dry-monads to help tidy up our code.

On this page

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