# frozen-string-literal: true
require "rack"
require "thread"
require "roda/version"
# The main class for Roda. Roda is built completely out of plugins, with the
# default plugin being Roda::RodaPlugins::Base, so this class is mostly empty
# except for some constants.
class Roda
# Error class raised by Roda
class RodaError < StandardError; end
# A thread safe cache class, offering only #[] and #[]= methods,
# each protected by a mutex.
class RodaCache
# Create a new thread safe cache.
def initialize
@mutex = Mutex.new
@hash = {}
end
# Make getting value from underlying hash thread safe.
def [](key)
@mutex.synchronize{@hash[key]}
end
# Make setting value in underlying hash thread safe.
def []=(key, value)
@mutex.synchronize{@hash[key] = value}
end
end
# Base class used for Roda requests. The instance methods for this
# class are added by Roda::RodaPlugins::Base::RequestMethods, the
# class methods are added by Roda::RodaPlugins::Base::RequestClassMethods.
class RodaRequest < ::Rack::Request
@roda_class = ::Roda
@match_pattern_cache = ::Roda::RodaCache.new
end
# Base class used for Roda responses. The instance methods for this
# class are added by Roda::RodaPlugins::Base::ResponseMethods, the class
# methods are added by Roda::RodaPlugins::Base::ResponseClassMethods.
class RodaResponse
@roda_class = ::Roda
end
@app = nil
@inherit_middleware = true
@middleware = []
@opts = {}
@route_block = nil
# Module in which all Roda plugins should be stored. Also contains logic for
# registering and loading plugins.
module RodaPlugins
# Stores registered plugins
@plugins = RodaCache.new
# If the registered plugin already exists, use it. Otherwise,
# require it and return it. This raises a LoadError if such a
# plugin doesn't exist, or a RodaError if it exists but it does
# not register itself correctly.
def self.load_plugin(name)
h = @plugins
unless plugin = h[name]
require "roda/plugins/#{name}"
raise RodaError, "Plugin #{name} did not register itself correctly in Roda::RodaPlugins" unless plugin = h[name]
end
plugin
end
# Register the given plugin with Roda, so that it can be loaded using #plugin
# with a symbol. Should be used by plugin files. Example:
#
# Roda::RodaPlugins.register_plugin(:plugin_name, PluginModule)
def self.register_plugin(name, mod)
@plugins[name] = mod
end
# The base plugin for Roda, implementing all default functionality.
# Methods are put into a plugin so future plugins can easily override
# them and call super to get the default behavior.
module Base
# Class methods for the Roda class.
module ClassMethods
# The rack application that this class uses.
attr_reader :app
# Whether middleware from the current class should be inherited by subclasses.
# True by default, should be set to false when using a design where the parent
# class accepts requests and uses run to dispatch the request to a subclass.
attr_accessor :inherit_middleware
# The settings/options hash for the current class.
attr_reader :opts
# The route block that this class uses.
attr_reader :route_block
# Call the internal rack application with the given environment.
# This allows the class itself to be used as a rack application.
# However, for performance, it's better to use #app to get direct
# access to the underlying rack app.
def call(env)
app.call(env)
end
# Clear the middleware stack
def clear_middleware!
@middleware.clear
build_rack_app
end
# Freeze the internal state of the class, to avoid thread safety issues at runtime.
# It's optional to call this method, as nothing should be modifying the
# internal state at runtime anyway, but this makes sure an exception will
# be raised if you try to modify the internal state after calling this.
#
# Note that freezing the class prevents you from subclassing it, mostly because
# it would cause some plugins to break.
def freeze
@opts.freeze
@middleware.freeze
super
end
# When inheriting Roda, copy the shared data into the subclass,
# and setup the request and response subclasses.
def inherited(subclass)
raise RodaError, "Cannot subclass a frozen Roda class" if frozen?
super
subclass.instance_variable_set(:@inherit_middleware, @inherit_middleware)
subclass.instance_variable_set(:@middleware, @inherit_middleware ? @middleware.dup : [])
subclass.instance_variable_set(:@opts, opts.dup)
subclass.opts.to_a.each do |k,v|
if (v.is_a?(Array) || v.is_a?(Hash)) && !v.frozen?
subclass.opts[k] = v.dup
end
end
subclass.instance_variable_set(:@route_block, @route_block)
subclass.send(:build_rack_app)
request_class = Class.new(self::RodaRequest)
request_class.roda_class = subclass
request_class.match_pattern_cache = thread_safe_cache
subclass.const_set(:RodaRequest, request_class)
response_class = Class.new(self::RodaResponse)
response_class.roda_class = subclass
subclass.const_set(:RodaResponse, response_class)
end
# Load a new plugin into the current class. A plugin can be a module
# which is used directly, or a symbol represented a registered plugin
# which will be required and then used. Returns nil.
#
# Roda.plugin PluginModule
# Roda.plugin :csrf
def plugin(plugin, *args, &block)
raise RodaError, "Cannot add a plugin to a frozen Roda class" if frozen?
plugin = RodaPlugins.load_plugin(plugin) if plugin.is_a?(Symbol)
plugin.load_dependencies(self, *args, &block) if plugin.respond_to?(:load_dependencies)
include(plugin::InstanceMethods) if defined?(plugin::InstanceMethods)
extend(plugin::ClassMethods) if defined?(plugin::ClassMethods)
self::RodaRequest.send(:include, plugin::RequestMethods) if defined?(plugin::RequestMethods)
self::RodaRequest.extend(plugin::RequestClassMethods) if defined?(plugin::RequestClassMethods)
self::RodaResponse.send(:include, plugin::ResponseMethods) if defined?(plugin::ResponseMethods)
self::RodaResponse.extend(plugin::ResponseClassMethods) if defined?(plugin::ResponseClassMethods)
plugin.configure(self, *args, &block) if plugin.respond_to?(:configure)
nil
end
# Setup routing tree for the current Roda application, and build the
# underlying rack application using the stored middleware. Requires
# a block, which is yielded the request. By convention, the block
# argument should be named +r+. Example:
#
# Roda.route do |r|
# r.root do
# "Root"
# end
# end
#
# This should only be called once per class, and if called multiple
# times will overwrite the previous routing.
def route(&block)
@route_block = block
build_rack_app
end
# A new thread safe cache instance. This is a method so it can be
# easily overridden for alternative implementations.
def thread_safe_cache
RodaCache.new
end
# Add a middleware to use for the rack application. Must be
# called before calling #route to have an effect. Example:
#
# Roda.use Rack::Session::Cookie, :secret=>ENV['secret']
def use(*args, &block)
@middleware << [args, block].freeze
build_rack_app
end
private
# Build the rack app to use
def build_rack_app
if block = @route_block
builder = Rack::Builder.new
@middleware.each{|a, b| builder.use(*a, &b)}
builder.run lambda{|env| new(env).call(&block)}
@app = builder.to_app
end
end
end
# Instance methods for the Roda class.
#
# In addition to the listed methods, the following two methods are available:
#
# request :: The instance of the request class related to this request.
# This is the same object yielded by Roda.route.
# response :: The instance of the response class related to this request.
module InstanceMethods
# Create a request and response of the appropriate class
def initialize(env)
klass = self.class
@_request = klass::RodaRequest.new(self, env)
@_response = klass::RodaResponse.new
end
# instance_exec the route block in the scope of the
# receiver, with the related request. Catch :halt so that
# the route block can throw :halt at any point with the
# rack response to use.
def call(&block)
catch(:halt) do
r = @_request
r.block_result(instance_exec(r, &block))
@_response.finish
end
end
# The environment hash for the current request. Example:
#
# env['REQUEST_METHOD'] # => 'GET'
def env
@_request.env
end
# The class-level options hash. This should probably not be
# modified at the instance level. Example:
#
# Roda.plugin :render
# Roda.route do |r|
# opts[:render_opts].inspect
# end
def opts
self.class.opts
end
attr_reader :_request # :nodoc:
alias request _request
remove_method :_request
attr_reader :_response # :nodoc:
alias response _response
remove_method :_response
# The session hash for the current request. Raises RodaError
# if no session exists. Example:
#
# session # => {}
def session
@_request.session
end
end
# Class methods for RodaRequest
module RequestClassMethods
# Reference to the Roda class related to this request class.
attr_accessor :roda_class
# The cache to use for match patterns for this request class.
attr_accessor :match_pattern_cache
# Return the cached pattern for the given object. If the object is
# not already cached, yield to get the basic pattern, and convert the
# basic pattern to a pattern that does not partial segments.
def cached_matcher(obj)
cache = @match_pattern_cache
unless pattern = cache[obj]
pattern = cache[obj] = consume_pattern(yield)
end
pattern
end
# Since RodaRequest is anonymously subclassed when Roda is subclassed,
# and then assigned to a constant of the Roda subclass, make inspect
# reflect the likely name for the class.
def inspect
"#{roda_class.inspect}::RodaRequest"
end
private
# The pattern to use for consuming, based on the given argument. The returned
# pattern requires the path starts with a string and does not match partial
# segments.
def consume_pattern(pattern)
/\A\/(?:#{pattern})(?=\/|\z)/
end
end
# Instance methods for RodaRequest, mostly related to handling routing
# for the request.
module RequestMethods
PATH_INFO = "PATH_INFO".freeze
SCRIPT_NAME = "SCRIPT_NAME".freeze
REQUEST_METHOD = "REQUEST_METHOD".freeze
EMPTY_STRING = "".freeze
SLASH = "/".freeze
COLON = ":".freeze
SEGMENT = "([^\\/]+)".freeze
TERM_INSPECT = "TERM".freeze
GET_REQUEST_METHOD = 'GET'.freeze
SESSION_KEY = 'rack.session'.freeze
TERM = Object.new
def TERM.inspect
TERM_INSPECT
end
TERM.freeze
# The current captures for the request. This gets modified as routing
# occurs.
attr_reader :captures
# The Roda instance related to this request object. Useful if routing
# methods need access to the scope of the Roda route block.
attr_reader :scope
# Store the roda instance and environment.
def initialize(scope, env)
@scope = scope
@captures = []
@remaining_path = env[PATH_INFO]
super(env)
end
# Handle match block return values. By default, if a string is given
# and the response is empty, use the string as the response body.
def block_result(result)
res = response
if res.empty? && (body = block_result_body(result))
res.write(body)
end
end
# Match GET requests. If no arguments are provided, matches all GET
# requests, otherwise, matches only GET requests where the arguments
# given fully consume the path.
def get(*args, &block)
_verb(args, &block) if is_get?
end
# Immediately stop execution of the route block and return the given
# rack response array of status, headers, and body. If no argument
# is given, uses the current response.
#
# r.halt [200, {'Content-Type'=>'text/html'}, ['Hello World!']]
#
# response.status = 200
# response['Content-Type'] = 'text/html'
# response.write 'Hello World!'
# r.halt
def halt(res=response.finish)
throw :halt, res
end
# Show information about current request, including request class,
# request method and full path.
#
# r.inspect
# # => '#<Roda::RodaRequest GET /foo/bar>'
def inspect
"#<#{self.class.inspect} #{@env[REQUEST_METHOD]} #{path}>"
end
# Does a terminal match on the current path, matching only if the arguments
# have fully matched the path. If it matches, the match block is
# executed, and when the match block returns, the rack response is
# returned.
#
# r.remaining_path
# # => "/foo/bar"
#
# r.is 'foo' do
# # does not match, as path isn't fully matched (/bar remaining)
# end
#
# r.is 'foo/bar' do
# # matches as path is empty after matching
# end
#
# If no arguments are given, matches if the path is already fully matched.
#
# r.on 'foo/bar' do
# r.is do
# # matches as path is already empty
# end
# end
#
# Note that this matches only if the path after matching the arguments
# is empty, not if it still contains a trailing slash:
#
# r.remaining_path
# # => "/foo/bar/"
#
# r.is 'foo/bar' do
# # does not match, as path isn't fully matched (/ remaining)
# end
#
# r.is 'foo/bar/' do
# # matches as path is empty after matching
# end
#
# r.on 'foo/bar' do
# r.is "" do
# # matches as path is empty after matching
# end
# end
def is(*args, &block)
if args.empty?
if empty_path?
always(&block)
end
else
args << TERM
if_match(args, &block)
end
end
# Optimized method for whether this request is a +GET+ request.
# Similar to the default Rack::Request get? method, but can be
# overridden without changing rack's behavior.
def is_get?
@env[REQUEST_METHOD] == GET_REQUEST_METHOD
end
# Does a match on the path, matching only if the arguments
# have matched the path. Because this doesn't fully match the
# path, this is usually used to setup branches of the routing tree,
# not for final handling of the request.
#
# r.remaining_path
# # => "/foo/bar"
#
# r.on 'foo' do
# # matches, path is /bar after matching
# end
#
# r.on 'bar' do
# # does not match
# end
#
# Like other routing methods, If it matches, the match block is
# executed, and when the match block returns, the rack response is
# returned. However, in general you will call another routing method
# inside the match block that fully matches the path and does the
# final handling for the request:
#
# r.on 'foo' do
# r.is 'bar' do
# # handle /foo/bar request
# end
# end
def on(*args, &block)
if args.empty?
always(&block)
else
if_match(args, &block)
end
end
# The already matched part of the path, including the original SCRIPT_NAME.
def matched_path
e = @env
e[SCRIPT_NAME] + e[PATH_INFO].chomp(@remaining_path)
end
# This an an optimized version of Rack::Request#path.
#
# r.env['SCRIPT_NAME'] = '/foo'
# r.env['PATH_INFO'] = '/bar'
# r.path
# # => '/foo/bar'
def path
e = @env
"#{e[SCRIPT_NAME]}#{e[PATH_INFO]}"
end
# The current path to match requests against.
attr_reader :remaining_path
# Match POST requests. If no arguments are provided, matches all POST
# requests, otherwise, matches only POST requests where the arguments
# given fully consume the path.
def post(*args, &block)
_verb(args, &block) if post?
end
# Immediately redirect to the path using the status code. This ends
# the processing of the request:
#
# r.redirect '/page1', 301 if r['param'] == 'value1'
# r.redirect '/page2' # uses 302 status code
# response.status = 404 # not reached
#
# If you do not provide a path, by default it will redirect to the same
# path if the request is not a +GET+ request. This is designed to make
# it easy to use where a +POST+ request to a URL changes state, +GET+
# returns the current state, and you want to show the current state
# after changing:
#
# r.is "foo" do
# r.get do
# # show state
# end
#
# r.post do
# # change state
# r.redirect
# end
# end
def redirect(path=default_redirect_path, status=default_redirect_status)
response.redirect(path, status)
throw :halt, response.finish
end
# The response related to the current request. See ResponseMethods for
# instance methods for the response, but in general the most common usage
# is to override the response status and headers:
#
# response.status = 200
# response['Header-Name'] = 'Header value'
def response
@scope.response
end
# Return the Roda class related to this request.
def roda_class
self.class.roda_class
end
# Routing matches that only matches +GET+ requests where the current
# path is +/+. If it matches, the match block is executed, and when
# the match block returns, the rack response is returned.
#
# [r.request_method, r.remaining_path]
# # => ['GET', '/']
#
# r.root do
# # matches
# end
#
# This is usuable inside other match blocks:
#
# [r.request_method, r.remaining_path]
# # => ['GET', '/foo/']
#
# r.on 'foo' do
# r.root do
# # matches
# end
# end
#
# Note that this does not match non-+GET+ requests:
#
# [r.request_method, r.remaining_path]
# # => ['POST', '/']
#
# r.root do
# # does not match
# end
#
# Use <tt>r.post ""</tt> for +POST+ requests where the current path
# is +/+.
#
# Nor does it match empty paths:
#
# [r.request_method, r.remaining_path]
# # => ['GET', '/foo']
#
# r.on 'foo' do
# r.root do
# # does not match
# end
# end
#
# Use <tt>r.get true</tt> to handle +GET+ requests where the current
# path is empty.
def root(&block)
if remaining_path == SLASH && is_get?
always(&block)
end
end
# Call the given rack app with the environment and return the response
# from the rack app as the response for this request. This ends
# the processing of the request:
#
# r.run(proc{[403, {}, []]}) unless r['letmein'] == '1'
# r.run(proc{[404, {}, []]})
# response.status = 404 # not reached
#
# This updates SCRIPT_NAME/PATH_INFO based on the current remaining_path
# before dispatching to another rack app, so the app still works as
# a URL mapper.
def run(app)
e = @env
path = @remaining_path
sn = SCRIPT_NAME
pi = PATH_INFO
script_name = e[sn]
path_info = e[pi]
begin
e[sn] += path_info.chomp(path)
e[pi] = path
throw :halt, app.call(e)
ensure
e[sn] = script_name
e[pi] = path_info
end
end
# The session for the current request. Raises a RodaError if
# a session handler has not been loaded.
def session
@env[SESSION_KEY] || raise(RodaError, "You're missing a session handler. You can get started by adding use Rack::Session::Cookie")
end
private
# Match any of the elements in the given array. Return at the
# first match without evaluating future matches. Returns false
# if no elements in the array match.
def _match_array(matcher)
matcher.any? do |m|
if matched = match(m)
if m.is_a?(String)
@captures.push(m)
end
end
matched
end
end
# Match the given hash if all hash matchers match.
def _match_hash(hash)
hash.all?{|k,v| send("match_#{k}", v)}
end
# Match the given regexp exactly if it matches a full segment.
def _match_regexp(re)
consume(self.class.cached_matcher(re){re})
end
# Match the given string to the request path. Regexp escapes the
# string so that regexp metacharacters are not matched, and recognizes
# colon tokens for placeholders.
def _match_string(str)
if str.index(COLON)
consume(self.class.cached_matcher(str){Regexp.escape(str).gsub(/:(\w+)/){|m| _match_symbol_regexp($1)}})
else
rp = @remaining_path
if rp.start_with?("/#{str}")
last = str.length + 1
case rp[last]
when SLASH
@remaining_path = rp[last, rp.length]
when nil
@remaining_path = EMPTY_STRING
when Integer
# :nocov:
# Ruby 1.8 support
if rp[last].chr == SLASH
@remaining_path = rp[last, rp.length]
end
# :nocov:
end
end
end
end
# Match the given symbol if any segment matches.
def _match_symbol(sym)
rp = @remaining_path
if rp[0, 1] == SLASH
if last = rp.index('/', 1)
if last > 1
@captures << rp[1, last-1]
@remaining_path = rp[last, rp.length]
end
elsif rp.length > 1
@captures << rp[1,rp.length]
@remaining_path = EMPTY_STRING
end
end
end
# The regular expression to use for matching symbols. By default, any non-empty
# segment matches.
def _match_symbol_regexp(s)
SEGMENT
end
# Backbone of the verb method support, using a terminal match if
# args is not empty, or a regular match if it is empty.
def _verb(args, &block)
if args.empty?
always(&block)
else
args << TERM
if_match(args, &block)
end
end
# Yield to the match block and return rack response after the block returns.
def always
block_result(yield)
throw :halt, response.finish
end
# The body to use for the response if the response does not return
# a body. By default, a String is returned directly, and nil is
# returned otherwise.
def block_result_body(result)
if result.is_a?(String)
result
end
end
# Attempts to match the pattern to the current path. If there is no
# match, returns false without changes. Otherwise, modifies
# SCRIPT_NAME to include the matched path, removes the matched
# path from PATH_INFO, and updates captures with any regex captures.
def consume(pattern)
if matchdata = remaining_path.match(pattern)
@remaining_path = matchdata.post_match
@captures.concat(matchdata.captures)
end
end
# The default path to use for redirects when a path is not given.
# For non-GET requests, redirects to the current path, which will
# trigger a GET request. This is to make the common case where
# a POST request will redirect to a GET request at the same location
# will work fine.
#
# If the current request is a GET request, raise an error, as otherwise
# it is easy to create an infinite redirect.
def default_redirect_path
raise RodaError, "must provide path argument to redirect for get requests" if is_get?
path
end
# The default status to use for redirects if a status is not provided,
# 302 by default.
def default_redirect_status
302
end
# Whether the current path is considered empty.
def empty_path?
remaining_path == EMPTY_STRING
end
# If all of the arguments match, yields to the match block and
# returns the rack response when the block returns. If any of
# the match arguments doesn't match, does nothing.
def if_match(args)
path = @remaining_path
# For every block, we make sure to reset captures so that
# nesting matchers won't mess with each other's captures.
@captures.clear
if match_all(args)
block_result(yield(*captures))
throw :halt, response.finish
else
@remaining_path = path
false
end
end
# Attempt to match the argument to the given request, handling
# common ruby types.
def match(matcher)
case matcher
when String
_match_string(matcher)
when Symbol
_match_symbol(matcher)
when TERM
empty_path?
when Regexp
_match_regexp(matcher)
when Hash
_match_hash(matcher)
when Array
_match_array(matcher)
when Proc
matcher.call
else
matcher
end
end
# Match only if all of the arguments in the given array match.
def match_all(args)
args.all?{|arg| match(arg)}
end
# Match by request method. This can be an array if you want
# to match on multiple methods.
def match_method(type)
if type.is_a?(Array)
type.any?{|t| match_method(t)}
else
type.to_s.upcase == @env[REQUEST_METHOD]
end
end
end
# Class methods for RodaResponse
module ResponseClassMethods
# Reference to the Roda class related to this response class.
attr_accessor :roda_class
# Since RodaResponse is anonymously subclassed when Roda is subclassed,
# and then assigned to a constant of the Roda subclass, make inspect
# reflect the likely name for the class.
def inspect
"#{roda_class.inspect}::RodaResponse"
end
end
# Instance methods for RodaResponse
module ResponseMethods
CONTENT_LENGTH = "Content-Length".freeze
DEFAULT_HEADERS = {"Content-Type" => "text/html".freeze}.freeze
LOCATION = "Location".freeze
# The body for the current response.
attr_reader :body
# The hash of response headers for the current response.
attr_reader :headers
# The status code to use for the response. If none is given, will use 200
# code for non-empty responses and a 404 code for empty responses.
attr_accessor :status
# Set the default headers when creating a response.
def initialize
@status = nil
@headers = {}
@body = []
@length = 0
end
# Return the response header with the given key. Example:
#
# response['Content-Type'] # => 'text/html'
def [](key)
@headers[key]
end
# Set the response header with the given key to the given value.
#
# response['Content-Type'] = 'application/json'
def []=(key, value)
@headers[key] = value
end
# The default headers to use for responses.
def default_headers
DEFAULT_HEADERS
end
# Whether the response body has been written to yet. Note
# that writing an empty string to the response body marks
# the response as not empty. Example:
#
# response.empty? # => true
# response.write('a')
# response.empty? # => false
def empty?
@body.empty?
end
# Return the rack response array of status, headers, and body
# for the current response. If the status has not been set,
# uses the return value of default_status if the body has
# been written to, otherwise uses a 404 status.
# Adds the Content-Length header to the size of the response body.
#
# Example:
#
# response.finish
# # => [200,
# # {'Content-Type'=>'text/html', 'Content-Length'=>'0'},
# # []]
def finish
b = @body
s = (@status ||= b.empty? ? 404 : default_status)
set_default_headers
h = @headers
h[CONTENT_LENGTH] ||= @length.to_s
[s, h, b]
end
# Return the rack response array using a given body. Assumes a
# 200 response status unless status has been explicitly set,
# and doesn't add the Content-Length header or use the existing
# body.
def finish_with_body(body)
set_default_headers
[@status || default_status, @headers, body]
end
# Return the default response status to be used when the body
# has been written to. This is split out to make overriding
# easier in plugins.
def default_status
200
end
# Show response class, status code, response headers, and response body
def inspect
"#<#{self.class.inspect} #{@status.inspect} #{@headers.inspect} #{@body.inspect}>"
end
# Set the Location header to the given path, and the status
# to the given status. Example:
#
# response.redirect('foo', 301)
# response.redirect('bar')
def redirect(path, status = 302)
@headers[LOCATION] = path
@status = status
end
# Return the Roda class related to this response.
def roda_class
self.class.roda_class
end
# Write to the response body. Returns nil.
#
# response.write('foo')
def write(str)
s = str.to_s
@length += s.bytesize
@body << s
nil
end
private
# For each default header, if a header has not already been set for the
# response, set the header in the response.
def set_default_headers
h = @headers
default_headers.each do |k,v|
h[k] ||= v
end
end
end
end
end
extend RodaPlugins::Base::ClassMethods
plugin RodaPlugins::Base
end