Skip to content
academia.sh

Lesson 02 / 13

Routing

Routing turns a path into a controller, an action and an identifier. The lesson measures that a path parameter becomes an integer, that a path that does not match the pattern is rejected, that the pattern order changes the result, and that the value stays only for this request.

Published Updated

In this lesson

  • Measures that a resource-based path pattern gives a controller, an action and an identifier
  • Shows that paths that do not match the pattern are rejected and that the identifier is converted to an integer
  • Measures that the pattern order decides which pattern wins
  • Shows that the stay of a path parameter is this request and that carrying the value falls to the client
Contents

The previous lesson showed that the template name derives from the controller and action names, but a routing step had supplied those names themselves. Routing turns the incoming path into the action of a particular controller. The question of this lesson is narrower: when the path is /products/7, where does the number 7 go, and where does it stand after that request ends?

The stay axis continues from the previous lesson: how many requests does this value stay for? The observation of this lesson is the path parameter row. The core’s stay.rb file is given again below; the block is source, it has no output, and it is pulled in with require_relative.

# stay.rb — a small request loop and a helper that classifies how many requests a value stays for

# Flash: a value written in this request is carried to the next request only.
class Flash
  def initialize(incoming)
    @incoming = incoming
    @fresh = {}
  end

  def [](key) = @fresh.fetch(key) { @incoming[key] }

  def []=(key, value)
    @fresh[key] = value
  end

  def passed_on = @fresh
end

# Every request gets a new context: params, flash and the request store start empty each time.
class Context
  attr_reader :app, :params, :session, :flash, :request_store

  def initialize(app, session)
    @app = app
    @params = {}
    @session = session
    @flash = Flash.new(session.delete(:flash) || {})
    @request_store = {}
  end
end

# The application store is the same across all requests and all clients;
# the session is chosen per client.
class App
  attr_reader :store

  def initialize
    @sessions = Hash.new { |h, client| h[client] = {} }
    @store = { products: {}, schema: { products: %i[name price] }, templates: {},
               queue: [], cache: {}, counter: 0 }
  end

  def request(client)
    context = Context.new(self, @sessions[client])
    result = yield context
    context.session[:flash] = context.flash.passed_on unless context.flash.passed_on.empty?
    result
  end
end

# Rule: request 1 (client A) writes the value; requests 2 and 3 (A) and request 4 (B) read it.
# If B sees it, persistent; if A's third request sees it, session; if only the second
# request sees it, next; if none sees it, this_request.
def stay(write, read)
  app = App.new
  first = app.request("A") { |b| write.call(b); read.call(b) }
  raise ArgumentError, "value could not be read in the request that wrote it" if first.nil?

  seen = [["A", 2], ["A", 3], ["B", 4]].map do |client, order|
    app.request(client) { |b| read.call(b) }.nil? ? "--" : "#{client}#{order}"
  end
  kind = if seen[2] != "--" then "persistent"
         elsif seen[1] != "--" then "session"
         elsif seen[0] != "--" then "next"
         else "this_request"
         end
  [kind, ["A1", *seen].join(" "), first]
end

Pattern, Action and Identifier

The core’s routing carries a single pattern. The pattern is a resource-based route: it starts with /products/, an identifier follows, and the target is written as products#show. The target is split into two parts: before # is the controller, after it is the action. The identifier is a named part of the pattern (id) and is read from the match.

The file below takes only the routing definitions from values.rb, exactly as they are, and calls stay with its own two lambdas.

# routing.rb — the stay of a path parameter
require_relative "stay"

# Routing: a resource-based path pattern is turned into a controller, an action and an id.
ROUTES = { %r{\A/products/(?<id>\d+)\z} => "products#show" }

def route(path)
  ROUTES.each do |pattern, target|
    match = pattern.match(path) or next
    controller, action = target.split("#")
    return { controller:, action:, id: match[:id].to_i }
  end
  nil
end

