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.

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

Comments

  1. 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 :)

    ReplyDelete

Post a comment