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

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
  2. With Rails 6.1.4.1 the lines

    errors.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?

    ReplyDelete
    Replies
    1. 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

Post a Comment