write = ->(b) { b.params.merge!(route("/products/7")) }
read = ->(b) { b.params[:id] }
p stay(write, read)
p route("/products/7")
p route("/products/7")[:id].class
["this_request", "A1 -- -- --", 7]
{controller: "products", action: "show", id: 7}
Integer

The first line is the same as the result in the table: the path parameter was read only in A1 and not in A2. The second line shows the hash that routing produced: controller, action and identifier. The third line says the identifier is an Integer. The 7 in the path is a string; route converted it with to_i. So the controller receives the identifier as a number and does not do the comparison on a string. FS5. The conversion is limited to the digits that the \d+ pattern matches; other spellings of a number format did not enter this measurement.

A Path That Does Not Match

Routing’s second job is to reject a path. The core’s pattern requires \d+ and ends with \z. Three paths test these two rules.

# reject.rb — paths that do not match the pattern
require_relative "stay"

# Routing: a resource-based path pattern is turned into a controller, an action and an id.
ROUTES = { %r{\A/products/(?<id>\d+)\z} => "products#show" }

def route(path)
  ROUTES.each do |pattern, target|
    match = pattern.match(path) or next
    controller, action = target.split("#")
    return { controller:, action:, id: match[:id].to_i }
  end
  nil
end

p route("/products/abc")
p route("/products/7/")
p route("/products/7?order=2")
p route("/products/7")
nil
nil
nil
{controller: "products", action: "show", id: 7}

/products/abc did not match because it is not digits, /products/7/ because it has an extra slash at the end, and /products/7?order=2 because it carries a query string at the end. The third is a limit of the core: the path and the query string must be separated before routing, because the pattern wants the whole path. In all three route gave nil; that is, the core reports a rejection not as an exception but as the absence of an answer. The \z anchor ties the pattern to the end of the path; without it 7/ would match too. How a nil is interpreted is the business of whoever calls it, not of routing. FS6. The form of the rejection is specific to this core; another routing step may give a different answer in the same situation, and this lesson did not measure it.

Pattern Order

When there is a single pattern, the order question does not arise. When the table grows, the first matching pattern wins; the core’s route also walks the table from start to end and returns at the first match. To measure this behavior with two patterns we build our own small table. There is a new helper here called first_match; it has no counterpart in the core.

# order.rb — order in two patterns
def first_match(table, path)
  table.each do |pattern, target|
    match = pattern.match(path) or next
    return [target, match[:id]]
  end
  nil
end

broad = { %r{\A/products/(?<id>[^/]+)\z} => "products#show_by_name",
          %r{\A/products/(?<id>\d+)\z} => "products#show" }
narrow = broad.to_a.reverse.to_h

p first_match(broad, "/products/7")
p first_match(narrow, "/products/7")
p first_match(narrow, "/products/abc")
["products#show_by_name", "7"]
["products#show", "7"]
["products#show_by_name", "abc"]

The two tables carry the same two patterns, only their order differs. When the broad pattern was first, /products/7 went to the name route, not the number route. When the narrow pattern was first, the same path went to the number route, and the non-numeric /products/abc fell to the broad pattern. FS7. This measurement is only for two patterns and three paths; with more patterns the overlap behavior must be tested separately. In a pattern table the order is a configuration, but it is not silent like a convention: when the position changes, the result changes.

A Nested Path

A resource can have another resource under it: the comments of a product, for example. The path carries two identifiers and the pattern needs two named parts. The pattern below is this lesson’s own definition and is not added to the core table; the first question is whether the core’s route recognizes this path.

# nested.rb — a path with two identifiers
require_relative "stay"

# Routing: a resource-based path pattern is turned into a controller, an action and an id.
ROUTES = { %r{\A/products/(?<id>\d+)\z} => "products#show" }

def route(path)
  ROUTES.each do |pattern, target|
    match = pattern.match(path) or next
    controller, action = target.split("#")
    return { controller:, action:, id: match[:id].to_i }
  end
  nil
end
NESTED = %r{\A/products/(?<id>\d+)/comments/(?<comment_id>\d+)\z}

