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