This application we’re creating in this document serves two purposes.
First is demonstrates the use of MerbAuth slices in a Merb-based application. Second, it acts as a test harness for updates to the used gems and slices.
This document describes the steps to create a simple cookbook application using the following, best-of-bread tools:
- Merb
- DataMapper
- HAML
- RSpec
- MerbAuth
Disclaimer: The main purpose is to show the use of MerbAuth and its slices and not the modeling of an application!
First, let us install all necessary gems.
TODO describe the steps.
Create the skeleton
$ merb-gen app cookbook --template-engine haml
$ cd cookbook
Lucky for us, the merb application generator now adds all necessary dependencies in the config/dependencies.rb
:
dependency "merb-action-args", "0.9.14" # Provides support for querystring arguments to be passed in to controller actions
dependency "merb-assets", "0.9.14" # Provides link_to, asset_path, auto_link, image_tag methods (and lots more)
dependency "merb-cache", "0.9.14" # Provides your application with caching functions
dependency "merb-helpers", "0.9.14" # Provides the form, date/time, and other helpers
dependency "merb-mailer", "0.9.14" # Integrates mail support via Merb Mailer
dependency "merb-slices", "0.9.14" # Provides a mechanism for letting plugins provide controllers, views, etc. to your app
dependency "merb-auth", "0.9.14" # An authentication slice (Merb's equivalent to Rails' restful authentication)
dependency "merb-param-protection", "0.9.14"
dependency "dm-core", "0.9.7" # The datamapper ORM
dependency "dm-aggregates", "0.9.7" # Provides your DM models with count, sum, avg, min, max, etc.
dependency "dm-migrations", "0.9.7" # Make incremental changes to your database.
dependency "dm-timestamps", "0.9.7" # Automatically populate created_at, created_on, etc. when those properties are present.
dependency "dm-types", "0.9.7" # Provides additional types, including csv, json, yaml.
dependency "dm-validations", "0.9.7" # Validation framework
Note: Depending on the date and time you’re generating the application, it might have the wrong version number(s) for the datamapper dependencies.
In my case I already have 0.9.7 and needed to update it.
In order to define the connection to the database we have two options
- established the database connection in the environment files
- define the database connection parameters in a separate configuration file
We will use the old fashion method and use the config/database.yml
file.
Update the file and define the connection parameters (adjust to your environment as needed):
---
development: &defaults
adapter: mysql
database: cookbook_development
username: cb_user
password: secret
host: localhost
test:
<<: *defaults
database: cookbook_test
production:
<<: *defaults
database: cookbook_production
rake:
<<: *defaults
Refer to your database manual on create the database.
Since we use thread-safe DataMapper as our ORM, mutex can be off, which it should be.
Check the Merb.config
in the config/init.rb
file:
Merb::Config.use do |c|
c[:use_mutex] = false
...
end
Since we’re looking at the config/init.rb
, lets change our template engine to HAML:
...
use_template_engine :haml
...
That’s it for setup. We have an application that has no models, no controllers, and no views. In sort: that does nothing!
Next we add the user model and the proper authentication.
Since we’re very appreciative to merb for the things it does for us, we remember that
it already added the necessary dependency to merb-auth in config/dependencies.rb
:
...
dependency "merb-auth", "0.9.14" # An authentication slice (Merb's equivalent to Rails' restful authentication)
...
Now if we run rake -T slices:merb-auth-slice-password
we see all kinds of goodies:
rake slices:merb-auth-slice-password:copy_assets # Copy public assets to host application
rake slices:merb-auth-slice-password:freeze # Freeze MerbAuthSlicePassword into your app (only merb-auth-slice-password/app)
rake slices:merb-auth-slice-password:freeze:app # Freezes MerbAuthSlicePassword by copying all files from merb-auth-slice-password/ap...
rake slices:merb-auth-slice-password:freeze:app_with_gem # Freezes MerbAuthSlicePassword as a gem and copies over merb-auth-slice-password/app
rake slices:merb-auth-slice-password:freeze:gem # Freezes MerbAuthSlicePassword by installing the gem into application/gems using mer...
rake slices:merb-auth-slice-password:freeze:models # Freeze all models into your application for easy modification
rake slices:merb-auth-slice-password:freeze:unpack # Freezes MerbAuthSlicePassword by unpacking all files into your application
rake slices:merb-auth-slice-password:freeze:views # Freeze all views into your application for easy modification
rake slices:merb-auth-slice-password:install # Install MerbAuthSlicePassword
rake slices:merb-auth-slice-password:migrate # Migrate the database / Migrate the database
rake slices:merb-auth-slice-password:patch # Copy stub files and views to host application
rake slices:merb-auth-slice-password:preflight # Test for any dependencies / Test for any dependencies
rake slices:merb-auth-slice-password:setup_directories # Setup directories
rake slices:merb-auth-slice-password:spec # Run slice specs within the host application context
rake slices:merb-auth-slice-password:spec:controller # Run all controller specs, run a spec for a specific Controller with CONTROLLER=MyCo...
rake slices:merb-auth-slice-password:spec:default # Run specs
rake slices:merb-auth-slice-password:spec:html # Run all specs and output the result in html
rake slices:merb-auth-slice-password:spec:model # Run all model specs, run a spec for a specific Model with MODEL=MyModel
rake slices:merb-auth-slice-password:spec:view # Run all view specs, run specs for a specific controller (and view) with CONTROLLER=...
rake slices:merb-auth-slice-password:stubs # Copy stub files to host application
We want to use it, so we run:
$ rake slices:merb-auth-slice-password:install
Since we want to manipulate the the view, we need to freeze them with:
$ rake slices:merb-auth-slice-password:freeze:views
Next we create the cook, our user model
$ merb-gen model --testing-framework rspec --orm datamapper cook
and the recipe resource
$ merb-gen resource --testing-framework rspec --orm datamapper recipe
Now we need to add the necessary properties and associations to the cook (user) model
class Cook
include DataMapper::Resource
# Attributes
property :id, Serial
property :login, String
property :email, String
property :identity_url, String
property :created_at, DateTime
property :updated_at, DateTime
# Associations
has n, :recipes
# Validations
validates_present :login, :email
validates_is_unique :login, :email
validates_format :email, :as => :email_address
end
Now we need to setup and configure merb-auth to do it’s magic. All configuration is done in merb/merb-auth
.
First look at merb/merb-auth/setup.rb
. Here we tell merb-auth to use our Cook
class as the user model:
...
Merb::Authentication.user_class = Cook
...
If we don’t do that, MerbAuth assumes our user class is User
and creates a users
table.
It also allows us to change the login and password field, e.g. to email, but for now we stick with the defaults.
Further down in the file you’ll see that it mixes the Merb::Authentication::Mixins::SaltedUser
into our
user model:
...
# Mixin the salted user mixin
require 'merb-auth-more/mixins/salted_user'
Merb::Authentication.user_class.class_eval{ include Merb::Authentication::Mixins::SaltedUser }
...
Mixing in the Merb::Authentication::Mixins::SaltedUser
module does a couple of things:
- It adds two properties:
crypted_password
(String)salt
(String)
- The new
password
property also gets two validations, if we require a password:- validation of the presence
- validation of confirmation
- It add the following class methods:
encrypt
, which takes a password and salt, concatenates them with some delimiters, and encrypt the resulting string using SHA1.authenticate
, which takes the login and password authenticates the user. It returns the user model if authenticated, otherwisenil
.
- It add the following instance methods:
authenticated?
, which allows system to check if user is authenticated by encrypting the passed in password and comparing it to stored, crypted password.encrypt
, which encrypts the password by calling theencrypt
class method with the passed in password and the salt for that specific user (instance).encrypt_password
, which calculates the salt if it is a new user and then encrypts the password for that specific user (instance).password_required?
, which is used in validations.
As usual, we’re more then welcome to override the methods if we desire.
In the other file, merb/merb-auth/setup.rb
, allows us to define our authentication strategies.
We’ll add support for OpenID:
# ...
Merb::Slices::config[:"merb-auth-slice-password"][:no_default_strategies] = true
Merb::Authentication.activate!(:default_password_form)
Merb::Authentication.activate!(:default_basic_auth)
Merb::Authentication.activate!(:default_openid)
Note: There is also a lib/authentication folder with configuration files, but I believe that’s left over.
Now fill in the recipe model
class Recipe
include DataMapper::Resource
# Attributes
property :id, Serial
property :title, String, :nullable => false
property :instructions, Text, :lazy => [:show]
property :created_at, DateTime
property :updated_at, DateTime
# Associations
belongs_to :cook
# Validations
end
Now that we have our models defined, we can run our migrations
$ rake db:automigrate
Next we need to hook up our recipe resource in the router:
Merb::Router.prepare do
# RESTful routes
resources :recipes
...
end
At this point we can fire up merb
$ merb
~ Loaded DEVELOPMENT Environment...
~ loading gem 'dm-timestamps' ...
~ loading gem 'dm-types' ...
~ loading gem 'dm-serializer' ...
...
and hit the recipe list.
Next we modify the views to display the info we have and allow us to enter our recipes.
To save a bit, I’ll skip listing it. Refer to source code for details.
Now that we can enter, view and edit out recipes, we want to protect them.
To do that we need to do a couple of things.
First, lets hook up the merb_auth routes and remove the default routes:
Merb.logger.info("Compiling routes...")
Merb::Router.prepare do
# RESTful routes
resources :recipes
# Adds the required routes for merb-auth using the password slice
slice(:merb_auth_slice_password, :name_prefix => nil, :path_prefix => "") do
match("/openid").to(:controller => "sessions", :action => "update").name(:openid)
end
match('/').to(:controller => 'recipes', :action =>'index')
end
Now we want to protect the recipes. We have two options.
We can either protect the controller actions by adding a before filter to the recipes controller:
class Recipes < Application
before :ensure_authenticated
...
end
Or we can protect the route:
Merb.logger.info("Compiling routes...")
Merb::Router.prepare do
# RESTful routes
authenticate do
resources :recipes
end
...
authenticate do
match('/').to(:controller => 'recipes', :action =>'index')
end
end
Which solution to choose depends on the desired control we want. For now we protect the route,
which provides the benefit not even hitting any controller for unauthorized access.
As a quick and dirty test, we can create a new user via the interactive Merb shell:
$ merb -i
~ Loaded DEVELOPMENT Environment...
~ loading gem 'dm-timestamps' ...
...
~ Activating slice 'MerbAuthPasswordSlice' ...
>> joe = Cook.new(:login => 'joe', :email => 'joe@example.com', :password => 'secret', :password_confirmation => 'secret')
=> #<Cook id=nil login="joe" email="joe@example.com" crypted_password=nil salt=nil identity_url=nil created_at=nil updated_at=nil>
>> joe.save
~ SELECT `id` FROM `cooks` WHERE (`login` = 'joe') ORDER BY `id` LIMIT 1
~ SELECT `id` FROM `cooks` WHERE (`email` = 'joe@example.com') ORDER BY `id` LIMIT 1
~ INSERT INTO `cooks` (`crypted_password`, `created_at`, `salt`, `updated_at`, `login`, `email`) VALUES ('65c790d0621cbc7e894320cb37622f64e621f9f0', '2008-10-02 21:06:04', '67f6635aac3645850ad5073de467a7baaab177d3', '2008-10-02 21:06:04', 'joe', 'joe@example.com')
=> true
If we now quit the interactive Merb shell and start the Merb server, pointing the browser to http://localhost:4000/recipes should bring up the login screen.
Entering the just created login and password will then forward us to the recipes listing.
Note: I added a link in the header to logout from the application.
Right now we allow any recipe to be viewed as long as the the user can login.
Ideally we want to protect out valuable family potato soup recipe from other cooks in the system.
To do that we need to modify the recipe controller.
But first, let us add another cook to the system via the command line
$ merb -i
~ Loaded DEVELOPMENT Environment...
~ loading gem 'dm-timestamps' ...
...
~ Activating slice 'MerbAuthPasswordSlice' ...
>> bill = Cook.new(:login => 'bill', :email => 'bill@example.com', :password => 'secret', :password_confirmation => 'secret')
=> #<Cook id=nil login="bill" email="bill@example.com" crypted_password=nil salt=nil identity_url=nil created_at=nil updated_at=nil>
>> bill.save
~ SELECT `id` FROM `cooks` WHERE (`login` = 'bill') ORDER BY `id` LIMIT 1
~ SELECT `id` FROM `cooks` WHERE (`email` = 'bill@example.com') ORDER BY `id` LIMIT 1
~ INSERT INTO `cooks` (`crypted_password`, `created_at`, `salt`, `updated_at`, `login`, `email`) VALUES ('e88d65490e8c3deb83845f0c49f3333f955f3fed', '2008-10-02 21:50:18', '6a2984f72020c4c6e5cd5c19a3e6760b07fe832f', '2008-10-02 21:50:18', 'bill', 'bill@example.com')
=> true
Now lets create some recipes:
$ merb -i
~ Loaded DEVELOPMENT Environment...
~ loading gem 'dm-timestamps' ...
...
~ Activating slice 'MerbAuthPasswordSlice' ...
>> joe = Cook.first(:login => "joe")
~ SELECT `id`, `login`, `email`, `crypted_password`, `salt`, `identity_url`, `created_at`, `updated_at` FROM `cooks` WHERE (`login` = 'joe') ORDER BY `id` LIMIT 1
=> #<Cook id=1 login="joe" email="joe@example.com" crypted_password="401518266932b42163c333c973070687b731eeb4" salt="c470ddbaff0032ac297baf842463fbd7f54649f1" identity_url=nil created_at=#<DateTime: 212089759657/86400,-1/6,2299161> updated_at=#<DateTime: 212089759657/86400,-1/6,2299161>>
>> joe.recipes.build(:title => "Potato Soup", :instructions => "Add the diced potatoes to chicken broth. Cover, and simmer for 25 minutes.").save
~ SELECT `id`, `title`, `created_at`, `updated_at`, `cook_id` FROM `recipes` WHERE (`cook_id` IN (1)) ORDER BY `id`
~ INSERT INTO `recipes` (`instructions`, `created_at`, `cook_id`, `title`, `updated_at`) VALUES ('Add the diced potatoes to chicken broth. Cover, and simmer for 25 minutes.', '2008-10-02 22:08:30', 1, 'Potato Soup', '2008-10-02 22:08:30')
=> true
>> bill = Cook.first(:login => "bill")
~ SELECT `id`, `login`, `email`, `crypted_password`, `salt`, `identity_url`, `created_at`, `updated_at` FROM `cooks` WHERE (`login` = 'bill') ORDER BY `id` LIMIT 1
=> #<Cook id=2 login="bill" email="bill@example.com" crypted_password="7143d5223fb0f71a46291bff7d4958c0ca8c7674" salt="e1455120494795a08a5214066d1da252112f2bda" identity_url=nil created_at=#<DateTime: 21208975967/8640,-1/6,2299161> updated_at=#<DateTime: 21208975967/8640,-1/6,2299161>>
>> bill.recipes.build(:title => "Ham N Cheese Sandwich", :instructions => "Put two slices of American cheese and on slice of ham on two slices of bread.").save
~ SELECT `id`, `title`, `created_at`, `updated_at`, `cook_id` FROM `recipes` WHERE (`cook_id` IN (2)) ORDER BY `id`
~ INSERT INTO `recipes` (`instructions`, `created_at`, `cook_id`, `title`, `updated_at`) VALUES ('Put two slices of American cheese and on slice of ham on two slices of bread.', '2008-10-02 22:08:51', 2, 'Ham N Cheese Sandwich', '2008-10-02 22:08:51')
=> true
Starting and logging into the application as either joe or bill shows both recipes.
First we change the index
action in the recipes controller to only list recipes that belong to the cook/user in the session:
class Recipes < Application
# provides :xml, :yaml, :js
def index
@recipes = session.user.recipes
display @recipes
end
...
end
But this still provides access to another cooks recipe if I enter the URL directly, e.g. for http://localhost:4000/recipes/2 .
To avoid that simply modify the show
action:
class Recipes < Application
...
def show(id)
@recipe = session.user.recipes.get(id)
raise NotFound unless @recipe
display @recipe
end
...
end
We need to adjust the edit
, update
, and destroy
actions in a similar fashion:
class Recipes < Application
...
def edit(id)
only_provides :html
@recipe = Recipe.get(id)
raise NotFound unless @recipe
display @recipe
end
...
def update(recipe)
@recipe = session.user.recipes.get(id)
raise NotFound unless @recipe
if @recipe.update_attributes(recipe)
redirect resource(@recipe)
else
display @recipe, :edit
end
end
def destroy(id)
@recipe = session.user.recipes.get(id)
raise NotFound unless @recipe
if @recipe.destroy
redirect resource(@recipes)
else
raise InternalServerError
end
end
...
end
That leaves the new
and create
actions.
For the new
action we simply add the user from the session:
class Recipes < Application
...
def new
only_provides :html
@recipe = Recipe.new(:cook => session.user)
render
end
...
end
For the create
action we use the build
method on the user.recipes
collection:
class Recipes < Application
...
def create(recipe)
@recipe = session.user.recipes.build(recipe)
if @recipe.save
redirect resource(@recipe), :message => {:notice => "Recipe was successfully created"}
else
render :new
end
end
...
end
Before we go any further, we probably should implement some unit test. Actually, we should have done it before writing any code to follow
proper Behavior Driven Development , but I guess better late then never ;).
Remember that we decided to use RSpec for unit testing when we created our project. There are other good unit testing
frameworks available for Merb and it should not be to hard to convert the tests.
Ok, lets start with the models and work out way up the stack to the views.
Since the Recipe
model currently does not contain any testable logic, we’ll focus on the Cook
model.
First, lets define a helper module to make our life easier later. It contains all attributes to create a valid Cook
model
module CookSpecHelper
def cook_attributes(options = {})
{
:login => 'joe',
:email => 'joe@example.com',
:password => 'secret',
:password_confirmation => 'secret'
}.merge(options)
end
end
To use it, we need to include it to our first spec. At the same time, lets create a new Cook
object before
each (spec) example:
describe Cook do
include CookSpecHelper
before(:each) do
@cook = Cook.new
end
end
With that in hand, we turn out attention to the validations in the model:
describe Cook do
...
it "should be invalid without a login" do
@cook.attributes = cook_attributes.except(:login)
@cook.should_not be_valid
@cook.errors.on(:login).should include("Login must not be blank")
end
it "should be invalid without an email" do
@cook.attributes = cook_attributes.except(:email)
@cook.should_not be_valid
@cook.errors.on(:email).should include("Email must not be blank")
end
it "should be valid with a full set of valid attributes" do
@cook.attributes = cook_attributes
@cook.should be_valid
end
end
Notice how we use our handy cook_attributes
method.
Time to run our newly created specs:
$ rake spec
(in /Users/ck/cookbook)
...P
Pending:
Recipe should have specs (Not Yet Implemented)
Finished in 0.026579 seconds
4 examples, 0 failures, 1 pending
Great all of our three cook specs passed, but we have one pending spec for the recipe as a reminder.
One more thing we probably should test is the email format validation""
describe Cook do
...
it "should be invalid without a proper email" do
[ "joe", "joe@", "joe example", "@example", "joe@example com" ].each do |email|
@cook.attributes = cook_attributes(:email => email)
@cook.should_not be_valid
@cook.errors.on(:email).should include("Email has an invalid format")
end
end
end
and re-run the specs.
$ rake spec
(in /Users/ck/cookbook)
....P
Pending:
Recipe should have specs (Not Yet Implemented)
Finished in 0.036376 seconds
5 examples, 0 failures, 1 pending
Now, lets turn our attention to regression testing. If you watched Yehuda Katz, aka wycats,
Testing Merb applications presentation at MerbCamp 1.0, you will feel right at home.
If you did not, stop here and take a look for the motivation of the tests.
Watched it, good! Replace the content of spec/requests/recipes_spec.rb
with
require File.join(File.dirname(__FILE__), '..', 'spec_helper.rb')
given "the cook is authenticated" do
request(url(:perform_login), :method => "PUT", :params => { :login => "joe", :password => "secret" })
end
describe "resource(:recipes)" do
before(:all) do
DataMapper.setup(:default, "sqlite3::memory:")
DataMapper.auto_migrate!
Cook.new(:login => 'joe', :email => 'joe@example.com', :password => 'secret', :password_confirmation => 'secret').save
end
after(:each) do
Cook.all.destroy!
Recipe.all.destroy!
end
describe "GET" do
before(:each) do
@response = request(resource(:recipes))
end
it "should return 'HTTP Error 401 - Unauthorized' status" do
@response.status == 401
end
end
describe "GET (authenticatd)", :given => "the cook is authenticated" do
before(:each) do
@response = request(resource(:recipes))
end
it "should return successfully" do
@response.status == 200
end
end
end
Since we’re using authenication, we added a given block, to automatically login, when needed.
Consider this the start of the regression test and feel free add more tests.
Until now they only way for a cook to signup and store his valuable recipes on our site, is to
contact us and request a login. That can be very time consuming and we rather spend our time
on fun stuff.
So, lets add automated signup and activation (with validation) to our application.
Right now merb-auth-slice-activation
is not part of merb/merb-more/merb-auth
and needs
to be installed separately :
$ git clone git://github.com/ck/merb-auth-slice-activation.git
$ cd merb-auth-slice-activation
$ sudo rake install
and then required explicitly
...
dependency "merb-auth-slice-activation", "0.9.14"
...
Note: There is still discussion where the slice should live, as part of merb-more, as part of merb-plugins, or completely standalone. Stay tuned!
Now if we run rake -T slices:merb-auth-slice-activation
we see all kinds of goodies:
rake slices:merb-auth-slice-activation:copy_assets # Copy public assets to host application
rake slices:merb-auth-slice-activation:freeze # Freeze MerbAuthSliceActivation into your app (only merb-auth-slice-activation/app)
rake slices:merb-auth-slice-activation:freeze:app # Freezes MerbAuthSliceActivation by copying all files from merb-auth-slice-activat...
rake slices:merb-auth-slice-activation:freeze:app_with_gem # Freezes MerbAuthSliceActivation as a gem and copies over merb-auth-slice-activati...
rake slices:merb-auth-slice-activation:freeze:gem # Freezes MerbAuthSliceActivation by installing the gem into application/gems
rake slices:merb-auth-slice-activation:freeze:mailers # Freeze all mailers into your application for easy modification
rake slices:merb-auth-slice-activation:freeze:models # Freeze all models into your application for easy modification
rake slices:merb-auth-slice-activation:freeze:unpack # Freezes MerbAuthSliceActivation by unpacking all files into your application
rake slices:merb-auth-slice-activation:freeze:views # Freeze all views into your application for easy modification
rake slices:merb-auth-slice-activation:install # Install MerbAuthSliceActivation
rake slices:merb-auth-slice-activation:migrate # Migrate the database
rake slices:merb-auth-slice-activation:patch # Copy stub files and views to host application
rake slices:merb-auth-slice-activation:preflight # Test for any dependencies
rake slices:merb-auth-slice-activation:setup_directories # Setup directories
rake slices:merb-auth-slice-activation:stubs # Copy stub files to host application
Again, to use it we run:
$ rake slices:merb-auth-slice-activation:install
Since we want to manipulate the the view, we need to freeze them
$ rake slices:merb-auth-slice-activation:freeze:views
and the mailers
$ rake slices:merb-auth-slice-activation:freeze:mailers
$ rake slices:merb-auth-slice-activation:stubs
Next we tell the Cook
class, our user model, to support activation
class Cook
include DataMapper::Resource
include Merb::Authentication::Mixins::ActivatedUser
# Attributes
...
end
That was easy, but what happens behind the scenes?
The Merb::Authentication::Mixins::ActivatedUser
mixin does two things. First it adds
two attributes to the model:
:activation_code
(String
), for the (random) code required for a user to become active. The code will be cleared out when the user is activated.:activated_at
(DateTime
), for the date and time the user was activated. If the value is null, the user is not active and therefore not properly authenticated.
Lets verify that by migrating our database
$ rake db:automigrate
and checking the fields in the database.
$ mysql -u cb_user -psecret cookbook_development
mysql> describe cooks;
+------------------+-------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+------------------+-------------+------+-----+---------+----------------+
| activated_at | datetime | YES | | NULL | |
| activation_code | varchar(50) | YES | | NULL | |
| id | int(11) | NO | PRI | NULL | auto_increment |
| login | varchar(50) | YES | | NULL | |
| email | varchar(50) | YES | | NULL | |
| identity_url | varchar(50) | YES | | NULL | |
| created_at | datetime | YES | | NULL | |
| updated_at | datetime | YES | | NULL | |
| crypted_password | varchar(50) | YES | | NULL | |
| salt | varchar(50) | YES | | NULL | |
+------------------+-------------+------+-----+---------+----------------+
10 rows in set (0.00 sec)
Secondly, it adds the following class method
make_key
, generates a random key using SHA1
and the following instance methods
activate
, which activates (and saves) the user.activated?
/active?
, returnstrue
if the user is active, otherwisefalse
recently_activated?
, returnstrue
if the user has been activated during the current ‘request’make_activation_code
, creates (usingclass.make_key
) and sets the activation code for the user
to the user model. As with the SaltedUser
, we’re more then welcome to override the methods.
Before activation will work, it requires some information from us.
First lets hook up the slice routes in config/router.rb
:
Merb.logger.info("Compiling routes...")
Merb::Router.prepare do
...
# Adds the required routes for merb-auth using the activation slice
slice(:merb_auth_slice_activation, :name_prefix => nil, :path_prefix => "")
match('/').to(:controller => 'recipes', :action =>'index')
end
Then we need to specify the following two required slice settings:
:from_email
, the email account to send the email from:activation_host
, the host to go to for activation. This is used to construct the activation link. Symbol, String or Procs are available. Procs will have theuser
object passed in.
We add them in the the config/init.rb
:
...
Merb::BootLoader.after_app_loads do
# MerbAuth Slice Activation config
Merb::Slices::config[:merb_auth_slice_activation][:from_email] = 'cook@cookingwithmerb.com'
Merb::Slices::config[:merb_auth_slice_activation][:activation_host] = 'localhost:4000'
end
In the same block we also need to add the merb mailer configuration we want to use.
Otherwise the system won’t know by what means to delivery the emails.
We’ll use sendmail:
...
Merb::BootLoader.after_app_loads do
Merb::Mailer.config = {:sendmail_path => '/usr/sbin/sendmail'}
Merb::Mailer.delivery_method = :sendmail
# MerbAuth Slice Activation config
Merb::Slices::config[:merb_auth_slice_activation][:from_email] = 'cook@example.com'
Merb::Slices::config[:merb_auth_slice_activation][:activation_host] = 'localhost:4000'
end
Adjust your sendmail path to match your system (to determine where sendmail is located on your machine, execute which sendmail
).
Note: For other mailer configuration options, like SMTP or Gmail SMTP, see MerbMailer README .
Ok, we’re done. Lets test our activation logic. Fire up interactive merb and add a user:
~/Projects/ck/cookbook(activation) $ merb -i
Loading init file from /Users/ck/Projects/ck/cookbook/config/init.rb
Loading /Users/ck/Projects/ck/cookbook/config/environments/development.rb
~ Connecting to database...
~ Loaded slice 'MerbAuthSliceActivation' ...
~ Loaded slice 'MerbAuthSlicePassword' ...
~ Parent pid: 13491
~ Compiling routes...
~ Activating slice 'MerbAuthSliceActivation' ...
~ Activating slice 'MerbAuthSlicePassword' ...
irb(main):001:0> joe = Cook.new(:login => 'joe', :email => 'joethecook@thisisnotmyrealemail.com', :password => 'secret', :password_confirmation => 'secret')
=> #<Cook activated_at=nil activation_code=nil id=nil login="joe" email="joethecook@thisisnotmyrealemail.com" identity_url=nil created_at=nil updated_at=nil crypted_password=nil salt=nil>
irb(main):002:0> joe.save
~ SELECT `id` FROM `cooks` WHERE (`login` = 'joe') ORDER BY `id` LIMIT 1
~ SELECT `id` FROM `cooks` WHERE (`email` = 'joethecook@thisisnotmyrealemail.com') ORDER BY `id` LIMIT 1
~ INSERT INTO `cooks` (`email`, `created_at`, `salt`, `login`, `activation_code`, `crypted_password`, `updated_at`) VALUES ('joethecook@thisisnotmyrealemail.com', '2008-10-22 08:02:17', '7de6ed3df0175d8a0d22ff7b3148bbdd99d801bb', 'joe', '6345866ba898ef52f5a121f865490a26395b1c16', '37a88b4dd8e07937127f2c5f525ab62a4f590cde', '2008-10-22 08:02:17')
~ Sending Signup to joethecook@thisisnotmyrealemail.com with code 6345866ba898ef52f5a121f865490a26395b1c16
~ signup sent to joethecook@thisisnotmyrealemail.com about =?utf-8?Q?Please_Activate_Your_Account?=
=> true
When we check our email, we see:
From: cook@cookingwithmerb.com
Subject: Please Activate Your Account
Charset: UTF-8
Your account has been created.
Username: joe
Visit this url to activate your account:
http://localhost:4000/6345866ba898ef52f5a121f865490a26395b1c16
The user is now prepped and just needs to verify his identity, i.e. email address, to become active.
Starting merb, press the link in the email, and voila Joe is active!
Next we want to customize activation even more to make any new cook feel right at home and entice them to
tell their friends and colleagues about our great site.
Lets start with the email subject lines. There are two email notifications that are part of the activation process:
- the signup notification
- the welcome notification
By default they use “Please Activate Your Account” and “Welcome” as their respective email subject lines.
To change this to more site specific simply add the following lines to config/init.rb
:
...
Merb::BootLoader.after_app_loads do
...
Merb::Slices::config[:merb_auth_slice_activation][:welcome_subject] = 'Welcome to Cookbook.com'
Merb::Slices::config[:merb_auth_slice_activation][:activation_subject] = 'Cookbook.com activation requested'
end
Now, that we took care of the email subject, we turn our attention to the email bodies.
During setup we executed rake slices:merb-auth-slice-activation:stubs
, which copied the two default
email templates
- activation.text.erb
- signup.text.erb
to the slice/merb-auth-slice-activation/mailers/views/activation_mailer
folder.
All we need to do now is modify them to our liking.
We change signup.text.erb
to
Your account has been created.
Username: <%= @user.login %>
All that is left, is to activate your account by simply visiting:
<%= activation_url(@user) %>
Sincerely,
Your Cookbook Team!
and activation.text.erb
to
Congratulation, you have been authenticated with the following email: <%= @user.email %>.
You can now manage all your favorite recipes securely online without the fear of every loosing them.
Sincerely,
Your Cookbook Team!
There are multiple ways to run the specs for a Merb project. I will outline a couple of them and the order I use that I found useful.
Pick the ones you like and make sure they fit nicely in your workflow, otherwise you will abandon tests and that is dangerous!
If you are TextMate use, you can install the RSpec Bundle (see here for instructions)
and use it to run a single example, all examples, or all examples in the folder.
One way to integrate it into your coding workflow is to
- Write the individual example.
- Run the individual example: Position cursor in example and press CMD-SHIFT-R.
- If the individual example passed, run all examples in the file: Position cursor in file and press CMD-R.
- Run all specs: Select ‘spec’ folder in folder drawer and press CMD-OPT-R.
- Rinse and repeat.