path = "/products/7/comments/3"
p route(path)
match = NESTED.match(path)
p match.names
p match.named_captures.transform_values(&:to_i)
nil
["id", "comment_id"]
{"id" => 7, "comment_id" => 3}

The core’s single pattern rejected the path, because \z wants the end right after the identifier. The nested pattern gave the two parts by name. Named parts are read by name, not by order; so whether comment_id comes after or before id does not change the result. The two numbers in the path are also strings, and they were converted to integers with to_i. FS8. This pattern stays only in this lesson’s file; if it were added to the core’s ROUTES table, the order question would arise again.

Building the Path Back

Routing goes from path to target. Another place in the application wants to go from target to path: when writing a link, to give the target and the identifier instead of writing /products/7 by hand. This direction also depends on the definition of routing. If the two directions are written separately, they can drift apart.

# reverse.rb — building a path from a target
require_relative "stay"

# Routing: a resource-based path pattern is turned into a controller, an action and an id.
ROUTES = { %r{\A/products/(?<id>\d+)\z} => "products#show" }

def route(path)
  ROUTES.each do |pattern, target|
    match = pattern.match(path) or next
    controller, action = target.split("#")
    return { controller:, action:, id: match[:id].to_i }
  end
  nil
end
def build_path(target, id) = "/#{target.split('#').first}/#{id}"

path = build_path("products#show", 7)
p path
p route(path)
p route(build_path("products#show", "abc"))
"/products/7"
{controller: "products", action: "show", id: 7}
nil

The built path was decoded back with the core’s pattern and gave the same identifier: the round trip is consistent. But build_path did not look at what the identifier is. When given "abc", it built a path that the pattern would reject, and routing turned it into nil. The building does not know the pattern, the pattern does not know the building; tying the two to the same definition is the job of the routing layer. FS9. This measurement is for a single pattern and a single target; in a table with many patterns, which pattern the reverse building will choose must be defined separately.

The Limits of the Identifier

The pattern wants only digits, but says nothing about how many digits or what value. Passing extreme values through the path and looking at what the identifier is shows what the Integer conversion guarantees.

# identifier.rb — extreme values arriving as the identifier
require_relative "stay"

# Routing: a resource-based path pattern is turned into a controller, an action and an id.
ROUTES = { %r{\A/products/(?<id>\d+)\z} => "products#show" }

def route(path)
  ROUTES.each do |pattern, target|
    match = pattern.match(path) or next
    controller, action = target.split("#")
    return { controller:, action:, id: match[:id].to_i }
  end
  nil
end

["/products/007", "/products/0", "/products/99999999999999999999"].each do |path|
  result = route(path)
  p [path, result && result[:id], result && result[:id].class]
end
["/products/007", 7, Integer]
["/products/0", 0, Integer]
["/products/99999999999999999999", 99999999999999999999, Integer]

The leading zeros were dropped: the path 007 gave the identifier 7, so two different paths go to the same resource. 0 was accepted as a valid identifier; the pattern does not reject zero, and whether a record exists with that identifier is learned only afterward. A very long digit sequence was not rejected either and still became an Integer, because in Ruby an integer has no fixed upper limit. FS10. Routing validates the form of the identifier, not its existence; several paths going to the same resource is a consequence that must be considered separately for addressing, caching and link comparison. For this reason code that compares the identifier of a link should compare the integer that routing gave, not the path; even if the two path texts differ, the resolved identifier is the same.

Generating Routes from a Resource

The name of the resource-based route comes from a set of routes being derived from a resource name. The core carries only the show pattern, but the same idea can take a name and produce several patterns. The two helpers below are this lesson’s own plain Ruby code and have no counterpart in the core. The pattern is looked up together with the HTTP method (GET, POST, DELETE); the same path can go to a different action with a different method. The method here is not a Ruby method but the kind of the request; it is named request_method.

