Nested API parameter validation in Rails with ActiveModel::Validations


Guardians of our API

You can find the final code example integrated to a Rails app here.

The Story

In an earlier project we had a few affiliate partners with a deep desire to interact with our system in an automated fashion. So, I naturally created a new basic API endpoint for them to send leads to our system. Then we started testing the integration together and everything was rosy until we met some internal hard to debug errors in our end because of the missing or wrong data we received.

So, I decided to put a validation layer in front of our API to prevent further hours of tedious debugging. (This proved to be worth it on the long run.)

The Circumstances

Unfortunately because of some “minor” legacy on the product level, we couldn’t just define all the validation rules on the model level and plug 'em into the new endpoint.

So, I chose to leverage the powerful ActiveModel::Validations and to 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 and their address with 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.


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:


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:


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 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 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 deeper 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!