Foundational stuff is too much overlooked in our Software Engineering. I mean, I know few people who want to dive into protocols and how servers work. Whereas I think these are key and thrilling understandings of how things work.
In this article, we will dive into how to build our own HTTP server with Ruby.
HTTP
We are not going to dive deep into each corner of the protocol. But at least having the definition is important: HTTP is a stateless protocol of the Application layer based on TCP.
In this article, we will focus on HTTP 1.1.
From TCP server
So this is dumb, but what is the role of a server? Get the request from the clients and give them an appropriate response. HTTP messages follow a structure. The request has the verb and the path they want to access and, finally, the protocol version.
So first thing, our server will need to accept the connection as we said before, HTTP is based upon TCP. So we will need to open a TCP socket on our machine.
In Ruby, nothing is that simple we can use the socket library. And since Ruby loves OOP we will wrap everything in a class.
# http_server.rb
require 'socket'
class HttpServer
def initialize(port)
@server = TCPServer.new port
end
end
Then we will need to accept the connection as we said and keep the connection open to any client in our HttpServer class let's define an accept_connection method.
def accept_connection
while session = @server.accept
end
end
To HTTP Server
Now that we accept connection over TCP, we can analyze the message we receive. First, we can see that the message we receive is an HTTP request.
And this is much simpler than we think. The sockets are Streams of data you can write and read from them as data come.
So we are going to read from our stream with the gets function.
Then we will need to do something really important in all HTTP servers: parsing the HTTP request to know what to answer to know how to respond to the client.
def accept_connection
while session = @server.accept
request = session.gets
verb,path,protocol = request.split(' ')
if protocol === 'HTTP/1.1'
session.print response_hello_world
else
session.print 'Connection Refuse'
end
end
end
For the response, we can define something like this :
def response_hello_world
<<-HTTPRESPONSE
HTTP/1.1 200
Content-Type: text/html
Hello World
HTTPRESPONSE
end
Let's build an HTTP client to see how our server behaves.
HTTP Client
As you can imagine, we will need a TCP socket again. We must connect to the already opened TCP socket for our Web server and then send an HTTP request.
# tcp_client.rb
require 'socket'
server = TCPSocket.new 'localhost', 5678
request = <<-HTTPMSQ
GET /test HTTP/1.1
HTTPMSQ
server.puts request
while line = server.gets
puts line
end
server.close
Here we put a correct HTTP request with the method, the header and the protocol. But if we did not, as we have seen before, we would end up with a Connection refuse
as defined in accept_connection
.
Routing and Controllers
So what we did was pretty simple, and we added only one path and case. Now what would happen if we want to take a different path?
We can define a route class that will take care of reading the path and routing to the correct resources.
class Router
def initialize(path)
@path = path
end
def route
if path === '/test'
"hello tester"
elsif path === '/world'
"hello world"
end
end
end
Then we can define a builder for the HTTP response :
class HttpResponse
def self.build(response)
<<-HTTPRESPONSE
HTTP/1.1 200
Content-Type: text/html
#{response}
HTTPRESPONSE
end
end
Let's change a bit our accept_connection
method :
def accept_connection
while session = @server.accept
request = session.gets
verb,path,protocol = request.split(' ')
if protocol === "HTTP/1.1"
response = Router.new(path).route
http_response = HttpResponse.build(response)
session.print http_response
else
session.print 'Connection Refuse'
end
session.close
end
end
But We could even do something more complicate, as within Rails with ActiveController and ActionPack.
Even Further
This is a bit of, but we could even do something more complicated with CRUD routes and controllers. This is inspired by this article: https://tommaso.pavese.me/2016/07/26/a-rack-application-from-scratch-part-2-routes-and-controllers/ and how ActionPack and ActionController works inside Rails.
class TestController
def index
"Hello Test"
end
end
class Router
def initialize(path)
@path = path
end
def camelize(string)
string = string.sub(/^[a-z\d]*/) { |match| match.capitalize! || match }
string.gsub!(/(_)([a-z\d]*)/i) do
word = $2
substituted = word.capitalize! || word
substituted
end
string
end
def constantize(camel_cased_word)
Object.const_get(camel_cased_word)
end
def route
controller_name = camelize(@path.split('/')[1]) << "Controller"
controller = constantize(controller_name)
controller.new.send('index')
end
end
Conclusion
It's pretty cool to stroll in the foundational stuff of the Web and understand in a concrete way what is going on. There is so much more to understand only with. Just the Headers or Cookies, for example.
But at least now we know how a Web Server can serve the response from our web application. Of course, this is a simplistic version, and it lacks many things.
We also did not use Rack to interface our web application and web servers. But I wanted to keep it as straightforward as possible to understand the bare bones of Web servers.
Top comments (0)