A small clone of Sinatra, as a learning exercise.
A small example app
$ gem install rack
$ rackup config.ru
Now 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.ru
Now 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 |