神刀安全网

Confirming Users with Trailblazer

Confirming Users with Trailblazer 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.

Adding the Routes

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.

Adding the Controller and the View

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.

Defining the Contract

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       if User.find_by(username: username)         errors.add(:username, :taken)       end     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.

Creating the Operation

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) do     return Confirmation::Confirm::Operation::Imported if model.imported?     return Confirmation::Confirm::Operation::Default   end    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) do       return Confirmation::Confirm::Operation::Imported if model.imported?       return Confirmation::Confirm::Operation::Default     end      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

Building the View

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 do |op, options|       op.model.imported? ? Imported : Default     end      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

Adding some Controller tests

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.

Conclusion

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.

转载本站任何文章请注明:转载至神刀安全网,谢谢神刀安全网 » Confirming Users with Trailblazer

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址