A small clone of Sinatra, as a learning exercise.
A small example app
$ gem install rack
$ rackup config.ruNow go to "http://localhost:9292/greet" and you'll be presented with a form.
A small clone of Sinatra, as a learning exercise.
A small example app
$ gem install rack
$ rackup config.ruNow go to "http://localhost:9292/greet" and you'll be presented with a form.
| require_relative 'fake_sinatra' | |
| class MyApp < FakeSinatra::Base | |
| get '/greet' do | |
| %Q'<DOCTYPE html!> | |
| <html> | |
| <head><title>Welcome!</title></head> | |
| <body> | |
| #{"<p>Hello, #{params[:name]}</p>" if params[:name]} | |
| <form action="/greet" method="get"> | |
| <label for="name_field">Name:</label> | |
| <input type="text" id="name_field" name="name" /> | |
| </form> | |
| </body> | |
| </html> | |
| ' | |
| end | |
| end | |
| run MyApp |
| module FakeSinatra | |
| class Base | |
| def self.call(env) | |
| instance = run controller_for(env), | |
| env, | |
| params_from(env) | |
| instance.rack_response | |
| end | |
| def self.controller_for(env) | |
| request_method = env['REQUEST_METHOD'].downcase.intern | |
| request_path = env['PATH_INFO'] | |
| *, controller = routes.find do |method, path, controller| | |
| method == request_method && path == request_path | |
| end | |
| controller || lambda { |*args| status 404 } | |
| end | |
| def self.params_from(env) | |
| params = Hash.new { |h, k| h[k.to_s] if k.kind_of? Symbol } | |
| params.merge! parse_urlencoded_params(env['QUERY_STRING']) | |
| has_body = env['REQUEST_METHOD'] != 'GET' | |
| is_urlencoded = (env['CONTENT_TYPE'] == 'application/x-www-form-urlencoded') | |
| content_length = env['CONTENT_LENGTH'] | |
| if has_body && is_urlencoded && content_length | |
| urlencoded = env['rack.input'].read(content_length.to_i) | |
| params.merge! parse_urlencoded_params(urlencoded) | |
| end | |
| params | |
| end | |
| def self.parse_urlencoded_params(urlencoded) | |
| urlencoded.split('&') | |
| .map { |kv| kv.split '=', 2 } | |
| .to_h | |
| end | |
| def self.run(controller, env, params) | |
| instance = new env, params | |
| catch :finished do | |
| body = instance.instance_exec &controller | |
| instance.body body | |
| end | |
| instance | |
| end | |
| def self.routes | |
| @routes ||= [] | |
| end | |
| def self.get(path, &controller) | |
| routes << [:get, path, controller] | |
| end | |
| def self.post(path, &controller) | |
| routes << [:post, path, controller] | |
| end | |
| def self.put(path, &controller) | |
| routes << [:put, path, controller] | |
| end | |
| def self.patch(path, &controller) | |
| routes << [:patch, path, controller] | |
| end | |
| def self.delete(path, &controller) | |
| routes << [:delete, path, controller] | |
| end | |
| attr_reader :params, :env | |
| def initialize(env, params) | |
| @env = env | |
| @params = params | |
| status 200 | |
| content_type 'text/html' | |
| end | |
| def response_headers | |
| @response_headers ||= {} | |
| end | |
| def response_status | |
| @status | |
| end | |
| def response_body | |
| @response_body ||= [] | |
| end | |
| def rack_response | |
| [response_status, response_headers, response_body] | |
| end | |
| def status(code) | |
| @status = code | |
| end | |
| def body(value) | |
| return unless value.kind_of? String | |
| @response_body = [value] | |
| end | |
| def content_type(type) | |
| response_headers['Content-Type'] = type | |
| end | |
| def redirect(location) | |
| status 302 | |
| response_headers['Location'] = location | |
| throw :finished | |
| end | |
| end | |
| end |
| require_relative 'fake_sinatra' | |
| require 'rack/test' | |
| require 'stringio' | |
| RSpec.describe FakeSinatra do | |
| def session_for(app) | |
| Capybara::Session.new(:rack_test, app) | |
| end | |
| def assert_request(app, method, path, assertions, &overrides) | |
| env = Rack::MockRequest.env_for path, method: method | |
| overrides.call env if overrides | |
| status, headers, body = app.call(env) | |
| assertions.each do |name, expectation| | |
| case name | |
| when :body | |
| expect(body.join).to eq expectation | |
| when :status | |
| expect(status).to eq expectation | |
| when :content_type | |
| expect(headers['Content-Type']).to eq expectation | |
| when :location | |
| expect(headers['Location']).to eq expectation | |
| else | |
| raise "Unexpected assertion: #{name.inspect}" | |
| end | |
| end | |
| end | |
| describe 'routing to a block' do | |
| it 'routes based on the method (get/post/put/patch/delete)' do | |
| app = Class.new FakeSinatra::Base do | |
| get('/') { 'get request to /' } | |
| post('/') { 'post request to /' } | |
| put('/') { 'put request to /' } | |
| patch('/') { 'patch request to /' } | |
| delete('/') { 'delete request to /' } | |
| end | |
| assert_request app, :get, '/', body: 'get request to /' | |
| assert_request app, :post, '/', body: 'post request to /' | |
| assert_request app, :put, '/', body: 'put request to /' | |
| assert_request app, :patch, '/', body: 'patch request to /' | |
| assert_request app, :delete, '/', body: 'delete request to /' | |
| end | |
| it 'routes based on the path' do | |
| app = Class.new FakeSinatra::Base do | |
| get('/a') { 'first' } | |
| get('/b') { 'second' } | |
| end | |
| assert_request app, :get, '/a', body: 'first' | |
| assert_request app, :get, '/b', body: 'second' | |
| end | |
| it 'routes based on both of these together' do | |
| app = Class.new FakeSinatra::Base do | |
| get('/a') { 'first' } | |
| get('/b') { 'second' } | |
| post('/a') { 'third' } | |
| post('/b') { 'fourth' } | |
| end | |
| assert_request app, :get, '/a', body: 'first' | |
| assert_request app, :post, '/a', body: 'third' | |
| assert_request app, :get, '/b', body: 'second' | |
| assert_request app, :post, '/b', body: 'fourth' | |
| end | |
| it 'returns a 404 when it can\'t find a match' do | |
| app = Class.new(FakeSinatra::Base) { get('/a') { '' } } | |
| assert_request app, :get, '/a', status: 200 | |
| assert_request app, :get, '/b', status: 404 | |
| end | |
| end | |
| describe 'routed code' do | |
| it 'returns the result as the body' do | |
| app = Class.new(FakeSinatra::Base) { get('/') { 'the body' } } | |
| assert_request app, :get, '/', body: 'the body' | |
| end | |
| it 'has an empty body if the block evaluates to a non-string' do | |
| app = Class.new(FakeSinatra::Base) { get('/') { } } | |
| assert_request app, :get, '/', body: '' | |
| end | |
| end | |
| describe 'the block of code' do | |
| it 'defaults the content-type to text/html, but allows it to be overridden' do | |
| app = Class.new FakeSinatra::Base do | |
| get('/a') { } | |
| get('/b') { content_type 'text/plain' } | |
| end | |
| assert_request app, :get, '/a', content_type: 'text/html' | |
| assert_request app, :get, '/b', content_type: 'text/plain' | |
| end | |
| it 'allows the status to be set' do | |
| app = Class.new FakeSinatra::Base do | |
| get('/a') { } | |
| get('/b') { status 400 } | |
| end | |
| assert_request app, :get, '/a', status: 200 | |
| assert_request app, :get, '/b', status: 400 | |
| end | |
| it 'has access to the params' do | |
| app = Class.new FakeSinatra::Base do | |
| get('/a') { "params: #{params.inspect}" } | |
| end | |
| assert_request app, :get, '/a?b=c', body: 'params: {"b"=>"c"}' | |
| end | |
| it 'has a convenience method "redirect", which sets the status, location, and halts execution' do | |
| app = Class.new FakeSinatra::Base do | |
| get('/a') do | |
| redirect 'http://www.example.com' | |
| raise "should not get here" | |
| end | |
| end | |
| assert_request app, :get, '/a', status: 302, location: 'http://www.example.com', body: '' | |
| end | |
| end | |
| it 'gives access to the env' do | |
| app = Class.new FakeSinatra::Base do | |
| get('/a') { "REQUEST_METHOD: #{env['REQUEST_METHOD']}" } | |
| end | |
| assert_request app, :get, '/a?b=c&d=e', body: 'REQUEST_METHOD: GET' | |
| end | |
| describe 'params' do | |
| describe 'parsing params with a Content-Type of application/x-www-form-urlencoded' do | |
| def assert_parses(urlencoded, expected) | |
| actual = FakeSinatra::Base.parse_urlencoded_params(urlencoded) | |
| expect(actual).to eq expected | |
| end | |
| it 'splits them on "&"' do | |
| assert_parses 'a=b&c=d', {'a' => 'b', 'c' => 'd'} | |
| end | |
| it 'splits keys and values on the first "="' do | |
| assert_parses 'a=b=c', {'a' => 'b=c'} | |
| end | |
| end | |
| it 'includes query parms' do | |
| app = Class.new FakeSinatra::Base do | |
| get('/a') { "params: #{params.inspect}" } | |
| end | |
| assert_request app, :get, '/a?b=c&d=e', body: 'params: {"b"=>"c", "d"=>"e"}' | |
| end | |
| context 'from form data' do | |
| let(:app) do | |
| Class.new FakeSinatra::Base do | |
| get('/a') { "params: #{params.inspect}" } | |
| post('/a') { "params: #{params.inspect}" } | |
| end | |
| end | |
| it 'does not read the form data when the request is GET' do | |
| assert_request app, :get, '/a', body: 'params: {}' do |env| | |
| env['CONTENT_TYPE'] = 'application/x-www-form-urlencoded' | |
| env['CONTENT_LENGTH'] = '7' | |
| env['rack.input'].string = 'a=1' | |
| end | |
| end | |
| it 'does not read the form data when the CONTENT_TYPE is not application/x-www-form-urlencoded' do | |
| assert_request app, :post, '/a', body: 'params: {}' do |env| | |
| env['CONTENT_TYPE'] = 'application/json' | |
| env['CONTENT_LENGTH'] = '7' | |
| env['rack.input'].string = 'a=1' | |
| end | |
| end | |
| it 'does not read the form data when there is no CONTENT_LENGTH' do | |
| assert_request app, :post, '/a', body: 'params: {}' do |env| | |
| env['CONTENT_TYPE'] = 'application/x-www-form-urlencoded' | |
| env['CONTENT_LENGTH'] = nil | |
| env['rack.input'].string = 'a=1' | |
| end | |
| end | |
| it 'only reads the form data as far as the CONTENT_LENGTH says it should' do | |
| assert_request app, :post, '/a', body: 'params: {"b"=>"c", "d"=>"e"}' do |env| | |
| env['CONTENT_TYPE'] = 'application/x-www-form-urlencoded' | |
| env['CONTENT_LENGTH'] = '7' | |
| env['rack.input'].string = "b=c&d=eTHIS IS NOT READ" | |
| end | |
| end | |
| end | |
| it 'returns nil if the param doesn\'t exist' do | |
| app = Class.new FakeSinatra::Base do | |
| get('/') { "nonexistent: #{params['nonexistent'].inspect}" } | |
| end | |
| assert_request app, :get, '/', body: 'nonexistent: nil' | |
| end | |
| it 'allows the params to be accessed with a string or a symbol' do | |
| app = Class.new FakeSinatra::Base do | |
| get('/') { "#{params['key']} #{params[:key]}" } | |
| end | |
| assert_request app, :get, '/?key=value', body: 'value value' | |
| end | |
| end | |
| end |