
You can find the final code example integrated to a Rails app here.
The Story
In an earlier project we had affiliate partners with a deep desire to interact with our system in an automated fashion. So I created new API endpoints for them to send leads to our system. Then we started testing the integration together and everything was rosy until we met some hard-to-debug errors because of the incomplete data we received.
So, I decided to put a validation layer in front of our API to prevent further hours of tedious debugging.
The Circumstances
Unfortunately, because of some “minor” legacy, we couldn’t just define all the validation rules on the model level.
I chose to leverage the power of ActiveModel::Validations and create a new ParamsValidation module to house all the necessary rules and functionality.
The API Design
The Expected Input Data
To simplify the madness: let’s say we need a customer with a name, an address and a postal code.
{
  customer: {
    name: 'Customer Name',
    address: {
      postal_code: '1234'
    }
  }
}
The Expected Error Response
In case of an empty post request the endpoint should return a 422 response with the following body:
{
  errors: {
    customer: {
      name: ["can't be blank"],
      address: {
        postal_code: ["can't be blank"]
      }
    }
  }
}
Testing The New Module
They say decent engineers start with the tests, so let’s imitate those professionals.
describe Affiliate::Lead::ParamsValidation do
  context 'no data' do
    it 'returns all the errors' do
      validator = described_class.validator({})
      validator.validate
      expected_errors = {
        customer: {
          name: ["can't be blank"],
          address: {
            postal_code: ["can't be blank"]
          }
        }
      }
      expect(validator.errors.to_hash).to eq(expected_errors)
    end
  end
  context 'minimum valid data' do
    it 'passes the validation' do
      params = {
        customer: {
          name: 'Name',
          address: {
            postal_code: '1234'
          }
        }
      }
      validator = described_class.validator(params)
      expect(validator.valid?).to eq(true)
    end
  end
end
The Implementation
We start with a helper method, so the outside world doesn’t need to worry about the details.
# lib/affiliate/lead/params_validation.rb
module Affiliate
  module Lead
    module ParamsValidation
      def self.validator(params)
        Main.new(params)
      end
    end
  end
end
A common base class to DRY things up and to allow the validation rule classes to focus on the rules:
# lib/affiliate/lead/params_validation/base.rb
module Affiliate
  module Lead
    module ParamsValidation
      class Base
        include ActiveModel::Validations
        attr_reader :data
        def initialize(data)
          @data = data || {}
        end
        def read_attribute_for_validation(key)
          data[key]
        end
        protected
        def add_nested_errors_for(attribute, other_validator)
          errors.messages[attribute] = other_validator.errors.messages
          errors.details[attribute]  = other_validator.errors.details
        end
      end
    end
  end
end
Classes to encapsulate the actual validation rules:
# lib/affiliate/lead/params_validation/main.rb
module Affiliate
  module Lead
    module ParamsValidation
      class Main < Base
        validate :validate_customer
        private
        def validate_customer
          customer_validator = Customer.new(data[:customer])
          return if customer_validator.valid?
          add_nested_errors_for(:customer, customer_validator)
        end
      end
    end
  end
end
# lib/affiliate/lead/params_validation/customer.rb
module Affiliate
  module Lead
    module ParamsValidation
      class Customer < Base
        validates_presence_of :name
        validate :validate_address
        private
        def validate_address
          address_validator = Address.new(data[:address])
          return if address_validator.valid?
          add_nested_errors_for(:address, address_validator)
        end
      end
    end
  end
end
# lib/affiliate/lead/params_validation/customer/address.rb
module Affiliate
  module Lead
    module ParamsValidation
      class Customer
        class Address < Base
          validates_presence_of :postal_code
        end
      end
    end
  end
end
Further Refactoring
There is some repetition in the validate_customer and validate_address methods. We could extract the functionality into the Base class into a class level helper method like validate_nested_attribute.
So, we can have even slimmer validation rule classes:
module Affiliate
  module Lead
    module ParamsValidation
      class Customer < Base
        validates_presence_of :name
        validate_nested_attribute :address, Address
      end
    end
  end
end
However, this would require some metaprogramming and more extensive testing. I find the current solution to be good enough to start with.
Rails Controller Integration
class Affiliate::LeadsController < ApplicationController
  before_action :validate_params
  def create
    head 201
  end
  private
  def validate_params
    validator = Affiliate::Lead::ParamsValidation.validator(params)
    return if validator.valid?
    render json: { errors: validator.errors }, status: 422
  end
end
Closing Thoughts
It is worth checking out the Grape gem. It has a built-in validation that can integrate with grape swagger to auto-generate Swagger docs for your API, which is pretty neat.
Please let me know about your experience with this topic or if you have any suggestions!
Nice post! I really enjoyed it and like the approach. I always face the same problem, so I created a gem for validating the params. You can have a look here https://github.com/felipefava/request_params_validation if you want, and of course any feedback is welcome :)
ReplyDeleteWith Rails 6.1.4.1 the lines
ReplyDeleteerrors.messages[attribute] = other_validator.errors.messages
errors.details[attribute] = other_validator.errors.details
produce FrozenError "can't modify frozen Hash" for me because the implementation of ActiveModel::Errors changed in some recent Rails version.
I changed it to following working code:
errors.add(attribute, other_validator.errors.full_messages.join(' | ')) if other_validator.invalid?
Indeeed, Rails 6.1 changed the ActiveModel::Errors class (https://code.lulalala.com/2020/0531-1013.html). Unfortunately it doesn't handle the nesting as before, I tried a few tricks but unfortunately non worked. I've just got a new job, but the problem sounds interesting, I'll look into that when I have time.
Delete