Add signup action
We will do a GET
for the page with the sign up form, and a POST
with the new user information to be added. It needs to pass through several operations that ends with adding a new user to the system, and returning the response to the user. If you are new to dry-rb this may not be an easy topic. Anyway, it has the ordered steps to build the action and some references to where you should search for detailed information.
Add test for the signup action
Let's add the file spec/features/main/signup_spec.rb
with a simple test for sign up.
# frozen_string_literal: true
require 'web_spec_helper'
RSpec.feature 'Sign Up' do
scenario 'sign up as new user' do
visit '/signup'
fill_in 'user[email]', with: 'user@example.com'
fill_in 'user[password]', with: 'secret'
click_on 'Sign Up'
expect(page).to have_content('Welcome!')
end
end
If you run the test the message will be Capybara::ElementNotFound ... user[email]
, as the page does not exist you can check the page.status_code
is 404, which is correct.
We will use the multi_route
plugin. So, you must activate it in the line 24 of the program apps/main/system/todo/main/web.rb
that has the r.multi_route
call.
Then you can add routes to the config.routes
path. Next, add the routes to /signup
in apps/main/web/routes/signup.rb
# frozen_string_literal: true
module Todo
module Main
class Web
route 'signup' do |r|
r.get do
r.view 'users.signup'
end
end
end
end
end
Now our test show the error Dry::Container::Error: Nothing registered with the key "views.users.signup"
that is because r.view 'users.signup'
expect a User::Signup
class registered under Views
namespace, this class should be a View::Controller
.
Visit the official site to learn more about the dry-view gem.
Add a view for sign up: apps/main/lib/todo/main/views/users/signup.rb
# frozen_string_literal: true
require 'todo/main/view/controller'
require 'todo/main/import'
module Todo
module Main
module Views
module Users
class Signup < View::Controller
configure do |config|
config.template = 'users/signup'
end
end
end
end
end
end
And in apps/main/web/templates/users/signup.html.slim
the template:
h1 Sign Up
form action='/signup' method='post'
== csrf_tag
label
| Email
input name='user[email]' type='text'
label
| Password
input name='user[password]' type='password'
input type='submit' value='Sign Up'
If we run the test we'll get an expected 404 response. Because we did not write the response to POST /signup
.
Routing the signup post
First, we need to add the route, in the file apps/main/web/routes/signup.rb
:
module Todo
module Main
class Application
route 'signup' do |r|
...
r.post do
r.resolve 'transactions.signup' do |signup|
signup.(r[:user]) do |m|
m.success do
flash[:notice] = 'Welcome!'
r.redirect '/'
end
m.failure do |validation|
r.view 'users.signup', validation: validation
end
end
end
end
end
end
end
end
The r.resolve
call is part of the Roda plugin flow, that delegates to the application #resolve
method, which calls the container #[]
(alias of the dry-container's #resolve
method).
Then it searches in the container the class Transactions::Signup
, instantiates a new object and calls the call
method with r[:user]
as a parameter, and pass a block for the process the result.
Signup transaction and its operations
For our Signup
class we will use the dry-transaction gem, and each step
will be an Operation
in our lib
folder.
A base class for transactions apps/main/lib/todo/main/transaction.rb
only for avoid include Dry::Transaction
in each one.
# frozen_string_literal: true
# auto_register: false
require 'dry/transaction'
module Todo
module Main
class Transaction
include Dry::Transaction(container: Container)
end
end
end
In transactions folder, I'll put the actions that require something more than retrieve data and show to the user. Usually, post, put, patch and delete methods. These will define my business rules and usually, will be the ones that update my DB.
In a transaction for signup apps/main/lib/todo/main/transactions/signup.rb
I need to validate the input data, encrypt the password before store it, and create the user record in my DB. Then this could be my Signup
transaction:
# frozen_string_literal: true
require 'todo/main/transaction'
module Todo
module Main
module Transactions
class Signup < Transaction
step :validate, with: 'operations.users.validate'
step :encrypt_password, with: 'operations.users.encrypt_password'
step :persist, with: 'operations.users.create'
end
end
end
end
If you did not read the dry-transaction documentation, please do it.
In a dry-transaction the steps are executed in sequence, the first receive the input of the transaction, and if all goes well, the output of each step is the input of the next one, and the last one output becomes the output of the transaction. But, if one of the steps decides that it cannot continue its output becomes the output of the transaction and the pending steps are never executed. The implementation requires that all operations return a Right
object if all is correct, and a Left
object if it wants to finish the transaction. And each operation should respond to call
(that's the method called in each step).
Each operation should inherit from Todo::Operation
that give access to Right
and Left
methods and prepends a #call
method that accepts parameters and a block, with the parameters it runs the call method that you write in the operation class, and then it processes the block.
The validation operation that I located in apps/main/lib/todo/main/operations/users/validate.rb
uses dry-validation Form to define the Schema
that will test the data. Its call
method returns a Right
object with the parameters for the next step if validation pass, otherwise it returns a Left
with the result of the validation.
# frozen_string_literal: true
require 'todo/operation'
require 'dry/validation'
module Todo
module Main
module Operations
module Users
class Validate < Todo::Operation
EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
Schema = Dry::Validation.Form do
required(:email).filled(format?: EMAIL_REGEX)
required(:password).filled(:str?, min_size?: 6)
end.freeze
private_constant :Schema
def call(*args)
validation = Schema.(*args)
if validation.success?
Right(validation.output)
else
Left(validation)
end
end
end
end
end
end
end
The next operation is EncryptPassword
and it's located in apps/main/lib/todo/main/operations/users/encrypt_password.rb
In order to encrypt the user password you need to add to your Gemfile
the gem bcrypt
.
This operation removes the password
from args and includes the password_digest
with the encrypted value before returning a Right
object.
# frozen_string_literal: true
require 'todo/operation'
require 'dry/validation'
require 'bcrypt'
module Todo
module Main
module Operations
module Users
class EncryptPassword < Todo::Operation
def call(args)
password = args.delete(:password)
output = args.merge(
password_digest: BCrypt::Password.create(password)
)
Right(output)
end
end
end
end
end
end
The persist step calls the create operation with the key operations.users.create
so the file apps/main/lib/todo/main/operations/users/create.rb
should be in place.
This Create
class include Import['core.repositories.users_repo']
that is a dry-auto_inject, it injects the users_repo
instance that give you access to the DB. It returns a Right
object because data is valid, the password was encrypted and at this point, there is no reason to cancel the transaction.
# frozen_string_literal: true
require 'todo/operation'
module Todo
module Main
module Operations
module Users
class Create < Todo::Operation
include Import['core.repositories.users_repo']
def call(attrs)
user = users_repo.create(attrs)
Right(user)
end
end
end
end
end
end
Now is needed to add the flash to application layout apps/main/web/templates/layouts/application.html.slim
html
body
== flash[:notice]
== flash[:alert]
== yield
And the test should pass. This was the happy path. Some minor changes should be added to process an invalid input.
Adding tests for invalid data
First include in spec/features/main/signup_spec.rb
these new tests:
RSpec.feature 'Sign Up' do
...
scenario 'show error with invalid email' do
visit '/signup'
fill_in 'user[email]', with: 'user'
fill_in 'user[password]', with: 'secret'
click_on 'Sign Up'
expect(find('[name="user[email]"]').value).to eq 'user'
expect(page).to have_content('Email: is in invalid format')
end
scenario 'show error with short password' do
visit '/signup'
fill_in 'user[email]', with: 'user@example.com'
fill_in 'user[password]', with: 'nope'
click_on 'Sign Up'
expect(page).to have_content('Password: size cannot be less than 6')
end
end
When data is invalid our route will send the validation object to the Views::Users::Signup
instance in this line r.view 'users.signup', validation: validation
. This data can be sent to the template with expose :validation
. It is expected that the view shows the invalid data in error, then I added a new_user
method that receives the input and builds a Struct
to share the values with the template. So this is the final status of the Signup view controller and its template.
# frozen_string_literal: true
require 'todo/main/view/controller'
require 'todo/main/import'
module Todo
module Main
module Views
module Users
class Signup < View::Controller
configure do |config|
config.template = 'users/signup'
end
expose :new_user, :validation
private
def new_user(input)
email = input[:validation].to_h[:email]
Struct.new(:email, :password).new(email, nil)
end
end
end
end
end
end
Now, show validation
errorrs and use the new_user
data in the template: apps/main/web/templates/users/signup.html.slim
h1 Sign Up
div
- if validation
- validation.messages.each do |key, all_messages|
b = key.capitalize
= ": #{all_messages.join(', ')}"
|
br
form action='/signup' method='post'
== csrf_tag
label
| Email
input name='user[email]' type='text' value=new_user.email
label
| Password
input name='user[password]' type='password'
input type='submit' value='Sign Up'
All tests should pass.