MVC on Rack

Checkout the finished source for this chapter at [BuildYourOwnSinatra/mvc-on-rack](https://github.com/BuildYourOwnSinatra/mvc-on-rack)

Building an app directly on the Rack will help cement the concepts we have already learned and teach us some new ones.

First, we need to decide what we are going to build. Todo apps and weather apps are both always useful. Let's build a todos app for weather. We can call it WeatherTasks.

Our app's structure will look like this:

- app
  - controllers
    - tasks.rb
  - models
    - task.rb
  - views
  - app.rb
- lib
  - base_app.rb
- spec
  - app_spec.rb
  - lib
    - base_app3_spec.rb
  - controllers
  - models
  - spec_helper.rb
- .rspec
- Gemfile
- Rakefile
- config.ru
- README.md

You can replicate the directory structure by;

  1. Cloning the repo and checking out the [step-1 branch](https://github.com/BuildYourOwnSinatra/mvc-on-rack/tree/step-1)
  2. You can use [a script](https://gist.github.com/k2052/436355abcc2de24e5a9e) to create it

Add dependencies to the Gemfile:

source 'https://rubygems.org'

gem 'puma'
gem 'rack'
gem 'tilt'
gem 'slim'

group :development, :test do
  gem 'rack-test'
  gem 'rspec'
  gem 'rake'
end

Then run:

$ bundle install

Now we need to get our specs running. Add the following to your .rspec file:

--color
--format progress
--require ./spec/spec_helper

Then add the following to spec/spec_helper.rb:

require 'rack/test'
require 'rack'
require_relative '../app/app'

RSpec.configure do |config|
  config.include Rack::Test::Methods
end

Rack::Test is going to be looking for an app instance, so let's create one.

Add the following to app/app.rb:

$LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__))

require_relative '../lib/base_app'

class App
  def call(env)
    [200, {}, ['Hello World!']]
  end
end

We will use Rack::Builder to construct our app's instance.

In config.ru add the following:

require_relative 'app/app.rb'

run App.new

This gives us an app instance that we can pass to rack-test:

RSpec.configure do |config|
  config.include Rack::Test::Methods

  let(:app) do
    path = File.expand_path('../config.ru', File.dirname(__FILE__))
    Rack::Builder.parse_file(path).first
  end
end

This parses our config.ru file with builder and returns the first app instance.

If we wanted to we could pass our app directly to rack-test:

let(:app) do
  App.new
end

The problem with this is that our tests would not be touching our rackup file. Our config.ru file could be completely broken and we'd never even know.

Let's add a spec to spec/app_spec.rb to test that everything is working:

describe App do
  describe 'GET /' do
    it 'returns Hello World' do
      get '/'
      expect(last_response.body).to eq('Hello World!')
    end
  end
end

Then add a spec task to the Rakefile:

require 'rspec/core/rake_task'

RSpec::Core::RakeTask.new(:spec)

task default: :spec

Test that everything is working by running:

$ bundle exec rake
0 examples, 0 failures

Models

We need a Task model; to keep it simple we will return all data statically.

Add a spec (spec/models/task_spec.rb) to test that our static model instances are returned:

describe Task do
  describe '.all' do
    it 'returns all the tasks' do
      expect(Task.all().length).to eq 3
      expect(Task.all()[0]).to be_an_instance_of Task
    end
  end
end

Then a model class to fulfill this spec:

class Task
  attr_accessor :name

  # Set attributes by key and value
  def initialize(attrs)
    attrs.each {|k,v| send("#{k}=",v)}
  end

  def self.all
    [Task.new(name: 'Make it rain!'),
      Task.new(name: 'Snow is kinda cold!'),
      Task.new(name: 'Snow is frozen')]
  end
end

Now run the spec:

$ bundle exec rake
weather_tasks/spec/models/task_spec.rb:1:in <top (required)>: uninitialized constant Task (NameError)

We are getting warnings about the class not existing.

The straightforward way to solve this would be to require the Task model in our spec file but we would have to do this for every class we write a spec for. Managing dependencies that way would end up look like something written in voynich not Ruby. Instead, we will add autoloads onto our application class and then require it from our spec helper.

In app/app.rb add the following:

$LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__))

autoload :Task, 'models/Task'

