DEV Community

Cover image for Validate Ruby objects with Active Model Validations
Phil Nash for Twilio

Posted on • Updated on • Originally published at twilio.com

Validate Ruby objects with Active Model Validations

In the world of Rails and Active Record, validating data and storing it in a database is easy. If you'd ever built a simple site that stores data in a Google Spreadsheet then you'd quickly learn that users can enter anything (or nothing). In this post we'll see how to validate input using part of Active Record: ActiveModel::Validations.

Spreadsheets can be databases too

In my last post we built a landing page for a new app using Sinatra and Google Spreadsheets as the database. The app needs one improvement though; users can enter any data they want in the form and it will happily submit. We need to guarantee we get some real data if we are going to contact our users when the app launches.

We should validate our input and ensure that

  • We get at least a name and email address
  • If we get an email address, it looks like a valid email address
  • If we get a phone number then it is a valid number

Let's improve this app so it can do all of the above.

Getting setup

You'll need a Google Spreadsheet and the application from the previous post if you want to follow along with the code in this tutorial. Don't worry if you haven't got those set up, the instructions to do so are below.

If you don't already have a Spreadsheet setup and permissions to edit it via the API then follow this post to generate your credentials, saved as a file called client_secret.json. Make sure to give your service edit access to your spreadsheet too.

Next, clone or download the application and check out the save-data branch. This is the current state of the app from the end of the previous blog post.

git clone https://github.com/philnash/ruby-google-sheets-sinatra.git
cd ruby-google-sheets-sinatra
git checkout save-data
Enter fullscreen mode Exit fullscreen mode

Install the dependencies with Bundler and run the application and visit at http://localhost:4567 to make sure it's working as expected.

bundle install
bundle exec ruby app.rb
Enter fullscreen mode Exit fullscreen mode

An animation showing a user completing the form in the application we are working with, and successfully posting their data to the spreadsheet.

To start with, the application needs a bit of a refactor.

Plain old Ruby objects

Currently the Sinatra application we built just takes the form parameters the user submits and sends them straight to the Google Sheets API. To start our refactoring of this process it would be better to capture the input as an object that we can then reason about. Create a class we can use to encapsulate this data.

class UserDetails
  attr_reader :name, :email, :phone_number
  def initialize(name=nil, email=nil, phone_number=nil)
    @name = name
    @email = email
    @phone_number = phone_number
  end

  def to_row
    [name, email, phone_number]
  end
end
Enter fullscreen mode Exit fullscreen mode

Give the class an initializer that receives the name, email and phone number as arguments and stores them as instance variables. Include an attr_reader to expose these properties and a to_row method that returns the properties in the correct format to add a row to the spreadsheet.

Now we can update our route to use this object instead of the raw parameters.

post "/" do
  user_details = UserDetails.new(params["name"], params["email"], params["phone_number"])
  begin
    worksheet.insert_rows(worksheet.num_rows 1, [user_details.to_row])
    worksheet.save
    erb :thanks
  rescue
    erb :index, locals: {
      error_message: "Your details could not be saved, please try again."
    }
  end
end
Enter fullscreen mode Exit fullscreen mode

If you start the application again you should find it still works as before. The work we've done so far is just a useful refactoring for the next part; validating the data.

Validation with ActiveModel::Validations

To make validating our user details easy and recognisable to any Rails developer, we can use ActiveModel::Validations to validate the object. Start by adding ActiveModel to your Gemfile.

# frozen_string_literal: true
source "https://rubygems.org"

gem "sinatra"
gem "google_drive"
gem "activemodel", require: "active_model"
Enter fullscreen mode Exit fullscreen mode

Update your dependencies by running bundle install. We're ready to add some validations to our objects.

Out of the box validations

Validating that the user submits a name and email address is nice and easy with ActiveModel::Validations. Let's add the validations to the UserDetails class. Include the ActiveModel::Validations module into the UserDetails class. Then use the class method validates to validate the presence of both the name and email fields.

