In "Creating HTML
Dynamically with erb" and
"Scaffolding and REST" we came
across routes. The configuration in config/routes.rb
defines what happens in
the Rails application when a user of a Rails application fetches a URL. A route
can be static and dynamic and pass any dynamic values with variables to the
controller. If several routes apply to the same URL, the one that is listed at
the top of config/routes.rb
wins.
Tip
|
If you do not have much time, you can skip this chapter for now and get back to it later if you have any specific questions. |
Let’s first build a test Rails application so we can experiment:
$ rails new shop
[...]
$ cd shop
$ rails db:migrate
With rails routes
we can display the routes of a project. Let’s try it
straight away in the freshly created project:
$ rails routes
You don't have any routes defined!
Please add some routes in config/routes.rb.
For more information about routes, see the Rails guide:
http://guides.rubyonrails.org/routing.html.
That’s what I call a good error message. It’s a new Rails project, there are no routes yet.
As you might know the HTTP protocol uses different so called verbs to access content on the web server (e.g. GET to request a page or POST to send a form to the server). First we’ll have a look at GET requests.
Create a controller with three pages:
$ rails generate controller Home index ping pong
create app/controllers/home_controller.rb
route get "home/pong"
route get "home/ping"
route get "home/index"
[...]
Now rails routes
lists a route for these three pages:
$ rails routes
Prefix Verb URI Pattern Controller#Action
home_index GET /home/index(.:format) home#index
home_ping GET /home/ping(.:format) home#ping
home_pong GET /home/pong(.:format) home#pong
The pages can be accessed at the following URLs after starting the Rails
server with rails server
:
-
http://localhost:3000/home/index
for
home_index GET /home/index(.:format) home#index
-
http://localhost:3000/home/ping
for
home_ping GET /home/ping(.:format) home#ping
-
http://localhost:3000/home/pong
for
home_pong GET /home/pong(.:format) home#pong
With the output home#index
, Rails tells us that the route home/index
goes into the controller home and there to the action/method index
.
These routes are defined in the file config/routes.rb
.
rails generate controller Home index ping pong
has automatically
inserted the following lines there:
get "home/index"
get "home/ping"
get "home/pong"
A route should always have an internal name which doesn’t change. In the section "HTTP Get Requests for Singular Resources" there is the following route:
home_pong GET /home/pong(.:format) home#pong
This route has the automatically created name home_pong
. Generally,
you should always try to work with the name of the route within a Rails
application. So you would point a link_to
to home_pong
and not to
/home/pong. This has the big advantage that you can later edit (in the
best case, optimize) the routing for visitors externally and do not need
to make any changes internally in the application. Of course, you need
to enter the old names with :as
in that case.
If you want to define the name of a route yourself, you can do so with :as. For example, the line
get "home/pong", as: 'different_name'
results in the route
different_name GET /home/pong(.:format) home#pong
With to you can define an other destination for a rout. For example, the line
get "home/applepie", to: "home#ping"
results in the route
home_applepie GET /home/applepie(.:format) home#ping
The routing engine can not just assign fixed routes but also pass parameters which are part of the URL. A typical example would be date specifications (e.g. http://example.com/2010/12/ for all December postings).
To demonstrate this, let’s create a mini blog application:
$ rails new blog
[...]
$ cd blog
$ rails generate scaffold post subject content published_on:date
[...]
$ rails db:migrate
[...]
As example data in the db/seeds.rb
we take:
Post.create(subject: 'A test', published_on: '01.10.2011')
Post.create(subject: 'Another test', published_on: '01.10.2011')
Post.create(subject: 'And yet one more test', published_on: '02.10.2011')
Post.create(subject: 'Last test', published_on: '01.11.2011')
Post.create(subject: 'Very final test', published_on: '01.11.2012')
With rails db:seed
we populate the database with this data:
$ rails db:seed
If we now start the Rails server with rails server
and go to the
page http://localhost:3000/posts in the browser, we will see this:
For this kind of blog it would of course be very useful if you could
render all entries for the year 2010 with the URL
http://localhost:3000/2010/ and all entries for October 1st 2010 with
http://localhost:3000/2010/10/01. We can do this by using optional
parameters. Please enter the following configuration in the
config/routes.rb
:
Blog::Application.routes.draw do
resources :posts
get ':year(/:month(/:day))', to: 'posts#index'
end
The round brackets represent optional parameters. In this case, you have
to specify the year, but not necessarily the month or day. rails routes
shows the new route at the last line:
$ rails routes
Prefix Verb URI Pattern Controller#Action
posts GET /posts(.:format) posts#index
POST /posts(.:format) posts#create
new_post GET /posts/new(.:format) posts#new
edit_post GET /posts/:id/edit(.:format) posts#edit
post GET /posts/:id(.:format) posts#show
PATCH /posts/:id(.:format) posts#update
PUT /posts/:id(.:format) posts#update
DELETE /posts/:id(.:format) posts#destroy
GET /:year(/:month(/:day))(.:format) posts#index
If we do not change anything else, we still get the same result when calling http://localhost:3000/2011/ and http://localhost:3000/2011/10/01 as we did with http://localhost:3000/posts. But have a look at the output of rails server for the request http://localhost:3000/2011
Started GET "/2011/" for 127.0.0.1 at 2017-03-24 11:18:52 +0100
(0.5ms) SELECT "schema_migrations"."version" FROM "schema_migrations"
ORDER BY "schema_migrations"."version" ASC
Processing by PostsController#index as HTML
Parameters: {"year"=>"2011"}
Rendering posts/index.html.erb within layouts/application
Post Load (0.5ms) SELECT "posts".* FROM "posts"
Rendered posts/index.html.erb within layouts/application (14.7ms)
Completed 200 OK in 122ms (Views: 99.1ms | ActiveRecord: 1.0ms)
The route has been recognised and an "year" ⇒ "2011"
has been
assigned to the hash params
(written misleadingly as Parameters
in
the output). Going to the URL http://localhost:3000/2010/12/24 results
in the following output, as expected:
Started GET "/2010/12/24" for 127.0.0.1 at 2017-03-24 11:19:38 +0100
Processing by PostsController#index as HTML
Parameters: {"year"=>"2010", "month"=>"12", "day"=>"24"}
Rendering posts/index.html.erb within layouts/application
Post Load (0.2ms) SELECT "posts".* FROM "posts"
Rendered posts/index.html.erb within layouts/application (2.9ms)
Completed 200 OK in 14ms (Views: 11.4ms | ActiveRecord: 0.2ms)
In case of the URL http://localhost:3000/2010/12/24, the following
values have been saved in the hash params
:
"year"⇒"2010", "month"⇒"12", "day"⇒"24"
In the controller, we can access params[]
to access the values defined
in the URL. We simply need to adapt the index method in
app/controllers/posts_controller.rb
to output the posts
entered for
the corresponding date, month or year:
# GET /posts
# GET /posts.json
def index
# Check if the URL requests a date.
if Date.valid_date? params[:year].to_i, params[:month].to_i, params[:day].to_i
start_date = Date.parse("#{params[:day]}.#{params[:month]}.#{params[:year]}")
end_date = start_date
# Check if the URL requests a month
elsif Date.valid_date? params[:year].to_i, params[:month].to_i, 1
start_date = Date.parse("1.#{params[:month]}.#{params[:year]}")
end_date = start_date.end_of_month
# Check if the URL requests a year
elsif params[:year] && Date.valid_date?(params[:year].to_i, 1, 1)
start_date = Date.parse("1.1.#{params[:year]}")
end_date = start_date.end_of_year
end
if start_date && end_date
@posts = Post.where(published_on: start_date..end_date)
else
@posts = Post.all
end
end
If we now go to http://localhost:3000/2011/10/01 , we can see all
posts
of October 1st 2011.
In the section "Parameters" I showed you how
you can read out parameters from the URL and pass them to the
controller. Unfortunately, the entry defined there in the
config/routes.rb
get ':year(/:month(/:day))', to: 'posts#index'
has one important disadvantage: it does not verify the individual elements. For example, the URL http://localhost:3000/just/an/example will be matched just the same and then of course results in an error:
In the log output in`log/development.log` we can see the following entry:
Started GET "/just/an/example" for 127.0.0.1 at 2017-03-24 13:18:21 +0100
Processing by PostsController#index as HTML
Parameters: {"year"=>"just", "month"=>"an", "day"=>"example"}
Completed 500 Internal Server Error in 2ms (ActiveRecord: 0.0ms)
ArgumentError (invalid date):
app/controllers/posts_controller.rb:19:in `parse'
app/controllers/posts_controller.rb:19:in `index'
Obviously, Date.parse("example.an.just")
does not work. A date is made
up of numbers, not letters.
Constraints can define the content of the URL more precisely via regular
expressions. In the case of our blog, the config/routes.rb
with
contraints would look like this:
Blog::Application.routes.draw do
resources :posts
get ':year(/:month(/:day))', to: 'posts#index',
constraints: { year: /\d{4}/, month: /\d{2}/, day: /\d{2}/ }
end
Warning
|
Please note that you cannot use regex anchors such as "^" in regular expressions in a constraint. |
If we go to the URL again with this configuration, Rails gives us an error message "No route matches":
Our current application answers request in the format YYYY/MM/DD (4 digits for the year, 2 digits for the month and 2 digits for the day). That is ok for machines but maybe a human would request a single digit month (like January) and a single digit day without adding the extra 0 to make it two digits. We can fix that with a couple of redirect rules which catch these URLs and redirect them to the correct ones.
Blog::Application.routes.draw do
resources :posts
get ':year/:month/:day', to: redirect("/%{year}/0%{month}/0%{day}"),
constraints: { year: /\d{4}/, month: /\d{1}/, day: /\d{1}/ }
get ':year/:month/:day', to: redirect("/%{year}/0%{month}/%{day}"),
constraints: { year: /\d{4}/, month: /\d{1}/, day: /\d{2}/ }
get ':year/:month/:day', to: redirect("/%{year}/%{month}/0%{day}"),
constraints: { year: /\d{4}/, month: /\d{2}/, day: /\d{1}/ }
get ':year/:month', to: redirect("/%{year}/0%{month}"),
constraints: { year: /\d{4}/, month: /\d{1}/ }
get ':year(/:month(/:day))', to: 'posts#index',
constraints: { year: /\d{4}/, month: /\d{2}/, day: /\d{2}/ }
end
With this set of redirect rules, we can ensure that a user of the page can also enter single-digit days and months and still ends up in the right place, or is redirected to the correct format.
Note
|
Redirects in the config/routes.rb are by default http redirects with
the code 301 ("Moved Permanently"). So even search engines will profit
from this.
|
Rails provides a short cut for the /
(root) route. Assuming
you’d want to render the index
view of the posts
controller
you’d have to use this configuration:
Blog::Application.routes.draw do
resources :posts
root :to => posts#index
end
If you don’t want to show any of the resource pages you can
create a new controller (e.g. Page
) with an index
view.
$ rails new controller Page index
Than you can use the following configuration to present it as your index (root) page:
Blog::Application.routes.draw do resources :posts get 'page/index' root :to => page#index end
resources
provides routes for a RESTful resource. Let’s try it with
the mini blog application:
$ rails new blog
[...]
$ cd blog
$ rails generate scaffold post subject content published_on:date
[...]
$ rails db:migrate
[...]
The scaffold generator automatically creates a resources
route in the
config/routes.rb
:
Blog::Application.routes.draw do
resources :posts
end
Note
|
New routes are always added at the beginning of config/routes.rb by
rails generate scripts .
|
The resulting routes:
$ rails routes
Prefix Verb URI Pattern Controller#Action
posts GET /posts(.:format) posts#index
POST /posts(.:format) posts#create
new_post GET /posts/new(.:format) posts#new
edit_post GET /posts/:id/edit(.:format) posts#edit
post GET /posts/:id(.:format) posts#show
PATCH /posts/:id(.:format) posts#update
PUT /posts/:id(.:format) posts#update
DELETE /posts/:id(.:format) posts#destroy
You have already encountered these RESTful routes in the chapter "Scaffolding and REST". They are required for displaying and editing records.
If you only want to use specific routes from the finished set of RESTful
routes, you can limit them with :only
or :except
.
The following config/routes.rb
defines only the routes for index
and
show
:
Blog::Application.routes.draw do
resources :posts, only: [:index, :show]
end
With rails routes
we can check the result:
$ rails routes
Prefix Verb URI Pattern Controller#Action
posts GET /posts(.:format) posts#index
post GET /posts/:id(.:format) posts#show
except
works exactly the other way round:
Blog::Application.routes.draw do
resources :posts, except: [:index, :show]
end
Now all routes except for index
and show
are possible:
$ rails routes
Prefix Verb URI Pattern Controller#Action
posts POST /posts(.:format) posts#create
new_post GET /posts/new(.:format) posts#new
edit_post GET /posts/:id/edit(.:format) posts#edit
post PATCH /posts/:id(.:format) posts#update
PUT /posts/:id(.:format) posts#update
DELETE /posts/:id(.:format) posts#destroy
Warning
|
When using only and except , please make sure you also adapt the
views generated by the scaffold generator. For example, there is a link
on the index page to the new view with
<%= link_to 'New Post', new_post_path %> but this view no longer
exists in the above only example.
|
Nested resources refer to routes of resources that work with an
association. These can be addressed precisely via
routes. Let’s create a blog with Post
and a second
resource Comment
:
$ rails new nested-blog
[...]
$ cd nested-blog
[...]
$ rails generate scaffold post subject body:text
[...]
$ rails generate scaffold comment post:references content
[...]
$ rails db:migrate
[...]
Now we associate the two resources. In the file app/models/post.rb
, we
add a has_many
:
class Post < ApplicationRecord
has_many :comments
end
And in the file app/models/comment.rb
, its counterpart belongs_to
:
class Comment < ApplicationRecord
belongs_to :post
end
The routes generated by the scaffold generator look like this:
$ rails routes
Prefix Verb URI Pattern Controller#Action
comments GET /comments(.:format) comments#index
POST /comments(.:format) comments#create
new_comment GET /comments/new(.:format) comments#new
edit_comment GET /comments/:id/edit(.:format) comments#edit
comment GET /comments/:id(.:format) comments#show
PATCH /comments/:id(.:format) comments#update
PUT /comments/:id(.:format) comments#update
DELETE /comments/:id(.:format) comments#destroy
posts POST /posts(.:format) posts#create
new_post GET /posts/new(.:format) posts#new
edit_post GET /posts/:id/edit(.:format) posts#edit
post PATCH /posts/:id(.:format) posts#update
PUT /posts/:id(.:format) posts#update
DELETE /posts/:id(.:format) posts#destroy
So we can get the first post with /posts/1
and all the comments with
/comments
. By using nesting, we could get all comments with the
post_id
1 via /posts/1/comments
.
To achive this we need to change the config/routes.rb
:
Blog::Application.routes.draw do
resources :posts do
resources :comments
end
end
This gives us the desired routes:
$ rails routes
Prefix Verb URI Pattern Controller#Action
post_comments GET /posts/:post_id/comments(.:format) comments#index
POST /posts/:post_id/comments(.:format) comments#create
new_post_comment GET /posts/:post_id/comments/new(.:format) comments#new
edit_post_comment GET /posts/:post_id/comments/:id/edit(.:format) comments#edit
post_comment GET /posts/:post_id/comments/:id(.:format) comments#show
PATCH /posts/:post_id/comments/:id(.:format) comments#update
PUT /posts/:post_id/comments/:id(.:format) comments#update
DELETE /posts/:post_id/comments/:id(.:format) comments#destroy
posts GET /posts(.:format) posts#index
POST /posts(.:format) posts#create
new_post GET /posts/new(.:format) posts#new
edit_post GET /posts/:id/edit(.:format) posts#edit
post GET /posts/:id(.:format) posts#show
PATCH /posts/:id(.:format) posts#update
PUT /posts/:id(.:format) posts#update
DELETE /posts/:id(.:format) posts#destroy
But we still need to make some changes in the file
app/controllers/comments_controller.rb
. This ensures that only the
Comments
of the specified Post
can be displayed or changed:
class CommentsController < ApplicationController
before_action :set_post
before_action :set_comment, only: [:show, :edit, :update, :destroy]
def index
@comments = @post.comments
end
def show
end
def new
@comment = @post.comments.build
end
def edit
end
def create
@comment = @post.comments.build(comment_params)
respond_to do |format|
if @comment.save
format.html { redirect_to post_comment_path(@post, @comment), notice: 'Comment was successfully created.' }
format.json { render :show, status: :created, location: @comment }
else
format.html { render :new }
format.json { render json: @comment.errors, status: :unprocessable_entity }
end
end
end
def update
respond_to do |format|
if @comment.update(comment_params)
format.html { redirect_to post_comments_path(@post, @comment), notice: 'Comment was successfully updated.' }
format.json { render :show, status: :ok, location: @comment }
else
format.html { render :edit }
format.json { render json: @comment.errors, status: :unprocessable_entity }
end
end
end
def destroy
@comment.destroy
respond_to do |format|
format.html { redirect_to post_comments_url(@post), notice: 'Comment was successfully destroyed.' }
format.json { head :no_content }
end
end
private
def set_post
@post = Post.find(params[:post_id])
end
def set_comment
@comment = @post.comments.find(params[:id])
end
def comment_params
params.require(:comment).permit(:content)
end
end
Unfortunately, this is only half the story, because the views still link to the old routes. So we need to adapt each view in accordance with the nested route.
Please note that you need to change the form_with
call to
form_with(model: [post, comment], local: true)
. But we don’t need
the post_id
field any more, because that information is already
in the URL.
<%= form_with(model: [post, comment], local: true) do |f| %>
<% if comment.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(comment.errors.count, "error") %> prohibited this comment from being saved:</h2>
<ul>
<% comment.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= f.label :content %>
<%= f.text_field :content %>
</div>
<div class="actions">
<%= f.submit %>
</div>
<% end %>
<h1>Editing Comment</h1>
<%= render 'form', comment: @comment, post: @post %>
<%= link_to 'Show', post_comment_path(@post, @comment) %> |
<%= link_to 'Back', post_comments_path(@post) %>
<p id="notice"><%= notice %></p>
<h1>Comments</h1>
<table>
<thead>
<tr>
<th>Post</th>
<th>Content</th>
<th colspan="3"></th>
</tr>
</thead>
<tbody>
<% @comments.each do |comment| %>
<tr>
<td><%= comment.post %></td>
<td><%= comment.content %></td>
<td><%= link_to 'Show', post_comment_path(@post, comment) %></td>
<td><%= link_to 'Edit', edit_post_comment_path(@post, comment) %></td>
<td><%= link_to 'Destroy', post_comment_url(@post, comment), method: :delete, data: { confirm: 'Are you sure?' } %></td>
</tr>
<% end %>
</tbody>
</table>
<br>
<%= link_to 'New Comment', new_post_comment_path(@post) %>
<h1>New Comment</h1>
<%= render 'form', comment: @comment, post: @post %>
<%= link_to 'Back', post_comments_path(@post) %>
<p id="notice"><%= notice %></p>
<p>
<strong>Post:</strong>
<%= @comment.post %>
</p>
<p>
<strong>Content:</strong>
<%= @comment.content %>
</p>
<%= link_to 'Edit', edit_post_comment_path(@post,@comment) %> |
<%= link_to 'Back', post_comments_path(@post) %>
Please go ahead and have a go at experimenting with the URLs listed
under rails routes. You can now generate a new post with /posts/new
and
a new comment for this post with /posts/:post_id/comments/new
.
If you want to see all comments of the first post you can access that with the URL http://localhost:3000/posts/1/comments. It would look like this:
Sometimes it is a better option to use shallow nesting. For our example
the config/routes.rb
would contain the following routes:
Blog::Application.routes.draw do
resources :posts do
resources :comments, only: [:index, :new, :create]
end
resources :comments, except: [:index, :new, :create]
end
That would lead to a less messy rails routes
output:
$ rails routes
Prefix Verb URI Pattern Controller#Action
post_comments GET /posts/:post_id/comments(.:format) comments#index
POST /posts/:post_id/comments(.:format) comments#create
new_post_comment GET /posts/:post_id/comments/new(.:format) comments#new
posts GET /posts(.:format) posts#index
POST /posts(.:format) posts#create
new_post GET /posts/new(.:format) posts#new
edit_post GET /posts/:id/edit(.:format) posts#edit
post GET /posts/:id(.:format) posts#show
PATCH /posts/:id(.:format) posts#update
PUT /posts/:id(.:format) posts#update
DELETE /posts/:id(.:format) posts#destroy
edit_comment GET /comments/:id/edit(.:format) comments#edit
comment GET /comments/:id(.:format) comments#show
PATCH /comments/:id(.:format) comments#update
PUT /comments/:id(.:format) comments#update
DELETE /comments/:id(.:format) comments#destroy
Shallow nesting trys to combine the best of two worlds. And because it
is often used there is a shortcut. You can use the following
config/routes.rb
to achieve it:
Blog::Application.routes.draw do
resources :posts do
resources :comments, shallow: true
end
end
Note
|
Generally, you should never nest more deeply than one level and nested resources should feel natural. After a while, you will get a feel for it. In my opinion, the most important point about RESTful routes is that they should feel logical. If you phone a fellow Rails programmer and say "I’ve got a resource post and a resource comment here", then both parties should immediately be clear on how you address these resources via REST and how you can nest them. |
The topic routes is far more complex than we can address here. For example, you can also involve other HTTP methods/verbs. The official routing documentation http://guides.rubyonrails.org/routing.html will give you a lot of information an examples for these features and edge cases.