In this project, I implement my own version of Ruby on Rails. The goals of this project were to gain a better understanding of the following:
- Know how the HTTP request-response cycle works
- Know how the server works
- Understand the
ControllerBase
class - Know how cookies are used by browsers and servers
- Implement a custom router
- Understand how the params are built out by the HTTP request's information
So what happens when you run rails server
? Rack is the software that sits in between a web server and a web application framework (Ruby, Sinatra). Since there are different frameworks, servers, etc., Rack makes it easier for all of them to communicate; it translates for them. So by using the Rack module, I can create a functional server.
app = Proc.new do |env|
req = Rack::Request.new(env)
res = Rack::Response.new
res['Content-Type'] = 'text/html'
res.write(req.path)
res.finish
end
Rack::Server.start(
app: app,
Port: 3000
)
Rack needs an app to call after receiving and processing the request from the web server so that's what I created above. env
is the variable generated by Rack::Server containing the request data. Rack also provides Request
and Response
classes to provide a more friendly API however so that we don't have to go digging through env
for the information we need. The Content-Type
header tells the browser what the server has given to it in response. More can be specified here, but for this simple demonstration, I'll only be getting HTML back. Finally, Rack::Response#write
puts things in the response body and res.finish
is called after the res
is don't being built so Rack knows to wrap everything up.
I then give the app to the Rack server and start it on port 3000. If you run the file and navigate to localhost:3000
and put some arbitrary url after that, it'll simply print the url as HTML on the screen. This was purely an exercise on how the Rack middleware works on a base level.
This is my version of ActionController::Base
in Rails. After the router has determined which controller to use for a request that's been made, the controller's job is to interpret the request and produce the appropriate output.
In the constructor, I save the Rack::Request
and Rack::Response
as instance variables because the request will be used to help fill out the response in one of the actions of the children classes.
#render_content(content, content_type)
is an instance method that populates the response with content and raises an error if the developer tries to double render.
def render_content(content, content_type)
unless already_built_response?
@res['Content-Type'] = content_type
@res.write(content)
@already_built_response = true
else
raise "Double render error"
end
session.store_session(@res)
end
Is similar to the previous function, only it also sets the reponse header's location to the specified url.
def redirect_to(url)
unless already_built_response?
@res.header["location"] = url
@res.status = 302
@already_built_response = true
else
raise "Double redirect error"
end
session.store_session(@res)
end
Next comes the render
method. The path to the template is constructed using the name of the class and the template name which is passed into the method. I used the ERB library to interpret the file's contents and then called .result(binding)
to capture the controller's local variables. I then call my #render_content
method to write the content.
def render(template_name)
unless already_built_response?
template_path = "views/#{self.class.to_s.underscore}/#{template_name}.html.erb"
contents = File.read(template_path)
template = ERB.new(contents).result(binding)
render_content(template, "text/html")
@already_built_response = true
else
raise "Double render error"
end
end
Servers store information on the browser through cookies which consist of a name and a value. I created a Session
class which will handle all of the logic of creating and storing the cookie. The constructor takes in a Rack::Request
since cookies can only be used during an incoming request via a method on said class. I use 'rails_lite_app' as the name for the session cookie
def initialize(req)
if req.cookies != {}
@cookies = JSON.parse(req.cookies["_rails_lite_app"])
else
@cookies = {} #in case there are none
end
end
The server will only have access to cookies with a path attribute that matches the current pack so I used '/' to indicate all paths. Cookies are added to the browser in the response
def store_session(res)
json = @cookies.to_json
cookies_attributes = {path: "/", value: json}
res.set_cookie("_rails_lite_app", cookies_attributes)
end
I also implemented getters and setters for easy access...
def [](key)
@cookies[key]
end
def []=(key, val)
@cookies[key] = val
end
...so that #render_content
and #redirect_to
store the session information when the response is built.
session.store_session(@res)
I first start by writing a Route
class which is analogous to a single row of the command rake routes
. An object of this class knows the path to match(pattern), what controller it belongs to, and what method to run within that controller:
def initialize(pattern, http_method, controller_class, action_name)
@pattern, @http_method = pattern, http_method
@controller_class, @action_name = controller_class, action_name
end
Route#matches?(req)
is a method that checks to make sure the request patch matches the pattern and that http_method
is the same as the request method.
def matches?(req)
(http_method == req.request_method.downcase.to_sym) && !!(pattern =~ req.path)
end
Finally, Route#run(req, res)
gets the params from the request path, instantiates an instance of the controller class, and then calls invoke_action
on the action specified. To simplify things, I'm not supporting defining routes as strings like Rails does. Instead, I'm requiring that Route
's pattern
argument to be a Regexp
which is definitely more complicated to write, but this way, Ruby will store all the named capture groups in a MatchData
object for easy access.
def run(req, res)
match_data = @pattern.match(req.path)
route_params = Hash[match_data.names.zip(match_data.captures)]
@controller_class
.new(req, res, route_params)
.invoke_action(action_name)
end
I then added @params = route_params.merge(req.params)
to my ControllerBase
initializer to consolidate all of the route params, the query params, and the body params.
The Router
class is given a Rack::Request
and then figures out which Route
matches the requested. It then instantiates the Route
's controller and runs the appropriate method. The goal of this class is to be able to write something like the following in the routes.rb
file:
# Rails routes.rb example
MyApp::Application.routes.draw do
get "/posts", "posts_controller#index"
get "/posts/:id/comments", "comments_controller#index"
end
To do this, I wrote a method called #draw(&proc)
which takes in the proc and then I use Object#instance_eval
which evaluates the block in the context of the object in which it was evaluated. This way, I can call methods get
and post
in the context of the block of code that's coming in even though they are methods of Router
.
Flash is a special part of the session which persist the stored values to the next request (though flash.now
have values only available to the current cycle). The Flash
class looks a lot like the Session
class except that I have separate instance variables for both @flash
and @now
.
def initialize(req)
cookie = req.cookies['_rails_lite_app_flash']
@now = cookie ? JSON.parse(cookie) : {}
@flash = {}
end
I have similar getters and setters as my Session
class as well as a store_flash
function, and I add the flash functionality to my ControllerBase
class...
def flash
@flash ||= Flash.new(@req)
end
...and add flash.store_flash(@res)
in redirect_to
and render_content
to save the flash information.
There are a bunch of different ways to expand on this project. I could expand the middleware to print errors, build out CSRF protection, implement patch and delete requests, implement url helpers, etc. I think this project was a great demonstration of not only rails specifically, but web technologies as a whole.
Project credit: App Academy