# resource.rb — generating routes from a resource name
def resource_routes(name)
  {
    ["GET", %r{\A/#{name}\z}] => "#{name}#list",
    ["GET", %r{\A/#{name}/(?<id>\d+)\z}] => "#{name}#show",
    ["POST", %r{\A/#{name}\z}] => "#{name}#add",
    ["DELETE", %r{\A/#{name}/(?<id>\d+)\z}] => "#{name}#remove",
  }
end

def find_route(table, request_method, path)
  table.each do |(verb, pattern), target|
    next unless verb == request_method
    match = pattern.match(path) or next
    return [target, match.names.empty? ? nil : match[:id].to_i]
  end
  nil
end

table = resource_routes("products").merge(resource_routes("orders"))
p table.size
p find_route(table, "GET", "/products")
p find_route(table, "GET", "/orders/3")
p find_route(table, "POST", "/products")
p find_route(table, "DELETE", "/products/7")
p find_route(table, "POST", "/products/7")
8
["products#list", nil]
["orders#show", 3]
["products#add", nil]
["products#remove", 7]
nil

Two resource names gave eight routes: each name produced four patterns. /products went to list with GET and to add with POST; the same path, a different action. /orders/3 went to the second resource’s show and the identifier was 3. In the last line there is the path /products/7, but there is no pattern defined with POST; the result is nil. If the path is recognized and the method is not, it is rejected too. Producing this set from a name instead of writing it by hand is the convention in routing: the resource name is given and the patterns derive. FS11. These four patterns are a choice; how many actions a resource will have does not follow from this measurement.

The Bounding Measurement

A path parameter is carried in the URL, but its value does not pass to the next request. The core’s result already said this: A2 is -- in the evidence column. The measurement here separates how the same value reaches two separate requests.

# limit.rb — the parameter comes from the URL, it does not pass from request to request
require_relative "stay"

# Routing: a resource-based path pattern is turned into a controller, an action and an id.
ROUTES = { %r{\A/products/(?<id>\d+)\z} => "products#show" }

def route(path)
  ROUTES.each do |pattern, target|
    match = pattern.match(path) or next
    controller, action = target.split("#")
    return { controller:, action:, id: match[:id].to_i }
  end
  nil
end

app = App.new
p app.request("A") { |b| b.params.merge!(route("/products/7")); b.params[:id] }
p app.request("A") { |b| b.params[:id] }
p app.request("A") { |b| b.params.merge!(route("/products/7")); b.params[:id] }

same_path = ->(b) { b.params.merge!(route("/products/7")); b.params[:id] }
p stay(same_path, same_path)
7
nil
7
["persistent", "A1 A2 A3 B4", 7]

The first request read 7. The second request did not ask for the path again and params[:id] came empty: nil. The third request gave the path again and received 7 again. The last line shows a trap in this. When the read lambda reran routing itself, every request and every client saw the value; the rule counts this as persistent. The value was not stored, it was derived again from the path each time. The rule reads the behavior, so the same mechanism falls into a different stay depending on how it is used. Carrying the value to the next request is not the job of routing but of the client: a link writes the identifier into the path again; a form carries it with the query string or the request body. FS12. This measurement was made with the in-memory core; whether a real client carries the identifier by another route (a header, a cookie) was not tested.

Summary

  • Routing turns a path into a controller, an action and an identifier; the named part in the pattern gives the identifier, and the identifier becomes an Integer.
  • A path that does not match the pattern is rejected: /products/abc and /products/7/, which carries a trailing slash, give nil in the core.
  • In a pattern table the first match wins; when the order of two patterns changes, the same path goes to a different action.
  • The stay of this lesson is this request: a path parameter is read only in the request that carries the path.
  • Carrying the value to the next request is the client’s job; giving the path again each time can make the value look persistent, but it does not store it.

Next Step

Routing gave the name of the action, but the class that runs the action is not yet on stage. The next lesson measures the controller and the filter that runs before the action, and looks at why a notification message appears once after saving and redirecting to another page.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close