Jun 22, 2016
Table of contents:
Over the last couple of weeks we’ve started building a registration process using Ruby on Rails and Trailblazer.
In Using Inheritance for Trailblazer Operations we created the functionality to create new users and import users from an existing application.
When a new user signs up to the application they will need to click on a link that will be emailed to them in order to confirm their email address. We implemented this functionality in Building out a User Confirmation flow in Trailblazer.
However, we also need to confirm the imported users and give them an opportunity to pick a username and set a password.
This presents an awkward situation where we need to accept data based upon the state of the user. Normally this would require messy if
statements in the Controller and the View.
Fortunately, thanks to Trailblazer’s abstraction, this should be pretty easy to deal with in this application!
In today’s tutorial we will be taking a look at how to build this functionality using Trailblazer and Ruby on Rails. If you missed the previous tutorials, you should probably take a look at those to set the context for this tutorial.
The first thing I’m going to do is to define the routes:
get 'confirmation/confirm', to: 'confirmation/confirm#new'
post 'confirmation/confirm', to: 'confirmation/confirm#create'
Firstly, I need a GET
request route that will display the form when the user clicks on their confirmation link from the email I send them.
Secondly, I will need a POST
route that will accept the form request. In the case of normal users, this will just consist of the user’s confirmation token, but in the case of imported users this will also include their chosen username and password.
The next thing we need to do is to add the Controller for accepting the requests:
module Confirmation
class ConfirmController < ApplicationController
def new
form Confirmation::Confirm::Operation::Base
end
def create
run Confirmation::Confirm::Operation::Base do |op|
return redirect_to login_url
end
render :new, status: 400
end
end
end
As you can see, I’m namespacing this Controller under Confirmation
as I did with in last week’s tutorial. This Controller is basically the same as all of the Controllers we’ve looked at so far in this series.
The one thing to note is that I’m using Confirmation::Confirm::Operation::Base
as the Operation. This is because I need to return the correct Operation class depending on the user who the confirmation token belongs to.
I’m also not sure I like these long ass namespaces, so that might change in the future.
We will also need a View for the #new
method to keep Rails happy:
h1 Confirm your account = concept("confirmation/confirm/cell", @operation)
As you can see, we’re going to be using a Cell as we did last week. We’ll be creating the Cell later in this tutorial.
Next up we need to define the Contract for the two Operations, one for imported users, and one for the default users:
module Confirmation::Confirm::Contract
class Base < Reform::Form
model User
property :token, from: :confirmation_token
end
class Imported < Base
property :username
property :password, virtual: true
validates :username, presence: true, username: true
validates :password, presence: true, length: { minimum: 8 }
validate :username_is_unique
def username_is_unique
errors.add(:username, :taken) if User.find_by(username: username)
end
end
class Default < Base
end
end
First I’m going to define the Base
class that will act as an abstract class for both child classes. This class simply defines the token
property so I can use it later.
In the Imported
class we can define the username
and password
properties as well as the validation rules that should apply.
One thing to note is that I had to add a custom method to check for uniqueness of the username. However, this is a good example of how easy it is to define your own validation rules by simply defining a method on the Contract.
Finally, the Default
Contract does not need any additional parameters or validations and so it can simply extend from the Base
Contract class.
With the Contracts in place, we can now turn our attention to the Operation classes. Create a new file called operation.rb
in the relevant concept directory:
module Confirmation::Confirm::Operation
class Base < Trailblazer::Operation
end
class Imported < Base
end
class Default < Base
end
end
As we saw in the Controller, we’re going to be using the Base
class as the interface to the two underlying Operation implementations. The Base
class needs to be able to delegate based upon whether the token belongs to an imported user or a default user.
Here is what the Base
class looks like:
class Base < Trailblazer::Operation
include Resolver
include Model
model User
builds ->(model, policy, params) {
return Confirmation::Confirm::Operation::Imported if model.imported?
return Confirmation::Confirm::Operation::Default
}
def self.model!(params)
User.find_by!(confirmation_token: params[:token], confirmed_at: nil)
end
def confirm
model.confirmed_at = Time.now
end
end
First I override the self.model!
class method to find the user by the given token. If the user is not found an Exception will be thrown to halt the process. This sets the model
on the Operation.
The next thing to note is the builds
block that will return the correct implementation based upon if the model is imported or not.
Finally I’m including a confirm
method that will set the confirmed_at
timestamp on the model because I’m going to need it in both of the child classes.
Next up I will add the Imported
class:
class Imported < Base
contract Confirmation::Confirm::Contract::Imported
def process(params)
validate(params[:user]) do |f|
generate_digest
confirm
f.save
end
end
def generate_digest
model.password_digest = BCrypt::Password.create(contract.password)
end
end
This is pretty much a standard Operation that we’ve seen a couple of times now so there isn’t really much to explain.
First I set the contract that we defined earlier.
Next in the process
method we first validate the incoming parameters. If the validation passes we generate the password digest, confirm the user and then call save.
Here is the Default
class:
class Default < Base
contract Confirmation::Confirm::Contract::Default
def process(params)
validate(params) do |f|
confirm
f.save
end
end
end
Again there is even less to explain here. First I set the contract from earlier again. Next inside the process method I confirm the user and then call save.
Here is this file in full:
require 'bcrypt'
module Confirmation::Confirm::Operation
class Base < Trailblazer::Operation
include Resolver
include Model
model User
builds ->(model, policy, params) {
if model.imported?
return Confirmation::Confirm::Operation::Imported
end
return Confirmation::Confirm::Operation::Default
}
def self.model!(params)
User.find_by!(confirmation_token: params[:token], confirmed_at: nil)
end
def confirm
model.confirmed_at = Time.now
end
end
class Imported < Base
contract Confirmation::Confirm::Contract::Imported
def process(params)
validate(params[:user]) do |f|
generate_digest
confirm
f.save
end
end
def generate_digest
model.password_digest = BCrypt::Password.create(contract.password)
end
end
class Default < Base
contract Confirmation::Confirm::Contract::Default
def process(params)
validate(params) do |f|
confirm
f.save
end
end
end
end
With the Operation classes in place, now I’ll write some tests to make sure everything is working as it should be:
require 'test_helper'
module Confirmation::Confirm::OperationTest
class TestCase < ActiveSupport::TestCase
def setup
@default =
User::Create::Operation::Default.(user: attributes_for(:user)).model
@imported =
User::Create::Operation::Imported.(user: attributes_for(:imported_user))
.model
end
end
end
First I’m going to define a TestCase
class so I can reuse the same setup
method in each of my implementation test classes.
Here I’m simply creating a default user and an imported user using the correct Operation implementation for each as a factory.
I’m not sure if this a best practice or not, but it works pretty well.
Next up I will write a couple of tests for the Base
class:
class BaseTest < TestCase
test 'throw exception on invalid token' do
assert_raises ActiveRecord::RecordNotFound do
Confirmation::Confirm::Operation::Base.run(token: 'abc')
end
end
test 'build Imported for imported user' do
op =
Confirmation::Confirm::Operation::Base.present(
token: @imported.confirmation_token
)
assert_instance_of(Confirmation::Confirm::Operation::Imported, op)
end
test 'build Default for default user' do
op =
Confirmation::Confirm::Operation::Base.present(
token: @default.confirmation_token
)
assert_instance_of(Confirmation::Confirm::Operation::Default, op)
end
end
First I make sure a ActiveRecord::RecordNotFound
Exception is thrown if the confirmation token does not exist. Next I assert that the Base
class instantiates the correct child class given a user token.
Next up I will write some tests for the Imported
class:
class ImportedTest < TestCase
test 'require presence of username and password' do
res, op =
Confirmation::Confirm::Operation::Imported.run(
token: @imported.confirmation_token,
user: {}
)
assert_not(res)
assert_includes(op.errors[:username], "can't be blank")
assert_includes(op.errors[:password], "can't be blank")
end
test 'username should be a valid username' do
res, op =
Confirmation::Confirm::Operation::Imported.run(
token: @imported.confirmation_token,
user: {
username: 'invalid username'
}
)
assert_not(res)
assert_includes(op.errors[:username], 'is invalid')
end
test 'username should be unique' do
res, op =
Confirmation::Confirm::Operation::Imported.run(
token: @imported.confirmation_token,
user: {
username: @default.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 =
Confirmation::Confirm::Operation::Imported.run(
token: @imported.confirmation_token,
user: {
password: 'abc'
}
)
assert_not(res)
assert_includes(
op.errors[:password],
'is too short (minimum is 8 characters)'
)
end
test 'confirm user' do
res, op =
Confirmation::Confirm::Operation::Imported.run(
token: @imported.confirmation_token,
user: {
username: 'username',
password: 'password'
}
)
assert(res)
assert(op.model.confirmed?)
end
end
If you have been following along with this series these tests should be fairly familiar to you now.
Finally I will write a quick test for the Default
class too:
class DefaultTest < TestCase
test 'confirm user' do
res, op =
Confirmation::Confirm::Operation::Default.run(
token: @default.confirmation_token
)
assert(res)
assert(op.model.confirmed?)
end
end
Here is that test file in full:
require 'test_helper'
module Confirmation::Confirm::OperationTest
class TestCase < ActiveSupport::TestCase
def setup
@default =
User::Create::Operation::Default.(user: attributes_for(:user)).model
@imported =
User::Create::Operation::Imported.(user: attributes_for(:imported_user))
.model
end
end
class BaseTest < TestCase
test 'throw exception on invalid token' do
assert_raises ActiveRecord::RecordNotFound do
Confirmation::Confirm::Operation::Base.run(token: 'abc')
end
end
test 'build Imported for imported user' do
op =
Confirmation::Confirm::Operation::Base.present(
token: @imported.confirmation_token
)
assert_instance_of(Confirmation::Confirm::Operation::Imported, op)
end
test 'build Default for default user' do
op =
Confirmation::Confirm::Operation::Base.present(
token: @default.confirmation_token
)
assert_instance_of(Confirmation::Confirm::Operation::Default, op)
end
end
class ImportedTest < TestCase
test 'require presence of username and password' do
res, op =
Confirmation::Confirm::Operation::Imported.run(
token: @imported.confirmation_token,
user: {}
)
assert_not(res)
assert_includes(op.errors[:username], "can't be blank")
assert_includes(op.errors[:password], "can't be blank")
end
test 'username should be a valid username' do
res, op =
Confirmation::Confirm::Operation::Imported.run(
token: @imported.confirmation_token,
user: {
username: 'invalid username'
}
)
assert_not(res)
assert_includes(op.errors[:username], 'is invalid')
end
test 'username should be unique' do
res, op =
Confirmation::Confirm::Operation::Imported.run(
token: @imported.confirmation_token,
user: {
username: @default.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 =
Confirmation::Confirm::Operation::Imported.run(
token: @imported.confirmation_token,
user: {
password: 'abc'
}
)
assert_not(res)
assert_includes(
op.errors[:password],
'is too short (minimum is 8 characters)'
)
end
test 'confirm user' do
res, op =
Confirmation::Confirm::Operation::Imported.run(
token: @imported.confirmation_token,
user: {
username: 'username',
password: 'password'
}
)
assert(res)
assert(op.model.confirmed?)
end
end
class DefaultTest < TestCase
test 'confirm user' do
res, op =
Confirmation::Confirm::Operation::Default.run(
token: @default.confirmation_token
)
assert(res)
assert(op.model.confirmed?)
end
end
end
The final piece of this puzzle is to display the correct form based upon whether the user has been imported or not. Normally this would require a messy if
statement in the View, but we’re going to be using Cells so we don’t have to deal with this nastiness.
Create a new file called cell.rb
in the concept directory:
module Confirmation::Confirm
class Cell < Trailblazer::Cell
builds { |op, options| op.model.imported? ? Imported : Default }
class Imported < Culttt::Cells::Form
def show
render
end
end
class Default < Culttt::Cells::Form
def show
render
end
end
end
end
Here again we use a similar technique as to what we used in the Operation to “build” the correct implementation given the state of the user.
Both of the components are going to be forms so we the Cells implementations look pretty similar to the code from last week other than the higher level Cell that does the building.
Here are the actual Slim templates we’re going to need. First up we have the imported user form:
- if form.errors.any? ul - form.errors.full_messages.each do |msg| li = msg =
form_for form, html: {class: "imported-confirmation-form"}, url:
confirmation_confirm_url, method: :post do |f| div = f.label :username, class:
"qa-username-label" = f.text_field :username, class: "qa-username-input" div =
f.label :password, class: "qa-password-label" = f.password_field :password,
class: "qa-password-input" div = hidden_field_tag :token, form.token, class:
"qa-token-input" div = f.submit "Confirm", class: "qa-submit"
As you can see, this form contains input elements for the username and password. I’ve also included the token as a hidden input field.
The default user form is pretty much the same but without the extra form elements:
- if form.errors.any? ul - form.errors.full_messages.each do |msg| li = msg =
form_for form, html: {class: "default-confirmation-form"}, url:
confirmation_confirm_url, method: :post do |f| div = hidden_field_tag :token,
form.token, class: "qa-token-input" div = f.submit "Confirm", class: "qa-submit"
With the Cell in place, I’ll next write some tests to make sure it’s work as I expect it to. First I will write some tests to make sure that the correct implementation is instantiated correctly:
require 'test_helper'
module Confirmation::Confirm::CellTest
class CellTest < ActiveSupport::TestCase
def setup
@default =
User::Create::Operation::Default.(user: attributes_for(:user)).model
@imported =
User::Create::Operation::Imported.(user: attributes_for(:imported_user))
.model
end
test 'build imported cell for imported user' do
cell =
Confirmation::Confirm::Cell.(
Confirmation::Confirm::Operation::Base.present(
token: @imported.confirmation_token
)
)
assert_instance_of(Confirmation::Confirm::Cell::Imported, cell)
end
test 'build default cell for default user' do
cell =
Confirmation::Confirm::Cell.(
Confirmation::Confirm::Operation::Base.present(
token: @default.confirmation_token
)
)
assert_instance_of(Confirmation::Confirm::Cell::Default, cell)
end
end
end
Next I will write a test to make sure the Imported cell has the correct markup:
class ImportedTest < Cell::TestCase
controller Confirmation::ConfirmController
def setup
@user =
User::Create::Operation::Imported.(user: attributes_for(:imported_user))
.model
@operation =
Confirmation::Confirm::Operation::Imported.present(
token: @user.confirmation_token
)
end
test 'has correct markup' do
html = concept('confirmation/confirm/cell', @operation).()
html.must_have_selector('form.imported-confirmation-form')
html.must_have_selector('label.qa-username-label')
html.must_have_selector('input.qa-username-input')
html.must_have_selector('label.qa-password-label')
html.must_have_selector('input.qa-password-input')
html.must_have_selector('input.qa-submit')
end
end
And finally I will do the same for the Default
class:
class DefaultTest < Cell::TestCase
controller Confirmation::ConfirmController
def setup
@user = User::Create::Operation::Default.(user: attributes_for(:user)).model
@operation =
Confirmation::Confirm::Operation::Default.present(
token: @user.confirmation_token
)
end
test 'has correct markup' do
html = concept('confirmation/confirm/cell', @operation).()
html.must_have_selector('form.default-confirmation-form')
html.wont_have_selector('label.qa-username-label')
html.wont_have_selector('input.qa-username-input')
html.wont_have_selector('label.qa-password-label')
html.wont_have_selector('input.qa-password-input')
html.must_have_selector('input.qa-submit')
end
end
Here is that test class in full:
require 'test_helper'
module Confirmation::Confirm::CellTest
class CellTest < ActiveSupport::TestCase
def setup
@default =
User::Create::Operation::Default.(user: attributes_for(:user)).model
@imported =
User::Create::Operation::Imported.(user: attributes_for(:imported_user))
.model
end
test 'build imported cell for imported user' do
cell =
Confirmation::Confirm::Cell.(
Confirmation::Confirm::Operation::Base.present(
token: @imported.confirmation_token
)
)
assert_instance_of(Confirmation::Confirm::Cell::Imported, cell)
end
test 'build default cell for default user' do
cell =
Confirmation::Confirm::Cell.(
Confirmation::Confirm::Operation::Base.present(
token: @default.confirmation_token
)
)
assert_instance_of(Confirmation::Confirm::Cell::Default, cell)
end
end
class ImportedTest < Cell::TestCase
controller Confirmation::ConfirmController
def setup
@user =
User::Create::Operation::Imported.(user: attributes_for(:imported_user))
.model
@operation =
Confirmation::Confirm::Operation::Imported.present(
token: @user.confirmation_token
)
end
test 'has correct markup' do
html = concept('confirmation/confirm/cell', @operation).()
html.must_have_selector('form.imported-confirmation-form')
html.must_have_selector('label.qa-username-label')
html.must_have_selector('input.qa-username-input')
html.must_have_selector('label.qa-password-label')
html.must_have_selector('input.qa-password-input')
html.must_have_selector('input.qa-submit')
end
end
class DefaultTest < Cell::TestCase
controller Confirmation::ConfirmController
def setup
@user =
User::Create::Operation::Default.(user: attributes_for(:user)).model
@operation =
Confirmation::Confirm::Operation::Default.present(
token: @user.confirmation_token
)
end
test 'has correct markup' do
html = concept('confirmation/confirm/cell', @operation).()
html.must_have_selector('form.default-confirmation-form')
html.wont_have_selector('label.qa-username-label')
html.wont_have_selector('input.qa-username-input')
html.wont_have_selector('label.qa-password-label')
html.wont_have_selector('input.qa-password-input')
html.must_have_selector('input.qa-submit')
end
end
end
Finally, to make sure that everything is working correctly, I will write a couple of Controller tests as verification:
require 'test_helper'
module Confirmation
class ConfirmControllerTest < ActionController::TestCase
def setup
@default =
User::Create::Operation::Default.(user: attributes_for(:user)).model
@imported =
User::Create::Operation::Imported.(user: attributes_for(:imported_user))
.model
end
test 'return 404 on invalid token' do
assert_raises ActiveRecord::RecordNotFound do
get :new
end
end
test 'display imported user confirmation form' do
get :new, token: @imported.confirmation_token
assert_response(:success)
end
test 'display default user confirmation form' do
get :new, token: @default.confirmation_token
assert_response(:success)
end
test 'fail with invalid imported user data' do
post :create, token: @imported.confirmation_token, user: {}
assert_response(400)
end
test 'confirm imported user' do
post :create,
token: @imported.confirmation_token,
user: {
username: 'username',
password: 'password'
}
assert_response(302)
end
test 'confirm default user' do
post :create, token: @default.confirmation_token
assert_response(302)
assert_redirected_to(login_url)
end
end
end
First I check to make sure that I’m returned a 404
when the token does not exist. Next I do a quick to make sure the form is returned correctly.
Next I check to make sure the user is redirected when attempting to confirm an imported user with invalid data. And finally I make sure that confirming both an imported user and a default user works as it should.
Phew, finally finished! Well done for getting this far.
This might seem like a bit of a contrived example because it is so specific to my application, but hopefully you can see how powerful the polymorphism aspect of Trailblazer’s ability to build the correct implementation of an Operation or a Cell.
Instead of having to deal with nasty conditionals we can move that logic inside of the class and let good old object-oriented programming deal with creating the class.
This provides a beautifully simple interface and it prevents that logic from slipping out into the controller or the view.