🛤️ Ruby on Rails - Basic Setup
Creating a Project
Install dependencies:
brew install postgresql # install PostgreSQL the way you want, I prefer `brew`
brew install rbenv # install Ruby the way you like, I like `rbenv`
# Check latest Heroku supported version at:
# https://devcenter.heroku.com/articles/ruby-support#ruby-versions
# e.g. 2.3.0 : patchlevel 0, Rubygems : 2.5.1
rbenv install 2.3.0
rbenv global 2.3.0
rbenv local 2.3.0 # lock Ruby version for this project
gem install rails --no-document
Create the project:
# keep project name as a single word
rails new -d postgresql --skip-turbolinks projectty
cd projectty
# edit `.gitignore`
*.DS_Store
Thumbs.db
.idea/workspace.xml
.idea/tasks.xml
/.bundle
/log/*
!/log/.keep
/tmp
.env
git init
git add -A
git commit -m "Init commit"
mine . # start project in RubyMine, wait for it to load
# close RubyMine, generates more project files after reopen
mine .
git add -A
git commit -m "Define IDEA project"
Edit Gemfile.
source 'https://rubygems.org'
ruby '2.3.0'
gem 'pg'
gem 'rails', '4.2.4'
gem 'bundler', '>= 1.8.4' # For Rails Assets
gem 'uglifier', '>= 1.3.0'
gem 'sass-rails', '~> 5.0'
gem 'coffee-rails', '~> 4.1.0'
gem 'jbuilder', '~> 2.0'
gem 'sdoc', '~> 0.4.0', group: :doc
gem 'puma'
gem 'bcrypt'
gem 'ar-uuid'
group :development do
gem 'spring'
end
source 'https://rails-assets.tenex.tech' do
gem 'rails-assets-jquery'
gem 'rails-assets-jquery-ujs'
end
# edit `app/assets/javascripts/application.js`
//= require jquery
//= require jquery-ujs
//= require_tree .
Install dependencies in the Gemfile:
bundle install
git add -A
git commit -m "Add Gemfile.lock"
Setup database:
# edit `config/database.yml`
default: &default
adapter: postgresql
encoding: unicode
pool: 5
development:
<<: *default
database: projectty_development
username: projectty
test:
<<: *default
database: projectty_test
username: projectty
# Heroku merges this file with their settings so no need for production
createuser projectty -s # PostgreSQL user as superuser
bundle exec rake db:create
# optional
# add to `development.rb` to show ran SQL queries in console
ActiveRecord::Base.logger = Logger.new(STDOUT) if defined?(ActiveRecord::Base)
Create controller for root pages:
rails g controller RootPages index
# edit `config/routes.rb`
Rails.application.routes.draw do
root "root_pages#index"
end
Setup webserver and foreman:
# create `config/puma.rb`
required_envs = %w(PORT RACK_ENV WEB_CONCURRENCY MAX_THREADS)
required_envs.each do |env|
if ENV[env].nil?
abort %Q(
#{env} environmental variable is not set.
Local: copy `.env.example` to `.env` and edit it.
Heroku: use `heroku config:set #{env}="value"`
)
end
end
workers Integer(ENV["WEB_CONCURRENCY"])
threads_count = Integer(ENV["MAX_THREADS"])
threads threads_count, threads_count
preload_app!
rackup DefaultRackup
port ENV["PORT"]
environment ENV["RACK_ENV"]
on_worker_boot do
ActiveRecord::Base.establish_connection
end
# `.env` is gitignored and used to fill process env variables using foreman.
# By copying `.env.example`, others devs can use to define their own settings.
# Production environments like Heroku should use normal environmental variables.
# Create `.env` and `.env.example` to the project root with:
WEB_CONCURRENCY=2
MAX_THREADS=5
PORT=3000
RACK_ENV=development
Starting the server:
# `Procfile` tells foreman which processes to start
# create file `Procfile` to root with:
web: bundle exec puma -C config/puma.rb
gem install foreman
foreman start
# now you can see your server at localhost:3000
Commit to version control:
git add -A
git commit -m "Add root pages"
Adding User Model
rails g migration EnableCitextAndUuid
rails g scaffold User display_name:text email:citext
class EnableCitextAndUuid < ActiveRecord::Migration
def change
enable_extension "citext"
enable_extension "uuid-ossp"
end
end
class CreateUsers < ActiveRecord::Migration
def change
create_table :users do |t|
t.text :display_name
t.citext :email
t.timestamps null: false
end
add_index :users, :email, unique: true
end
end
# add uniqueness validation to `app/models/user.rb`
class User < ActiveRecord::Base
validates :email, uniqueness: true
end
bundle exec rake db:migrate
git add -A
git commit -m "Add users"
Running Tests
foreman run bundle exec rake test
# `foreman run` sets the environmental variables from `.env`
# `bundle exec` makes sure you use the right gems
# Note that tests will fail, like they should as tests will try to
# create users with duplicate emails. You can fix those tests easily.
Removing a Project
cd /path/to/projectty
bundle exec rake db:drop
dropuser projectty
cd ..
rm -rf projectty
# Remove the git repos and Heroku app.
Heroku
Now is a good time to setup a staging server to see if it all really works.
# edit Gemfile
group :production do
gem 'rails_12factor' # Heroku
end
# install Heroku Toolbelt
heroku auth:whoami
heroku logout
heroku login
heroku auth:whoami
# create an app
heroku apps:create -r stage --region us projectty-stage
heroku config # check the environmental variables
heroku config:set WEB_CONCURRENCY=2 # add the missing ones
heroku config:set MAX_THREADS=5
# add a database
heroku addons:create heroku-postgresql:hobby-dev
heroku pg:wait # wait for the database to come online
git push stage master:master
heroku run rake db:schema:load # if you've done migrations
heroku logs -t
# after you create production server for this app, you must use `-a` with
# all Heroku commands and configure additional remote for git, `production`
heroku logs -t -r stage
# database commands
heroku pg:info -r stage # database details and limitations
heroku pg:diagnose -r stage # check for problems
heroku pg:ps -r stage # view active queries
heroku pg:psql -r stage # open SQL connection to DB
heroku pg:credentials DATABASE_URL -r stage # show DB access credentials
# the app should now work, visit the page
https://projectty-stage.herokuapp.com/
# put on maintenance mode when you don't need the staging
heroku maintenance:on
# heroku maintenance:off
DNS for Heroku
First, extend application to redirect all naked domain calls to www
when production. Using basic www
for the main consumer facing domain is advisable; it's established standard and it improves search engine rankings, although marginally.
# in `app/controllers/application_controller.rb`
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
before_filter :add_www_subdomain
private
def add_www_subdomain
is_production = ENV['RACK_ENV'] == 'production'
if is_production && ! /^www/.match(request.host)
redirect_to(
"#{request.protocol}www.#{request.host_with_port}",
status: 301
)
end
end
end
# Second, buy a domain from a domain registry.
# Then tell Heroku all domains you will be configuring.
heroku domains:add projectty.com
heroku domains:add www.projectty.com
heroku domains
# Figure out your app's load balancer using `dig`
dig my-app.herokuapp.com
# the first CNAME is your load balancer e.g. elbXXX.us-east-1.elb.amazonaws.com
AWS > Route 53 > Create Hosted Zone
Domain Name: projectty.com
Zone > Create Record Set
A (ALIAS) projectty.com -> elbXXX.us-east-1.elb.amazonaws.com
CNAME www.projectty.com -> projectty.herokuapp.com
Hosted Zone List > Select Hosted Zone
Copy the 4 name server URLs and configure them to your domain provider.
Other possible DNS configurations:
# route all unspecified subdomains to a single app
CNAME *.projectty.com -> subdomainizer.herokuapp.com
# if using SSL endpoint, remember to use that
heroku certs
CNAME www.projectty.com -> tokyo-123.herokussl.com
SSL in Heroku
I this day and age, every website should be hosted using SSL, even small personal projects and staging servers.
Namecheap:
1. Figure out what domains and subdomains you want to secure.
All subdomain cerficates e.g. www include the root domain.
The most common selections are:
www.example.com (implicit example.com)
*.example.com (implicit example.com)
2. Buy certificates for the domain or domains.
3. Activate the SSL:
1. Figure out your or your organization's Country Code
https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
2. The domain you are securing is sometimes dubbed `common name`.
3. Generate CSR:
brew install openssl
openssl genrsa -des3 -out server.pass.key 2048
openssl rsa -in server.pass.key -out server.key
openssl req -nodes -new -key server.key -out server.csr
US
www.example.com
Fill the rest of the info, but those two are the most important.
4. Provide CSR contents of server.csr to activate it.
4. If asked, web server is Nginx.
5. If asked, certificate format is X.509.
5. Select DCV authenticaion using DNS.
6. Provide your administrative information.
8. Submit
7. Go to Certificate Details page.
8. Edit Methods > Get Record
Go to your DNS e.g. Route 53.
Add a new record set.
CNAME <FROM_NAMECHEAP>.example.com <FROM_NAMECHEAP>.comodoca.com
9. It will take from 6h to 1 day to get verified.
4. Install SSL addon:
heroku addons:create ssl:endpoint
heroku certs:add server.crt server.key
heroku domains
heroku domains:add www.example.com
heroku certs
5. Modify A in your DSN (Route53):
Make sure load balancer is the same as before
dig example-2121.herokussl.com
If it has changed, update the A record.
A (ALIAS) projectty.com elbXXX.us-east-1.elb.amazonaws.com
5. Modify CNAME in your DSN (Route53):
CNAME www.projectty.com example-2121.herokussl.com.
or
CNAME *.projectty.com example-2121.herokussl.com.
6. Test that it works:
curl -kvI https://www.example.com
Expect to find "SSL certificate verify ok"
# add this to config/environments/production.rb
config.force_ssl = true
# update your app
# check that http://www.example.com/ forced to https.
Turn all asset paths to relative:
<script src="http://code.jquery.com/jquery-2.1.0.js"></script>
<script src="//code.jquery.com/jquery-2.1.0.js"></script>
Now all of the following are redirected to `https://www.example.com`.
1. example.com
2. http://example.com
3. https://example.com
4. http://www.example.com
1. Updating certificates is just as easy:
Get a new certificate using steps 2 and 3 of installation.
heroku certs:update server.crt server.key
Enable Dynamic Error Pages
rails g controller ErrorPages show
# add this to `config/application.rb`
config.exceptions_app = self.routes
# edit `config/routes.rb`
%w(404 422 500).each do |code|
get code, :to => "error_pages#show", :code => code
end
class ErrorPagesController < ApplicationController
def show
render status_code.to_s, :status => status_code
end
protected
def status_code
params[:code] || 500
end
end
# copy and rename `app/views/error_pages/show.html.erb` and remove the original
show.html.erb ->
404.html.erb
422.html.erb
500.html.erb
# remove static error pages at `public/`
404.html
422.html
500.html
# to test in development, temporarily edit `config/environments/development.rb`
config.consider_all_requests_local = false
git add -A
git commit -m "Add dynamic error pages"
Sending Emails
Creating mailer:
rails g mailer UserMailer test_email
# edit `application_mailer.rb`
class ApplicationMailer < ActionMailer::Base
default from: ENV["MAILER_SENDER"]
layout "mailer"
end
# edit `user_mailer.rb`
class UserMailer < ApplicationMailer
def test_email(user)
@user = user
mail(to: @user.email, subject: "Welcome to Projectty")
end
end
# edit `.env` and `.env.example`
MAILER_SENDER=Projectty <Projectty@example.com>
Edit email templates:
# html version
<h1>UserMailer#test_email</h1>
<p>
<%= @user.display_name %>, find me in app/views/user_mailer/test_email.html.erb
</p>
# text version
UserMailer#test_email
<%= @user.display_name %>, find me in app/views/user_mailer/test_email.text.erb
Create email sending action:
class RootPagesController < ApplicationController
# ...
def email_test
@user = User.first
UserMailer.test_email(@user).deliver
render json: {status: :ok}
end
end
# edit `routes.rb`
get "mail" => "root_pages#mail"
# crash when an email cannot be sent, `development.rb`
config.action_mailer.raise_delivery_errors = true
git add -A
git commit -m "Add user mailer"
Email Provider
Select transactional email provider. I personally prefer Sendgrid but many marketing departments want Mailchimp + Mandrill. Sendgrid registration takes couple hours to get verified but has 12k free emails per month.
https://sendgrid.com
# create account
# wait for verification, takes couple of hours
# go to dashboard
# setup default whitelabel subdomain to mailer.example.com
# create the whitelabel subdomain inside SendGrid app
# go to your DNS provider and setup records e.g. Route 53
# if you have multiple varifications in TXT record:
v=spf1 include:xxx.sendgrid.net include:xxx.mandrillapp.com ?all
# You will get this info to sent mailer, not `sendgrid.me`:
mailed-by: mailer.example.com
# Setup default whitelabel email link to el.example.com.
# Create the whitelabel subdomain inside SendGrid app.
# Go to your DNS provider and setup records e.g. Route 53.
# All links in sent emails will now have a redirect links to this domain
# for analytics, not to SendGrid's domain.
# edit `.env` and `.env.example`
APP_HOSTNAME=http://localhost
APP_PORT=3000
SENDGRID_PORT=587
SENDGRID_USERNAME=SAME_AS_WEBSITE
SENDGRID_PASSWORD=SAME_AS_WEBSITE
SENDGRID_DOMAIN=example.com
# add settings for `application.rb`
config.action_mailer.smtp_settings = {
:address => 'smtp.sendgrid.net',
:port => ENV['SENDGRID_PORT'],
:user_name => ENV['SENDGRID_USERNAME'],
:password => ENV['SENDGRID_PASSWORD'],
:domain => ENV['SENDGRID_DOMAIN'],
:enable_starttls_auto => true,
:authentication => :plain,
}
config.action_mailer.default_url_options = { host: ENV['APP_HOSTNAME'] }
if ENV['APP_PORT'].present?
config.action_mailer.default_url_options[:port] = ENV['APP_PORT']
end
git add -A
git commit -m "add SendGrid"
git push stage master:master
heroku config:set APP_HOSTNAME=https://projectty-stage.herokuapp.com
heroku config:set SENDGRID_PORT=587
heroku config:set SENDGRID_USERNAME=<SENDGRID_LOGIN>
heroku config:set SENDGRID_PASSWORD=<SENDGRID_LOGIN>
heroku config:set SENDGRID_DOMAIN=example.com
heroku config:set MAILER_SENDER="Projectty Stage <projectty@example.com>"
Sending with Sidekiq
Sidekiq allows sending emails asynchronously so visitors don't have to wait for the sending.
brew install redis # and follow instructions
# add to Gemfile
gem 'sidekiq'
bundle install
foreman run bundle exec sidekiq # test that it works and stop it
# add to `Procfile`
worker: bundle exec sidekiq -q emails
# edit `application.rb` so that active jobs use Sidekiq
config.active_job.queue_adapter = :sidekiq
# change `root_pages#mail` function to
def mail
@user = User.first
UserMailer.test_email(@user).deliver_later(queue: :emails)
render json: {status: :ok}
end
foreman start
# go to http://localhost:3000/mail
git add -A
git commit -m "send emails asynchronously"
git push stage master:master
# for Heroku, you need to add a redis addon
# as the time of writing, Redis Cloud is the best and cheapest option
heroku addons:create rediscloud:30
heroku config:set REDIS_PROVIDER=REDISCLOUD_URL
heroku ps:scale worker=1
heroku ps
# go to Heroku Dashboard:
# - Your `worker` dyno should be enabled.
# - Redis Cloud has a dashboard where you can see the queues.
Additionally, you can now use jobs for all your background tasks!
Production Errors - Sentry
Using Sentry to receive notifications about production and staging errors. It's free for solo developers with 7 day history.
https://getsentry.com
# create an account if you don't have one
# create a project, I name it the same as heroku app e.g. projectty-stage
# you should receive a DSN that will be place in env variables
# add to Gemfile
group :production do
# ...
gem 'sentry-raven'
end
bundle install
# add to `app/controllers/root_pages_controller.rb`
class RootPagesController < ApplicationController
# ...
def crash
@user = User.first
@user.i_dont_exist()
end
end
# add to `config/routes.rb`
get "crash" => 'root_pages#crash'
git add -A
git commit -m "add sentry"
git push stage master:master
heroku config:set SENTRY_DSN=YOU_GET_THIS_FROM_SENTRY
Authentication - Devise
# add to Gemfile
gem 'devise'
gem 'devise-async'
bundle install
rails g devise:install # create basic files
rails g devise:views # generate views for customization
# edit following settings in `config/initializers/devise.rb`
config.mailer_sender = ENV["MAILER_SENDER"]
config.paranoid = true
config.pepper = "100_character_random_string"
# create `config/initializers/devise_async.rb`
Devise::Async.backend = :sidekiq
Devise::Async.queue = :emails
# add to `app/views/layouts/application.html.erb`
<p class="notice"><%= notice %></p>
<p class="alert"><%= alert %></p>
Bind devise
to User model:
# attach devise to the user model
rails g devise User
# edit `app/models/user.rb` to include more features as needed
# at least add :async
class User < ActiveRecord::Base
# Include default devise modules. Others available are:
# :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable,
:confirmable, :lockable, :timeoutable, :async
end
# go to the generated migration
# - change all `string` to `text`
# - uncomment features you want to use
# - emove email column and index as you already have email defined
bundle exec rake db:migrate
Configure routes:
# replace in `config/routes.rb`
devise_for :users,
path: "user",
path_names: {
sign_in: "sign-in",
sign_out: "sign-out",
password: "password",
confirmation: "confirmation",
unlock: "unlock",
registration: "registration",
sign_up: "sign-up"
}
Usage:
# add this to all controllers that require authentication
before_action :authenticate_user!
before_action :authenticate_user!, only: [:destroy]
before_action :authenticate_user!, except: [:index]
# following methods are usable in controllers and views
user_signed_in?
current_user
user_session
after_sign_in_path_for
after_sign_out_path_for
# following helpers are available in views
new_user_session_path # => GET login page
new_user_password_path # => GET start password reset
new_user_registration_path # => GET start registration
destroy_user_session_path # => DELETE here to logout
edit_user_registration_path # => GET edit email and password
cancel_user_registration_path # => GET cancel (delete) user account
# example navigation:
<div>
<%= link_to "Sign In", new_user_session_path %>
<% if user_signed_in? %>
<%= link_to "Edit", edit_user_registration_path %>
<%= link_to "Sign Out", destroy_user_session_path, method: :delete %>
<% end %>
</div>
git add -A
git commit -m "add devise"
git push stage master:master
heroku run rake db:migrate
heroku restart
Authorization - CanCanCan
Install cancancan:
gem 'cancancan', '~> 1.10'
gem 'role_model'
bundle install
rails g migration AddRolesMaskToUser roles_mask:integer
rails g cancan:ability
# edit the migration
class AddRolesMaskToUser < ActiveRecord::Migration
def change
add_column :users, :roles_mask, :integer, default: 0, null: false
end
end
# `app/models/ability.rb` defines all user permissions
class Ability
include CanCan::Ability
def initialize(user)
return if user.nil?
# generic and low level permissions top
can :show, User if user.has_role? :member
can :update, User if user.has_role? :moderator
can :edit, User if user.has_role? :moderator
can :manage, :all if user.has_role? :admin
end
end
# edit `app/models/user.rb` to include role information
class User < ActiveRecord::Base
include RoleModel
roles_attribute :roles_mask
roles :admin, :moderator, :member
end
# use controller level macros to limit actions
class UsersController < ApplicationController
before_action :authenticate_user!, except: [:index]
load_and_authorize_resource :except => [:index]
# => automatically sets @user and check for permission to the action
end
bundle exec rake db:migrate
Editing roles:
User.valid_roles # get all roles
User.mask_for :admin, :member # calculate role mask for this set of roles
u.roles # get user's roles
u.roles = [:member, :moderator] # set roles
u.roles << :member # add roles
u.has_role? :member # does user have the role?
u.has_any_role? :member, :manager # does user have any of the roles?
u.has_all_roles? :member, :manager # does user have all of the roles?
u.save
Ability definitions:
# :manage and :all are special permission definitions,
# allows all actions on all targets
can :manage, :all
# the first argument is `ability action` as a symbol,
# may or may not be tied to controller actions
can :update, :all
# second argument is `target scope`, usually a Rails model
# but can also be any symbol
can :update, User
can :conquer, :the_world
# you can also deny actions
# allow doing everything except destroy action
can :manage, Project
cannot :destroy, Project
# third argument is an optional method hash for additional model filtering
# (Article instance must have method `published` that returns true)
can :update, Article, :published => true
# check if owner
can :manage, Article, :user_id => user.id
Ability checks:
# returns true or false
# note that third argument method hash is ignored on class checks
can? :destroy, Article
cannot? :destroy, Article
# if you have the third argument specified and require an instance
can? :destroy, @article
cannot? :destroy, @article
# can current user update all articles?
Article.accessible_by(current_ability).count == Article.count
# will raise CanCan::AccessDenied if not allowed by the current user
def show
@article = Article.find(params[:id])
authorize! :show, @article
end
# you can use `authorize_resource` to add `authorize!` check before each action
class ArticlesController < ActionController::Base
authorize_resource
def discontinue
# Automatically does the following:
# authorize! :discontinue, Article
end
end
class ArticlesController < ActionController::Base
load_and_authorize_resource :except => :index
skip_load_resource :only => :new
def discontinue
# Automatically does the following:
# @article = Article.find(params[:id])
# authorize! :discontinue, @article
end
def new
# Automatically does the following:
# authorize! :new, Article
end
end
Handling unauthorized access error:
class ApplicationController < ActionController::Base
rescue_from CanCan::AccessDenied do |exception|
redirect_to root_url, :alert => exception.message
end
end