Autoload isn't thread safe except in Ruby versions > 2.0.See: [https://github.com/rkh/rack-protection/issues/45](https://github.com/rkh/rack-protection/issues/45)

Now run our specs again:

$ bundle exec rake

Finished in 0.02785 seconds (files took 0.10673 seconds to load)
2 examples, 0 failures

They are passing! That does it for our models!

Tasks Controller

Our task's controller will have a single route that returns all the weather tasks. We will want both a json response and html.

The spec for this is:

describe TasksController do
  describe 'GET /tasks' do
    context 'Client Accepts JSON' do
      it 'returns all the tasks as json' do
        header 'ACCEPT', 'application/json'
        get '/tasks'

        expect(last_response.status).to eq 200
        expect(last_response.body).to eq Task.all().to_json
      end
    end

    context 'Client Accepts HTML' do
      it 'returns all the tasks as html' do
        names = Task.all().map { |task| task.name }

        get '/tasks'

        expect(last_response.status).to eq 200
        expect(last_response.body).to have_tag('li', text: /#{names.join("|")}/, count: 3)
      end
    end
  end
end

For testing html response we are using a gem called 'rspec-html-matchers'. Add it to the Gemfile:

gem 'rspec-html-matchers', github: 'kucaahbe/rspec-html-matchers'

And require it in spec/spec_helper.rb:

require 'rspec-html-matchers'

RSpec.configure do |config|
  config.include RSpecHtmlMatchers
# ...
end

Now add a tasks controller=app/controllers/tasks.rb=:

class TasksController
  def call(env)
  end
end

Then add an autoload declaration for our controller to app/app.rb:

autoload :TasksController, 'controllers/tasks'

And add the controller to config.ru

map '/tasks' do
  run TasksController.new
end

run App.new

We need to return two types of responses; One for when the client requests json and another for html. To do this we can check the accept header in env:

if env['HTTP_ACCEPT'] == 'application/json'
  # do stuff
end

Under real world circumstances our accept header checking would need to be a lot more robust than this but when we control all the clients we can cheat a little.

For easy and robust accept header parsing checkout [rack-accept](https://github.com/mjackson/rack-accept)

Now we just need to use the check in our call method:

def call(env)
  if env['HTTP_ACCEPT'] == 'application/json'
    [200, {"Content-Type" => "application/json"}, [Task.all.to_json]]
  else
    resp = '<ul>'
    Task.all.each do |task|
      resp << "<li>#{task.name}</li>"
    end
    resp << '</ul>'
    [200, {"Content-Type" => "text/html"}, [resp]]
  end
end

Running our specs now will result in some pretty green:

$ bundle exec spec
....

A Better Controller

Having our controllers as pure Rack apps has some drawbacks, namely, it's hard to route methods to actions. The simplest way to add some routing is to put it on our controllers. When we hit the call method we can then figure out which method should route to a given path.

For example:

class TasksController
  self.routes = {
    '/tasks': :all
  }

  def call(env)
    if route = @@routes[env['PATH_INFO']]
      self.send(route, env)
    else
      Rack::Response.new('Not found', 404)
    end
  end

  def all env
    # ...
  end
end

The route matching here is pretty dumb. If for example, our path had a trailing slash this would fail to route. So, how do we match the edge cases? The trick is to turn a string for the route into a regular expression. This is fundamentally how most routers work, they take a route string and then "compile" it into a regular expression.

Before we get started with this let's turn routes into an accessor method so we can cache our regular expressions:

def self.routes=(routes)
  routes.each do |route_string, method|
    @routes.push {route: compile(route_string), handler: method}
  end
end

This loops through our routes and calls compile on the route's path. A naive implementation of compile is to split the path string into segments:

def self.compile
  postfix = '/' if path =~ /\/\z/
  segments = path.split('/')
  /\A#{segments.join('/')}#{postfix}\z/
end

Now we need to add a method to find a matching route

def find_route path_info
  @@routes.each do |route|
    return route if route.match(path_info)
  end

  nil
end

Now our call method only needs to call find_route:

def call(env)
  if route = find_route env['PATH_INFO']
    self.send(route[:handler], env)
  else
    Rack::Response.new('Not found', 404)
  end
end

This works but it has some major flaws;

  1. It doesn't support splats
  2. It wont handle strange characters
  3. It wont allow us to route by request method

We can hackishly support the first two by allowing a regular expression as our route string:

def self.compile path
  if path.respond_to? :match
    path
  elsif path.respond_to? :to_str
    postfix  = '/' if path =~ /\/\z/
    segments = path.split('/')
    /\A#{segments.join('/')}#{postfix}\z/
  else
    raise TypeError, path
  end
end

Of course, it would be ideal to support splats and all edge cases of strange characters, but that is beyond the scope of this chapter. We will cover a fully featured router when we build our framework.

The simplest way to support routing by request method is to make the assumption that all methods will only respond to one request method. This way we can key our routes by request methods, making it easy to determine what method responds to which request method.

Let's change our call method to pass find_route a request_method and path_info:

def call(env)
  if route = find_route env['REQUEST_METHOD'], env['PATH_INFO']
    self.send(route[:handler], env)
  else
    Rack::Response.new('Not found', 404)
  end
end

Now modify find_route to lookup routes by request_method:

def find_route method, path
  @@routes[method].each do |route|
    return route if route.match(path)
  end

  nil
end

Then modify our routes API to look like this:

self.routes = [
  {path: '/tasks', handler: :all, method: 'GET'}
]

Now modify our routes accessor to accept an array of route hashes:

def self.routes=(routes)
  routes.each do |route|
    method = route['method'] || 'GET' # makes the method optional
    @routes[method].push {route: compile(route['path']), handler: route['handler']}
  end
end

This works but it is ugly. It would be nice to have convenience methods for adding routes, something like get '/', :all etc

To do this we will need to split up our route adding code into a separate method:

def self.add_route(path: path, handler: handler, method: 'GET')
  @routes[method].push {route: compile(path), handler: handler}
end

def self.routes
  @routes ||= {'GET': [], 'POST': [], 'PUT': [], 'DELETE': []}
end

Now in our route's setter we will call add_route:

def self.routes=(routes)
  routes.each { |route| add_route route }
end

This allows us to construct a DSL for adding routes:

def self.get path, handler
  add_route path: path, handler: handler, method: 'GET'
end

def self.put path, handler
  add_route path: path, handler: handler, method: 'PUT'
end

def self.post path, handler
  add_route path: path, handler: handler, method: 'POST'
end

def self.delete path, handler
  add_route path: path, handler: handler, method: 'DELETE'
end

We can shorten this by using define_method:

%w(GET PUT POST DELETE).each do |verb|
  define_method verb.downcase.to_sym do |path, handler|
    add_route path: path, handler: handler, method: verb
  end
end

Now we can add routes to our controller like this:

class TasksController
  get '/tasks', :all
end

We should extract what we have done and place it into a base controller. Then can extend it from our Tasks controller or any other controller that needs routing.

Our base app/controller looks like this:

    class BaseApp
      class << self
        %w(GET PUT POST DELETE).each do |verb|
          define_method verb.downcase.to_sym do |path, handler|
            add_route path: path, handler: handler, method: verb
          end
        end

    def routes
      @routes ||= {'GET' => [], 'PUT' => [], 'POST' => [], 'DELETE' => []}
    end

    def add_route(path: path, handler: handler, method: 'GET')
      routes[method].push({route: compile(path), handler: handler})
    end

    def compile path
      if path.respond_to? :match
        path
      elsif path.respond_to? :to_str
        postfix = '/' if path =~ /\/\z/
        segments = path.split('/')
        /\A#{segments.join('/')}#{postfix}\z/
      else
        raise TypeError, path
      end
    end
  end

  def find_route method, path
    self.class.routes[method].each do |route|
      return route if route[:route].match(path)
    end

    nil
  end

  def call(env)
    if route = find_route(env['REQUEST_METHOD'], env['PATH_INFO'])
      self.send(route[:handler], env)
    else
      Rack::Response.new('Not found', 404)
    end
  end
end

Our final TasksController is:

class TasksController < BaseController
  get '/tasks', :all

  def all(env)
    if env['HTTP_ACCEPT'] == 'application/json'
      [200, {"Content-Type" => "application/json"}, [Task.all().to_json]]
    else
      resp = '<ul>'
      Task.all().each do |task|
        resp << "<li>#{task.name}</li>"
      end
      resp << '</ul>'
      [200, {"Content-Type" => "text/html"}, [resp]]
    end
  end
end

A Quick Look at Better Views

Right now we are manually rendering our html but we can do a lot better. Most of the rendering systems we use in our favorite frameworks rest on the foundations of one gem, Tilt. Tilt is a thin wrapper over almost all the templating systems we might want to use.

First require tilt and slim in app/app.rb:

    require 'tilt'
    require 'slim'

Utilizing Tilt is dead simple, we simply instantiate a new class against a filepath and Tilt will figure out which engine to process it with. Then we only need to call the render method and return it as a response body.

Our render method is really simple:

def render(path)
  Tilt.new(path).render(self)
end

We pass our current controller instance to Tilt's render method. This scopes the template to the controller instance and allows the views to access any of the controller's methods and properties.

Having to pass off a full path to render is a little annoying. Let's automatically append the full path:

def render(path)
  Tilt.new(file(path)).render(self)
end

def file(path)
  File.join(File.expand_path(File.dirname(__FILE__)), '..', 'app', 'views', path)
end

Add these two methods to the BaseApp (lib/bass_app.rb) class.

Then add a view called index.slim to app/views/tasks:

ul#task
  - @tasks.each do |task|
    li.task== task.name

Now we can change the all() method on our controller to use our new view and render method:

def all(env)
  @tasks = Task.all()

  if env['HTTP_ACCEPT'] == 'application/json'
    [200, {"Content-Type" => "application/json"}, [@tasks.to_json]]
  else
    [200, {"Content-Type" => "text/html"}, [render('tasks/index')]]
  end
end

Helpers

It is common that views will need some sort of help along their way. Since our controllers are classes we can just include any helpers like we would a module. For example, if we needed a helper method for appending "cats are awesome!" to a string we could do the following:

module TextHelpers
  def cats_are_awesome(text)
    text + " cats are awesome!"
  end
end

Then in our controller we would include this module:

class TasksController
  include TextHelpers
end

And to use it in our views we only need to call it:

span== cats_are_awesome "dogs are okay!"

Multiple Controllers

Because our controllers are just Rack apps we can use them together just like we would well -- Rack apps. We will pass each of our controllers into Rack::Builder and have it run them together.

Since our app is dedicated to weather it would be nice to have a place to get the weather's status.

First let's define a spec for our new controller:

describe WeatherController do
  describe 'GET /weather/status' do
    it "returns the weather's status" do
      get '/weather/status'
      expect(last_response.status).to eq 200
      expect(last_response.body).to eq 'It is sunny with a slight chance of apocalypse!'
    end
  end
end

Now create a new file called app/controllers/weather.rb:

class WeatherController < BaseApp
  get '/status', :status

  def status(env)
    Rack::Response.new('It is sunny with a slight chance of apocalypse!', 200)
  end
end

Let's open our config.ru file and add the WeatherController:

app = Rack::Builder.new do
  map '/tasks' do
    run TasksController.new
  end

  map '/weather' do
    run WeatherController.new
  end
end

You might wonder why we just cant just put full path's on our routes and instead of scoping our controllers using `map`, just run both. Like this:

class WeatherController < BaseApp
  get '/weather/status', :status
  # ...
end

class TasksController < BaseApp
  get '/tasks/all', :all
  # ...
end

use TasksController.new
run WeatherController.new

The problem is our first controller (TasksController) would return a 404 status when no route is found. This would end the calls and cause Rack to return a 404 to the client.

If we want to mount our controllers like this we can use Rack::Cascade. Rack::Cascade takes a 404 as just a suggestion, it keeps calling Rack apps until one returns something other than 404 or it runs out of apps.

We can use it like this:

Rack::Cascade.new [TasksController.new, WeatherController.new]

Note: There is one caveat with this, Rack::Cascade doesn't like Rack::Response, which our controllers use. To get Rack::Response support you can monkey patch Rack::Cascade to call `to_a`. See: [Eldr::Cascade](https://github.com/eldr-rb/eldr/blob/master/lib/eldr/cascade.rb)

Now run our specs:

$ bundle exec rake
....

There are lots of ways to improve on our app further. We could add better route matching, split our controllers into separate files, add response helpers, parameter parsing etc. The only problem is we would quickly find ourselves with something looking more like a ruby framework than Rack. Thankfully though, building a framework is the exact goal of this book.