ruby

Running Rack: How Ruby HTTP servers run Rails apps

Jeff Kreeftmeijer

Jeff Kreeftmeijer on

Running Rack: How Ruby HTTP servers run Rails apps

In the Ruby Magic series we love to take software apart to learn how it functions under the hood. It's all about the process; the end result isn't something you'd use in production, we learn about the internal workings of the Ruby language and its popular libraries. We publish a new article about once a month, so be sure to subscribe to our newsletter if you're into this sort of thing too.

In an earlier edition of Ruby Magic we implemented a 30-line HTTP server in Ruby. Without having to write a lot of code, we were able to handle HTTP GET requests and serve a simple Rack application. This time, we'll take our home made server a bit further. When we're done, we'll have a web server that can serve Rails' famous fifteen minute blog that allows you to create, update and delete posts.

Where we left off

Last time, we implemented just enough of a server to have it serve Rack::Lobster as an example application.

  1. Our implementation opened a TCP server and waited for a request to come in.
  2. When that happened, the request-line (GET /?flip=left HTTP/1.1\r\n) was parsed to get the request method (GET), the path (/), and the query parameters (flip=left).
  3. The request method, the path and the query string were passed to the Rack app, which returned a triplet with a status, some response headers and the response body.
  4. Using those, we were able to build an HTTP response to send back to the browser, before closing the connection to wait for a new request to come in.
1# http_server.rb
2require 'socket'
3require 'rack'
4require 'rack/lobster'
5
6app = Rack::Lobster.new
7server = TCPServer.new 5678
8
9#1
10while session = server.accept
11  request = session.gets
12  puts request
13
14  #2
15  method, full_path = request.split(' ')
16  path, query = full_path.split('?')
17
18  #3
19  status, headers, body = app.call({
20    'REQUEST_METHOD' => method,
21    'PATH_INFO' => path,
22    'QUERY_STRING' => query
23  })
24
25  #4
26  session.print "HTTP/1.1 #{status}\r\n"
27  headers.each do |key, value|
28    session.print "#{key}: #{value}\r\n"
29  end
30  session.print "\r\n"
31  body.each do |part|
32    session.print part
33  end
34  session.close
35end

We'll be continuing with the code we wrote last time. If you want to follow along, here's the code we ended up with.

Rack and Rails

Ruby frameworks like Rails and Sinatra are built on top of the Rack interface. Just like the instance of Rack::Lobster we're using to test our server right now, Rails' Rails.application is a Rack application object. In theory, this would mean that our server should already be able to serve a Rails application.

To test that, I've prepared a simple Rails application. Let's clone that into the same directory as our server.

1$ ls
2http_server.rb
3$ git clone https://github.com/jeffkreeftmeijer/wups.git blog
4Cloning into 'blog'...
5remote: Counting objects: 162, done.
6remote: Compressing objects: 100% (112/112), done.
7remote: Total 162 (delta 32), reused 162 (delta 32), pack-reused 0
8Receiving objects: 100% (162/162), 29.09 KiB | 0 bytes/s, done.
9Resolving deltas: 100% (32/32), done.
10Checking connectivity... done.
11$ ls
12blog           http_server.rb

Then, in our server, require the Rails application's environment file instead of rack and rack/lobster, and put the Rails.application in the app variable instead of Rack::Lobster.new.

1# http_server.rb
2require 'socket'
3require_relative 'blog/config/environment'
4
5app = Rails.application
6server = TCPServer.new 5678
7# ...

Starting the server (ruby http_server.rb) and opening http://localhost:5678 shows us we're not quite there yet. The server doesn't crash, but we're greeted with an internal server error in the browser.

500 Internal Server Error. If you are the administrator of this website, then please read this web application's log file and/or the web server's log file to find out what went wrong.

Checking our server's logs, we can see that we're missing something called rack.input. It turns out that we've been lazy while implementing our server last time, so there's more work to do before we can get this Rails application to work.

