Learn Ruby and Rails by building your own very simplistic Rails-like Framework.
In the simple rackapp we used a case statement to allow for different actions based on the URL path. In this section we'll create a Routing class to match a request url path to a controller and action. Before we get into the code we'll start with a quick overview of a URL and how it's used in a Web application.
Let's start with the difference between a URL and an URI since you often see those terms used seemingly interchangeably.
URL (Uniform Resource Locator): is a web address. It specifies the location of web resource on a computer network, most commonly on the World Wide Web. URLs can reference web pages (http), but are also used for file transfer (ftp), email (mailto), and others.
URI (Uniform Resource Identifier): Is a string of characters used to identify a resource, but it doesn't necessarily tell you how to locate the resource. URLs are URIs for locating web addresses. Because URI is more inclusive than URL and can encompass the corner cases, URI is often considered a more correct term to use in technical literature. For our tutorial, both terms are correct.
Format: scheme://host[:port][/]path[?query][#fragment] Example: http://www.example.com/articles/21?title=Ruby#summaryScheme or protocol - (http): http, https, ftp, mailto, file, data, etc. followed by ://
Okay, let's build. In the call method we'll instantiate a new Routing object and chain the dispatch method to it. This will ultimately return a Rack response object which will get sent back to the client. Change the call method to the below:
# lib/jails.rb ... module Jails class Application # Instantiate a Request object, instantiate Routing object and call dispatch method, ultimately return response. def call(env) request = Rack::Request.new(env) puts("\n\nStarted #{request.request_method} \"#{request.path}\" for #{request.ip} #{Time.now}") response = Routing.new(request).dispatch return response end end end
Let's breaks this down by statement line.
1) Create a request object and assign it to a local variable request.
2) We'll start doing some logging using the puts method. Puts will print out the argument to the terminal screen that is running our app server. This is the first log message that will be printed after our server receives an HTTP request. The two \n are backslash notations to create two new lines before our initial log message. We will log information from the request object including the request method (e.g., GET, POST), the path (e.g., /about), and the requester's ip address. Time is a Ruby core class. Time.now returns the current date and time.
3-4) Instantiate a Routing object, passing in the request object as the argument, and immediately call the dispatch method on the new object. This will ultimately return a Rack Response object.
Of course the Routing class and the dispatch method don't exist yet. We'll create that shortly, but first let's go to the app's routes.rb file. That's where we define the actual route paths and their corresponding controllers and actions for our Blog application. This file is part of the application, as opposed to the Jails framework library, and will be called by the Jails Routing class. We'll define a class method called routes, which will return a proc object with all the routes in a block of code. We'll explain what this means shortly.
# config/routes.rb class Application def self.routes Proc.new do match("/", "pages#home") match("/about", "pages#about") match("/user", "pages#user") match("/pages/:id", "pages#show") match("/jogin", "pages#jogin") match("/jogout", "pages#jogout") end end end
Now populate the Jails library's routing.rb file with the Routing class.
# lib/jails/routing.rb class Routing # Set request object as an attribute. Declare a routes attribute. Call the draw method to populate it with a hash of routes. def initialize(request) @request = request @routes = {} draw(Application.routes) end # Build the @routes hash by evaluating the block passed in as a proc object from the Application.routes method. def draw(block) instance_eval(&block) end # Add route to @routes hash with the url's path as the key and target as the value. def match(path, target) @routes["#{path}"] = target end # Match request path to @routes hash. If match, create controller object and call the action. def dispatch path = @request.path id = nil segments = path.split("/").reject { |segment| segment.empty? } if segments[1] =~ /^\d+$/ id = segments[1] segments[1] = ":id" path = "/#{segments.join('/')}" end if @routes.key?(path) target = @routes[path] resource_name, action_name = target.split('#') @request.params.merge!(resource: resource_name, action: action_name, id: id) klass = Object.const_get("#{resource_name.capitalize}Controller") puts("Processing by #{klass}##{action_name}") klass.new(@request).send(action_name) else Rack::Response.new(["Nothing found"], 404, {"Content-Type" => "text/html"}) end rescue StandardError => error puts error.message puts error.backtrace Rack::Response.new(["Internal error"], 500, {"Content-Type" => "text/html"}) end end
Let's break this down method by method.
def initialize(request) @request = request # 1 @routes = {} # 2 draw(Application.routes) # 3 end
When a request is made which calls the "call" method, we instantiate a new Routing object and call the dispatch method on it: Routing.new(request).dispatch
. Objects are created from classes before they are used. Calling the new method on a class creates a new object. The new method calls the (optional) initialize method automatically. In the initialize method we put in code that will add attributes to our object and call any methods we want run when the object is created. Initialize method statements:
1) In the first statement we declare the @request instance variable, assigning it to the request object that was passed in as a parameter. Assigning an instance variable in the initialize method will make it an attribute of the new Routing object.
2) Declare a @routes instance variable and assign it to an empty hash. The next statement will populate that hash with the app's routes and their corresponding controller name and action. Later, the @routes collection will be compared to the actual url path from a request.
3) We want to populate @routes with the actual routes and controller and actions of the application. So we'll define a draw method and call it, passing in a method, Application.routes, as the argument. We defined that method in the app's config/routes.rb file. When called, it returns a proc object that contains a block with statements for all the app's routes.
Let's jump back to the application routes file.
# config/routes.rb - Application class def self.routes Proc.new do match("/", "pages#home") match("/about", "pages#about") ... end end
Proc (rhymes with block) is short for procedure and is a Ruby core class. A proc is similar to a method in that it holds a block of code. But a method is not an object and a proc is. As an object a proc can be assigned to a variable, or passed as an argument in a method. Our routes class method instantiates a proc object with a block of code containing our application's routes and controller#actions. So, when we call the routes method it returns the block of routes as a proc object. Now back to our framework's Routing methods.
def draw(block) instance_eval(&block) end def match(path, target) @routes["#{path}"] = target end
We called the draw method from intialize so it is called every time a new routing object is created. It passes in a block of routes packaged as a proc as it's argument. To run the block of code in the proc, we call the instance_eval method. In the application's routes method, each route path and it's corresponding controller and action separated by # are arguments to the match method. Match adds the route to the @routes hash with the url as the key and target as the value. When the whole block is finished being evaluated, it will have put all the routes into the @routes hash.
def dispatch path = @request.path # 1 id = nil # 2 segments = path.split("/").reject { |segment| segment.empty? } if segments[1] =~ /^\d+$/ id = segments[1] segments[1] = ":id" path = "/#{segments.join('/')}" end if @routes.key?(path) # 9 target = @routes[path] resource_name, action_name = target.split('#') @request.params.merge!(resource: resource_name, action: action_name, id: id) klass = Object.const_get("#{resource_name.capitalize}Controller") # 13 puts("Processing by #{klass}##{action_name}") # 14 klass.new(@request).send(action_name) # 15 else # 16 Rack::Response.new(["Nothing found"], 404, {"Content-Type" => "text/html"}) end rescue StandardError => error # 19 puts("-"*100) puts("#{error.class.name}: #{error.message}") puts("Exception source stack displays filename, line number, and method the exception is in:") puts(error.backtrace) puts("-"*100) Rack::Response.new(["Internal error"], 500, {"Content-Type" => "text/html"}) endThe dispatch method will call the controller action that is mapped to the url path or return an error message.
rescue StandardError => error
. StandardError encompasses exceptions with your code. You can get more specific with the multiple StandardError subclasses. By deliberately putting a typo in your code, then refreshing your the browser, you can see the Exception output.
Let's test it out. The dispatch method calls the controller and action for the url's path. We created routes for a pages controller. It's actually not even a full resource, just a controller with some actions. We created the pages_controller file in the last section. For now populate it with this:
# app/controllers/pages_controller.rb class PagesController def initialize(request) @request = request end def home response end def about response end def user response end def show response end private def response Rack::Response.new(["<b>Path</b>: #{@request.path} | <b>Resource</b>: #{@request.params[:resource]} | <b>Action</b>: #{@request.params[:action]} | <b>id</b>: #{@request.params[:id] || 'nil'}"], 200, {"Content-Type" => "text/html"}) end end
We define a PagesController class. Add an initialize method so we can make the request object an attribute accessible with an instance variable. The initialize method is automatically called every time a new PagesController object is instantiated.
We also add the relevant actions. Actions are a methods that have corresponding routes. Each action will call a private method called response.
The response method returns a Rack response with the request object properties of path, and the params that we added on for resource, action, and id. If id does not have a value we'll return the string "nil." Private methods follow the private keyword and are only accessible within the class (or subclass).
Now test it out. Because of our Rack Reloader middleware, you shouldn't have to restart the server. Go to your browser and enter the following URLs:
http://localhost:9292 http://localhost:9292/about http://localhost:9292/user http://localhost:9292/pages/3 http://localhost:9292/pages/264232134 http://localhost:9292/badlink
For each url you should see the path, resource, action, and id displayed in your browser. Notice any numbers in the path after /pages/ are assigned to the id params. The last url should return "Nothing Found" and status code 404.
That concludes the Routing section. Next we'll create our controllers and views.