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
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
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
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
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"
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
You can test this has worked by loading the application up in irb. Just run
irb -r ./app.rb
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
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
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"
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
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 }
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
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
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>
Restart the app one more time and try to enter some invalid data.
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.