1$ ruby http_server.rb
2GET / HTTP/1.1
3Error during failsafe response: Missing rack.input
4  ...
5  http_server.rb:15:in `<main>'

The Rack environment

Back when we implemented our server, we glossed over the Rack environment and ignored most of the variables that are required to properly serve Rack applications. We ended up only implementing the REQUEST_METHOD, PATH_INFO, and QUERY_STRING variables, as those were sufficient for our simple Rack app.

As we've already seen from the exception when we tried to start our new application, Rails needs rack.input, which is used as an input stream for raw HTTP POST data. Besides that, there are some more variables we need to pass, like the server's port number, and the request cookie data.

Luckily, Rack provides Rack::Lint to help make sure all variables in the Rack environment are present and valid. We can use it to test our server by wrapping our Rails app in it by calling Rack::Lint.new and passing the Rails.application.

1# http_server.rb
2require 'socket'
3require_relative 'blog/config/environment'
4
5app = Rack::Lint.new(Rails.application)
6server = TCPServer.new 5678
7# ...

Rack::Lint will throw an exception when a variable in the environment is missing or invalid. Right now, starting our server again and opening http://localhost:5678 will crash the server and Rack::Lint will notify us of the first error: the SERVER_NAME variable wasn't set.

1~/Appsignal/http-server (master) $ ruby http_server.rb
2GET / HTTP/1.1
3/Users/jeff/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/rack-2.0.1/lib/rack/lint.rb:20:in `assert': env missing required key SERVER_NAME (Rack::Lint::LintError)
4        ...
5        from http_server.rb:15:in `<main>'

By fixing each error that is thrown at us, we can keep adding variables until Rack::Lint stops crashing our server. Let's go over each of the variables Rack::Lint requires.

  • SERVER_NAME: the server's hostname. We're only running this server locally right now, so we'll use "localhost".
  • SERVER_PORT: the port our server is running on. We've hardcoded the port number (5678), so we'll just pass that to the Rack environment.
  • rack.version: the targeted Rack protocol version number as an array of integers. [1,3] at the time of writing.
  • rack.input: the input stream containing the raw HTTP post data. We'll get to this later, but we'll pass an empty StringIO instance (with an ASCII-8BIT encoding) for now.
  • rack.errors: the error stream for Rack::Logger to write to. We're using $stderr.
  • rack.multithread: our server is single-threaded, so this can be set to false.
  • rack.multiprocess: our server is running in a single process, so this can be set to false as well.
  • rack.run_once: our server can handle multiple sequential requests in one process, so this is false too.
  • rack.url_scheme: no SSL support, so this can be set to "http" instead of "https".

After adding all missing variables, Rack::Lint will notify us of one more problem in our environment.

1$ ruby http_server.rb
2GET / HTTP/1.1
3/Users/jeff/.rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/gems/rack-2.0.1/lib/rack/lint.rb:20:in `assert': env variable QUERY_STRING has non-string value nil (Rack::Lint::LintError)
4        ...
5        from http_server.rb:18:in `<main>'

When there's no query string in the request, we'll now pass nil as the QUERY_STRING, which is not allowed. In that case, Rack expects an empty string instead. After implementing the missing variables and updating the query string, this is what our environment looks like:

1# http_server.rb
2# ...
3  method, full_path = request.split(' ')
4  path, query = full_path.split('?')
5
6  input = StringIO.new
7  input.set_encoding 'ASCII-8BIT'
8
9  status, headers, body = app.call({
10    'REQUEST_METHOD' => method,
11    'PATH_INFO' => path,
12    'QUERY_STRING' => query || '',
13    'SERVER_NAME' => 'localhost',
14    'SERVER_PORT' => '5678',
15    'rack.version' => [1,3],
16    'rack.input' => input,
17    'rack.errors' => $stderr,
18    'rack.multithread' => false,
19    'rack.multiprocess' => false,
20    'rack.run_once' => false,
21    'rack.url_scheme' => 'http'
22  })
23
24  session.print "HTTP/1.1 #{status}\r\n"
25# ...

Restarting the server and visiting http://localhost:5678 again, we'll be greeted with Rails' "You're on Rails!"-page, meaning we're now running an actual Rails application on our home made server!

Yay! You're on Rails!

Parsing HTTP POST bodies

This application is more than just that index page. Visiting http://localhost:5678/posts will display an empty list of posts. If we try to create a new post by filling in the new post form and pressing "Create post", we're greeted by an ActionController::InvalidAuthenticityToken exception.

The authenticity token is sent along when posting a form and is used to check if the request came from a trusted source. Our server is completely ignoring POST data right now, so the token isn't sent, and the request can't be verified.

Back when we first implemented our HTTP server, we used session.gets to get the first line (called the Request-Line), and parsed the HTTP method and path from that. Besides parsing the Request-Line, we ignored the rest of the request.

To be able to extract the POST data, we'll first need to understand how an HTTP request is structured. Looking at an example, we can see that the structure resembles an HTTP response:

1POST /posts HTTP/1.1\r\n
2Host: localhost:5678\r\n
3Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\r\n
4Accept-Encoding: gzip, deflate\r\n
5Accept-Language: en-us\r\n
6Content-Type: application/x-www-form-urlencoded\r\n
7Origin: http://localhost:5678\r\n
8User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_1) AppleWebKit/602.2.14 (KHTML, like Gecko) Version/10.0.1 Safari/602.2.14\r\n
9Cookie: _wups_session=LzE0Z2hSZFNseG5TR3dEVEwzNE52U0lFa0pmVGlQZGtZR3AveWlyMEFvUHRPeXlQUzQ4L0xlKzNLVWtqYld2cjdiWkpmclZIaEhJd1R6eDhaZThFbVBlN2p6QWpJdllHL2F4Z3VseUZ6NU1BRTU5Y1crM2lLRVY0UzdSZkpwYkt2SGFLZUQrYVFvaFE0VjZmZlIrNk5BPT0tLUpLTHQvRHQ0T3FycWV0ZFZhVHZWZkE9PQ%3D%3D--4ef4508c936004db748da10be58731049fa190ee\r\n
10Connection: keep-alive\r\n
11Upgrade-Insecure-Requests: 1\r\n
12Referer: http://localhost:5678/posts/new\r\n
13Content-Length: 369\r\n
14\r\n
15utf8=%E2%9C%93&authenticity_token=3fu7e8v70K0h9o%2FGNiXxaXSVg3nZ%2FuoL60nlhssUEHpQRz%2BM4ZIHjQduQMexvXrNoC2pjmhNPI4xNNA0Qkh5Lg%3D%3D&post%5Btitle%5D=My+first+post&post%5Bcreated_at%281i%29%5D=2017&post%5Bcreated_at%282i%29%5D=1&post%5Bcreated_at%283i%29%5D=23&post%5Bcreated_at%284i%29%5D=18&post%5Bcreated_at%285i%29%5D=47&post%5Bbody%5D=It+works%21&commit=Create+Post

Much like a response, an HTTP request consists of:

  • A Request-Line (POST /posts HTTP/1.1\r\n), consisting of a method token (POST), a request URI (/posts/), and the HTTP version (HTTP/1.1), followed by a CRLF (a carriage return: \r, followed by line feed: \n) to indicate the end of the line
  • Header lines (Host: localhost:5678\r\n). The header key, followed by a colon, then the value, and a CRLF.
  • A newline (or a double CRLF) to separate the request line and headers from the body: (\r\n\r\n)
  • The URL encoded POST body

After using session.gets to take the first line of the request (the Request-Line), we're left with some header lines and a body. To get the header lines, we need to retrieve lines from the session until we find a newline (\r\n).

For each header line, we'll split on the first colon. Everything before the colon is the key, and everything after is the value. We #strip the value to remove the newline from the end.

To know how many bytes we need to read from the request to get the body, we use the "Content-Length" header, which the browser automatically includes when sending a request.

1# http_server.rb
2# ...
3  headers = {}
4  while (line = session.gets) != "\r\n"
5    key, value = line.split(':', 2)
6    headers[key] = value.strip
7  end
8
9  body = session.read(headers["Content-Length"].to_i)
10# ...

Now, instead of sending an empty object, we'll send a StringIO instance with the body we received via the request. Also, since we're now parsing the cookies from the request's header, we can add them to the Rack environment in the HTTP_COOKIE variable to pass the request authenticity check.

1# http_server.rb
2# ...
3  status, headers, body = app.call({
4    # ...
5    'REMOTE_ADDR' => '127.0.0.1',
6    'HTTP_COOKIE' => headers['Cookie'],
7    'rack.version' => [1,3],
8    'rack.input' => StringIO.new(body),
9    'rack.errors' => $stderr,
10    # ...
11  })
12# ...

There we go. If we restart the server and try to submit the form again, you'll see that we successfully created the first post on our blog!

Post was successfully created.

We seriously upgraded our web server this time. Instead of just accepting GET requests from a Rack app, we're now serving a complete Rails app that handles POST requests. And we still haven't written more than fifty lines of code in total!

If you want to play around with our new and improved server, here's the code. Let us know at @AppSignal if you want to know more, or have a specific question.

Share this article

RSS

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps