├── .gitignore ├── test ├── views │ ├── home.mote │ ├── about.str │ ├── frag.mote │ ├── test.erb │ ├── about.erb │ ├── custom │ │ └── abs_path.mote │ ├── content-yield.erb │ ├── home.str │ ├── home.erb │ ├── layout-yield.erb │ ├── layout.mote │ ├── layout.erb │ ├── layout.str │ └── layout-alternative.erb ├── helper.rb ├── session.rb ├── rack.rb ├── extension.rb ├── redirect.rb ├── run.rb ├── host.rb ├── number.rb ├── cookie.rb ├── middleware.rb ├── segment.rb ├── with.rb ├── plugin.rb ├── settings.rb ├── root.rb ├── path.rb ├── safe.rb ├── match.rb ├── param.rb ├── accept.rb ├── composition.rb ├── integration.rb ├── csrf.rb ├── on.rb ├── render.rb └── captures.rb ├── makefile ├── .gems ├── examples ├── views │ ├── home.mote │ └── layout.mote ├── config.ru ├── measure.rb └── rack-response.ru ├── lib ├── cuba │ ├── test.rb │ ├── capybara.rb │ ├── safe.rb │ ├── safe │ │ ├── csrf.rb │ │ └── secure_headers.rb │ └── render.rb └── cuba.rb ├── cuba.gemspec ├── CONTRIBUTING ├── LICENSE ├── CHANGELOG └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /pkg 2 | webrat.log 3 | -------------------------------------------------------------------------------- /test/views/home.mote: -------------------------------------------------------------------------------- 1 |

Home

2 | -------------------------------------------------------------------------------- /test/views/about.str: -------------------------------------------------------------------------------- 1 |

#{title}

2 | -------------------------------------------------------------------------------- /test/views/frag.mote: -------------------------------------------------------------------------------- 1 |

{{ foo }}

2 | -------------------------------------------------------------------------------- /test/views/test.erb: -------------------------------------------------------------------------------- 1 | Displaying {{ i }} 2 | -------------------------------------------------------------------------------- /test/views/about.erb: -------------------------------------------------------------------------------- 1 |

<%= title %>

2 | -------------------------------------------------------------------------------- /test/views/custom/abs_path.mote: -------------------------------------------------------------------------------- 1 |

Abs Path

2 | -------------------------------------------------------------------------------- /test/views/content-yield.erb: -------------------------------------------------------------------------------- 1 | This is the actual content. 2 | -------------------------------------------------------------------------------- /test/views/home.str: -------------------------------------------------------------------------------- 1 |

Home

2 |

Hello #{name}

3 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | 3 | test: 4 | cutest ./test/*.rb 5 | -------------------------------------------------------------------------------- /test/views/home.erb: -------------------------------------------------------------------------------- 1 |

Home

2 |

Hello <%= name %>

3 | -------------------------------------------------------------------------------- /test/views/layout-yield.erb: -------------------------------------------------------------------------------- 1 | Header 2 | <%= yield %> 3 | Footer 4 | -------------------------------------------------------------------------------- /test/views/layout.mote: -------------------------------------------------------------------------------- 1 | {{title}} 2 | {{ content }} 3 | -------------------------------------------------------------------------------- /test/views/layout.erb: -------------------------------------------------------------------------------- 1 | Cuba: <%= title %> 2 | <%= content %> 3 | -------------------------------------------------------------------------------- /test/views/layout.str: -------------------------------------------------------------------------------- 1 | Cuba: #{ title } 2 | #{ content } 3 | -------------------------------------------------------------------------------- /test/views/layout-alternative.erb: -------------------------------------------------------------------------------- 1 | Alternative Layout: <%= title %> 2 | <%= content %> 3 | -------------------------------------------------------------------------------- /.gems: -------------------------------------------------------------------------------- 1 | cutest -v 1.2.3 2 | tilt -v 2.1.0 3 | rack -v 3.0.4.1 4 | rack-test -v 2.0.2 5 | rack-session -v 2.0.0 6 | -------------------------------------------------------------------------------- /examples/views/home.mote: -------------------------------------------------------------------------------- 1 | This is the body 2 | 3 | 8 | -------------------------------------------------------------------------------- /examples/views/layout.mote: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Home 6 | 7 | 8 | 9 | {{ content }} 10 | 11 | 12 | -------------------------------------------------------------------------------- /lib/cuba/test.rb: -------------------------------------------------------------------------------- 1 | require "cuba" 2 | require "cutest" 3 | require "rack/test" 4 | 5 | class Cutest::Scope 6 | include Rack::Test::Methods 7 | 8 | def app 9 | Cuba 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/cuba/capybara.rb: -------------------------------------------------------------------------------- 1 | require "cuba" 2 | require "cutest" 3 | require "capybara/dsl" 4 | 5 | class Cutest::Scope 6 | if defined? Capybara::DSL 7 | include Capybara::DSL 8 | else 9 | include Capybara 10 | end 11 | end 12 | 13 | Capybara.app = Cuba 14 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | $:.unshift(File.expand_path("../lib", File.dirname(__FILE__))) 2 | require "cuba" 3 | 4 | prepare { Cuba.reset! } 5 | 6 | def assert_response(body, expected) 7 | arr = body.map { |line| line.strip } 8 | 9 | flunk "#{arr.inspect} != #{expected.inspect}" unless arr == expected 10 | print "." 11 | end 12 | -------------------------------------------------------------------------------- /examples/config.ru: -------------------------------------------------------------------------------- 1 | require "../lib/cuba" 2 | require "cuba/contrib" 3 | 4 | Cuba.plugin Cuba::Mote 5 | 6 | ITEMS = ("A".."Z").to_a 7 | 8 | Cuba.define do 9 | def mote_vars(content) 10 | { content: content } 11 | end 12 | 13 | on default do 14 | res.write view("home", list: ITEMS) 15 | end 16 | end 17 | 18 | run Cuba 19 | -------------------------------------------------------------------------------- /examples/measure.rb: -------------------------------------------------------------------------------- 1 | require "benchmark" 2 | require "rack" 3 | 4 | Benchmark.bmbm do |x| 5 | 6 | x.report "Rack::HeaderHash" do 7 | 1000.times do 8 | Rack::Utils::HeaderHash.new("Content-Type" => "text/html") 9 | end 10 | end 11 | 12 | x.report "Hash" do 13 | 1000.times do 14 | { "Content-Type" => "text/html" } 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/session.rb: -------------------------------------------------------------------------------- 1 | require_relative "helper" 2 | 3 | test do 4 | Cuba.define do 5 | on default do 6 | begin 7 | session 8 | rescue Exception => e 9 | res.write e.message 10 | end 11 | end 12 | end 13 | 14 | _, _, body = Cuba.call({}) 15 | 16 | body.each do |e| 17 | assert e =~ /Cuba.use Rack::Session::Cookie/ 18 | end 19 | end 20 | 21 | 22 | -------------------------------------------------------------------------------- /examples/rack-response.ru: -------------------------------------------------------------------------------- 1 | require "../lib/cuba" 2 | require "cuba/contrib" 3 | 4 | Cuba.plugin Cuba::Mote 5 | 6 | ITEMS = ("A".."Z").to_a 7 | 8 | Cuba.send :remove_const, :Response 9 | Cuba::Response = Rack::Response 10 | 11 | Cuba.define do 12 | def mote_vars(content) 13 | { content: content } 14 | end 15 | 16 | on default do 17 | res.write view("home", list: ITEMS) 18 | end 19 | end 20 | 21 | run Cuba 22 | -------------------------------------------------------------------------------- /test/rack.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("helper", File.dirname(__FILE__)) 2 | require "cuba/test" 3 | 4 | scope do 5 | test do 6 | Cuba.define do 7 | on root do 8 | res.write "home" 9 | end 10 | 11 | on "about" do 12 | res.write "about" 13 | end 14 | end 15 | 16 | get "/" 17 | assert_equal "home", last_response.body 18 | 19 | get "/about" 20 | assert_equal "about", last_response.body 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/extension.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("helper", File.dirname(__FILE__)) 2 | 3 | setup do 4 | Cuba.define do 5 | on "styles" do 6 | on extension("css") do |file| 7 | res.write file 8 | end 9 | end 10 | end 11 | 12 | { "SCRIPT_NAME" => "/", "PATH_INFO" => "/styles" } 13 | end 14 | 15 | test "/styles/reset.css" do |env| 16 | env["PATH_INFO"] += "/reset.css" 17 | 18 | _, _, resp = Cuba.call(env) 19 | 20 | assert_response resp, ["reset"] 21 | end 22 | -------------------------------------------------------------------------------- /test/redirect.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("helper", File.dirname(__FILE__)) 2 | 3 | test "redirect" do 4 | Cuba.define do 5 | on "hello" do 6 | res.write "hello, world" 7 | end 8 | 9 | on "" do 10 | res.redirect "/hello" 11 | end 12 | end 13 | 14 | env = { "SCRIPT_NAME" => "/", "PATH_INFO" => "/" } 15 | 16 | status, headers, body = Cuba.call(env) 17 | 18 | assert_equal status, 302 19 | assert_equal headers, { "location" => "/hello" } 20 | assert_response body, [] 21 | end 22 | -------------------------------------------------------------------------------- /lib/cuba/safe.rb: -------------------------------------------------------------------------------- 1 | require_relative "safe/csrf" 2 | require_relative "safe/secure_headers" 3 | 4 | class Cuba 5 | # == Cuba::Safe 6 | # 7 | # This plugin contains security related features for Cuba 8 | # applications. It takes ideas from secureheaders[1]. 9 | # 10 | # == Usage 11 | # 12 | # require "cuba" 13 | # require "cuba/safe" 14 | # 15 | # Cuba.plugin(Cuba::Safe) 16 | # 17 | module Safe 18 | def self.setup(app) 19 | app.plugin(Safe::SecureHeaders) 20 | app.plugin(Safe::CSRF) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/run.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("helper", File.dirname(__FILE__)) 2 | 3 | test "redirect canonical example" do 4 | Cuba.define do 5 | def redirect(*args) 6 | run Cuba.new { on(true) { res.redirect(*args) }} 7 | end 8 | 9 | on "account" do 10 | redirect "/login", 307 11 | 12 | res.write "Super secure content" 13 | end 14 | end 15 | 16 | env = { "SCRIPT_NAME" => "/", "PATH_INFO" => "/account" } 17 | 18 | status, headers, resp = Cuba.call(env) 19 | 20 | assert_equal "/login", headers["location"] 21 | assert_equal 307, status 22 | assert_response resp, [] 23 | end 24 | -------------------------------------------------------------------------------- /test/host.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("helper", File.dirname(__FILE__)) 2 | 3 | test "matches a host" do 4 | Cuba.define do 5 | on host("example.com") do 6 | res.write "worked" 7 | end 8 | end 9 | 10 | env = { "HTTP_HOST" => "example.com" } 11 | 12 | _, _, resp = Cuba.call(env) 13 | 14 | assert_response resp, ["worked"] 15 | end 16 | 17 | test "matches a host with a regexp" do 18 | Cuba.define do 19 | on host(/example/) do 20 | res.write "worked" 21 | end 22 | end 23 | 24 | env = { "HTTP_HOST" => "example.com" } 25 | 26 | _, _, resp = Cuba.call(env) 27 | 28 | assert_response resp, ["worked"] 29 | end 30 | -------------------------------------------------------------------------------- /cuba.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = "cuba" 3 | s.version = "4.0.3" 4 | s.summary = "Microframework for web applications." 5 | s.description = "Cuba is a microframework for web applications." 6 | s.authors = ["Michel Martens"] 7 | s.email = ["michel@soveran.com"] 8 | s.homepage = "https://github.com/soveran/cuba" 9 | s.license = "MIT" 10 | 11 | s.files = `git ls-files`.split("\n") 12 | 13 | s.add_dependency "rack", ">= 3.0.0" 14 | s.add_dependency "rack-session", ">= 2.0.0" 15 | s.add_development_dependency "cutest" 16 | s.add_development_dependency "rack-test" 17 | s.add_development_dependency "tilt" 18 | end 19 | -------------------------------------------------------------------------------- /test/number.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("helper", File.dirname(__FILE__)) 2 | 3 | setup do 4 | { "SCRIPT_NAME" => "/", "PATH_INFO" => "/about/1/2" } 5 | end 6 | 7 | test "paths and numbers" do |env| 8 | Cuba.define do 9 | on "about" do 10 | on :one, :two do |one, two| 11 | res.write one 12 | res.write two 13 | end 14 | end 15 | end 16 | 17 | _, _, resp = Cuba.call(env) 18 | 19 | assert_response resp, ["1", "2"] 20 | end 21 | 22 | test "paths and decimals" do |env| 23 | Cuba.define do 24 | on "about" do 25 | on(/(\d+)/) do |one| 26 | res.write one 27 | end 28 | end 29 | end 30 | 31 | env["PATH_INFO"] = "/about/1.2" 32 | 33 | _, _, resp = Cuba.call(env) 34 | 35 | assert_response resp, [] 36 | end 37 | -------------------------------------------------------------------------------- /test/cookie.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("helper", File.dirname(__FILE__)) 2 | 3 | test "set cookie" do 4 | Cuba.define do 5 | on default do 6 | res.set_cookie("foo", "bar") 7 | res.set_cookie("bar", "baz") 8 | res.write "Hello" 9 | end 10 | end 11 | 12 | env = { "SCRIPT_NAME" => "/", "PATH_INFO" => "/" } 13 | 14 | _, headers, body = Cuba.call(env) 15 | 16 | assert_equal ["foo=bar", "bar=baz"], headers["set-cookie"] 17 | end 18 | 19 | test "delete cookie" do 20 | Cuba.define do 21 | on default do 22 | res.set_cookie("foo", "bar") 23 | res.delete_cookie("foo") 24 | res.write "Hello" 25 | end 26 | end 27 | 28 | env = { "SCRIPT_NAME" => "/", "PATH_INFO" => "/" } 29 | 30 | _, headers, body = Cuba.call(env) 31 | 32 | assert_equal ["foo=bar", "foo=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT"], 33 | headers["set-cookie"] 34 | end 35 | -------------------------------------------------------------------------------- /test/middleware.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("helper", File.dirname(__FILE__)) 2 | 3 | class Shrimp 4 | def initialize(app, foo:) 5 | @app = app 6 | @foo = foo 7 | end 8 | 9 | def call(env) 10 | status, headers, resp = @app.call(env) 11 | 12 | arr = [] 13 | resp.each { |e| arr << e } 14 | 15 | [status, headers, arr.reverse] 16 | end 17 | end 18 | 19 | test do 20 | class API < Cuba 21 | use Shrimp, foo: 'bar' 22 | 23 | define do 24 | on "v1/test" do 25 | res.write "OK" 26 | res.write "1" 27 | res.write "2" 28 | end 29 | end 30 | end 31 | 32 | Cuba.define do 33 | on "api" do 34 | run API 35 | end 36 | end 37 | 38 | _, _, body = Cuba.call({ "PATH_INFO" => "/api/v1/test", "SCRIPT_NAME" => "/" }) 39 | 40 | arr = [] 41 | 42 | body.each do |line| 43 | arr << line 44 | end 45 | 46 | assert_equal ["2", "1", "OK"], arr 47 | end 48 | -------------------------------------------------------------------------------- /test/segment.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("helper", File.dirname(__FILE__)) 2 | 3 | setup do 4 | Cuba.define do 5 | on "post" do 6 | on :id do |id| 7 | res.write id 8 | end 9 | end 10 | end 11 | 12 | { "SCRIPT_NAME" => "/", "PATH_INFO" => "/post" } 13 | end 14 | 15 | test "matches numeric ids" do |env| 16 | env["PATH_INFO"] += "/1" 17 | 18 | _, _, resp = Cuba.call(env) 19 | 20 | assert_response resp, ["1"] 21 | end 22 | 23 | test "matches decimal numbers" do |env| 24 | env["PATH_INFO"] += "/1.1" 25 | 26 | _, _, resp = Cuba.call(env) 27 | 28 | assert_response resp, ["1.1"] 29 | end 30 | 31 | test "matches slugs" do |env| 32 | env["PATH_INFO"] += "/my-blog-post-about-cuba" 33 | 34 | _, _, resp = Cuba.call(env) 35 | 36 | assert_response resp, ["my-blog-post-about-cuba"] 37 | end 38 | 39 | test "matches only the first segment available" do |env| 40 | env["PATH_INFO"] += "/one/two/three" 41 | 42 | _, _, resp = Cuba.call(env) 43 | 44 | assert_response resp, ["one"] 45 | end 46 | -------------------------------------------------------------------------------- /test/with.rb: -------------------------------------------------------------------------------- 1 | require_relative "helper" 2 | 3 | test do 4 | class UserPhotos < Cuba 5 | define do 6 | on root do 7 | res.write "uid: %d" % vars[:user_id] 8 | res.write "site: %s" % vars[:site] 9 | end 10 | end 11 | end 12 | 13 | class Photos < Cuba 14 | define do 15 | on ":id/photos" do |id| 16 | with user_id: id do 17 | _, _, body = UserPhotos.call(req.env) 18 | 19 | body.each do |line| 20 | res.write line 21 | end 22 | end 23 | 24 | res.write vars.inspect 25 | end 26 | end 27 | end 28 | 29 | Cuba.define do 30 | on "users" do 31 | with user_id: "default", site: "main" do 32 | run Photos 33 | end 34 | end 35 | end 36 | 37 | _, _, body = Cuba.call({ "PATH_INFO" => "/users/1001/photos", 38 | "SCRIPT_NAME" => "" }) 39 | 40 | assert_response body, ["uid: 1001", "site: main", 41 | '{:user_id=>"default", :site=>"main"}'] 42 | end 43 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | This code tries to solve a particular problem with a very simple 2 | implementation. We try to keep the code to a minimum while making 3 | it as clear as possible. The design is very likely finished, and 4 | if some feature is missing it is possible that it was left out on 5 | purpose. That said, new usage patterns may arise, and when that 6 | happens we are ready to adapt if necessary. 7 | 8 | A good first step for contributing is to meet us on IRC and discuss 9 | ideas. We spend a lot of time on #lesscode at freenode, always ready 10 | to talk about code and simplicity. If connecting to IRC is not an 11 | option, you can create an issue explaining the proposed change and 12 | a use case. We pay a lot of attention to use cases, because our 13 | goal is to keep the code base simple. Usually the result of a 14 | conversation is the creation of a different tool. 15 | 16 | Please don't start the conversation with a pull request. The code 17 | should come at last, and even though it may help to convey an idea, 18 | more often than not it draws the attention to a particular 19 | implementation. 20 | -------------------------------------------------------------------------------- /lib/cuba/safe/csrf.rb: -------------------------------------------------------------------------------- 1 | class Cuba 2 | module Safe 3 | module CSRF 4 | def csrf 5 | @csrf ||= Cuba::Safe::CSRF::Helper.new(req) 6 | end 7 | 8 | class Helper 9 | attr :req 10 | 11 | def initialize(req) 12 | @req = req 13 | end 14 | 15 | def token 16 | session[:csrf_token] ||= SecureRandom.base64(32) 17 | end 18 | 19 | def reset! 20 | session.delete(:csrf_token) 21 | end 22 | 23 | def safe? 24 | return req.get? || req.head? || 25 | req.params["csrf_token"] == token || 26 | req.env["HTTP_X_CSRF_TOKEN"] == token 27 | end 28 | 29 | def unsafe? 30 | return !safe? 31 | end 32 | 33 | def form_tag 34 | return %Q() 35 | end 36 | 37 | def meta_tag 38 | return %Q() 39 | end 40 | 41 | def session 42 | return req.env["rack.session"] 43 | end 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/plugin.rb: -------------------------------------------------------------------------------- 1 | require_relative "helper" 2 | 3 | scope do 4 | module Helper 5 | def clean(str) 6 | str.strip 7 | end 8 | end 9 | 10 | test do 11 | Cuba.plugin Helper 12 | 13 | Cuba.define do 14 | on default do 15 | res.write clean " foo " 16 | end 17 | end 18 | 19 | _, _, body = Cuba.call({}) 20 | 21 | assert_response body, ["foo"] 22 | end 23 | end 24 | 25 | scope do 26 | module Number 27 | def num 28 | 1 29 | end 30 | end 31 | 32 | module Plugin 33 | def self.setup(app) 34 | app.plugin Number 35 | end 36 | 37 | def bar 38 | "baz" 39 | end 40 | 41 | module ClassMethods 42 | def foo 43 | "bar" 44 | end 45 | end 46 | end 47 | 48 | setup do 49 | Cuba.plugin Plugin 50 | 51 | Cuba.define do 52 | on default do 53 | res.write bar 54 | res.write num 55 | end 56 | end 57 | end 58 | 59 | test do 60 | assert_equal "bar", Cuba.foo 61 | end 62 | 63 | test do 64 | _, _, body = Cuba.call({}) 65 | 66 | assert_response body, ["baz", "1"] 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008-2009 Christian Neukirchen 2 | Copyright (c) 2010-2015 Michel Martens 3 | Copyright (c) 2010-2015 Damian Janowski 4 | Copyright (c) 2010-2015 Cyril David 5 | Copyright (c) 2013-2015 Francesco Rodríguez 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /test/settings.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("helper", File.dirname(__FILE__)) 2 | 3 | test "settings contains request and response classes by default" do 4 | assert_equal Cuba.settings[:req], Rack::Request 5 | assert_equal Cuba.settings[:res], Cuba::Response 6 | end 7 | 8 | test "is inheritable and allows overriding" do 9 | Cuba.settings[:foo] = "bar" 10 | 11 | class Admin < Cuba; end 12 | 13 | assert_equal "bar", Admin.settings[:foo] 14 | 15 | Admin.settings[:foo] = "baz" 16 | 17 | assert_equal "bar", Cuba.settings[:foo] 18 | assert_equal "baz", Admin.settings[:foo] 19 | end 20 | 21 | test do 22 | Cuba.settings[:hello] = "Hello World" 23 | 24 | Cuba.define do 25 | on default do 26 | res.write settings[:hello] 27 | end 28 | end 29 | 30 | _, _, resp = Cuba.call({ "PATH_INFO" => "/", "SCRIPT_NAME" => ""}) 31 | 32 | body = [] 33 | 34 | resp.each do |line| 35 | body << line 36 | end 37 | 38 | assert_equal ["Hello World"], body 39 | end 40 | 41 | # The following tests the settings clone bug where 42 | # we share the same reference. Deep cloning is the solution here. 43 | Cuba.settings[:mote] ||= {} 44 | Cuba.settings[:mote][:layout] ||= "layout" 45 | 46 | class Login < Cuba 47 | settings[:mote][:layout] = "layout/guest" 48 | end 49 | 50 | test do 51 | assert Login.settings[:mote].object_id != Cuba.settings[:mote].object_id 52 | end 53 | -------------------------------------------------------------------------------- /test/root.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("helper", File.dirname(__FILE__)) 2 | 3 | test "matching an empty segment" do 4 | Cuba.define do 5 | on "" do 6 | res.write req.path 7 | end 8 | end 9 | 10 | env = { 11 | "SCRIPT_NAME" => "", 12 | "PATH_INFO" => "/" 13 | } 14 | 15 | _, _, resp = Cuba.call(env) 16 | 17 | assert_response resp, ["/"] 18 | end 19 | 20 | test "nested empty segments" do 21 | Cuba.define do 22 | on "" do 23 | on "" do 24 | on "1" do 25 | res.write "IT WORKS!" 26 | res.write req.path 27 | end 28 | end 29 | end 30 | end 31 | 32 | env = { 33 | "SCRIPT_NAME" => "", 34 | "PATH_INFO" => "///1" 35 | } 36 | 37 | _, _, resp = Cuba.call(env) 38 | 39 | assert_response resp, ["IT WORKS!", "///1"] 40 | end 41 | 42 | test "/events/? scenario" do 43 | class Events < Cuba 44 | define do 45 | on root do 46 | res.write "Hooray" 47 | end 48 | end 49 | end 50 | 51 | Cuba.define do 52 | on "events" do 53 | run Events 54 | end 55 | end 56 | 57 | env = { 58 | "SCRIPT_NAME" => "", 59 | "PATH_INFO" => "/events" 60 | } 61 | 62 | _, _, resp = Cuba.call(env) 63 | 64 | assert_response resp, ["Hooray"] 65 | 66 | env = { 67 | "SCRIPT_NAME" => "", 68 | "PATH_INFO" => "/events/" 69 | } 70 | 71 | _, _, resp = Cuba.call(env) 72 | 73 | assert_response resp, ["Hooray"] 74 | 75 | env = { 76 | "SCRIPT_NAME" => "", 77 | "PATH_INFO" => "/events/a" 78 | } 79 | 80 | _, _, resp = Cuba.call(env) 81 | 82 | assert_response resp, [] 83 | end 84 | -------------------------------------------------------------------------------- /lib/cuba/safe/secure_headers.rb: -------------------------------------------------------------------------------- 1 | # == Secure HTTP Headers 2 | # 3 | # This plugin will automatically apply several headers that are 4 | # related to security. This includes: 5 | # 6 | # - HTTP Strict Transport Security (HSTS) [2]. 7 | # - X-Frame-Options [3]. 8 | # - X-XSS-Protection [4]. 9 | # - X-Content-Type-Options [5]. 10 | # - X-Download-Options [6]. 11 | # - X-Permitted-Cross-Domain-Policies [7]. 12 | # 13 | # Due to HTTP/2 specifications and Rack specifications, field names are applied in all lowercase. 14 | # 15 | # == References 16 | # 17 | # [1]: https://github.com/twitter/secureheaders 18 | # [2]: https://tools.ietf.org/html/rfc6797 19 | # [3]: https://tools.ietf.org/html/draft-ietf-websec-x-frame-options-02 20 | # [4]: http://msdn.microsoft.com/en-us/library/dd565647(v=vs.85).aspx 21 | # [5]: http://msdn.microsoft.com/en-us/library/ie/gg622941(v=vs.85).aspx 22 | # [6]: http://msdn.microsoft.com/en-us/library/ie/jj542450(v=vs.85).aspx 23 | # [7]: https://www.adobe.com/devnet/adobe-media-server/articles/cross-domain-xml-for-streaming.html 24 | # [8]: https://datatracker.ietf.org/doc/html/rfc9113#name-http-fields 25 | # 26 | class Cuba 27 | module Safe 28 | module SecureHeaders 29 | HEADERS = { 30 | "x-content-type-options" => "nosniff", 31 | "x-download-options" => "noopen", 32 | "x-frame-options" => "SAMEORIGIN", 33 | "x-permitted-cross-domain-policies" => "none", 34 | "x-xss-protection" => "1; mode=block", 35 | "strict-transport-security" => "max-age=2628000" 36 | } 37 | 38 | def self.setup(app) 39 | app.settings[:default_headers].merge!(HEADERS) 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/path.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("helper", File.dirname(__FILE__)) 2 | 3 | setup do 4 | { "SCRIPT_NAME" => "/", "PATH_INFO" => "/about" } 5 | end 6 | 7 | test "one level path" do |env| 8 | Cuba.define do 9 | on "about" do 10 | res.write "About" 11 | end 12 | end 13 | 14 | _, _, resp = Cuba.call(env) 15 | 16 | assert_response resp, ["About"] 17 | end 18 | 19 | test "two level nested paths" do |env| 20 | Cuba.define do 21 | on "about" do 22 | on "1" do 23 | res.write "+1" 24 | end 25 | 26 | on "2" do 27 | res.write "+2" 28 | end 29 | end 30 | end 31 | 32 | env["PATH_INFO"] = "/about/1" 33 | 34 | _, _, resp = Cuba.call(env) 35 | 36 | assert_response resp, ["+1"] 37 | 38 | env["PATH_INFO"] = "/about/2" 39 | 40 | _, _, resp = Cuba.call(env) 41 | 42 | assert_response resp, ["+2"] 43 | end 44 | 45 | test "two level inlined paths" do |env| 46 | Cuba.define do 47 | on "a/b" do 48 | res.write "a" 49 | res.write "b" 50 | end 51 | end 52 | 53 | env["PATH_INFO"] = "/a/b" 54 | 55 | _, _, resp = Cuba.call(env) 56 | 57 | assert_response resp, ["a", "b"] 58 | end 59 | 60 | test "a path with some regex captures" do |env| 61 | Cuba.define do 62 | on "user(\\d+)" do |uid| 63 | res.write uid 64 | end 65 | end 66 | 67 | env["PATH_INFO"] = "/user123" 68 | 69 | _, _, resp = Cuba.call(env) 70 | 71 | assert_response resp, ["123"] 72 | end 73 | 74 | test "matching the root" do |env| 75 | Cuba.define do 76 | on "" do 77 | res.write "Home" 78 | end 79 | end 80 | 81 | env["PATH_INFO"] = "/" 82 | 83 | _, _, resp = Cuba.call(env) 84 | 85 | assert_response resp, ["Home"] 86 | end 87 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 4.0.3 2 | 3 | * Change secure headers to lowercase. 4 | 5 | 3.9.0 6 | 7 | * Cache regular expressions. 8 | 9 | 3.8.1 10 | 11 | * Remove requirement on Time. 12 | 13 | 3.8.0 14 | 15 | * Change gemspec to allow Rack 2. 16 | 17 | 3.7.0 18 | 19 | * Add syntactic sugar for matching all HTTP methods. 20 | 21 | 3.6.0 22 | 23 | * Set Strict-Transport-Security to a better default. 24 | 25 | 3.5.0 26 | 27 | * Add `not_found` hook for customizing the 404 error. 28 | 29 | * Remove undocumented `header` matcher. 30 | 31 | * Depend explicitly on Rack 1.6.x. 32 | 33 | * Experimental feature: `param` now accepts a second parameter 34 | with a default value. 35 | 36 | 3.4.0 37 | 38 | * Add `Cuba::Safe` plugin. This plugin contains security related 39 | defaults. 40 | 41 | 3.3.0 42 | 43 | * Restrict when to add the default content type. 44 | 45 | 3.2.0 46 | 47 | * Return 404 when status is not explicitly set and body is empty. 48 | 49 | 3.1.1 50 | 51 | * Add support for custom default layouts. 52 | 53 | 3.1.0 54 | 55 | * Do a deep clone of the settings object during inheritance. 56 | * Start namespacing plugins (i.e. settings[:render]). 57 | * Use rack/test when doing `require 'cuba/test'`. 58 | * Capybara available via `require 'cuba/capybara'`. 59 | * Use a default hash for the render plugin. 60 | * Allow the use of custom Request and Response objects. 61 | 62 | 3.0.0 63 | 64 | * Remove Cuba.build. Use subclassing instead. 65 | * Remove warnings. 66 | * Use Cuba::Response instead of Rack::Response. 67 | * Integrate Cuba.plugin and Cuba.settings. 68 | * Remove Cuba::VERSION. 69 | * Rename _call to call! (inspired from Sinatra). 70 | * Fix a memory leak with the caching used in Tilt. 71 | * Adding syntax highlighting to the README Code blocks. 72 | -------------------------------------------------------------------------------- /test/safe.rb: -------------------------------------------------------------------------------- 1 | require_relative "helper" 2 | require "cuba/safe" 3 | 4 | scope do 5 | test "secure headers" do 6 | Cuba.plugin(Cuba::Safe) 7 | 8 | class Hello < Cuba 9 | define do 10 | on root do 11 | res.write("hello") 12 | end 13 | end 14 | end 15 | 16 | Cuba.define do 17 | on root do 18 | res.write("home") 19 | end 20 | 21 | on "hello" do 22 | run(Hello) 23 | end 24 | end 25 | 26 | secure_headers = Cuba::Safe::SecureHeaders::HEADERS 27 | 28 | _, headers, _ = Cuba.call("PATH_INFO" => "/", "SCRIPT_NAME" => "/") 29 | secure_headers.each do |header, value| 30 | assert_equal(value, headers[header]) 31 | end 32 | 33 | _, headers, _ = Cuba.call("PATH_INFO" => "/hello", "SCRIPT_NAME" => "/") 34 | secure_headers.each do |header, value| 35 | assert_equal(value, headers[header]) 36 | end 37 | end 38 | 39 | test "secure headers only in sub app" do 40 | Cuba.settings[:default_headers] = {} 41 | 42 | class About < Cuba 43 | plugin(Cuba::Safe) 44 | 45 | define do 46 | on root do 47 | res.write("about") 48 | end 49 | end 50 | end 51 | 52 | Cuba.define do 53 | on root do 54 | res.write("home") 55 | end 56 | 57 | on "about" do 58 | run(About) 59 | end 60 | end 61 | 62 | secure_headers = Cuba::Safe::SecureHeaders::HEADERS 63 | 64 | _, headers, _ = Cuba.call("PATH_INFO" => "/", "SCRIPT_NAME" => "/") 65 | secure_headers.each do |header, _| 66 | assert(!headers.key?(header)) 67 | end 68 | 69 | _, headers, _ = Cuba.call("PATH_INFO" => "/about", "SCRIPT_NAME" => "/") 70 | secure_headers.each do |header, value| 71 | assert_equal(value, headers[header]) 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /test/match.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("helper", File.dirname(__FILE__)) 2 | 3 | setup do 4 | { "SCRIPT_NAME" => "/", "PATH_INFO" => "/posts/123" } 5 | end 6 | 7 | test "text-book example" do |env| 8 | Cuba.define do 9 | on "posts/:id" do |id| 10 | res.write id 11 | end 12 | end 13 | 14 | _, _, resp = Cuba.call(env) 15 | 16 | assert_response resp, ["123"] 17 | end 18 | 19 | test "multi-param" do |env| 20 | Cuba.define do 21 | on "u/:uid/posts/:id" do |uid, id| 22 | res.write uid 23 | res.write id 24 | end 25 | end 26 | 27 | env["PATH_INFO"] = "/u/jdoe/posts/123" 28 | 29 | _, _, resp = Cuba.call(env) 30 | 31 | assert_response resp, ["jdoe", "123"] 32 | end 33 | 34 | test "regex nesting" do |env| 35 | Cuba.define do 36 | on(/u\/(\w+)/) do |uid| 37 | res.write uid 38 | 39 | on(/posts\/(\d+)/) do |id| 40 | res.write id 41 | end 42 | end 43 | end 44 | 45 | env["PATH_INFO"] = "/u/jdoe/posts/123" 46 | 47 | _, _, resp = Cuba.call(env) 48 | 49 | assert_response resp, ["jdoe", "123"] 50 | end 51 | 52 | test "regex nesting colon param style" do |env| 53 | Cuba.define do 54 | on(/u:(\w+)/) do |uid| 55 | res.write uid 56 | 57 | on(/posts:(\d+)/) do |id| 58 | res.write id 59 | end 60 | end 61 | end 62 | 63 | env["PATH_INFO"] = "/u:jdoe/posts:123" 64 | 65 | _, _, resp = Cuba.call(env) 66 | 67 | assert_response resp, ["jdoe", "123"] 68 | end 69 | 70 | test "symbol matching" do |env| 71 | Cuba.define do 72 | on "user", :id do |uid| 73 | res.write uid 74 | 75 | on "posts", :pid do |id| 76 | res.write id 77 | end 78 | end 79 | end 80 | 81 | env["PATH_INFO"] = "/user/jdoe/posts/123" 82 | 83 | _, _, resp = Cuba.call(env) 84 | 85 | assert_response resp, ["jdoe", "123"] 86 | end 87 | -------------------------------------------------------------------------------- /test/param.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("helper", File.dirname(__FILE__)) 2 | require "stringio" 3 | 4 | prepare do 5 | Cuba.define do 6 | on get, "signup", param("email") do |email| 7 | res.write email 8 | end 9 | 10 | on get, "login", param("username", "guest") do |username| 11 | res.write username 12 | end 13 | 14 | on default do 15 | res.write "No email" 16 | end 17 | end 18 | end 19 | 20 | test "yields a param" do 21 | env = { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/signup", 22 | "SCRIPT_NAME" => "/", "rack.input" => StringIO.new, 23 | "QUERY_STRING" => "email=john@doe.com" } 24 | 25 | _, _, resp = Cuba.call(env) 26 | 27 | assert_response resp, ["john@doe.com"] 28 | end 29 | 30 | test "doesn't yield a missing param" do 31 | env = { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/signup", 32 | "SCRIPT_NAME" => "/", "rack.input" => StringIO.new, 33 | "QUERY_STRING" => "" } 34 | 35 | _, _, resp = Cuba.call(env) 36 | 37 | assert_response resp, ["No email"] 38 | end 39 | 40 | test "doesn't yield an empty param" do 41 | env = { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/signup", 42 | "SCRIPT_NAME" => "/", "rack.input" => StringIO.new, 43 | "QUERY_STRING" => "email=" } 44 | 45 | _, _, resp = Cuba.call(env) 46 | 47 | assert_response resp, ["No email"] 48 | end 49 | 50 | test "yields a default param" do 51 | env = { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/login", 52 | "SCRIPT_NAME" => "/", "rack.input" => StringIO.new, 53 | "QUERY_STRING" => "username=john" } 54 | 55 | _, _, resp = Cuba.call(env) 56 | 57 | assert_response resp, ["john"] 58 | 59 | env = { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/login", 60 | "SCRIPT_NAME" => "/", "rack.input" => StringIO.new, 61 | "QUERY_STRING" => "" } 62 | 63 | _, _, resp = Cuba.call(env) 64 | 65 | assert_response resp, ["guest"] 66 | end 67 | -------------------------------------------------------------------------------- /test/accept.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("helper", File.dirname(__FILE__)) 2 | 3 | test "accept mimetypes" do 4 | Cuba.define do 5 | on accept("application/xml") do 6 | res.write res["content-type"] 7 | end 8 | end 9 | 10 | env = { "HTTP_ACCEPT" => "application/xml", 11 | "SCRIPT_NAME" => "/", "PATH_INFO" => "/post" } 12 | 13 | _, _, body = Cuba.call(env) 14 | 15 | assert_response body, ["application/xml"] 16 | end 17 | 18 | test "tests don't fail when you don't specify an accept type" do 19 | Cuba.define do 20 | on accept("application/xml") do 21 | res.write res["Content-Type"] 22 | end 23 | 24 | on default do 25 | res.write "Default action" 26 | end 27 | end 28 | 29 | _, _, body = Cuba.call({}) 30 | 31 | assert_response body, ["Default action"] 32 | end 33 | 34 | test "accept HTML mimetype" do 35 | Cuba.define do 36 | on accept("text/html") do 37 | res.write Cuba::Response::ContentType::HTML 38 | end 39 | end 40 | 41 | env = { "HTTP_ACCEPT" => "text/html", 42 | "SCRIPT_NAME" => "/", "PATH_INFO" => "/post" } 43 | 44 | _, _, body = Cuba.call(env) 45 | 46 | assert_response body, ["text/html"] 47 | end 48 | 49 | test "accept TEXT mimetype" do 50 | Cuba.define do 51 | on accept("text/plain") do 52 | res.write Cuba::Response::ContentType::TEXT 53 | end 54 | end 55 | 56 | env = { "HTTP_ACCEPT" => "text/plain", 57 | "SCRIPT_NAME" => "/", "PATH_INFO" => "/post" } 58 | 59 | _, _, body = Cuba.call(env) 60 | 61 | assert_response body, ["text/plain"] 62 | end 63 | 64 | test "accept JSON mimetype" do 65 | Cuba.define do 66 | on accept("application/json") do 67 | res.write Cuba::Response::ContentType::JSON 68 | end 69 | end 70 | 71 | env = { "HTTP_ACCEPT" => "application/json", 72 | "SCRIPT_NAME" => "/", "PATH_INFO" => "/get" } 73 | 74 | _, _, body = Cuba.call(env) 75 | 76 | assert_response body, ["application/json"] 77 | end 78 | -------------------------------------------------------------------------------- /lib/cuba/render.rb: -------------------------------------------------------------------------------- 1 | require "tilt" 2 | 3 | class Cuba 4 | module Render 5 | def self.setup(app) 6 | app.settings[:render] ||= {} 7 | app.settings[:render][:template_engine] ||= "erb" 8 | app.settings[:render][:layout] ||= "layout" 9 | app.settings[:render][:views] ||= File.expand_path("views", Dir.pwd) 10 | app.settings[:render][:options] ||= { 11 | default_encoding: Encoding.default_external 12 | } 13 | end 14 | 15 | def render(template, locals = {}, layout = settings[:render][:layout]) 16 | res.headers[Rack::CONTENT_TYPE] ||= "text/html; charset=utf-8" 17 | res.write(view(template, locals, layout)) 18 | end 19 | 20 | def view(template, locals = {}, layout = settings[:render][:layout]) 21 | partial(layout, locals.merge(content: partial(template, locals))) 22 | end 23 | 24 | def partial(template, locals = {}) 25 | _render(template_path(template), locals, settings[:render][:options]) 26 | end 27 | 28 | def template_path(template) 29 | dir = settings[:render][:views] 30 | ext = settings[:render][:template_engine] 31 | 32 | return File.join(dir, "#{ template }.#{ ext }") 33 | end 34 | 35 | # @private Renders any type of template file supported by Tilt. 36 | # 37 | # @example 38 | # 39 | # # Renders home, and is assumed to be HAML. 40 | # _render("home.haml") 41 | # 42 | # # Renders with some local variables 43 | # _render("home.haml", site_name: "My Site") 44 | # 45 | # # Renders with HAML options 46 | # _render("home.haml", {}, ugly: true, format: :html5) 47 | # 48 | # # Renders in layout 49 | # _render("layout.haml") { _render("home.haml") } 50 | # 51 | def _render(template, locals = {}, options = {}, &block) 52 | _cache.fetch(template) { 53 | Tilt.new(template, 1, options.merge(outvar: '@_output')) 54 | }.render(self, locals, &block) 55 | end 56 | 57 | # @private Used internally by #_render to cache the 58 | # Tilt templates. 59 | def _cache 60 | Thread.current[:_cache] ||= Tilt::Cache.new 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/composition.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("helper", File.dirname(__FILE__)) 2 | 3 | test "composing on top of a PATH" do 4 | Services = Cuba.new do 5 | on "services/:id" do |id| 6 | res.write "View #{id}" 7 | end 8 | end 9 | 10 | Cuba.define do 11 | on "provider" do 12 | run Services 13 | end 14 | end 15 | 16 | env = { "SCRIPT_NAME" => "/", "PATH_INFO" => "/provider/services/101" } 17 | 18 | _, _, resp = Cuba.call(env) 19 | 20 | assert_response resp, ["View 101"] 21 | end 22 | 23 | test "redefining not_found" do 24 | class Users < Cuba 25 | def not_found 26 | res.status = 404 27 | res.write "Not found!" 28 | end 29 | 30 | define do 31 | on root do 32 | res.write "Users" 33 | end 34 | end 35 | end 36 | 37 | class Cuba 38 | def not_found 39 | res.status = 404 40 | res.write "Error 404" 41 | end 42 | end 43 | 44 | Cuba.define do 45 | on "users" do 46 | run Users 47 | end 48 | end 49 | 50 | env = { "SCRIPT_NAME" => "/", "PATH_INFO" => "/users" } 51 | 52 | _, _, resp = Cuba.call(env) 53 | 54 | assert_response resp, ["Users"] 55 | 56 | env = { "SCRIPT_NAME" => "/", "PATH_INFO" => "/users/42" } 57 | 58 | status, _, resp = Cuba.call(env) 59 | 60 | assert_response resp, ["Not found!"] 61 | assert_equal status, 404 62 | 63 | env = { "SCRIPT_NAME" => "/", "PATH_INFO" => "/guests" } 64 | 65 | status, _, resp = Cuba.call(env) 66 | 67 | assert_response resp, ["Error 404"] 68 | assert_equal status, 404 69 | end 70 | 71 | test "multi mount" do 72 | A = Cuba.new do 73 | on "a" do 74 | res.write "View a" 75 | end 76 | end 77 | 78 | B = Cuba.new do 79 | on "b" do 80 | res.write "View b" 81 | end 82 | end 83 | 84 | class Cuba 85 | def mount(app) 86 | result = app.call(req.env) 87 | halt result if result[0] != 404 88 | end 89 | end 90 | 91 | Cuba.define do 92 | on default do 93 | mount A 94 | mount B 95 | end 96 | end 97 | 98 | env = { "SCRIPT_NAME" => "/", "PATH_INFO" => "/b" } 99 | 100 | _, _, resp = Cuba.call(env) 101 | 102 | assert_response resp, ["View b"] 103 | end 104 | -------------------------------------------------------------------------------- /test/integration.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("helper", File.dirname(__FILE__)) 2 | 3 | test "resetting" do 4 | old = Cuba.app 5 | assert old.object_id == Cuba.app.object_id 6 | 7 | Cuba.reset! 8 | assert old.object_id != Cuba.app.object_id 9 | end 10 | 11 | class Middle 12 | def initialize(app, first, second, &block) 13 | @app, @first, @second, @block = app, first, second, block 14 | end 15 | 16 | def call(env) 17 | env["m.first"] = @first 18 | env["m.second"] = @second 19 | env["m.block"] = @block.call 20 | 21 | @app.call(env) 22 | end 23 | end 24 | 25 | test "use passes in the arguments and block" do 26 | Cuba.use Middle, "First", "Second" do 27 | "this is the block" 28 | end 29 | 30 | Cuba.define do 31 | on get do 32 | on "hello" do 33 | "Default" 34 | end 35 | end 36 | end 37 | 38 | env = { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/hello", 39 | "SCRIPT_NAME" => "/" } 40 | 41 | Cuba.call(env) 42 | 43 | assert "First" == env["m.first"] 44 | assert "Second" == env["m.second"] 45 | assert "this is the block" == env["m.block"] 46 | end 47 | 48 | test "reset and use" do 49 | Cuba.use Middle, "First", "Second" do 50 | "this is the block" 51 | end 52 | 53 | Cuba.define do 54 | on get do 55 | on "hello" do 56 | res.write "Default" 57 | end 58 | end 59 | end 60 | 61 | Cuba.reset! 62 | 63 | Cuba.use Middle, "1", "2" do 64 | "3" 65 | end 66 | 67 | Cuba.define do 68 | on get do 69 | on "hello" do 70 | res.write "2nd Default" 71 | end 72 | end 73 | end 74 | 75 | env = { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/hello", 76 | "SCRIPT_NAME" => "/" } 77 | 78 | status, headers, resp = Cuba.call(env) 79 | 80 | assert_equal 200, status 81 | assert "text/html; charset=utf-8" == headers["content-type"] 82 | assert_response resp, ["2nd Default"] 83 | 84 | assert "1" == env["m.first"] 85 | assert "2" == env["m.second"] 86 | assert "3" == env["m.block"] 87 | end 88 | 89 | test "custom response" do 90 | class MyResponse < Cuba::Response 91 | def foobar 92 | write "Default" 93 | end 94 | end 95 | 96 | Cuba.settings[:res] = MyResponse 97 | 98 | Cuba.define do 99 | on get do 100 | on "hello" do 101 | res.foobar 102 | end 103 | end 104 | end 105 | 106 | env = { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/hello", 107 | "SCRIPT_NAME" => "/" } 108 | 109 | status, headers, resp = Cuba.call(env) 110 | 111 | assert 200 == status 112 | assert "text/html; charset=utf-8" == headers["content-type"] 113 | assert_response resp, ["Default"] 114 | end 115 | -------------------------------------------------------------------------------- /test/csrf.rb: -------------------------------------------------------------------------------- 1 | require_relative "helper" 2 | require "cuba/safe/csrf" 3 | require "cuba/test" 4 | 5 | def assert_no_raise 6 | yield 7 | success 8 | end 9 | 10 | class UnsafeRequest < RuntimeError; end 11 | 12 | scope do 13 | setup do 14 | Cuba.reset! 15 | 16 | Cuba.use(Rack::Session::Cookie, 17 | secret: "R6zSBQWz0VGVSwvT8THurhJwaVqzpnsH27J5FoI58pxoIciDQYvE4opVvDTLMyfjj7c5inIc6PDNaQWvArMvK3") 18 | Cuba.plugin(Cuba::Safe::CSRF) 19 | end 20 | 21 | test "safe http methods" do 22 | Cuba.define do 23 | raise UnsafeRequest if csrf.unsafe? 24 | end 25 | 26 | assert_no_raise do 27 | get "/" 28 | head "/" 29 | end 30 | end 31 | 32 | test "invalid csrf param" do 33 | Cuba.define do 34 | if csrf.unsafe? 35 | csrf.reset! 36 | end 37 | 38 | res.write(csrf.token) 39 | end 40 | 41 | get "/" 42 | 43 | old_token = last_response.body 44 | 45 | post "/", "csrf_token" => "nonsense" 46 | 47 | new_token = last_response.body 48 | 49 | assert(old_token != new_token) 50 | end 51 | 52 | test "valid csrf param" do 53 | Cuba.define do 54 | raise unless csrf.safe? 55 | 56 | on get do 57 | res.write(csrf.token) 58 | end 59 | 60 | on post do 61 | res.write("safe") 62 | end 63 | end 64 | 65 | get "/" 66 | 67 | csrf_token = last_response.body 68 | 69 | assert(!csrf_token.empty?) 70 | 71 | assert_no_raise do 72 | post "/", "csrf_token" => csrf_token 73 | end 74 | end 75 | 76 | test "http header" do 77 | csrf_token = SecureRandom.hex(32) 78 | 79 | Cuba.define do 80 | session[:csrf_token] = csrf_token 81 | raise if csrf.unsafe? 82 | end 83 | 84 | assert_no_raise do 85 | post "/", {}, { "HTTP_X_CSRF_TOKEN" => csrf_token } 86 | end 87 | end 88 | 89 | test "sub app raises too" do 90 | class App < Cuba 91 | define do 92 | on post do 93 | res.write("unsafe") 94 | end 95 | end 96 | end 97 | 98 | Cuba.define do 99 | raise UnsafeRequest unless csrf.safe? 100 | 101 | on "app" do 102 | run(App) 103 | end 104 | end 105 | 106 | assert_raise(UnsafeRequest) do 107 | post "/app" 108 | end 109 | end 110 | 111 | test "only sub app" do 112 | class App < Cuba 113 | define do 114 | raise UnsafeRequest unless csrf.safe? 115 | 116 | on post do 117 | res.write("unsafe") 118 | end 119 | end 120 | end 121 | 122 | Cuba.define do 123 | on "app" do 124 | run(App) 125 | end 126 | 127 | on default do 128 | res.write("safe") 129 | end 130 | end 131 | 132 | assert_no_raise do 133 | post "/" 134 | end 135 | 136 | assert_raise(UnsafeRequest) do 137 | post "/app" 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /test/on.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("helper", File.dirname(__FILE__)) 2 | 3 | test "executes on true" do 4 | Cuba.define do 5 | on true do 6 | res.write "+1" 7 | end 8 | end 9 | 10 | _, _, resp = Cuba.call({}) 11 | 12 | assert_response resp, ["+1"] 13 | end 14 | 15 | test "executes on non-false" do 16 | Cuba.define do 17 | on "123" do 18 | res.write "+1" 19 | end 20 | end 21 | 22 | _, _, resp = Cuba.call({ "PATH_INFO" => "/123", "SCRIPT_NAME" => "/" }) 23 | 24 | assert_response resp, ["+1"] 25 | end 26 | 27 | test "ensures SCRIPT_NAME and PATH_INFO are reverted" do 28 | Cuba.define do 29 | on lambda { env["SCRIPT_NAME"] = "/hello"; false } do 30 | res.write "Unreachable" 31 | end 32 | end 33 | 34 | env = { "SCRIPT_NAME" => "/", "PATH_INFO" => "/hello" } 35 | 36 | _, _, resp = Cuba.call(env) 37 | 38 | assert_equal "/", env["SCRIPT_NAME"] 39 | assert_equal "/hello", env["PATH_INFO"] 40 | assert_response resp, [] 41 | end 42 | 43 | test "skips consecutive matches" do 44 | Cuba.define do 45 | on true do 46 | env["foo"] = "foo" 47 | 48 | res.write "foo" 49 | end 50 | 51 | on true do 52 | env["bar"] = "bar" 53 | 54 | res.write "bar" 55 | end 56 | end 57 | 58 | env = {} 59 | 60 | _, _, resp = Cuba.call(env) 61 | 62 | assert_equal "foo", env["foo"] 63 | assert_response resp, ["foo"] 64 | 65 | assert ! env["bar"] 66 | end 67 | 68 | test "finds first match available" do 69 | Cuba.define do 70 | on false do 71 | res.write "foo" 72 | end 73 | 74 | on true do 75 | res.write "bar" 76 | end 77 | end 78 | 79 | _, _, resp = Cuba.call({}) 80 | 81 | assert_response resp, ["bar"] 82 | end 83 | 84 | test "reverts a half-met matcher" do 85 | Cuba.define do 86 | on "post", false do 87 | res.write "Should be unmet" 88 | end 89 | end 90 | 91 | env = { "PATH_INFO" => "/post", "SCRIPT_NAME" => "/" } 92 | _, _, resp = Cuba.call(env) 93 | 94 | assert_response resp, [] 95 | assert_equal "/post", env["PATH_INFO"] 96 | assert_equal "/", env["SCRIPT_NAME"] 97 | end 98 | 99 | test "responds 404 if conditions are not met" do 100 | Cuba.define do 101 | on root do 102 | res.write("Should be unmet") 103 | end 104 | end 105 | 106 | env = { "PATH_INFO" => "/notexists", "SCRIPT_NAME" => "/" } 107 | status, _, body = Cuba.call(env) 108 | 109 | assert_equal 404, status 110 | assert body.empty? 111 | end 112 | 113 | test "responds 404 if nested conditions are not met" do 114 | Cuba.define do 115 | on get do 116 | on root do 117 | res.write("Should be unmet") 118 | end 119 | end 120 | 121 | on default do 122 | res.write("Should be unmet") 123 | end 124 | end 125 | 126 | env = { 127 | "REQUEST_METHOD" => "GET", 128 | "PATH_INFO" => "/notexists", 129 | "SCRIPT_NAME" => "/" 130 | } 131 | 132 | status, _, body = Cuba.call(env) 133 | 134 | assert_equal 404, status 135 | assert body.empty? 136 | end 137 | 138 | test "responds 200 even with an empty body if status is set" do 139 | Cuba.define do 140 | on get do 141 | on root do 142 | res.status = 200 143 | end 144 | end 145 | end 146 | 147 | env = { 148 | "REQUEST_METHOD" => "GET", 149 | "PATH_INFO" => "/", 150 | "SCRIPT_NAME" => "/" 151 | } 152 | 153 | status, _, body = Cuba.call(env) 154 | 155 | assert_equal 200, status 156 | assert body.empty? 157 | end 158 | -------------------------------------------------------------------------------- /test/render.rb: -------------------------------------------------------------------------------- 1 | require_relative "helper" 2 | 3 | require "cuba/render" 4 | 5 | test "doesn't override the settings if they already exist" do 6 | Cuba.settings[:render] = { 7 | :views => "./test/views", 8 | :template_engine => "haml" 9 | } 10 | 11 | Cuba.plugin Cuba::Render 12 | 13 | assert_equal "./test/views", Cuba.settings[:render][:views] 14 | assert_equal "haml", Cuba.settings[:render][:template_engine] 15 | end 16 | 17 | scope do 18 | setup do 19 | Cuba.plugin Cuba::Render 20 | Cuba.settings[:render][:views] = "./test/views" 21 | Cuba.settings[:render][:template_engine] = "erb" 22 | 23 | Cuba.define do 24 | on "home" do 25 | res.write view("home", name: "Agent Smith", title: "Home") 26 | end 27 | 28 | on "about" do 29 | res.write partial("about", title: "About Cuba") 30 | end 31 | 32 | on "render" do 33 | render("about", title: "About Cuba") 34 | end 35 | end 36 | end 37 | 38 | test "partial" do 39 | _, _, body = Cuba.call({ "PATH_INFO" => "/about", "SCRIPT_NAME" => "/" }) 40 | 41 | assert_response body, ["

About Cuba

"] 42 | end 43 | 44 | test "view" do 45 | _, _, body = Cuba.call({ "PATH_INFO" => "/home", "SCRIPT_NAME" => "/" }) 46 | 47 | assert_response body, ["Cuba: Home\n

Home

\n

Hello Agent Smith

"] 48 | end 49 | 50 | test "render" do 51 | _, _, body = Cuba.call({ "PATH_INFO" => "/render", "SCRIPT_NAME" => "/" }) 52 | 53 | assert_response body, ["Cuba: About Cuba\n

About Cuba

"] 54 | end 55 | 56 | test "partial with str as engine" do 57 | Cuba.settings[:render][:template_engine] = "str" 58 | 59 | _, _, body = Cuba.call({ "PATH_INFO" => "/about", "SCRIPT_NAME" => "/" }) 60 | 61 | assert_response body, ["

About Cuba

"] 62 | end 63 | 64 | test "view with str as engine" do 65 | Cuba.settings[:render][:template_engine] = "str" 66 | 67 | _, _, body = Cuba.call({ "PATH_INFO" => "/home", "SCRIPT_NAME" => "/" }) 68 | 69 | assert_response body, ["Cuba: Home\n

Home

\n

Hello Agent Smith

"] 70 | end 71 | 72 | test "custom default layout support" do 73 | Cuba.settings[:render][:layout] = "layout-alternative" 74 | 75 | _, _, body = Cuba.call({ "PATH_INFO" => "/home", "SCRIPT_NAME" => "/" }) 76 | 77 | assert_response body, ["Alternative Layout: Home\n

Home

\n

Hello Agent Smith

"] 78 | end 79 | end 80 | 81 | test "caching behavior" do 82 | Thread.current[:_cache] = nil 83 | 84 | Cuba.plugin Cuba::Render 85 | Cuba.settings[:render][:views] = "./test/views" 86 | 87 | Cuba.define do 88 | on "foo/:i" do |i| 89 | res.write partial("test", title: i) 90 | end 91 | end 92 | 93 | 10.times do |i| 94 | _, _, resp = Cuba.call({ "PATH_INFO" => "/foo/#{i}", "SCRIPT_NAME" => "" }) 95 | end 96 | 97 | assert_equal 1, Thread.current[:_cache].instance_variable_get(:@cache).size 98 | end 99 | 100 | test "overrides layout" do 101 | Cuba.plugin Cuba::Render 102 | Cuba.settings[:render][:views] = "./test/views" 103 | 104 | Cuba.define do 105 | on true do 106 | res.write view("home", { name: "Agent Smith", title: "Home" }, "layout-alternative") 107 | end 108 | end 109 | 110 | _, _, body = Cuba.call({}) 111 | 112 | assert_response body, ["Alternative Layout: Home\n

Home

\n

Hello Agent Smith

"] 113 | end 114 | 115 | test "ensures content-type header is set" do 116 | Cuba.plugin(Cuba::Render) 117 | 118 | Cuba.define do 119 | on default do 120 | res.status = 403 121 | render("about", title: "Hello Cuba") 122 | end 123 | end 124 | 125 | _, headers, _ = Cuba.call({}) 126 | 127 | assert_equal("text/html; charset=utf-8", headers["content-type"]) 128 | end 129 | -------------------------------------------------------------------------------- /test/captures.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("helper", File.dirname(__FILE__)) 2 | 3 | test "doesn't yield HOST" do 4 | Cuba.define do 5 | on host("example.com") do |*args| 6 | res.write args.size 7 | end 8 | end 9 | 10 | env = { "HTTP_HOST" => "example.com" } 11 | 12 | _, _, resp = Cuba.call(env) 13 | 14 | assert_response resp, ["0"] 15 | end 16 | 17 | test "doesn't yield the verb" do 18 | Cuba.define do 19 | on get do |*args| 20 | res.write args.size 21 | end 22 | end 23 | 24 | env = { "REQUEST_METHOD" => "GET" } 25 | 26 | _, _, resp = Cuba.call(env) 27 | 28 | assert_response resp, ["0"] 29 | end 30 | 31 | test "doesn't yield the path" do 32 | Cuba.define do 33 | on get, "home" do |*args| 34 | res.write args.size 35 | end 36 | end 37 | 38 | env = { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/home", 39 | "SCRIPT_NAME" => "/" } 40 | 41 | _, _, resp = Cuba.call(env) 42 | 43 | assert_response resp, ["0"] 44 | end 45 | 46 | test "yields the segment" do 47 | Cuba.define do 48 | on get, "user", :id do |id| 49 | res.write id 50 | end 51 | end 52 | 53 | env = { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/user/johndoe", 54 | "SCRIPT_NAME" => "/" } 55 | 56 | _, _, resp = Cuba.call(env) 57 | 58 | assert_response resp, ["johndoe"] 59 | end 60 | 61 | test "yields a number" do 62 | Cuba.define do 63 | on get, "user", :id do |id| 64 | res.write id 65 | end 66 | end 67 | 68 | env = { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/user/101", 69 | "SCRIPT_NAME" => "/" } 70 | 71 | _, _, resp = Cuba.call(env) 72 | 73 | assert_response resp, ["101"] 74 | end 75 | 76 | test "yield a file name with a matching extension" do 77 | Cuba.define do 78 | on get, "css", extension("css") do |file| 79 | res.write file 80 | end 81 | end 82 | 83 | env = { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/css/app.css", 84 | "SCRIPT_NAME" => "/" } 85 | 86 | _, _, resp = Cuba.call(env) 87 | 88 | assert_response resp, ["app"] 89 | end 90 | 91 | test "yields a segment per nested block" do 92 | Cuba.define do 93 | on :one do |one| 94 | on :two do |two| 95 | on :three do |three| 96 | res.write one 97 | res.write two 98 | res.write three 99 | end 100 | end 101 | end 102 | end 103 | 104 | env = { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/one/two/three", 105 | "SCRIPT_NAME" => "/" } 106 | 107 | _, _, resp = Cuba.call(env) 108 | 109 | assert_response resp, ["one", "two", "three"] 110 | end 111 | 112 | test "consumes a slash if needed" do 113 | Cuba.define do 114 | on get, "(.+\\.css)" do |file| 115 | res.write file 116 | end 117 | end 118 | 119 | env = { "REQUEST_METHOD" => "GET", "PATH_INFO" => "/foo/bar.css", 120 | "SCRIPT_NAME" => "/" } 121 | 122 | _, _, resp = Cuba.call(env) 123 | 124 | assert_response resp, ["foo/bar.css"] 125 | end 126 | 127 | test "regex captures in string format" do 128 | Cuba.define do 129 | on get, "posts/(\\d+)-(.*)" do |id, slug| 130 | res.write id 131 | res.write slug 132 | end 133 | end 134 | 135 | 136 | env = { "REQUEST_METHOD" => "GET", 137 | "PATH_INFO" => "/posts/123-postal-service", 138 | "SCRIPT_NAME" => "/" } 139 | 140 | _, _, resp = Cuba.call(env) 141 | 142 | 143 | assert_response resp, ["123", "postal-service"] 144 | end 145 | 146 | test "regex captures in regex format" do 147 | Cuba.define do 148 | on get, %r{posts/(\d+)-(.*)} do |id, slug| 149 | res.write id 150 | res.write slug 151 | end 152 | end 153 | 154 | env = { "REQUEST_METHOD" => "GET", 155 | "PATH_INFO" => "/posts/123-postal-service", 156 | "SCRIPT_NAME" => "/" } 157 | 158 | _, _, resp = Cuba.call(env) 159 | 160 | 161 | assert_response resp, ["123", "postal-service"] 162 | end 163 | -------------------------------------------------------------------------------- /lib/cuba.rb: -------------------------------------------------------------------------------- 1 | require "delegate" 2 | require "rack" 3 | require "rack/session" 4 | 5 | class Cuba 6 | SLASH = "/".freeze 7 | EMPTY = "".freeze 8 | SEGMENT = "([^\\/]+)".freeze 9 | DEFAULT = "text/html; charset=utf-8".freeze 10 | REGEXES = Hash.new { |h, pattern| h[pattern] = /\A\/(#{pattern})(\/|\z)/ } 11 | 12 | class Response 13 | LOCATION = "location".freeze 14 | 15 | module ContentType 16 | HTML = "text/html".freeze # :nodoc: 17 | TEXT = "text/plain".freeze # :nodoc: 18 | JSON = "application/json".freeze # :nodoc: 19 | end 20 | 21 | attr_accessor :status 22 | 23 | attr :body 24 | attr :headers 25 | 26 | def initialize(headers = {}) 27 | @status = nil 28 | @headers = headers 29 | @body = [] 30 | @length = 0 31 | end 32 | 33 | def [](key) 34 | @headers[key] 35 | end 36 | 37 | def []=(key, value) 38 | @headers[key] = value 39 | end 40 | 41 | def write(str) 42 | s = str.to_s 43 | 44 | @length += s.bytesize 45 | @headers[Rack::CONTENT_LENGTH] = @length.to_s 46 | @body << s 47 | end 48 | 49 | # Write response body as text/plain 50 | def text(str) 51 | @headers[Rack::CONTENT_TYPE] = ContentType::TEXT 52 | write(str) 53 | end 54 | 55 | # Write response body as text/html 56 | def html(str) 57 | @headers[Rack::CONTENT_TYPE] = ContentType::HTML 58 | write(str) 59 | end 60 | 61 | # Write response body as application/json 62 | def json(str) 63 | @headers[Rack::CONTENT_TYPE] = ContentType::JSON 64 | write(str) 65 | end 66 | 67 | def redirect(path, status = 302) 68 | @headers[LOCATION] = path 69 | @status = status 70 | end 71 | 72 | def finish 73 | [@status, @headers, @body] 74 | end 75 | 76 | def set_cookie(key, value) 77 | Rack::Utils.set_cookie_header!(@headers, key, value) 78 | end 79 | 80 | def delete_cookie(key, value = {}) 81 | Rack::Utils.delete_cookie_header!(@headers, key, value) 82 | end 83 | end 84 | 85 | def self.reset! 86 | @app = nil 87 | @prototype = nil 88 | end 89 | 90 | def self.app 91 | @app ||= Rack::Builder.new 92 | end 93 | 94 | def self.use(middleware, *args, **kwargs, &block) 95 | app.use(middleware, *args, **kwargs, &block) 96 | end 97 | 98 | def self.define(&block) 99 | app.run new(&block) 100 | end 101 | 102 | def self.prototype 103 | @prototype ||= app.to_app 104 | end 105 | 106 | def self.call(env) 107 | prototype.call(env) 108 | end 109 | 110 | def self.plugin(mixin) 111 | include mixin 112 | extend mixin::ClassMethods if defined?(mixin::ClassMethods) 113 | 114 | mixin.setup(self) if mixin.respond_to?(:setup) 115 | end 116 | 117 | def self.settings 118 | @settings ||= {} 119 | end 120 | 121 | def self.deepclone(obj) 122 | Marshal.load(Marshal.dump(obj)) 123 | end 124 | 125 | def self.inherited(child) 126 | child.settings.replace(deepclone(settings)) 127 | end 128 | 129 | attr :env 130 | attr :req 131 | attr :res 132 | attr :captures 133 | 134 | def initialize(&blk) 135 | @blk = blk 136 | @captures = [] 137 | end 138 | 139 | def settings 140 | self.class.settings 141 | end 142 | 143 | def call(env) 144 | dup.call!(env) 145 | end 146 | 147 | def call!(env) 148 | @env = env 149 | @req = settings[:req].new(env) 150 | @res = settings[:res].new(settings[:default_headers].dup) 151 | 152 | # This `catch` statement will either receive a 153 | # rack response tuple via a `halt`, or will 154 | # fall back to issuing a 404. 155 | # 156 | # When it `catch`es a throw, the return value 157 | # of this whole `call!` method will be the 158 | # rack response tuple, which is exactly what we want. 159 | catch(:halt) do 160 | instance_eval(&@blk) 161 | 162 | not_found 163 | res.finish 164 | end 165 | end 166 | 167 | def session 168 | env["rack.session"] || raise(RuntimeError, 169 | "You're missing a session handler. You can get started " + 170 | "by adding Cuba.use Rack::Session::Cookie") 171 | end 172 | 173 | # The heart of the path / verb / any condition matching. 174 | # 175 | # @example 176 | # 177 | # on get do 178 | # res.write "GET" 179 | # end 180 | # 181 | # on get, "signup" do 182 | # res.write "Signup" 183 | # end 184 | # 185 | # on "user/:id" do |uid| 186 | # res.write "User: #{uid}" 187 | # end 188 | # 189 | # on "styles", extension("css") do |file| 190 | # res.write render("styles/#{file}.sass") 191 | # end 192 | # 193 | def on(*args, &block) 194 | try do 195 | # For every block, we make sure to reset captures so that 196 | # nesting matchers won't mess with each other's captures. 197 | @captures = [] 198 | 199 | # We stop evaluation of this entire matcher unless 200 | # each and every `arg` defined for this matcher evaluates 201 | # to a non-false value. 202 | # 203 | # Short circuit examples: 204 | # on true, false do 205 | # 206 | # # PATH_INFO=/user 207 | # on true, "signup" 208 | return unless args.all? { |arg| match(arg) } 209 | 210 | # The captures we yield here were generated and assembled 211 | # by evaluating each of the `arg`s above. Most of these 212 | # are carried out by #consume. 213 | yield(*captures) 214 | 215 | if res.status.nil? 216 | if res.body.empty? 217 | not_found 218 | else 219 | res.headers[Rack::CONTENT_TYPE] ||= DEFAULT 220 | res.status = 200 221 | end 222 | end 223 | 224 | halt(res.finish) 225 | end 226 | end 227 | 228 | # @private Used internally by #on to ensure that SCRIPT_NAME and 229 | # PATH_INFO are reset to their proper values. 230 | def try 231 | script, path = env[Rack::SCRIPT_NAME], env[Rack::PATH_INFO] 232 | 233 | yield 234 | 235 | ensure 236 | env[Rack::SCRIPT_NAME], env[Rack::PATH_INFO] = script, path 237 | end 238 | private :try 239 | 240 | def consume(pattern) 241 | matchdata = env[Rack::PATH_INFO].match(REGEXES[pattern]) 242 | 243 | return false unless matchdata 244 | 245 | path, *vars = matchdata.captures 246 | 247 | env[Rack::SCRIPT_NAME] += "/#{path}" 248 | env[Rack::PATH_INFO] = "#{vars.pop}#{matchdata.post_match}" 249 | 250 | captures.push(*vars) 251 | end 252 | private :consume 253 | 254 | def match(matcher, segment = SEGMENT) 255 | case matcher 256 | when String then consume(matcher.gsub(/:\w+/, segment)) 257 | when Regexp then consume(matcher) 258 | when Symbol then consume(segment) 259 | when Proc then matcher.call 260 | else 261 | matcher 262 | end 263 | end 264 | 265 | # A matcher for files with a certain extension. 266 | # 267 | # @example 268 | # # PATH_INFO=/style/app.css 269 | # on "style", extension("css") do |file| 270 | # res.write file # writes app 271 | # end 272 | def extension(ext = "\\w+") 273 | lambda { consume("([^\\/]+?)\.#{ext}\\z") } 274 | end 275 | 276 | # Ensures that certain request parameters are present. Acts like a 277 | # precondition / assertion for your route. A default value can be 278 | # provided as a second argument. In that case, it always matches 279 | # and the result is either the parameter or the default value. 280 | # 281 | # @example 282 | # # POST with data like user[fname]=John&user[lname]=Doe 283 | # on "signup", param("user") do |atts| 284 | # User.create(atts) 285 | # end 286 | # 287 | # on "login", param("username", "guest") do |username| 288 | # # If not provided, username == "guest" 289 | # end 290 | def param(key, default = nil) 291 | value = req.params[key.to_s] || default 292 | 293 | lambda { captures << value unless value.to_s.empty? } 294 | end 295 | 296 | # Useful for matching against the request host (i.e. HTTP_HOST). 297 | # 298 | # @example 299 | # on host("account1.example.com"), "api" do 300 | # res.write "You have reached the API of account1." 301 | # end 302 | def host(hostname) 303 | hostname === req.host 304 | end 305 | 306 | # If you want to match against the HTTP_ACCEPT value. 307 | # 308 | # @example 309 | # # HTTP_ACCEPT=application/xml 310 | # on accept("application/xml") do 311 | # # automatically set to application/xml. 312 | # res.write res["Content-Type"] 313 | # end 314 | def accept(mimetype) 315 | lambda do 316 | accept = String(env["HTTP_ACCEPT"]).split(",") 317 | 318 | if accept.any? { |s| s.strip == mimetype } 319 | res[Rack::CONTENT_TYPE] = mimetype 320 | end 321 | end 322 | end 323 | 324 | # Syntactic sugar for providing catch-all matches. 325 | # 326 | # @example 327 | # on default do 328 | # res.write "404" 329 | # end 330 | def default 331 | true 332 | end 333 | 334 | # Access the root of the application. 335 | # 336 | # @example 337 | # 338 | # # GET / 339 | # on root do 340 | # res.write "Home" 341 | # end 342 | def root 343 | env[Rack::PATH_INFO] == SLASH || env[Rack::PATH_INFO] == EMPTY 344 | end 345 | 346 | # Syntatic sugar for providing HTTP Verb matching. 347 | # 348 | # @example 349 | # on get, "signup" do 350 | # end 351 | # 352 | # on post, "signup" do 353 | # end 354 | def get; req.get? end 355 | def post; req.post? end 356 | def put; req.put? end 357 | def patch; req.patch? end 358 | def delete; req.delete? end 359 | def head; req.head? end 360 | def options; req.options? end 361 | def link; req.link? end 362 | def unlink; req.unlink? end 363 | def trace; req.trace? end 364 | 365 | # If you want to halt the processing of an existing handler 366 | # and continue it via a different handler. 367 | # 368 | # @example 369 | # def redirect(*args) 370 | # run Cuba.new { on(default) { res.redirect(*args) }} 371 | # end 372 | # 373 | # on "account" do 374 | # redirect "/login" unless session["uid"] 375 | # 376 | # res.write "Super secure account info." 377 | # end 378 | def run(app) 379 | halt app.call(req.env) 380 | end 381 | 382 | def halt(response) 383 | throw :halt, response 384 | end 385 | 386 | # Adds ability to pass information to a nested Cuba application. 387 | # It receives two parameters: a hash that represents the passed 388 | # information and a block. The #vars method is used to retrieve 389 | # a hash with the passed information. 390 | # 391 | # class Platforms < Cuba 392 | # define do 393 | # platform = vars[:platform] 394 | # 395 | # on default do 396 | # res.write(platform) # => "heroku" or "salesforce" 397 | # end 398 | # end 399 | # end 400 | # 401 | # Cuba.define do 402 | # on "(heroku|salesforce)" do |platform| 403 | # with(platform: platform) do 404 | # run(Platforms) 405 | # end 406 | # end 407 | # end 408 | # 409 | def with(dict = {}) 410 | old, env["cuba.vars"] = vars, vars.merge(dict) 411 | yield 412 | ensure 413 | env["cuba.vars"] = old 414 | end 415 | 416 | # Returns a hash with the information set by the #with method. 417 | # 418 | # with(role: "admin", site: "main") do 419 | # on default do 420 | # res.write(vars.inspect) 421 | # end 422 | # end 423 | # # => '{:role=>"admin", :site=>"main"}' 424 | # 425 | def vars 426 | env["cuba.vars"] ||= {} 427 | end 428 | 429 | def not_found 430 | res.status = 404 431 | end 432 | end 433 | 434 | Cuba.settings[:req] = Rack::Request 435 | Cuba.settings[:res] = Cuba::Response 436 | Cuba.settings[:default_headers] = {} 437 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Cuba 2 | ==== 3 | 4 | _n_. a microframework for web development. 5 | 6 | ![Cuba and Rum, by Jan Sochor](http://farm3.static.flickr.com/2619/4032103097_8324c6fecf.jpg) 7 | 8 | Community 9 | --------- 10 | 11 | Meet us on IRC: [#cuba.rb][irc] on [freenode.net][freenode]. 12 | 13 | [irc]: irc://chat.freenode.net/#cuba.rb 14 | [freenode]: http://freenode.net/ 15 | 16 | Description 17 | ----------- 18 | 19 | Cuba is a microframework for web development originally inspired 20 | by [Rum][rum], a tiny but powerful mapper for [Rack][rack] 21 | applications. 22 | 23 | It integrates many templates via [Tilt][tilt], and testing via 24 | [Cutest][cutest] and [Capybara][capybara]. 25 | 26 | [rum]: http://github.com/chneukirchen/rum 27 | [rack]: http://github.com/rack/rack 28 | [tilt]: http://github.com/rtomayko/tilt 29 | [cutest]: http://github.com/djanowski/cutest 30 | [capybara]: http://github.com/jnicklas/capybara 31 | [rack-test]: https://github.com/brynary/rack-test 32 | 33 | Installation 34 | ------------ 35 | 36 | ``` console 37 | $ gem install cuba 38 | ``` 39 | 40 | Usage 41 | ----- 42 | 43 | Here's a simple application: 44 | 45 | ``` ruby 46 | # cat hello_world.rb 47 | require "cuba" 48 | require "cuba/safe" 49 | 50 | Cuba.use Rack::Session::Cookie, :secret => "__a_very_long_string__" 51 | 52 | Cuba.plugin Cuba::Safe 53 | 54 | Cuba.define do 55 | on get do 56 | on "hello" do 57 | res.write "Hello world!" 58 | end 59 | 60 | on root do 61 | res.redirect "/hello" 62 | end 63 | end 64 | end 65 | ``` 66 | 67 | And the test file: 68 | 69 | ``` ruby 70 | # cat hello_world_test.rb 71 | require "cuba/test" 72 | require "./hello_world" 73 | 74 | scope do 75 | test "Homepage" do 76 | get "/" 77 | 78 | follow_redirect! 79 | 80 | assert_equal "Hello world!", last_response.body 81 | end 82 | end 83 | ``` 84 | 85 | To run it, you can create a `config.ru` file: 86 | 87 | ``` ruby 88 | # cat config.ru 89 | require "./hello_world" 90 | 91 | run Cuba 92 | ``` 93 | 94 | You can now run `rackup` and enjoy what you have just created. 95 | 96 | Matchers 97 | -------- 98 | 99 | Here's an example showcasing how different matchers work: 100 | 101 | ``` ruby 102 | require "cuba" 103 | require "cuba/safe" 104 | 105 | Cuba.use Rack::Session::Cookie, :secret => "__a_very_long_string__" 106 | 107 | Cuba.plugin Cuba::Safe 108 | 109 | Cuba.define do 110 | 111 | # only GET requests 112 | on get do 113 | 114 | # / 115 | on root do 116 | res.write "Home" 117 | end 118 | 119 | # /about 120 | on "about" do 121 | res.write "About" 122 | end 123 | 124 | # /styles/basic.css 125 | on "styles", extension("css") do |file| 126 | res.write "Filename: #{file}" #=> "Filename: basic" 127 | end 128 | 129 | # /post/2011/02/16/hello 130 | on "post/:y/:m/:d/:slug" do |y, m, d, slug| 131 | res.write "#{y}-#{m}-#{d} #{slug}" #=> "2011-02-16 hello" 132 | end 133 | 134 | # /username/foobar 135 | on "username/:username" do |username| 136 | user = User.find_by_username(username) # username == "foobar" 137 | 138 | # /username/foobar/posts 139 | on "posts" do 140 | 141 | # You can access `user` here, because the `on` blocks 142 | # are closures. 143 | res.write "Total Posts: #{user.posts.size}" #=> "Total Posts: 6" 144 | end 145 | 146 | # /username/foobar/following 147 | on "following" do 148 | res.write user.following.size #=> "1301" 149 | end 150 | end 151 | 152 | # /search?q=barbaz 153 | on "search", param("q") do |query| 154 | res.write "Searched for #{query}" #=> "Searched for barbaz" 155 | end 156 | end 157 | 158 | # only POST requests 159 | on post do 160 | on "login" do 161 | 162 | # POST /login, user: foo, pass: baz 163 | on param("user"), param("pass") do |user, pass| 164 | res.write "#{user}:#{pass}" #=> "foo:baz" 165 | end 166 | 167 | # If the params `user` and `pass` are not provided, this 168 | # block will get executed. 169 | on true do 170 | res.write "You need to provide user and pass!" 171 | end 172 | end 173 | end 174 | end 175 | ``` 176 | 177 | Note that once an `on` block matches, processing halts at the conclusion of that block. 178 | 179 | Status codes 180 | ------------ 181 | 182 | If you don't assign a status code and you don't write to the `res` 183 | object, the status will be set as `404`. The method `not_found` is 184 | in charge of setting the proper status code, and you can redefine 185 | it if you want to render a template or configure custom headers. 186 | 187 | For example: 188 | 189 | ``` ruby 190 | Cuba.define do 191 | on get do 192 | on "hello" do 193 | res.write "hello world" 194 | end 195 | end 196 | end 197 | 198 | # Requests: 199 | # 200 | # GET / # 404 201 | # GET /hello # 200 202 | # GET /hello/world # 200 203 | ``` 204 | 205 | As you can see, as soon as something was written to the response, 206 | the status code was changed to 200. 207 | 208 | If you want to match just "hello", but not "hello/world", you can do 209 | as follows: 210 | 211 | ``` ruby 212 | Cuba.define do 213 | on get do 214 | on "hello" do 215 | on root do 216 | res.write "hello world" 217 | end 218 | end 219 | end 220 | end 221 | 222 | # Requests: 223 | # 224 | # GET / # 404 225 | # GET /hello # 200 226 | # GET /hello/world # 404 227 | ``` 228 | 229 | You can also use a regular expression to match the end of line: 230 | 231 | ``` ruby 232 | Cuba.define do 233 | on get do 234 | on /hello\/?\z/ do 235 | res.write "hello world" 236 | end 237 | end 238 | end 239 | 240 | # Requests: 241 | # 242 | # GET / # 404 243 | # GET /hello # 200 244 | # GET /hello/world # 404 245 | ``` 246 | 247 | This last example is not a common usage pattern. It's here only to 248 | illustrate how Cuba can be adapted for different use cases. 249 | 250 | If you need this behavior, you can create a helper: 251 | 252 | ``` ruby 253 | module TerminalMatcher 254 | def terminal(path) 255 | /#{path}\/?\z/ 256 | end 257 | end 258 | 259 | Cuba.plugin TerminalMatcher 260 | 261 | Cuba.define do 262 | on get do 263 | on terminal("hello") do 264 | res.write "hello world" 265 | end 266 | end 267 | end 268 | ``` 269 | 270 | Security 271 | -------- 272 | 273 | The most important security consideration is to use `https` for all 274 | requests. If that's not the case, any attempt to secure the application 275 | could be in vain. The rest of this section assumes `https` is 276 | enforced. 277 | 278 | When building a web application, you need to include a security 279 | layer. Cuba ships with the `Cuba::Safe` plugin, which applies several 280 | security related headers to prevent attacks like clickjacking and 281 | cross-site scripting, among others. It is not included by default 282 | because there are legitimate uses for plain Cuba (for instance, 283 | when designing an API). 284 | 285 | Here's how to include it: 286 | 287 | ```ruby 288 | require "cuba/safe" 289 | 290 | Cuba.plugin Cuba::Safe 291 | ``` 292 | 293 | You should also always set a session secret to some undisclosed 294 | value. Keep in mind that the content in the session cookie is 295 | *not* encrypted. 296 | 297 | ``` ruby 298 | Cuba.use(Rack::Session::Cookie, :secret => "__a_very_long_string__") 299 | ``` 300 | 301 | In the end, your application should look like this: 302 | 303 | ```ruby 304 | require "cuba" 305 | require "cuba/safe" 306 | 307 | Cuba.use Rack::Session::Cookie, :secret => "__a_very_long_string__" 308 | 309 | Cuba.plugin Cuba::Safe 310 | 311 | Cuba.define do 312 | on csrf.unsafe? do 313 | csrf.reset! 314 | 315 | res.status = 403 316 | res.write("Not authorized") 317 | 318 | halt(res.finish) 319 | end 320 | 321 | # Now your app is protected against a wide range of attacks. 322 | ... 323 | end 324 | ``` 325 | 326 | The `Cuba::Safe` plugin is composed of two modules: 327 | 328 | * `Cuba::Safe::SecureHeaders` 329 | * `Cuba::Safe::CSRF` 330 | 331 | You can include them individually, but while the modularity is good 332 | for development, it's very common to use them in tandem. As that's 333 | the normal use case, including `Cuba::Safe` is the preferred way. 334 | 335 | Cross-Site Request Forgery 336 | -------------------------- 337 | 338 | The `Cuba::Safe::CSRF` plugin provides a `csrf` object with the 339 | following methods: 340 | 341 | * `token`: the current security token. 342 | * `reset!`: forces the token to be recreated. 343 | * `safe?`: returns `true` if the request is safe. 344 | * `unsafe?`: returns `true` if the request is unsafe. 345 | * `form_tag`: returns a string with the `csrf_token` hidden input tag. 346 | * `meta_tag`: returns a string with the `csrf_token` meta tag. 347 | 348 | Here's an example of how to use it: 349 | 350 | ```ruby 351 | require "cuba" 352 | require "cuba/safe" 353 | 354 | Cuba.use Rack::Session::Cookie, :secret => "__a_very_long_string__" 355 | 356 | Cuba.plugin Cuba::Safe 357 | 358 | Cuba.define do 359 | on csrf.unsafe? do 360 | csrf.reset! 361 | 362 | res.status = 403 363 | res.write("Forbidden") 364 | 365 | halt(res.finish) 366 | end 367 | 368 | # Here comes the rest of your application 369 | # ... 370 | end 371 | ``` 372 | 373 | You have to include `csrf.form_tag` in your forms and `csrf.meta_tag` 374 | among your meta tags. Here's an example that assumes you are using 375 | `Cuba::Mote` from `cuba-contrib`: 376 | 377 | ```html 378 | 379 | 380 | 381 | {{ app.csrf.meta_tag }} 382 | ... 383 | 384 | ... 385 | 386 |
387 | {{ app.csrf.form_tag }} 388 | ... 389 |
390 | ... 391 | 392 | 393 | ``` 394 | 395 | HTTP Verbs 396 | ---------- 397 | 398 | There are matchers defined for the following HTTP Verbs: `get`, 399 | `post`, `put`, `patch`, `delete`, `head`, `options`, `link`, `unlink` 400 | and `trace`. As you have the whole request available via the `req` 401 | object, you can also query it with helper methods like `req.options?` 402 | or `req.head?`, or you can even go to a lower level and inspect the 403 | environment via the `env` object, and check for example if 404 | `env["REQUEST_METHOD"]` equals the verb `PATCH`. 405 | 406 | What follows is an example of different ways of saying the same thing: 407 | 408 | ``` ruby 409 | on env["REQUEST_METHOD"] == "GET", "api" do ... end 410 | 411 | on req.get?, "api" do ... end 412 | 413 | on get, "api" do ... end 414 | ``` 415 | 416 | Actually, `get` is syntax sugar for `req.get?`, which in turn is syntax sugar 417 | for `env["REQUEST_METHOD"] == "GET"`. 418 | 419 | Headers 420 | ------- 421 | 422 | You can set the headers by assigning values to the hash `req.headers`. 423 | If you want to inspect the incoming headers, you have to read from 424 | the `env` hash. For example, if you want to know the referrer you 425 | can check `env["HTTP_REFERER"]`. 426 | 427 | Request and Response 428 | -------------------- 429 | 430 | You may have noticed we use `req` and `res` a lot. Those variables are 431 | instances of [Rack::Request][request] and `Cuba::Response` respectively, and 432 | `Cuba::Response` is just an optimized version of 433 | [Rack::Response][response]. 434 | 435 | [request]: http://www.rubydoc.info/github/rack/rack/Rack/Request 436 | [response]: http://www.rubydoc.info/github/rack/rack/Rack/Response 437 | 438 | Those objects are helpers for accessing the request and for building 439 | the response. Most of the time, you will just use `res.write`. 440 | 441 | If you want to use custom `Request` or `Response` objects, you can 442 | set the new values as follows: 443 | 444 | ``` ruby 445 | Cuba.settings[:req] = MyRequest 446 | Cuba.settings[:res] = MyResponse 447 | ``` 448 | 449 | Make sure to provide classes compatible with those from Rack. 450 | 451 | Captures 452 | -------- 453 | 454 | You may have noticed that some matchers yield a value to the block. The rules 455 | for determining if a matcher will yield a value are simple: 456 | 457 | 1. Regex captures: `"posts/(\\d+)-(.*)"` will yield two values, corresponding to each capture. 458 | 2. Placeholders: `"users/:id"` will yield the value in the position of :id. 459 | 3. Symbols: `:foobar` will yield if a segment is available. 460 | 4. File extensions: `extension("css")` will yield the basename of the matched file. 461 | 5. Parameters: `param("user")` will yield the value of the parameter user, if present. 462 | 463 | The first case is important because it shows the underlying effect of regex 464 | captures. 465 | 466 | In the second case, the substring `:id` gets replaced by `([^\\/]+)` and the 467 | string becomes `"users/([^\\/]+)"` before performing the match, thus it reverts 468 | to the first form we saw. 469 | 470 | In the third case, the symbol--no matter what it says--gets replaced 471 | by `"([^\\/]+)"`, and again we are in presence of case 1. 472 | 473 | The fourth case, again, reverts to the basic matcher: it generates the string 474 | `"([^\\/]+?)\.#{ext}\\z"` before performing the match. 475 | 476 | The fifth case is different: it checks if the the parameter supplied is present 477 | in the request (via POST or QUERY_STRING) and it pushes the value as a capture. 478 | 479 | Composition 480 | ----------- 481 | 482 | You can mount a Cuba app, along with middlewares, inside another Cuba app: 483 | 484 | ``` ruby 485 | class API < Cuba; end 486 | 487 | API.use SomeMiddleware 488 | 489 | API.define do 490 | on param("url") do |url| 491 | ... 492 | end 493 | end 494 | 495 | Cuba.define do 496 | on "api" do 497 | run API 498 | end 499 | end 500 | ``` 501 | 502 | If you need to pass information to one sub-app, you can use the 503 | `with` method and access it with `vars`: 504 | 505 | ```ruby 506 | class Platforms < Cuba 507 | define do 508 | platform = vars[:platform] 509 | 510 | on default do 511 | res.write(platform) # => "heroku" or "salesforce" 512 | end 513 | end 514 | end 515 | 516 | Cuba.define do 517 | on "(heroku|salesforce)" do |platform| 518 | with(platform: platform) do 519 | run(Platforms) 520 | end 521 | end 522 | end 523 | ``` 524 | 525 | ## Embedding routes from other modules 526 | 527 | While the `run` command allows you to handle over the control to a 528 | sub app, sometimes you may want to just embed routes defined in 529 | another module. There's no built-in method to do it, but if you are 530 | willing to experiment you can try the following. 531 | 532 | Let's say you have defined routes in modules `A` and `B`, and you 533 | want to mount those routes in your application. 534 | 535 | First, you will have to extend Cuba with this code: 536 | 537 | ```ruby 538 | class Cuba 539 | def mount(app) 540 | result = app.call(req.env) 541 | halt result if result[0] != 404 542 | end 543 | end 544 | ``` 545 | 546 | It doesn't matter where you define it as long as Cuba has already 547 | been required. For instance, you could extract that to a plugin and 548 | it would work just fine. 549 | 550 | Then, in your application, you can use it like this: 551 | 552 | ```ruby 553 | Cuba.define do 554 | on default do 555 | mount A 556 | mount B 557 | end 558 | end 559 | ``` 560 | 561 | It should halt the request only if the resulting status from calling 562 | the mounted app is not 404. If you run into some unexpected behavior, 563 | let me know by creating an issue and we'll look at how to workaround 564 | any difficulties. 565 | 566 | Testing 567 | ------- 568 | 569 | Given that Cuba is essentially Rack, it is very easy to test with 570 | `Rack::Test`, `Webrat` or `Capybara`. Cuba's own tests are written 571 | with a combination of [Cutest][cutest] and [Rack::Test][rack-test], 572 | and if you want to use the same for your tests it is as easy as 573 | requiring `cuba/test`: 574 | 575 | ``` ruby 576 | require "cuba/test" 577 | require "your/app" 578 | 579 | scope do 580 | test "Homepage" do 581 | get "/" 582 | 583 | assert_equal "Hello world!", last_response.body 584 | end 585 | end 586 | ``` 587 | 588 | If you prefer to use [Capybara][capybara], instead of requiring 589 | `cuba/test` you can require `cuba/capybara`: 590 | 591 | ``` ruby 592 | require "cuba/capybara" 593 | require "your/app" 594 | 595 | scope do 596 | test "Homepage" do 597 | visit "/" 598 | 599 | assert has_content?("Hello world!") 600 | end 601 | end 602 | ``` 603 | 604 | To read more about testing, check the documentation for 605 | [Cutest][cutest], [Rack::Test][rack-test] and [Capybara][capybara]. 606 | 607 | Settings 608 | -------- 609 | 610 | Each Cuba app can store settings in the `Cuba.settings` hash. The settings are 611 | inherited if you happen to subclass `Cuba` 612 | 613 | ``` ruby 614 | Cuba.settings[:layout] = "guest" 615 | 616 | class Users < Cuba; end 617 | class Admin < Cuba; end 618 | 619 | Admin.settings[:layout] = "admin" 620 | 621 | assert_equal "guest", Users.settings[:layout] 622 | assert_equal "admin", Admin.settings[:layout] 623 | ``` 624 | 625 | Feel free to store whatever you find convenient. 626 | 627 | Rendering 628 | --------- 629 | 630 | Cuba includes a plugin called `Cuba::Render` that provides a couple of helper 631 | methods for rendering templates. This plugin uses [Tilt][tilt], which serves as 632 | an interface to a bunch of different Ruby template engines (ERB, Haml, Sass, 633 | CoffeeScript, etc.), so you can use the template engine of your choice. 634 | 635 | To set up `Cuba::Render`, do: 636 | 637 | ```ruby 638 | require "cuba" 639 | require "cuba/render" 640 | require "erb" 641 | 642 | Cuba.plugin Cuba::Render 643 | ``` 644 | 645 | This example uses ERB, a template engine that comes with Ruby. If you want to 646 | use another template engine, one [supported by Tilt][templates], you need to 647 | install the required gem and change the `template_engine` setting as shown 648 | below. 649 | 650 | ```ruby 651 | Cuba.settings[:render][:template_engine] = "haml" 652 | ``` 653 | 654 | The plugin provides three helper methods for rendering templates: `partial`, 655 | `view` and `render`. 656 | 657 | ```ruby 658 | Cuba.define do 659 | on "about" do 660 | # `partial` renders a template called `about.erb` without a layout. 661 | res.write partial("about") 662 | end 663 | 664 | on "home" do 665 | # Opposed to `partial`, `view` renders the same template 666 | # within a layout called `layout.erb`. 667 | res.write view("about") 668 | end 669 | 670 | on "contact" do 671 | # `render` is a shortcut to `res.write view(...)` 672 | render("contact") 673 | end 674 | end 675 | ``` 676 | 677 | By default, `Cuba::Render` assumes that all templates are placed in a folder 678 | named `views` and that they use the proper extension for the chosen template 679 | engine. Also for the `view` and `render` methods, it assumes that the layout 680 | template is called `layout`. 681 | 682 | The defaults can be changed through the `Cuba.settings` method: 683 | 684 | ```ruby 685 | Cuba.settings[:render][:template_engine] = "haml" 686 | Cuba.settings[:render][:views] = "./views/admin/" 687 | Cuba.settings[:render][:layout] = "admin" 688 | ``` 689 | 690 | NOTE: Cuba doesn't ship with Tilt. You need to install it (`gem install tilt`). 691 | 692 | [templates]: https://github.com/rtomayko/tilt/blob/master/docs/TEMPLATES.md 693 | 694 | Plugins 695 | ------- 696 | 697 | Cuba provides a way to extend its functionality with plugins. 698 | 699 | ### How to create plugins 700 | 701 | Authoring your own plugins is pretty straightforward. 702 | 703 | ``` ruby 704 | module MyOwnHelper 705 | def markdown(str) 706 | BlueCloth.new(str).to_html 707 | end 708 | end 709 | 710 | Cuba.plugin MyOwnHelper 711 | ``` 712 | 713 | That's the simplest kind of plugin you'll write. In fact, that's exactly how 714 | the `markdown` helper is written in `Cuba::TextHelpers`. 715 | 716 | A more complicated plugin can make use of `Cuba.settings` to provide default 717 | values. In the following example, note that if the module has a `setup` method, it will 718 | be called as soon as it is included: 719 | 720 | ``` ruby 721 | module Render 722 | def self.setup(app) 723 | app.settings[:template_engine] = "erb" 724 | end 725 | 726 | def partial(template, locals = {}) 727 | render("#{template}.#{settings[:template_engine]}", locals) 728 | end 729 | end 730 | 731 | Cuba.plugin Render 732 | ``` 733 | 734 | This sample plugin actually resembles how `Cuba::Render` works. 735 | 736 | Finally, if a module called `ClassMethods` is present, `Cuba` will be extended 737 | with it. 738 | 739 | ``` ruby 740 | module GetSetter 741 | module ClassMethods 742 | def set(key, value) 743 | settings[key] = value 744 | end 745 | 746 | def get(key) 747 | settings[key] 748 | end 749 | end 750 | end 751 | 752 | Cuba.plugin GetSetter 753 | 754 | Cuba.set(:foo, "bar") 755 | 756 | assert_equal "bar", Cuba.get(:foo) 757 | assert_equal "bar", Cuba.settings[:foo] 758 | ``` 759 | 760 | Contributing 761 | ------------ 762 | 763 | A good first step is to meet us on IRC and discuss ideas. If that's 764 | not possible, you can create an issue explaining the proposed change 765 | and a use case. We pay a lot of attention to use cases, because our 766 | goal is to keep the code base simple. In many cases, the result of 767 | a conversation will be the creation of another tool, instead of the 768 | modification of Cuba itself. 769 | 770 | If you want to test Cuba, you may want to use a gemset to isolate 771 | the requirements. We recommend the use of tools like [dep][dep] and 772 | [gs][gs], but you can use similar tools like [gst][gst] or [bs][bs]. 773 | 774 | The required gems for testing and development are listed in the 775 | `.gems` file. If you are using [dep][dep], you can create a gemset 776 | and run `dep install`. 777 | 778 | [dep]: http://cyx.github.io/dep/ 779 | [gs]: http://soveran.github.io/gs/ 780 | [gst]: https://github.com/tonchis/gst 781 | [bs]: https://github.com/educabilia/bs 782 | --------------------------------------------------------------------------------