class UserDetails
  include ActiveModel::Validations

  attr_reader :name, :email, :phone_number

  validates :name, presence: true
  validates :email, presence: true

  # etc
end
Enter fullscreen mode Exit fullscreen mode

You can test this has worked by loading the application up in irb. Just run

irb -r ./app.rb
Enter fullscreen mode Exit fullscreen mode

This will give you a Ruby REPL with access to the UserDetails class.

user_details = UserDetails.new
user_details.valid?
#=> false
user_details.errors.full_messages
#=> ["Name can't be blank", "Email can't be blank"]
user_details2 = UserDetails.new("Phil", "Phil's email")
user_details2.valid?
#=> true
Enter fullscreen mode Exit fullscreen mode

ActiveModel::Validations has added the valid? and errors methods to the UserDetails object. Those will be useful later, there are more validations to write first.

We also wanted to check that the email address at least looks like an email address. Email validation doesn't need to be 100% perfect, the only real way to check an email address belongs to the person that entered it is to send it an email, but it would be nice to catch obvious typos. We can use the format validation to check against a regular expression. I have no wish to write my own regular expression to check for email address formats, so I'm going to borrow one from Devise.

class UserDetails
  include ActiveModel::Validations

  attr_reader :name, :email, :phone_number

  validates :name, presence: true
  validates :email, presence: true, format: { with: /A[^@s] @[^@s] z/, allow_blank: true }

  # etc
end
Enter fullscreen mode Exit fullscreen mode

Custom validations

We want to check that if the user supplies a phone number that it looks valid too. There is no out of the box validation or good regular expression for this. This is where custom validations are useful. The next thing to do is to write a custom validation using the Twilio Lookups API, wrapping up the technique Greg showed when he wrote about verifying phone numbers in Ruby.

First, we should add the Twilio Ruby gem to the Gemfile.

# frozen_string_literal: true
source "https://rubygems.org"

gem "sinatra"
gem "google_drive"
gem "activemodel", require: "active_model"
gem "twilio-ruby"
Enter fullscreen mode Exit fullscreen mode

Install the latest dependencies with bundle install.

When you write a custom validation for an attribute you need to create a subclass of ActiveModel::EachValidator and implement the validate_each method.

