Jun 01, 2016
Table of contents:
Over the last couple of week’s we’ve been looking at some of the basic concepts that go into building a Ruby application using Trailblazer.
A technique that is promoted heavily in the Trailblazer book is inheritance. Inheritance is of course one of the basic building blocks of Ruby (Understanding Inheritance in Ruby), and so you are probably already familiar with how it can be used (and abused!).
Inheritance is actually used in a number of Trailblazer components, but in today’s tutorial we are going to be looking at Inheritance in the context of Operations.
Before we get into this article, lets first make it clear what we mean by Operation Inheritance.
As with normal inheritance in Ruby, when you inherit from an existing Operation class, the child class will have access to the methods and properties of it’s parent.
You can then modify anything in the child class that is different, or specialised from the parent.
For example, you might have an Article::Create
Operation that defines certain business rules and methods.
You might also have an Article::Update
Operation that has mostly the same rules and methods, but with a couple of slight adjustments.
In this case, instead of repeating that logic, you could inherit from the Article::Create
and make the adjustments in the Article::Update
.
This would ensure that any changes to the business rules in the future would not leave discrepancies between the two actions.
Of course, inheritance is often a really bad design decision, and so the choice to use inheritance is not always this clear. You should be confident that inheritance is the correct design decision, and not just a way of reducing duplication.
I typically don’t use inheritance that often in my day-to-day programming as I find it often causes more hassle than it’s worth (Understanding Inheritance in Ruby).
However, under certain circumstances, it is most definitely the correct decision.
In today’s tutorial I’m going to using Operation inheritance to deal with the different ways a user can be added to the application.
Firstly I’m going to need to import users from an existing system where I only know the user’s email address. These users will not have a username or password, and they will need to be confirmed by the user.
Secondly I’m going to need a way to create confirmed users so they don’t have to go through the process of confirming their email address. This will allow me to seed the database with users initially, and it will be useful as a factory in my tests.
And thirdly, I need to write the Operation that will be used to create new users once the application is live. This Operation will allow the user to add their username, email address, and password, and then trigger an email confirmation so they can confirm their email address.
So we need 3 different variations of the User::Create
Operation. The rules are largely the same, but there are small variations between the versions. As each implementation is essentially a specialised version, this is a perfect opportunity to use inheritance.
In this tutorial I’m going to be using Rails and Minitest. Trailblazer is decoupled from Rails, and you can use whatever testing framework you want, it doesn’t really matter.
So the first thing I’m going to do is to create a new Rails project. I’ve covered how to do this a number of times, but if you’re unsure, take a look at Getting started with Trailblazer and Ruby on Rails.
Next we need to create the User model, migration, and test file:
bin/rails g model User email:string username:string password_digest:string confirmed_at:timestamp imported_at:timestamp
As you can see this is a fairly typical user database table with the email, username, and password digest columns.
I’m also including the confirmed_at
timestamps to show confirmed users, and the imported_at
timestamp for imported users.
The first thing I’m going to do is to define the Contract for the Operations. As we saw in What are Trailblazer Contracts?, in this example I’m going to create the Contract as a standalone class as I feel it makes understanding the validation rules of these Operations easier.
Create a new file called contract.rb
under your user
concept directory. I’m splitting my Operation classes into a further create
namespace, but you don’t have to if you don’t want to:
require 'reform/form/validation/unique_validator.rb'
module User::Create::Contract
class Base < Reform::Form
model User
property :email
property :username
property :password, virtual: true
validates :email, presence: true, unique: true, email: true
validates :username, username: true, unique: true, allow_blank: true
validates :password, presence: true, length: { minimum: 8 }
end
class Confirmed < Base
validates :username, presence: true
end
class Imported < Base
end
class Default < Base
validates :username, presence: true
end
end
First I define a Base
class that holds the majority of the properties and validation rules.
I then create three child classes for Confirmed
, Imported
, and Default
and then I add any additional validation rules that are required for those specialised instances.
In this case I simply require the presence of the username
in the Confirmed
and Default
instances, and leave it not required in the Imported
instance.
Next up we need to build out the Operation classes for each of the different ways a new user can be created in the application.
Create a new file called operation.rb
in the same concept directory as before:
require 'bcrypt'
module User::Create::Operation
end
Again, I’m using an extra Create
namespace, for this particular application, but that is not required so you don’t have to include it if you don’t want to.
First up I’ll add the Base
class that acts as the abstract class that will be extended from. This class includes a couple of methods we will need in the child classes:
module User::Create::Operation
class Base < Trailblazer::Operation
include Model
model User, :create
def generate_digest
model.password_digest = BCrypt::Password.create(contract.password)
end
def generate_token
model.confirmation_token = SecureRandom.urlsafe_base64
end
def process(params)
raise NotImplementedError,
'User::Create::Operation::Base is an abstract class'
end
end
end
Ruby does not have the concept of abstract classes and so we can simply throw a NotImplementedError
exception if this class is used as a concrete class. Using Ruby Exceptions.
As you can see, I’ve defined a couple of methods for generating digests and tokens.
Next up I will add the Confirmed
implementation:
class Confirmed < Base
contract User::Create::Contract::Confirmed
def process(params)
validate(params[:user]) do |f|
generate_digest
set_confirmed_timestamp
f.save
end
end
def set_confirmed_timestamp
model.confirmed_at = Time.now
end
end
First I set the User::Create::Contract::Confirmed
contract from earlier.
Next, I define the process
method. In this method I validate that the incoming parameters are valid, and then I generate the password digest and I set the confirmed timestamp before saving the properties to the model.
Next up I will add the Imported
implementation:
class Imported < Base
contract User::Create::Contract::Imported
def process(params)
validate(params[:user]) do |f|
generate_digest
generate_token
set_imported_timestamp
f.save
end
end
def set_imported_timestamp
model.imported_at = Time.now
end
end
Again I will first set the Contract on this Operation.
This time, inside the process
method I will validate the incoming parameters, generate the password digest, generate the confirmation token, and then I will set the imported timestamp to show that this was an imported user.
When importing users I’m going to generate a random password so all users do actually have a password in the database.
And finally I will add the Default
implementation:
class Default < Base
contract User::Create::Contract::Default
def process(params)
validate(params[:user]) do |f|
generate_digest
generate_token
f.save
send_confirmtion_email
end
end
def send_confirmtion_email
UserMailer.confirm_membership(model).deliver_later
end
end
Once again I first set the correct Contract on the Operation class.
This time inside of the process
method I validate the incoming parameters, generate the password digest, generate the confirmation token, and then I save the new user to the database.
Once the new user is created I will send the confirmation email as an after save callback.
Here is this class in full:
require 'bcrypt'
module User::Create::Operation
class Base < Trailblazer::Operation
include Model
model User, :create
def generate_digest
model.password_digest = BCrypt::Password.create(contract.password)
end
def generate_token
model.confirmation_token = SecureRandom.urlsafe_base64
end
def process(params)
raise NotImplementedError,
'User::Create::Operation::Base is an abstract class'
end
end
class Confirmed < Base
contract User::Create::Contract::Confirmed
def process(params)
validate(params[:user]) do |f|
generate_digest
set_confirmed_timestamp
f.save
end
end
def set_confirmed_timestamp
model.confirmed_at = Time.now
end
end
class Imported < Base
contract User::Create::Contract::Imported
def process(params)
validate(params[:user]) do |f|
generate_digest
generate_token
set_imported_timestamp
f.save
end
end
def set_imported_timestamp
model.imported_at = Time.now
end
end
class Default < Base
contract User::Create::Contract::Default
def process(params)
validate(params[:user]) do |f|
generate_digest
generate_token
f.save
send_confirmtion_email
end
end
def send_confirmtion_email
# Send email
end
end
end
With all of the Operation implementations in place I can now write some tests to make sure everything is working as it should.
I know this isn’t strictly TDD. I actually did use TDD to get to this point when I initially wrote this code, but I thought it would be easier to understand if I didn’t write this tutorial using TDD.
First up I will ensure that the Base
class throws a NotImplementedError
exception when it is run:
require 'test_helper'
module User::Create::OperationTest
class BaseTest < ActiveSupport::TestCase
test 'throw NotImplementedError exception' do
assert_raises NotImplementedError do
User::Create::Operation::Base.run({})
end
end
end
end
Strictly speaking, you don’t really need this type of test, but I thought I would include it because it shows that “abstract” class technique of throw exceptions for methods that should be implemented in child classes.
Next up we have the Confirmed
implementation tests:
class ConfirmedTest < ActiveSupport::TestCase
def setup
@user =
User::Create::Operation::Confirmed.(user: attributes_for(:user)).model
end
test 'require presence of email username and password' do
res, op = User::Create::Operation::Confirmed.run(user: {})
assert_not(res)
assert_includes(op.errors[:email], "can't be blank")
assert_includes(op.errors[:username], "can't be blank")
assert_includes(op.errors[:password], "can't be blank")
end
test 'email should be a valid email' do
res, op =
User::Create::Operation::Confirmed.run(user: { email: 'invalid email' })
assert_not(res)
assert_includes(op.errors[:email], 'is invalid')
end
test 'email should be unique' do
res, op =
User::Create::Operation::Confirmed.run(user: { email: @user.email })
assert_not(res)
assert_includes(op.errors[:email], 'has already been taken')
end
test 'username should be a valid username' do
res, op =
User::Create::Operation::Confirmed.run(
user: {
username: 'invalid username'
}
)
assert_not(res)
assert_includes(op.errors[:username], 'is invalid')
end
test 'username should be unique' do
res, op =
User::Create::Operation::Confirmed.run(user: { username: @user.username })
assert_not(res)
assert_includes(op.errors[:username], 'has already been taken')
end
test 'password should be greater than 8 characters' do
res, op = User::Create::Operation::Confirmed.run(user: { password: 'abc' })
assert_not(res)
assert_includes(
op.errors[:password],
'is too short (minimum is 8 characters)'
)
end
test 'create confirmed user' do
res, op =
User::Create::Operation::Confirmed.run(user: attributes_for(:user))
assert(res)
assert(op.model.confirmed?)
end
end
I’ll not go through each test as we’ve covered this kind of testing a number of times now and so it should be fairly easy to see what’s going on in each test.
A couple of things I will note.
First I’m seeding a user in the database in the setup
method using the Operation itself.
I’m using attributes_for(:user)
from factory girl Replacing Fixtures with Factory Girl in Ruby on Rails just as a way of generating hashes of correct data when creating users. That’s the only bit of factory girl I’m using, I’m not actually using the factories.
And I’ve added a confirmed?
method to the User
model for checking whether a user is confirmed or not.
Next up we have the Imported
implementation tests:
class ImportedTest < ActiveSupport::TestCase
def setup
@user =
User::Create::Operation::Imported.(user: attributes_for(:imported_user))
.model
end
test 'require presence of email and password' do
res, op = User::Create::Operation::Imported.run(user: {})
assert_not(res)
assert_includes(op.errors[:email], "can't be blank")
assert_includes(op.errors[:password], "can't be blank")
end
test 'email should be a valid email' do
res, op =
User::Create::Operation::Imported.run(user: { email: 'invalid email' })
assert_not(res)
assert_includes(op.errors[:email], 'is invalid')
end
test 'email should be unique' do
res, op =
User::Create::Operation::Imported.run(user: { email: @user.email })
assert_not(res)
assert_includes(op.errors[:email], 'has already been taken')
end
test 'password should be greater than 8 characters' do
res, op = User::Create::Operation::Imported.run(user: { password: 'abc' })
assert_not(res)
assert_includes(
op.errors[:password],
'is too short (minimum is 8 characters)'
)
end
test 'create imported user' do
res, op =
User::Create::Operation::Imported.run(
user: attributes_for(:imported_user)
)
assert(res)
assert_not(op.model.confirmed?)
assert(op.model.imported?)
end
end
And finally we have the Default
implementation tests:
class DefaultTest < ActiveSupport::TestCase
def setup
@user =
User::Create::Operation::Confirmed.(user: attributes_for(:user)).model
end
test 'require presence of email username and password' do
res, op = User::Create::Operation::Default.run(user: {})
assert_not(res)
assert_includes(op.errors[:email], "can't be blank")
assert_includes(op.errors[:username], "can't be blank")
assert_includes(op.errors[:password], "can't be blank")
end
test 'email should be a valid email' do
res, op =
User::Create::Operation::Default.run(user: { email: 'invalid email' })
assert_not(res)
assert_includes(op.errors[:email], 'is invalid')
end
test 'email should be unique' do
res, op = User::Create::Operation::Default.run(user: { email: @user.email })
assert_not(res)
assert_includes(op.errors[:email], 'has already been taken')
end
test 'username should be a valid username' do
res, op =
User::Create::Operation::Default.run(
user: {
username: 'invalid username'
}
)
assert_not(res)
assert_includes(op.errors[:username], 'is invalid')
end
test 'username should be unique' do
res, op =
User::Create::Operation::Default.run(user: { username: @user.username })
assert_not(res)
assert_includes(op.errors[:username], 'has already been taken')
end
test 'password should be greater than 8 characters' do
res, op = User::Create::Operation::Default.run(user: { password: 'abc' })
assert_not(res)
assert_includes(
op.errors[:password],
'is too short (minimum is 8 characters)'
)
end
test 'create default user' do
res, op = User::Create::Operation::Default.run(user: attributes_for(:user))
assert(res)
assert_not(op.model.confirmed?)
assert_not(op.model.imported?)
end
end
If you run all of those tests you should see them pass.
So hopefully this was a good illustration of how to use Trailblazer Operations and inheritance.
In today’s tutorial I required the functionality to create users in three different ways. This is the perfect opportunity to use inheritance because we can say that each method is a specialisation of the create user process.
In this example we benefit from being able to reuse the methods to create the password digest and create a confirmation token. But probably more important is the semantic importance of understanding the relationship between the three classes.
As I mentioned in the introduction to this post, I usually tend to avoid inheritance as I find there is often a much better solution.
But inheritance does have a time and a place, and hopefully as you can see from today’s tutorial, it makes our code a lot better when used correctly.