Create a new Twilio::REST::LookupsClient to use to access the Lookups API. I use environment variables to supply my Twilio credentials, you can do the same (check out this post by Dominik on how to set environment variables if you don't know how) or just add in your actual Account SID and Auth Token from your Twilio console.

Look up the value, which will be the phone number submitted by the user, with the lookups_client, with response = lookups_client.phone_numbers.get(value). The Twilio library is lazy, so it doesn't actually perform the request until we try to inspect a property of the result, so call on response.phone_number. In this case, the API will return successfully if the phone number is real and will return a 404 error if it isn't. As Greg described you need to rescue if there is a Twilio::REST::RequestError and check for a 20404 code (20404 is the Twilio API's version of a 404). If it is 20404, add an error to the record's errors object for the attribute being validated. If some other error occurred then raise the error again.

class PhoneNumberValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    lookups_client = Twilio::REST::LookupsClient.new(ENV["TWILIO_ACCOUNT_SID"], ENV["TWILIO_AUTH_TOKEN"])
    begin
      response = lookups_client.phone_numbers.get(value)
      response.phone_number
    rescue Twilio::REST::RequestError => error
      if error.code == 20404
        record.errors[attribute] << (options[:message] || 'is not a valid phone number')
      else
        raise error
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The custom validator is written, so we can use it like our existing ones. Since the phone number is not a required field, add allow_blank: true to the options.

  validates :name, presence: true
  validates :email, presence: true, format: { with: /A[^@s] @[^@s] z/, allow_blank: true }
  validates :phone_number, phone_number: { allow_blank: true }
Enter fullscreen mode Exit fullscreen mode

Validating on submit

We have written our validations for our class, so they need to be put to action. Earlier we updated our post '/' route to use the UserDetails class. Now we need to update again to use our new valid? method to render the index again if the object isn't valid.

post "/" do
  user_details = UserDetails.new(params["name"], params["email"], params["phone_number"])
  if user_details.valid?
    begin
      worksheet.insert_rows(worksheet.num_rows 1, [user_details.to_row])
      worksheet.save
      erb :thanks
    rescue
      erb :index, locals: {
        error_message: "Your details could not be saved, please try again."
      }
    end
  else
    erb :index
  end
end
Enter fullscreen mode Exit fullscreen mode

Restart the application and load it up in the browser. Try submitting an empty form. You should no longer see the page that says "thank you", rather the index will be rendered again. But we're getting no feedback either. We need to send our user_details object to the view and use the user_details.errors object to display error messages. To make it easy on our view, update both routes to send a user_details object.

get "/" do
  erb :index, locals: { user_details: UserDetails.new }
end

post "/" do
  user_details = UserDetails.new(params["name"], params["email"], params["phone_number"])
  if user_details.valid?
    begin
      worksheet.insert_rows(worksheet.num_rows 1, [user_details.to_row])
      worksheet.save
      erb :thanks
    rescue
      erb :index, locals: {
        error_message: "Your details could not be saved, please try again."
      }
    end
  else
    erb :index, locals: { user_details: user_details }
  end
end
Enter fullscreen mode Exit fullscreen mode

Update the form in views/index.erb to display the feedback. You can use user_details.errors.include?(attribute_name) to calculate whether the field does have any errors, and add a "has-error" class to the surrounding <div>. To display the errors for an attribute, loop through user_details.errors.full_messages_for(attribute_name), printing out the error message each time.

Here's the fully updated form from the template:

        <form action="/" method="POST">
          <div class="form-group<%= ' has-error' if user_details.errors.include?(:name) %>">
            <label for="name" class="control-label">Name</label>
            <input type="text" class="form-control" name="name" id="name" value="<%= user_details.name %>">
            <% user_details.errors.full_messages_for(:name).each do |message| %>
              <span class="help-block"><%= message %></span>
            <% end %>
          </div>
          <div class="form-group<%= ' has-error' if user_details.errors.include?(:email) %>">
            <label for="email" class="control-label">Email address</label>
            <input type="email" class="form-control" name="email" id="email" value="<%= user_details.email %>">
            <% user_details.errors.full_messages_for(:email).each do |message| %>
              <span class="help-block"><%= message %></span>
            <% end %>
          </div>
          <div class="form-group<%= ' has-error' if user_details.errors.include?(:phone_number) %>">
            <label for="phone_number" class="control-label">Phone number</label>
            <input type="tel" class="form-control" name="phone_number" id="phone_number" value="<%= user_details.phone_number %>">
            <% user_details.errors.full_messages_for(:phone_number).each do |message| %>
              <span class="help-block"><%= message %></span>
            <% end %>
          </div>
          <button type="submit" class="btn btn-primary">Submit</button>
        </form>
Enter fullscreen mode Exit fullscreen mode

Restart the app one more time and try to enter some invalid data.

Now when you try to submit the form without any data you will see error messages on the form elements that are invalid.

Success! We are validating our data and showing feedback to the user if something has gone wrong. Fill in the correct data and it is posted through to our Google Spreadsheet.

A spreadsheet full of sensible data

You've seen how to validate data easily within any Ruby application, including writing custom validations. And our main Ruby app is still under 70 lines long. Check out this branch on GitHub with the final code for our application.

ActiveModel has some other useful modules you can take advantage of outside of Rails applications. Check out ActiveModel::Translation for internationalisation or ActiveModel::Serialization if you are building an API.

Have you used ActiveModel::Validations without Active Record before? Or built applications using other Rails components outside of Rails. I'd be interested to hear what you're experiences have been. Drop me a comment below or hit me up on Twitter at @philnash.


Validate Ruby objects with Active Model Validations was originally published on the Twilio blog on June 14, 2017.

Top comments (0)